From 4487fed9f7065ae1c63eeea8b57a8ae7902bacb5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 11 May 2026 02:48:21 +0200 Subject: [PATCH 1/2] docs: add .affex face-interop manifest spec draft (refs #84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writes docs/specs/affex-spec.md, covering: - Design decision: Shape 3 (face-interop manifest) as the primary form, with Shape 1 (side-by-side listing) as a derived view generated on demand - File extension policy: tooling-only, not parsed by the compiler - JSON format schema (affex/1): package manifest with per-declaration face renderings, body digest, override blocks - CLI surface: affex generate, affex render, affex diff - LSP integration plan - Generation strategy and CI recommendation - Explicit satisfaction criteria for closing #84 (per the requirements-target closure rule) Does not close #84 — closure requires explicit mutual agreement recorded in the issue thread. Co-Authored-By: Claude Sonnet 4.6 --- docs/specs/affex-spec.md | 245 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/specs/affex-spec.md diff --git a/docs/specs/affex-spec.md b/docs/specs/affex-spec.md new file mode 100644 index 0000000..3130376 --- /dev/null +++ b/docs/specs/affex-spec.md @@ -0,0 +1,245 @@ +# `.affex` — AffineScript Face-Interop Manifest Specification v0.1 + +**Status**: Draft · Linked to requirements-target issue #84 + +--- + +## 1. Problem + +AffineScript supports multiple *faces* — surface syntaxes that all lower to the +same canonical AST. A programmer fluent in `canonical` reading a file written in +`lucid` or `cafe` cannot tell what is going on, even though the semantics are +identical. + +`.affex` is a **tooling-only, per-package manifest** that captures +face-specific renderings of every top-level declaration, so readers can work in +their preferred face without leaving their `.affine` files. + +--- + +## 2. Design decision: Shape 3 (face-interop manifest) + +The four speculative shapes from the issue were: + +| # | Shape | Chosen? | +|---|-------|---------| +| 1 | Rosetta-stone side-by-side listing | Derived output only | +| 2 | Canonical-equivalence annotations | Not chosen | +| **3** | **Face-interop manifest** | **Primary form** | +| 4 | Mixed hand-written + auto | Supported (override blocks) | + +**Why Shape 3**: the compiler already holds the canonical↔face bijection +through the lowering pass. A manifest that captures this mapping once per +package lets all tooling (LSP, CLI renderer, web viewer) derive any face view +on demand — without storing N copies of source. Shape 1 (side-by-side listing) +is a *derived view* generated from Shape 3 + the source, not a stored artifact. + +--- + +## 3. File extension policy + +`.affex` files are **tooling artifacts only**. The compiler (`check`, `compile`, +`eval`) does not parse or consume them. They are generated by the CLI and +consumed by the LSP and renderer. + +Scope: **one `.affex` file per package**, placed at the package root alongside +`dune` and the package's `.affine` source files. + +``` +src/ + dune + pixi.affine + renderer.affine + pixi.affex ← generated manifest for this package +``` + +--- + +## 4. File format + +`.affex` files are UTF-8 JSON with schema version `"affex/1"`. + +### 4.1 Top-level structure + +```json +{ + "affex": "1", + "package": "affinescript-pixijs", + "generated_at": "2026-05-11T00:00:00Z", + "source_hash": "sha256:abc123...", + "faces": ["canonical", "lucid", "cafe"], + "mappings": [ ... ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `affex` | `"1"` | Schema version. Must be `"1"` for this spec. | +| `package` | string | Package name (matches `dune` library name). | +| `generated_at` | ISO 8601 | Timestamp of last generation run. | +| `source_hash` | string | SHA-256 of all `.affine` source files combined, to detect staleness. | +| `faces` | string[] | Faces present in this manifest. Always includes `"canonical"`. | +| `mappings` | object[] | One entry per top-level declaration. See §4.2. | + +### 4.2 Mapping entry + +```json +{ + "id": "init_pixi", + "kind": "fn", + "source_file": "pixi.affine", + "canonical_span": { "start": [3, 1], "end": [5, 1] }, + "faces": { + "canonical": { + "head": "pub fn init_pixi(width: Int, height: Int) -> Application", + "body_digest": "sha256:def456..." + }, + "lucid": { + "head": "pub function init_pixi(width :: Int, height :: Int) :: Application", + "body_digest": "sha256:def456...", + "override": false + }, + "cafe": { + "head": "pub init_pixi = (width: Int, height: Int) -> Application", + "body_digest": "sha256:def456...", + "override": true, + "override_source": "pixi.affex#overrides/init_pixi/cafe" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Canonical name of the declaration. | +| `kind` | `"fn"` \| `"type"` \| `"const"` \| `"effect"` \| `"trait"` \| `"impl"` | Declaration kind. | +| `source_file` | string | Relative path to the `.affine` file. | +| `canonical_span` | `{start: [line, col], end: [line, col]}` | Source location of the canonical declaration. | +| `faces..head` | string | Face-specific rendering of the declaration *signature* (no body). | +| `faces..body_digest` | string | SHA-256 of the canonical body. Identical across faces — a mismatch signals the manifest is stale. | +| `faces..override` | bool | `true` if the face rendering was hand-written rather than auto-generated. | +| `faces..override_source` | string? | Pointer to the override block (§5) when `override` is `true`. | + +**`head` only, not `body`**: the manifest stores *signatures*, not bodies. Full +body rendering is done on demand by the renderer using the compiler's face +lowering pass. This keeps the manifest compact and avoids duplicating source. + +### 4.3 Override blocks + +When the auto-generated `head` for a face is wrong or unnatural, a developer +can add a hand-written override in a top-level `"overrides"` section: + +```json +{ + "affex": "1", + ... + "overrides": { + "init_pixi": { + "cafe": "pub init_pixi: (Int, Int) -> Application" + } + }, + "mappings": [ ... ] +} +``` + +The generator preserves existing `overrides` on regeneration and merges them +with newly auto-generated entries. + +--- + +## 5. Tooling + +### 5.1 CLI commands + +#### Generate + +``` +affinescript affex generate [] +``` + +Reads all `.affine` files in `` (default: current directory), +runs the face detection and lowering passes, and writes +`/.affex`. Existing override blocks are preserved. + +Options: +- `--faces canonical,lucid,cafe` — limit faces to generate (default: all installed faces) +- `--force` — regenerate even if source hash is unchanged + +#### Render + +``` +affinescript affex render --face +``` + +Reads the package's `.affex` manifest, then renders `` in +`` to stdout. If no manifest exists, falls back to running the face +lowering pass directly (slower). + +#### Diff + +``` +affinescript affex diff --face --face +``` + +Renders `` in both faces side-by-side (Shape 1 output) using the +manifest as the source of truth. + +### 5.2 LSP integration + +The LSP reads the nearest `.affex` manifest when opening a `.affine` file. +When the user configures a preferred face (e.g. `"affinescript.face": "lucid"` +in editor settings), hover tooltips, inlay hints, and the document outline +render declaration signatures in that face rather than the source face. + +The LSP does not rewrite file contents — it only affects rendered metadata. + +--- + +## 6. Generation strategy + +| Scenario | Approach | +|----------|----------| +| Clean package with no `.affex` | `affex generate` creates one from scratch | +| Source changed since last generate | `source_hash` mismatch; re-run `affex generate` | +| Override present for a declaration | Generator merges override, marks `"override": true` | +| New declaration added | Generator appends mapping; existing entries untouched | +| Declaration removed | Generator removes stale mapping entry | + +**CI recommendation**: add `affinescript affex generate --check` (exits non-zero +if manifest is stale) to CI for estates that adopt multi-face conventions. + +--- + +## 7. What this is NOT + +- Not a compiler concern. The compiler lowers all faces independently. +- Not an AffineScript↔non-AffineScript FFI layer. See `extern fn` for that. +- Not required for single-face estates. A project using only `canonical` has no + need for a `.affex` file. + +--- + +## 8. Open questions (pre-implementation) + +- [ ] Should `affinescript affex generate` be a separate binary or a subcommand of the main `affinescript` CLI? +- [ ] Face names in the manifest should match exactly the values in `lib/face.ml`'s `face` type. Confirm the canonical string identifiers: `canonical`, `python`, `js`, `pseudocode`, `lucid`, `cafe`. +- [ ] Should `head` rendering use the compiler's pretty-printer or store the raw source span? Raw source span is simpler and always correct; pretty-printed form is more useful when the source face differs from the reader face. +- [ ] Staleness detection: `source_hash` over all `.affine` files in the package is coarse. Consider per-file hashes to support incremental regeneration. +- [ ] Override syntax: embedding overrides inside the JSON file is workable but verbose. An alternative is a sidecar `.affex.overrides` file. + +--- + +## 9. Satisfaction criteria for closing #84 + +This issue closes when Claude and the user explicitly agree, in a recorded +exchange on this issue, that the following are satisfied: + +1. This spec (or a revised version) is merged into `docs/specs/affex-spec.md`. +2. The file format schema (§4) is agreed to be correct and complete. +3. The tooling surface (§5) matches the CLI and LSP integration plan. +4. At least one of the open questions in §8 has a recorded resolution (or is + explicitly deferred with a rationale). + +*The presence of a PR, a draft, or a partial implementation does not satisfy +these criteria alone — explicit mutual agreement in a comment on issue #84 is +required.* From 976ec4257aaed29d9f054df1037aa722bac5ee8c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 11 May 2026 04:36:27 +0200 Subject: [PATCH 2/2] docs(spec): rename affex-spec.md to .adoc and convert to AsciiDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estate rule: docs/ files use .adoc extension; .md is reserved for files GitHub community-health rules special-case by name (CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, CHANGELOG.md, README.md). General specs must be .adoc. This commit: - git mv docs/specs/affex-spec.md -> docs/specs/affex-spec.adoc - Converts Markdown headings (#, ##, ###, ####) to AsciiDoc (=, ==, ===, ====) - Converts Markdown pipe tables to AsciiDoc [cols=...] |=== blocks - Converts code fences to AsciiDoc [source,json] ---- listing blocks - Converts emphasis (bold, italic) to AsciiDoc (* and _ markers) - Adds standard AffineScript-spec preamble (SPDX, :toc:, :source-highlighter:) - Updates internal §9 reference to point at the new .adoc filename Refs #84. Co-Authored-By: Claude Opus 4.7 --- docs/specs/{affex-spec.md => affex-spec.adoc} | 224 +++++++++--------- 1 file changed, 112 insertions(+), 112 deletions(-) rename docs/specs/{affex-spec.md => affex-spec.adoc} (60%) diff --git a/docs/specs/affex-spec.md b/docs/specs/affex-spec.adoc similarity index 60% rename from docs/specs/affex-spec.md rename to docs/specs/affex-spec.adoc index 3130376..66e0348 100644 --- a/docs/specs/affex-spec.md +++ b/docs/specs/affex-spec.adoc @@ -1,67 +1,68 @@ -# `.affex` — AffineScript Face-Interop Manifest Specification v0.1 +// SPDX-License-Identifier: PMPL-1.0-or-later += `.affex` — AffineScript Face-Interop Manifest Specification v0.1 +:toc: macro +:toclevels: 2 +:source-highlighter: rouge -**Status**: Draft · Linked to requirements-target issue #84 +*Status*: Draft · Linked to requirements-target issue #84 ---- +toc::[] -## 1. Problem +== 1. Problem -AffineScript supports multiple *faces* — surface syntaxes that all lower to the +AffineScript supports multiple _faces_ — surface syntaxes that all lower to the same canonical AST. A programmer fluent in `canonical` reading a file written in `lucid` or `cafe` cannot tell what is going on, even though the semantics are identical. -`.affex` is a **tooling-only, per-package manifest** that captures +`.affex` is a *tooling-only, per-package manifest* that captures face-specific renderings of every top-level declaration, so readers can work in their preferred face without leaving their `.affine` files. ---- - -## 2. Design decision: Shape 3 (face-interop manifest) +== 2. Design decision: Shape 3 (face-interop manifest) The four speculative shapes from the issue were: -| # | Shape | Chosen? | -|---|-------|---------| -| 1 | Rosetta-stone side-by-side listing | Derived output only | -| 2 | Canonical-equivalence annotations | Not chosen | -| **3** | **Face-interop manifest** | **Primary form** | -| 4 | Mixed hand-written + auto | Supported (override blocks) | +[cols="1,3,2", options="header"] +|=== +| # | Shape | Chosen? +| 1 | Rosetta-stone side-by-side listing | Derived output only +| 2 | Canonical-equivalence annotations | Not chosen +| *3* | *Face-interop manifest* | *Primary form* +| 4 | Mixed hand-written + auto | Supported (override blocks) +|=== -**Why Shape 3**: the compiler already holds the canonical↔face bijection +*Why Shape 3*: the compiler already holds the canonical↔face bijection through the lowering pass. A manifest that captures this mapping once per package lets all tooling (LSP, CLI renderer, web viewer) derive any face view on demand — without storing N copies of source. Shape 1 (side-by-side listing) -is a *derived view* generated from Shape 3 + the source, not a stored artifact. - ---- +is a _derived view_ generated from Shape 3 + the source, not a stored artifact. -## 3. File extension policy +== 3. File extension policy -`.affex` files are **tooling artifacts only**. The compiler (`check`, `compile`, +`.affex` files are *tooling artifacts only*. The compiler (`check`, `compile`, `eval`) does not parse or consume them. They are generated by the CLI and consumed by the LSP and renderer. -Scope: **one `.affex` file per package**, placed at the package root alongside +Scope: *one `.affex` file per package*, placed at the package root alongside `dune` and the package's `.affine` source files. -``` +---- src/ dune pixi.affine renderer.affine - pixi.affex ← generated manifest for this package -``` - ---- + pixi.affex <- generated manifest for this package +---- -## 4. File format +== 4. File format `.affex` files are UTF-8 JSON with schema version `"affex/1"`. -### 4.1 Top-level structure +=== 4.1 Top-level structure -```json +[source,json] +---- { "affex": "1", "package": "affinescript-pixijs", @@ -70,20 +71,23 @@ src/ "faces": ["canonical", "lucid", "cafe"], "mappings": [ ... ] } -``` - -| Field | Type | Description | -|-------|------|-------------| -| `affex` | `"1"` | Schema version. Must be `"1"` for this spec. | -| `package` | string | Package name (matches `dune` library name). | -| `generated_at` | ISO 8601 | Timestamp of last generation run. | -| `source_hash` | string | SHA-256 of all `.affine` source files combined, to detect staleness. | -| `faces` | string[] | Faces present in this manifest. Always includes `"canonical"`. | -| `mappings` | object[] | One entry per top-level declaration. See §4.2. | - -### 4.2 Mapping entry - -```json +---- + +[cols="1,1,3", options="header"] +|=== +| Field | Type | Description +| `affex` | `"1"` | Schema version. Must be `"1"` for this spec. +| `package` | string | Package name (matches `dune` library name). +| `generated_at` | ISO 8601 | Timestamp of last generation run. +| `source_hash` | string | SHA-256 of all `.affine` source files combined, to detect staleness. +| `faces` | string[] | Faces present in this manifest. Always includes `"canonical"`. +| `mappings` | object[] | One entry per top-level declaration. See §4.2. +|=== + +=== 4.2 Mapping entry + +[source,json] +---- { "id": "init_pixi", "kind": "fn", @@ -107,29 +111,32 @@ src/ } } } -``` - -| Field | Type | Description | -|-------|------|-------------| -| `id` | string | Canonical name of the declaration. | -| `kind` | `"fn"` \| `"type"` \| `"const"` \| `"effect"` \| `"trait"` \| `"impl"` | Declaration kind. | -| `source_file` | string | Relative path to the `.affine` file. | -| `canonical_span` | `{start: [line, col], end: [line, col]}` | Source location of the canonical declaration. | -| `faces..head` | string | Face-specific rendering of the declaration *signature* (no body). | -| `faces..body_digest` | string | SHA-256 of the canonical body. Identical across faces — a mismatch signals the manifest is stale. | -| `faces..override` | bool | `true` if the face rendering was hand-written rather than auto-generated. | -| `faces..override_source` | string? | Pointer to the override block (§5) when `override` is `true`. | - -**`head` only, not `body`**: the manifest stores *signatures*, not bodies. Full +---- + +[cols="1,1,3", options="header"] +|=== +| Field | Type | Description +| `id` | string | Canonical name of the declaration. +| `kind` | `"fn"` \| `"type"` \| `"const"` \| `"effect"` \| `"trait"` \| `"impl"` | Declaration kind. +| `source_file` | string | Relative path to the `.affine` file. +| `canonical_span` | `{start: [line, col], end: [line, col]}` | Source location of the canonical declaration. +| `faces..head` | string | Face-specific rendering of the declaration _signature_ (no body). +| `faces..body_digest` | string | SHA-256 of the canonical body. Identical across faces — a mismatch signals the manifest is stale. +| `faces..override` | bool | `true` if the face rendering was hand-written rather than auto-generated. +| `faces..override_source` | string? | Pointer to the override block (§4.3) when `override` is `true`. +|=== + +*`head` only, not `body`*: the manifest stores _signatures_, not bodies. Full body rendering is done on demand by the renderer using the compiler's face lowering pass. This keeps the manifest compact and avoids duplicating source. -### 4.3 Override blocks +=== 4.3 Override blocks When the auto-generated `head` for a face is wrong or unnatural, a developer can add a hand-written override in a top-level `"overrides"` section: -```json +[source,json] +---- { "affex": "1", ... @@ -140,51 +147,50 @@ can add a hand-written override in a top-level `"overrides"` section: }, "mappings": [ ... ] } -``` +---- The generator preserves existing `overrides` on regeneration and merges them with newly auto-generated entries. ---- +== 5. Tooling -## 5. Tooling +=== 5.1 CLI commands -### 5.1 CLI commands +==== Generate -#### Generate - -``` +---- affinescript affex generate [] -``` +---- Reads all `.affine` files in `` (default: current directory), runs the face detection and lowering passes, and writes `/.affex`. Existing override blocks are preserved. Options: -- `--faces canonical,lucid,cafe` — limit faces to generate (default: all installed faces) -- `--force` — regenerate even if source hash is unchanged -#### Render +* `--faces canonical,lucid,cafe` — limit faces to generate (default: all installed faces) +* `--force` — regenerate even if source hash is unchanged + +==== Render -``` +---- affinescript affex render --face -``` +---- Reads the package's `.affex` manifest, then renders `` in `` to stdout. If no manifest exists, falls back to running the face lowering pass directly (slower). -#### Diff +==== Diff -``` +---- affinescript affex diff --face --face -``` +---- Renders `` in both faces side-by-side (Shape 1 output) using the manifest as the source of truth. -### 5.2 LSP integration +=== 5.2 LSP integration The LSP reads the nearest `.affex` manifest when opening a `.affine` file. When the user configures a preferred face (e.g. `"affinescript.face": "lucid"` @@ -193,53 +199,47 @@ render declaration signatures in that face rather than the source face. The LSP does not rewrite file contents — it only affects rendered metadata. ---- - -## 6. Generation strategy +== 6. Generation strategy -| Scenario | Approach | -|----------|----------| -| Clean package with no `.affex` | `affex generate` creates one from scratch | -| Source changed since last generate | `source_hash` mismatch; re-run `affex generate` | -| Override present for a declaration | Generator merges override, marks `"override": true` | -| New declaration added | Generator appends mapping; existing entries untouched | -| Declaration removed | Generator removes stale mapping entry | +[cols="1,2", options="header"] +|=== +| Scenario | Approach +| Clean package with no `.affex` | `affex generate` creates one from scratch +| Source changed since last generate | `source_hash` mismatch; re-run `affex generate` +| Override present for a declaration | Generator merges override, marks `"override": true` +| New declaration added | Generator appends mapping; existing entries untouched +| Declaration removed | Generator removes stale mapping entry +|=== -**CI recommendation**: add `affinescript affex generate --check` (exits non-zero +*CI recommendation*: add `affinescript affex generate --check` (exits non-zero if manifest is stale) to CI for estates that adopt multi-face conventions. ---- +== 7. What this is NOT -## 7. What this is NOT - -- Not a compiler concern. The compiler lowers all faces independently. -- Not an AffineScript↔non-AffineScript FFI layer. See `extern fn` for that. -- Not required for single-face estates. A project using only `canonical` has no +* Not a compiler concern. The compiler lowers all faces independently. +* Not an AffineScript↔non-AffineScript FFI layer. See `extern fn` for that. +* Not required for single-face estates. A project using only `canonical` has no need for a `.affex` file. ---- - -## 8. Open questions (pre-implementation) - -- [ ] Should `affinescript affex generate` be a separate binary or a subcommand of the main `affinescript` CLI? -- [ ] Face names in the manifest should match exactly the values in `lib/face.ml`'s `face` type. Confirm the canonical string identifiers: `canonical`, `python`, `js`, `pseudocode`, `lucid`, `cafe`. -- [ ] Should `head` rendering use the compiler's pretty-printer or store the raw source span? Raw source span is simpler and always correct; pretty-printed form is more useful when the source face differs from the reader face. -- [ ] Staleness detection: `source_hash` over all `.affine` files in the package is coarse. Consider per-file hashes to support incremental regeneration. -- [ ] Override syntax: embedding overrides inside the JSON file is workable but verbose. An alternative is a sidecar `.affex.overrides` file. +== 8. Open questions (pre-implementation) ---- +* [ ] Should `affinescript affex generate` be a separate binary or a subcommand of the main `affinescript` CLI? +* [ ] Face names in the manifest should match exactly the values in `lib/face.ml`'s `face` type. Confirm the canonical string identifiers: `canonical`, `python`, `js`, `pseudocode`, `lucid`, `cafe`. +* [ ] Should `head` rendering use the compiler's pretty-printer or store the raw source span? Raw source span is simpler and always correct; pretty-printed form is more useful when the source face differs from the reader face. +* [ ] Staleness detection: `source_hash` over all `.affine` files in the package is coarse. Consider per-file hashes to support incremental regeneration. +* [ ] Override syntax: embedding overrides inside the JSON file is workable but verbose. An alternative is a sidecar `.affex.overrides` file. -## 9. Satisfaction criteria for closing #84 +== 9. Satisfaction criteria for closing #84 This issue closes when Claude and the user explicitly agree, in a recorded exchange on this issue, that the following are satisfied: -1. This spec (or a revised version) is merged into `docs/specs/affex-spec.md`. -2. The file format schema (§4) is agreed to be correct and complete. -3. The tooling surface (§5) matches the CLI and LSP integration plan. -4. At least one of the open questions in §8 has a recorded resolution (or is - explicitly deferred with a rationale). +. This spec (or a revised version) is merged into `docs/specs/affex-spec.adoc`. +. The file format schema (§4) is agreed to be correct and complete. +. The tooling surface (§5) matches the CLI and LSP integration plan. +. At least one of the open questions in §8 has a recorded resolution (or is + explicitly deferred with a rationale). -*The presence of a PR, a draft, or a partial implementation does not satisfy +_The presence of a PR, a draft, or a partial implementation does not satisfy these criteria alone — explicit mutual agreement in a comment on issue #84 is -required.* +required._