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)