From 2a697e3c99813002aafea25e56594d7c045e1733 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:15:52 +0100 Subject: [PATCH] feat(dom): prove INT-08 reconciler runs end-to-end; ship the e2e harness (Refs #183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dom.affine header said "RUNTIME blocked by #255; no runtime e2e harness is shipped until then (a harness that cannot pass would be dishonest)". #255 was fixed in PR #257 — and the harness now CAN pass, so per the header's own bar, it ships: - affinescript-dom/e2e/run.sh — concatenates src/dom.affine + a driver `main`, compiles to core-WASM, runs under Node against an Int-handle host DOM, asserts the mutation log + final tree. Skips loudly (exit 0) when the compiler or node is absent; AFFINESCRIPT_BIN overrides the binary path for CI. - affinescript-dom/e2e/driver_main.affine — mounts
["hello", ["x"]]
, then reconciles to
["world"]
. Exercises every loop-bearing path (the #255 class): render attr+children loops, patch_attrs add+remove (attr_has), and the `while` child-reconcile loop (in-place text patch + surplus-child removal). The affine discipline is load-bearing in the driver itself: `mount` consumes its tree, so reconcile takes a fresh copy — reuse would be a use-after-move type error. - affinescript-dom/e2e/dom_host.mjs — the host: Int-handle node map, mutation log, hard assertions (title added, class removed, text patched in place, span removed, reconcile returned the in-place handle). Observed run (2026-07-07, node 26, dune 3.24 build): query(#root) … setAttr(#2,title=t2) removeAttr(#2,class) setText(#3,"world") remove(#2,#4) <#root> └
└ "world" ALL ASSERTIONS PASS Docs reconciled to VERIFIED (dom.affine header; ECOSYSTEM satellite + INT-08/INT-11 rows; TECH-DEBT INT-08; bindings-roadmap DOM row). INT-11's remaining leg is browser-host parity (this run is Node). Gates: dune build exit 0; doc-truthing OK; soundness ledger all-5 OK; e2e harness exit 0 from the branch's own build. Co-Authored-By: Claude Opus 4.8 (1M context) --- affinescript-dom/e2e/dom_host.mjs | 65 +++++++++++++++++++++++++ affinescript-dom/e2e/driver_main.affine | 29 +++++++++++ affinescript-dom/e2e/run.sh | 24 +++++++++ affinescript-dom/src/dom.affine | 13 ++--- docs/ECOSYSTEM.adoc | 23 +++++---- docs/TECH-DEBT.adoc | 3 +- docs/bindings-roadmap.adoc | 6 +-- 7 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 affinescript-dom/e2e/dom_host.mjs create mode 100644 affinescript-dom/e2e/driver_main.affine create mode 100755 affinescript-dom/e2e/run.sh diff --git a/affinescript-dom/e2e/dom_host.mjs b/affinescript-dom/e2e/dom_host.mjs new file mode 100644 index 00000000..4b8ef457 --- /dev/null +++ b/affinescript-dom/e2e/dom_host.mjs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MPL-2.0 +// e2e host for dom_drive.wasm — Int-handle DOM, mutation log, assertions. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +let inst = null; +const readString = (ptr) => { + const dv = new DataView(inst.exports.memory.buffer); + const len = dv.getUint32(ptr, true); + return new TextDecoder('utf-8').decode( + new Uint8Array(inst.exports.memory.buffer, ptr + 4, len)); +}; + +// handle 1 = #root +const nodes = new Map([[1, { tag: '#root', attrs: new Map(), children: [], text: null }]]); +let next = 2; +const log = []; +const mk = (n) => { const h = next++; nodes.set(h, n); return h; }; + +const env = { + dom_query_selector: (p) => { const s = readString(p); log.push(`query(${s})`); return s === '#root' ? 1 : 0; }, + dom_create_element: (p) => { const t = readString(p); const h = mk({ tag: t, attrs: new Map(), children: [], text: null }); log.push(`createElement(${t})=#${h}`); return h; }, + dom_create_text_node: (p) => { const s = readString(p); const h = mk({ tag: '#text', attrs: new Map(), children: [], text: s }); log.push(`createText("${s}")=#${h}`); return h; }, + dom_append_child: (p, c) => { nodes.get(p).children.push(c); log.push(`append(#${p},#${c})`); }, + dom_replace_child: (p, o, n) => { const cs = nodes.get(p).children; const i = cs.indexOf(o); assert.ok(i >= 0, `replace: #${o} not under #${p}`); cs[i] = n; log.push(`replace(#${p},#${o}->#${n})`); }, + dom_remove_child: (p, c) => { const cs = nodes.get(p).children; const i = cs.indexOf(c); assert.ok(i >= 0, `remove: #${c} not under #${p}`); cs.splice(i, 1); log.push(`remove(#${p},#${c})`); }, + dom_child_at: (p, i) => { const c = nodes.get(p).children[i] ?? 0; log.push(`childAt(#${p},${i})=#${c}`); return c; }, + dom_set_attribute: (el, n, v) => { const name = readString(n), val = readString(v); nodes.get(el).attrs.set(name, val); log.push(`setAttr(#${el},${name}=${val})`); }, + dom_remove_attribute: (el, n) => { const name = readString(n); nodes.get(el).attrs.delete(name); log.push(`removeAttr(#${el},${name})`); }, + dom_set_text: (el, p) => { const s = readString(p); nodes.get(el).text = s; log.push(`setText(#${el},"${s}")`); }, + dom_str_eq: (a, b) => (readString(a) === readString(b) ? 1 : 0), +}; + +const dump = (h, d = 0) => { + const n = nodes.get(h); + const attrs = [...n.attrs].map(([k, v]) => ` ${k}="${v}"`).join(''); + const line = n.tag === '#text' ? `"${n.text}"` : `<${n.tag}${attrs}> #${h}`; + return [' '.repeat(d) + line, ...n.children.flatMap((c) => dump(c, d + 1))].join('\n'); +}; + +const bytes = await readFile(process.argv[2]); +const { instance } = await WebAssembly.instantiate(bytes, { + env, wasi_snapshot_preview1: { fd_write: () => 0 }, +}); +inst = instance; + +const ret = inst.exports.main(); +console.log('main() =', ret); +console.log('--- mutation log ---'); console.log(log.join('\n')); +console.log('--- final DOM under #root ---'); console.log(dump(1)); + +// assertions: reconcile patched in place +const root = nodes.get(1); +assert.equal(root.children.length, 1, 'root has exactly one child'); +const div = nodes.get(root.children[0]); +assert.equal(div.tag, 'div'); +assert.equal(div.attrs.get('id'), 'app'); +assert.equal(div.attrs.get('title'), 't2', 'title added by patch_attrs'); +assert.ok(!div.attrs.has('class'), 'class removed by patch_attrs'); +assert.equal(div.children.length, 1, 'span child removed by while-loop reconcile'); +const t = nodes.get(div.children[0]); +assert.equal(t.tag, '#text'); +assert.equal(t.text, 'world', 'text updated in place'); +assert.equal(ret, root.children[0], 'reconcile returned the in-place div handle'); +console.log('ALL ASSERTIONS PASS — reconciler ran end-to-end (loops executed)'); diff --git a/affinescript-dom/e2e/driver_main.affine b/affinescript-dom/e2e/driver_main.affine new file mode 100644 index 00000000..010b61e1 --- /dev/null +++ b/affinescript-dom/e2e/driver_main.affine @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// e2e driver `main` for the DOM reconciler. NOT standalone: `run.sh` +// concatenates `../src/dom.affine` + this file (codegen is single-pass in +// declaration order, so `main` must come after the functions it calls). +// +// Exercises every loop-bearing path (the #255 class): render's attr + +// children loops, patch_attrs' add + remove loops (via attr_has), and the +// `while` child-reconcile loop (in-place text patch at i=0, surplus-child +// removal at i=1). +// +// Affine detail: `mount` consumes its tree, so the reconcile call gets a +// freshly built copy — reusing `old_tree` would be a use-after-move type +// error. The discipline the module sells is load-bearing in its own test. +fn main() -> Int { + let old_tree = h("div", [("id", "app"), ("class", "old")], + [text("hello"), h("span", [], [text("x")])]); + let ok = mount("#root", old_tree); + if ok { + let parent = dom_query_selector("#root"); + let old_node = dom_child_at(parent, 0); + let old_v = h("div", [("id", "app"), ("class", "old")], + [text("hello"), h("span", [], [text("x")])]); + let new_v = h("div", [("id", "app"), ("title", "t2")], + [text("world")]); + reconcile(parent, old_node, old_v, new_v) + } else { + 0 - 1 + } +} diff --git a/affinescript-dom/e2e/run.sh b/affinescript-dom/e2e/run.sh new file mode 100755 index 00000000..04b2f2e1 --- /dev/null +++ b/affinescript-dom/e2e/run.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# End-to-end runtime test for the DOM reconciler (INT-08 / #183). +# Concatenates src/dom.affine + driver_main.affine, compiles to core-WASM, +# runs it under Node against an Int-handle host DOM (dom_host.mjs), and +# asserts the mutation log + final tree. Exit 0 = the reconciler (including +# every for/while loop — the #255 class) executed correctly in compiled wasm. +# +# Requires: the compiler built (dune build → _build/default/bin/main.exe; +# override with AFFINESCRIPT_BIN=/path/to/main.exe) +# and Node ≥ 18 on PATH. Skips (exit 0, loud) if either is missing so the +# harness can sit in CI paths that lack the OCaml toolchain. +set -uo pipefail +cd "$(dirname "$0")" +REPO="$(cd ../.. && pwd)" +BIN="${AFFINESCRIPT_BIN:-$REPO/_build/default/bin/main.exe}" +[ -x "$BIN" ] || { echo "SKIP: compiler not built ($BIN missing) — run dune build"; exit 0; } +command -v node >/dev/null 2>&1 || { echo "SKIP: node not on PATH"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cat ../src/dom.affine driver_main.affine > "$TMP/dom_drive.affine" +"$BIN" compile "$TMP/dom_drive.affine" -o "$TMP/dom_drive.wasm" +node dom_host.mjs "$TMP/dom_drive.wasm" diff --git a/affinescript-dom/src/dom.affine b/affinescript-dom/src/dom.affine index 3e2303f1..0a2ba06c 100644 --- a/affinescript-dom/src/dom.affine +++ b/affinescript-dom/src/dom.affine @@ -15,12 +15,13 @@ * * GATE: this module compiles end-to-end (resolve → typecheck → codegen * → wasm), the same bar as the Stage-C stdlib AOT suite. RUNTIME is - * blocked by #255 — a pre-existing wasm-codegen defect where `for-in` / - * `while` loop bodies never execute in the compiled module (so - * `vnode_len`, the attr loops, and the child reconcile loop iterate - * zero times). The reconciler logic here is correct AffineScript; it - * will run once #255 lands. No runtime e2e harness is shipped until - * then (a harness that cannot pass would be dishonest). + * verified end-to-end (2026-07-07): #255 — the wasm loop-codegen defect + * that blocked it — was fixed in PR #257 (closed 2026-05-19), and + * `../e2e/run.sh` now drives this reconciler against a real Int-handle + * host DOM: mount, in-place attr patch (add + remove), in-place text + * update, and surplus-child removal, with the mutation log asserted. + * `vnode_len`, the attr loops, and the `while` child-reconcile loop all + * execute in the compiled wasm. * * Codegen note: AffineScript codegen is single-pass in source * declaration order (lib/codegen.ml `func_indices`), so a function may diff --git a/docs/ECOSYSTEM.adoc b/docs/ECOSYSTEM.adoc index 64064892..c030fc02 100644 --- a/docs/ECOSYSTEM.adoc +++ b/docs/ECOSYSTEM.adoc @@ -149,9 +149,10 @@ The contract is *narrower than older prose claimed* and is exactly this: (non-parsing 39-line stub) renamed to canonical `src/dom.affine` and replaced with a real VDOM + render + mount + minimal-mutation reconciler that compiles end-to-end. The #255 wasm loop-codegen defect that -gated its runtime is *FIXED* (PR #257; issue closed 2026-05-19), so the -runtime is no longer blocked by it; end-to-end DOM runtime remains to be -re-verified. +gated its runtime is *FIXED* (PR #257; issue closed 2026-05-19), and the +runtime is *VERIFIED end-to-end* (2026-07-07): `affinescript-dom/e2e/run.sh` +drives mount + in-place attr patch + text update + surplus-child removal +against a real Int-handle host DOM, mutation log asserted. |`affinescript-pixijs` |skeleton |Migration-prerequisite scaffold (#56). @@ -170,8 +171,9 @@ canonical `affinescript tea-bridge` + a re-entrancy fixture. shipped + closed 2026-05-31 as `packages/affine-js/loader.js` — already host-agnostic (Deno/Node/browser parity). Whether the satellite repo still earns its keep (vs. folding into `affine-js`) is the open question -in #489; revisit when INT-08 reconciler runtime (#183) is re-verified (#255 -loop-codegen fixed via #257) and dictates any DOM-specific loader surface. +in #489; INT-08 reconciler runtime (#183) is verified end-to-end 2026-07-07 +(`affinescript-dom/e2e/run.sh`; #255 fixed via #257) — revisit when it +dictates any DOM-specific loader surface. |`affinescript-cadre` |scaffold |Was imaginary until #175. Router/navigation satellite (internal `lib/tea_router.ml` contract exists). @@ -271,17 +273,18 @@ cleared (#253). Router/nav = separate INT-09 |INT-08 |DOM reconciler in `affinescript-dom` |#183 |reconciler implemented + compiles (resolve→typecheck→codegen→wasm); `.as`→`.affine` corrected. INT-02 dep cleared. #255 (wasm loop-codegen defect) that gated -the runtime is FIXED (PR #257, closed 2026-05-19); runtime to be re-verified -end-to-end +the runtime is FIXED (PR #257, closed 2026-05-19); runtime VERIFIED +end-to-end 2026-07-07 via `affinescript-dom/e2e/run.sh` (mount + attr +patch + text update + child removal, mutation log asserted) |INT-09 |`affinescript-cadre` router/navigation runtime |ledger-only |planned (blocked by INT-07) |INT-10 |LSP distribution (`affinescript-lsp`) |#282 |unblocked — distribution decided (ADR-019: Releases + thin Deno/JSR shim, #260). Consumes the shim once #260 S2/S3 land |INT-11 |Browser host parity (DOM loader + reconciler end-to-end) | -ledger-only |planned (INT-02 dep cleared 2026-05-31 via #179; #255 that -gated INT-08 runtime is fixed via #257 — pending INT-08 runtime -re-verification). Satellite-repo question = #489. +ledger-only |planned (INT-02 dep cleared 2026-05-31 via #179; INT-08 +runtime verified end-to-end 2026-07-07 under Node — browser-host parity +is the remaining leg). Satellite-repo question = #489. |INT-12 |typed-wasm convergence: AffineScript-emitted fixtures into the typed-wasm cross-compat suite (closes the Stage-E runway) |ledger-only | planned (Stage E; coordinates with `hyperpolymath/typed-wasm` C5.1) diff --git a/docs/TECH-DEBT.adoc b/docs/TECH-DEBT.adoc index 3ff319f6..6b5867ff 100644 --- a/docs/TECH-DEBT.adoc +++ b/docs/TECH-DEBT.adoc @@ -424,7 +424,8 @@ licence in root `LICENSE`). (TeaApp/parseTeaLayout, Linear-msg enforced); INT-01 cleared (#253) |INT-08 |DOM reconciler |S2 |#183 implemented + compiles; `.as`→`.affine` fixed; #255 (wasm loop-codegen defect) that gated the runtime is fixed -(PR #257, closed 2026-05-19) — runtime to be re-verified end-to-end +(PR #257, closed 2026-05-19) — runtime VERIFIED end-to-end 2026-07-07 via +`affinescript-dom/e2e/run.sh` |INT-10 |`affinescript-lsp` distribution |S2 |**DONE end-to-end live** (#282, ADR-019 S4): LSP resolves the compiler via `AFFINESCRIPT_COMPILER` → `affinescript` on `PATH` → the `@hyperpolymath/affinescript` shim diff --git a/docs/bindings-roadmap.adoc b/docs/bindings-roadmap.adoc index 25e24d18..7dbd9b38 100644 --- a/docs/bindings-roadmap.adoc +++ b/docs/bindings-roadmap.adoc @@ -84,10 +84,10 @@ no further significant ReScript → AffineScript work is tractable. |idaptik `src/app/multiplayer/PhoenixSocket.res` + LobbyManager; needed for any multiplayer / sync-server feature. |7 -|*DOM* — re-verify the reconciler runs end-to-end (the `for-in` / `while` codegen gap, issue #255, is fixed) -|`●` / `◯` #255-fixed; re-verify runtime +|*DOM* — reconciler runs end-to-end (verified 2026-07-07, `affinescript-dom/e2e/run.sh`; the `for-in` / `while` codegen gap, issue #255, is fixed) +|`●` verified |`affinescript-dom` -|187-line virtual-DOM + reconciler exists and compiles. The wasm loop-codegen defect (#255) that blocked its runtime is *fixed* (PR #257, issue closed 2026-05-19); end-to-end runtime remains to be re-verified. *Was not a binding gap — a codegen bug, now resolved.* +|187-line virtual-DOM + reconciler exists, compiles, and *runs*: verified end-to-end 2026-07-07 (`affinescript-dom/e2e/run.sh` — mount, attr patch, text update, surplus-child removal against an Int-handle host DOM, mutation log asserted). The wasm loop-codegen defect (#255) that blocked it was fixed in PR #257. *Was not a binding gap — a codegen bug, now resolved and runtime-proven.* |8 |*WebGL / Canvas2D context* (HTMLCanvasElement, getContext, drawImage, fillRect, transform stack)