diff --git a/docs/specs/affex-spec.adoc b/docs/specs/affex-spec.adoc new file mode 100644 index 0000000..66e0348 --- /dev/null +++ b/docs/specs/affex-spec.adoc @@ -0,0 +1,245 @@ +// 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 + +toc::[] + +== 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: + +[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 +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 + +[source,json] +---- +{ + "affex": "1", + "package": "affinescript-pixijs", + "generated_at": "2026-05-11T00:00:00Z", + "source_hash": "sha256:abc123...", + "faces": ["canonical", "lucid", "cafe"], + "mappings": [ ... ] +} +---- + +[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", + "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" + } + } +} +---- + +[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 + +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: + +[source,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 + +[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 +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: + +. 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 +these criteria alone — explicit mutual agreement in a comment on issue #84 is +required._