|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | += `.affex` — AffineScript Face-Interop Manifest Specification v0.1 |
| 3 | +:toc: macro |
| 4 | +:toclevels: 2 |
| 5 | +:source-highlighter: rouge |
| 6 | + |
| 7 | +*Status*: Draft · Linked to requirements-target issue #84 |
| 8 | + |
| 9 | +toc::[] |
| 10 | + |
| 11 | +== 1. Problem |
| 12 | + |
| 13 | +AffineScript supports multiple _faces_ — surface syntaxes that all lower to the |
| 14 | +same canonical AST. A programmer fluent in `canonical` reading a file written in |
| 15 | +`lucid` or `cafe` cannot tell what is going on, even though the semantics are |
| 16 | +identical. |
| 17 | + |
| 18 | +`.affex` is a *tooling-only, per-package manifest* that captures |
| 19 | +face-specific renderings of every top-level declaration, so readers can work in |
| 20 | +their preferred face without leaving their `.affine` files. |
| 21 | + |
| 22 | +== 2. Design decision: Shape 3 (face-interop manifest) |
| 23 | + |
| 24 | +The four speculative shapes from the issue were: |
| 25 | + |
| 26 | +[cols="1,3,2", options="header"] |
| 27 | +|=== |
| 28 | +| # | Shape | Chosen? |
| 29 | +| 1 | Rosetta-stone side-by-side listing | Derived output only |
| 30 | +| 2 | Canonical-equivalence annotations | Not chosen |
| 31 | +| *3* | *Face-interop manifest* | *Primary form* |
| 32 | +| 4 | Mixed hand-written + auto | Supported (override blocks) |
| 33 | +|=== |
| 34 | + |
| 35 | +*Why Shape 3*: the compiler already holds the canonical↔face bijection |
| 36 | +through the lowering pass. A manifest that captures this mapping once per |
| 37 | +package lets all tooling (LSP, CLI renderer, web viewer) derive any face view |
| 38 | +on demand — without storing N copies of source. Shape 1 (side-by-side listing) |
| 39 | +is a _derived view_ generated from Shape 3 + the source, not a stored artifact. |
| 40 | + |
| 41 | +== 3. File extension policy |
| 42 | + |
| 43 | +`.affex` files are *tooling artifacts only*. The compiler (`check`, `compile`, |
| 44 | +`eval`) does not parse or consume them. They are generated by the CLI and |
| 45 | +consumed by the LSP and renderer. |
| 46 | + |
| 47 | +Scope: *one `.affex` file per package*, placed at the package root alongside |
| 48 | +`dune` and the package's `.affine` source files. |
| 49 | + |
| 50 | +---- |
| 51 | +src/ |
| 52 | + dune |
| 53 | + pixi.affine |
| 54 | + renderer.affine |
| 55 | + pixi.affex <- generated manifest for this package |
| 56 | +---- |
| 57 | + |
| 58 | +== 4. File format |
| 59 | + |
| 60 | +`.affex` files are UTF-8 JSON with schema version `"affex/1"`. |
| 61 | + |
| 62 | +=== 4.1 Top-level structure |
| 63 | + |
| 64 | +[source,json] |
| 65 | +---- |
| 66 | +{ |
| 67 | + "affex": "1", |
| 68 | + "package": "affinescript-pixijs", |
| 69 | + "generated_at": "2026-05-11T00:00:00Z", |
| 70 | + "source_hash": "sha256:abc123...", |
| 71 | + "faces": ["canonical", "lucid", "cafe"], |
| 72 | + "mappings": [ ... ] |
| 73 | +} |
| 74 | +---- |
| 75 | + |
| 76 | +[cols="1,1,3", options="header"] |
| 77 | +|=== |
| 78 | +| Field | Type | Description |
| 79 | +| `affex` | `"1"` | Schema version. Must be `"1"` for this spec. |
| 80 | +| `package` | string | Package name (matches `dune` library name). |
| 81 | +| `generated_at` | ISO 8601 | Timestamp of last generation run. |
| 82 | +| `source_hash` | string | SHA-256 of all `.affine` source files combined, to detect staleness. |
| 83 | +| `faces` | string[] | Faces present in this manifest. Always includes `"canonical"`. |
| 84 | +| `mappings` | object[] | One entry per top-level declaration. See §4.2. |
| 85 | +|=== |
| 86 | + |
| 87 | +=== 4.2 Mapping entry |
| 88 | + |
| 89 | +[source,json] |
| 90 | +---- |
| 91 | +{ |
| 92 | + "id": "init_pixi", |
| 93 | + "kind": "fn", |
| 94 | + "source_file": "pixi.affine", |
| 95 | + "canonical_span": { "start": [3, 1], "end": [5, 1] }, |
| 96 | + "faces": { |
| 97 | + "canonical": { |
| 98 | + "head": "pub fn init_pixi(width: Int, height: Int) -> Application", |
| 99 | + "body_digest": "sha256:def456..." |
| 100 | + }, |
| 101 | + "lucid": { |
| 102 | + "head": "pub function init_pixi(width :: Int, height :: Int) :: Application", |
| 103 | + "body_digest": "sha256:def456...", |
| 104 | + "override": false |
| 105 | + }, |
| 106 | + "cafe": { |
| 107 | + "head": "pub init_pixi = (width: Int, height: Int) -> Application", |
| 108 | + "body_digest": "sha256:def456...", |
| 109 | + "override": true, |
| 110 | + "override_source": "pixi.affex#overrides/init_pixi/cafe" |
| 111 | + } |
| 112 | + } |
| 113 | +} |
| 114 | +---- |
| 115 | + |
| 116 | +[cols="1,1,3", options="header"] |
| 117 | +|=== |
| 118 | +| Field | Type | Description |
| 119 | +| `id` | string | Canonical name of the declaration. |
| 120 | +| `kind` | `"fn"` \| `"type"` \| `"const"` \| `"effect"` \| `"trait"` \| `"impl"` | Declaration kind. |
| 121 | +| `source_file` | string | Relative path to the `.affine` file. |
| 122 | +| `canonical_span` | `{start: [line, col], end: [line, col]}` | Source location of the canonical declaration. |
| 123 | +| `faces.<name>.head` | string | Face-specific rendering of the declaration _signature_ (no body). |
| 124 | +| `faces.<name>.body_digest` | string | SHA-256 of the canonical body. Identical across faces — a mismatch signals the manifest is stale. |
| 125 | +| `faces.<name>.override` | bool | `true` if the face rendering was hand-written rather than auto-generated. |
| 126 | +| `faces.<name>.override_source` | string? | Pointer to the override block (§4.3) when `override` is `true`. |
| 127 | +|=== |
| 128 | + |
| 129 | +*`head` only, not `body`*: the manifest stores _signatures_, not bodies. Full |
| 130 | +body rendering is done on demand by the renderer using the compiler's face |
| 131 | +lowering pass. This keeps the manifest compact and avoids duplicating source. |
| 132 | + |
| 133 | +=== 4.3 Override blocks |
| 134 | + |
| 135 | +When the auto-generated `head` for a face is wrong or unnatural, a developer |
| 136 | +can add a hand-written override in a top-level `"overrides"` section: |
| 137 | + |
| 138 | +[source,json] |
| 139 | +---- |
| 140 | +{ |
| 141 | + "affex": "1", |
| 142 | + ... |
| 143 | + "overrides": { |
| 144 | + "init_pixi": { |
| 145 | + "cafe": "pub init_pixi: (Int, Int) -> Application" |
| 146 | + } |
| 147 | + }, |
| 148 | + "mappings": [ ... ] |
| 149 | +} |
| 150 | +---- |
| 151 | + |
| 152 | +The generator preserves existing `overrides` on regeneration and merges them |
| 153 | +with newly auto-generated entries. |
| 154 | + |
| 155 | +== 5. Tooling |
| 156 | + |
| 157 | +=== 5.1 CLI commands |
| 158 | + |
| 159 | +==== Generate |
| 160 | + |
| 161 | +---- |
| 162 | +affinescript affex generate [<package_dir>] |
| 163 | +---- |
| 164 | + |
| 165 | +Reads all `.affine` files in `<package_dir>` (default: current directory), |
| 166 | +runs the face detection and lowering passes, and writes |
| 167 | +`<package_dir>/<package_name>.affex`. Existing override blocks are preserved. |
| 168 | + |
| 169 | +Options: |
| 170 | + |
| 171 | +* `--faces canonical,lucid,cafe` — limit faces to generate (default: all installed faces) |
| 172 | +* `--force` — regenerate even if source hash is unchanged |
| 173 | + |
| 174 | +==== Render |
| 175 | + |
| 176 | +---- |
| 177 | +affinescript affex render --face <face> <file.affine> |
| 178 | +---- |
| 179 | + |
| 180 | +Reads the package's `.affex` manifest, then renders `<file.affine>` in |
| 181 | +`<face>` to stdout. If no manifest exists, falls back to running the face |
| 182 | +lowering pass directly (slower). |
| 183 | + |
| 184 | +==== Diff |
| 185 | + |
| 186 | +---- |
| 187 | +affinescript affex diff --face <face_a> --face <face_b> <file.affine> |
| 188 | +---- |
| 189 | + |
| 190 | +Renders `<file.affine>` in both faces side-by-side (Shape 1 output) using the |
| 191 | +manifest as the source of truth. |
| 192 | + |
| 193 | +=== 5.2 LSP integration |
| 194 | + |
| 195 | +The LSP reads the nearest `.affex` manifest when opening a `.affine` file. |
| 196 | +When the user configures a preferred face (e.g. `"affinescript.face": "lucid"` |
| 197 | +in editor settings), hover tooltips, inlay hints, and the document outline |
| 198 | +render declaration signatures in that face rather than the source face. |
| 199 | + |
| 200 | +The LSP does not rewrite file contents — it only affects rendered metadata. |
| 201 | + |
| 202 | +== 6. Generation strategy |
| 203 | + |
| 204 | +[cols="1,2", options="header"] |
| 205 | +|=== |
| 206 | +| Scenario | Approach |
| 207 | +| Clean package with no `.affex` | `affex generate` creates one from scratch |
| 208 | +| Source changed since last generate | `source_hash` mismatch; re-run `affex generate` |
| 209 | +| Override present for a declaration | Generator merges override, marks `"override": true` |
| 210 | +| New declaration added | Generator appends mapping; existing entries untouched |
| 211 | +| Declaration removed | Generator removes stale mapping entry |
| 212 | +|=== |
| 213 | + |
| 214 | +*CI recommendation*: add `affinescript affex generate --check` (exits non-zero |
| 215 | +if manifest is stale) to CI for estates that adopt multi-face conventions. |
| 216 | + |
| 217 | +== 7. What this is NOT |
| 218 | + |
| 219 | +* Not a compiler concern. The compiler lowers all faces independently. |
| 220 | +* Not an AffineScript↔non-AffineScript FFI layer. See `extern fn` for that. |
| 221 | +* Not required for single-face estates. A project using only `canonical` has no |
| 222 | + need for a `.affex` file. |
| 223 | + |
| 224 | +== 8. Open questions (pre-implementation) |
| 225 | + |
| 226 | +* [ ] Should `affinescript affex generate` be a separate binary or a subcommand of the main `affinescript` CLI? |
| 227 | +* [ ] 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`. |
| 228 | +* [ ] 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. |
| 229 | +* [ ] Staleness detection: `source_hash` over all `.affine` files in the package is coarse. Consider per-file hashes to support incremental regeneration. |
| 230 | +* [ ] Override syntax: embedding overrides inside the JSON file is workable but verbose. An alternative is a sidecar `.affex.overrides` file. |
| 231 | + |
| 232 | +== 9. Satisfaction criteria for closing #84 |
| 233 | + |
| 234 | +This issue closes when Claude and the user explicitly agree, in a recorded |
| 235 | +exchange on this issue, that the following are satisfied: |
| 236 | + |
| 237 | +. This spec (or a revised version) is merged into `docs/specs/affex-spec.adoc`. |
| 238 | +. The file format schema (§4) is agreed to be correct and complete. |
| 239 | +. The tooling surface (§5) matches the CLI and LSP integration plan. |
| 240 | +. At least one of the open questions in §8 has a recorded resolution (or is |
| 241 | + explicitly deferred with a rationale). |
| 242 | + |
| 243 | +_The presence of a PR, a draft, or a partial implementation does not satisfy |
| 244 | +these criteria alone — explicit mutual agreement in a comment on issue #84 is |
| 245 | +required._ |
0 commit comments