Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions docs/specs/affex-spec.adoc
Original file line number Diff line number Diff line change
@@ -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.<name>.head` | string | Face-specific rendering of the declaration _signature_ (no body).
| `faces.<name>.body_digest` | string | SHA-256 of the canonical body. Identical across faces — a mismatch signals the manifest is stale.
| `faces.<name>.override` | bool | `true` if the face rendering was hand-written rather than auto-generated.
| `faces.<name>.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 [<package_dir>]
----

Reads all `.affine` files in `<package_dir>` (default: current directory),
runs the face detection and lowering passes, and writes
`<package_dir>/<package_name>.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 <face> <file.affine>
----

Reads the package's `.affex` manifest, then renders `<file.affine>` in
`<face>` to stdout. If no manifest exists, falls back to running the face
lowering pass directly (slower).

==== Diff

----
affinescript affex diff --face <face_a> --face <face_b> <file.affine>
----

Renders `<file.affine>` 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._
Loading