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