Skip to content

Commit cf6e2f5

Browse files
docs: .affex face-interop manifest spec draft (#93)
* docs: add .affex face-interop manifest spec draft (refs #84) 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 <noreply@anthropic.com> * docs(spec): rename affex-spec.md to .adoc and convert to AsciiDoc 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6590304 commit cf6e2f5

1 file changed

Lines changed: 245 additions & 0 deletions

File tree

docs/specs/affex-spec.adoc

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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

Comments
 (0)