From bcc99b25df5a2ded515995b8fb0e3470bf87258e Mon Sep 17 00:00:00 2001 From: DevBot Date: Thu, 27 Aug 2026 20:24:40 +0800 Subject: [PATCH] feat(v0.44): prove compiled element vertical spike --- docs/current/v0.44.0-EXECUTION-STATE.json | 16 +- .../v0.44.0-agent-loops/a0-001/dispatch.md | 116 +++ .../a0-001/implementer-result.md | 169 +++++ .../a0-001/repair-1-result.md | 134 ++++ .../v0.44.0-agent-loops/a0-001/repair-1.md | 63 ++ .../a0-001/review-repair-1.md | 68 ++ .../v0.44.0-agent-loops/a0-001/review.md | 82 ++ .../compiled-element-spike/counter.tsx | 43 ++ .../expected-program.json | 153 ++++ .../unsupported-spread.tsx | 18 + .../__tests__/compiled-element-spike.test.ts | 249 ++++++ .../src/internal/compiler/compile.ts | 615 +++++++++++++++ .../src/internal/compiler/plugin.ts | 35 + .../src/internal/compiler/program.ts | 44 ++ packages/adapter-vite/src/plugin.ts | 11 +- .../compiled-part-program-spike.test.ts | 683 +++++++++++++++++ .../element/src/internal/compiled/program.ts | 259 +++++++ .../element/src/internal/compiled/runtime.ts | 712 ++++++++++++++++++ 18 files changed, 3461 insertions(+), 9 deletions(-) create mode 100644 docs/evidence/v0.44.0-agent-loops/a0-001/dispatch.md create mode 100644 docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md create mode 100644 docs/evidence/v0.44.0-agent-loops/a0-001/repair-1-result.md create mode 100644 docs/evidence/v0.44.0-agent-loops/a0-001/repair-1.md create mode 100644 docs/evidence/v0.44.0-agent-loops/a0-001/review-repair-1.md create mode 100644 docs/evidence/v0.44.0-agent-loops/a0-001/review.md create mode 100644 packages/adapter-vite/__fixtures__/compiled-element-spike/counter.tsx create mode 100644 packages/adapter-vite/__fixtures__/compiled-element-spike/expected-program.json create mode 100644 packages/adapter-vite/__fixtures__/compiled-element-spike/unsupported-spread.tsx create mode 100644 packages/adapter-vite/__tests__/compiled-element-spike.test.ts create mode 100644 packages/adapter-vite/src/internal/compiler/compile.ts create mode 100644 packages/adapter-vite/src/internal/compiler/plugin.ts create mode 100644 packages/adapter-vite/src/internal/compiler/program.ts create mode 100644 packages/element/__tests__/compiled-part-program-spike.test.ts create mode 100644 packages/element/src/internal/compiled/program.ts create mode 100644 packages/element/src/internal/compiled/runtime.ts diff --git a/docs/current/v0.44.0-EXECUTION-STATE.json b/docs/current/v0.44.0-EXECUTION-STATE.json index 002df17b4..0be07f15e 100644 --- a/docs/current/v0.44.0-EXECUTION-STATE.json +++ b/docs/current/v0.44.0-EXECUTION-STATE.json @@ -2,13 +2,13 @@ "schemaVersion": 1, "train": "0.44.0", "goal": "alpha.0-through-beta.2-rc-admission", - "status": "VERIFIED", + "status": "REVIEWED", "candidate": "0.44.0-alpha.0", - "currentIssue": 1182, - "currentSlice": "control-plane-bootstrap-repair", - "loopId": "a0-000-repair-1", - "baseSha": "1fef7199f0d7b14842dc9231f9c75fa3a098e744", - "branch": "codex/v044-1182-control-plane", + "currentIssue": 1160, + "currentSlice": "one-component-vite-part-program-vertical-spike", + "loopId": "a0-001", + "baseSha": "e31ab34eb8cea52fe07d6d138d0b0cfbba9d0f17", + "branch": "codex/v044-1160-compiler-spike", "implementer": { "command": "kimi", "model": "kimi-code/k3-256k", @@ -29,6 +29,6 @@ "lastAcceptedLoop": "a0-000-repair-1", "lastClosureEvidence": null, "humanGate": null, - "nextAction": "Commit and push the accepted #1182 bootstrap evidence, open a PR to dev, wait for required CI, merge it, then complete #1182 issue-level closure before dispatching #1160.", - "updatedAt": "2026-08-27T05:48:23Z" + "nextAction": "Commit the reviewed a0-001 slice, push codex/v044-1160-compiler-spike, open a PR to dev, wait for required CI, merge, and close issue #1160 with immutable evidence.", + "updatedAt": "2026-08-27T12:23:48Z" } diff --git a/docs/evidence/v0.44.0-agent-loops/a0-001/dispatch.md b/docs/evidence/v0.44.0-agent-loops/a0-001/dispatch.md new file mode 100644 index 000000000..d61650ae4 --- /dev/null +++ b/docs/evidence/v0.44.0-agent-loops/a0-001/dispatch.md @@ -0,0 +1,116 @@ +# #1160 dispatch: TSX-to-Part Program vertical compiler spike + +```yaml +loopId: a0-001 +kind: implementation +candidate: 0.44.0-alpha.0 +issue: 1160 +acceptanceSlice: one-component-vite-part-program-vertical-spike +baseSha: e31ab34eb8cea52fe07d6d138d0b0cfbba9d0f17 +branch: codex/v044-1160-compiler-spike +risk: critical +ownedPaths: + - packages/adapter-vite/src/internal/compiler/ + - packages/adapter-vite/src/plugin.ts + - packages/adapter-vite/__tests__/compiled-element-spike.test.ts + - packages/adapter-vite/__fixtures__/compiled-element-spike/ + - packages/element/src/internal/compiled/ + - packages/element/__tests__/compiled-part-program-spike.test.ts + - docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md +forbiddenPaths: + - packages/*/deno.json + - deno.json + - packages/element/src/index.ts + - packages/adapter-vite/src/index.ts + - packages/element/src/internal/core/ + - docs/adr/ + - docs/current/ + - docs/governance/ + - docs/roadmap/ + - .agents/ + - .github/ +authority: + - docs/adr/ADR-0143-0-44-compiled-element-model-reentry.md + - docs/current/VERSION_PLAN.md#wp-1--compiler-and-authoring-alpha0alpha1 + - docs/roadmap/v0.44.0-ISSUES.md#v0440-alpha0--constitution-and-foundations + - https://github.com/open-element/openelement/issues/1160 +requiredTests: + - A fixture with one `@element` class extending `OpenElement`, one reflected `@property`, dynamic text, a DOM property sink, an event, a conditional Region and a keyed list Region is transformed through the actual Vite plugin hook. + - The transform emits one deterministic, serializable Part Program containing static structure plus typed Part/Region instructions; it does not emit or discover VNodes, BindingDescriptors, hydration scopes or a fallback interpreter. + - Server serialization, fresh browser DOM creation and existing-DOM claim consume that same program and produce equivalent observable structure. + - Successful claim preserves existing node identity and live input value while attaching the event and reactive sinks. + - Updating one test Signal mutates only its subscribed Part or Region; an unrelated sink records no write. + - Unsupported syntax for this spike fails at transform time with a source-located diagnostic and never falls back to the legacy runtime. + - Re-running the transform yields byte-identical program output. + - Tests record generated byte size, program instruction count, allocations or an explicit deterministic allocation proxy, and activation work against a frozen 0.43-equivalent fixture. Measurements are evidence, not a performance GO claim. +requiredCommands: + - deno test -A packages/adapter-vite/__tests__/compiled-element-spike.test.ts + - deno test -A packages/element/__tests__/compiled-part-program-spike.test.ts + - deno task --cwd packages/adapter-vite typecheck + - deno task --cwd packages/element typecheck + - deno lint packages/adapter-vite/src/internal/compiler packages/adapter-vite/__tests__/compiled-element-spike.test.ts packages/element/src/internal/compiled packages/element/__tests__/compiled-part-program-spike.test.ts + - deno fmt --check packages/adapter-vite/src/internal/compiler packages/adapter-vite/src/plugin.ts packages/adapter-vite/__tests__/compiled-element-spike.test.ts packages/adapter-vite/__fixtures__/compiled-element-spike packages/element/src/internal/compiled packages/element/__tests__/compiled-part-program-spike.test.ts + - deno task arch:check + - git diff --check +maxRepairAttempts: 5 +``` + +## Objective + +Prove one production-shaped compiler vertical slice through the official Vite plugin +boundary without freezing the Part Program v1 grammar prematurely. The result must make +the ADR-0143 replacement architecture observable end to end for exactly one bounded +fixture. + +## Architecture boundary + +- This is an internal alpha.0 spike. Do not add or change public exports, package + topology, package versions, consumer configuration or release state. +- `OpenElement extends HTMLElement` remains the class and ownership center. +- The compiler may recognize only the fixture grammar needed by this packet. Every + unsupported construct must fail closed with a diagnostic; no runtime fallback is + allowed. +- One generated Part Program is the sole structural/mutation authority for all three + execution modes. Do not translate it back into VNodes, BindingDescriptors or generic + DOM discovery. +- Generated code may be self-contained or use package-local internals, but it must not + import a private cross-package path or rely on a workspace alias. +- Do not refactor or delete the 0.43 renderer in this slice. Replacement deletion is + owned by later issues; this spike must remain cleanly isolated from it. +- Do not invent a public decorator contract beyond the exact fixture. #1161 and #1162 + own the frozen grammar and canonical decorator lowering. + +## Test-first contract + +Before implementation, add the targeted tests and run them against the base SHA. Record +the exact RED commands, exit codes and failure reasons. The RED evidence must prove the +new vertical behavior is absent, not merely fail because of a syntax error, missing +dependency or broken test harness. + +Implement the smallest coherent path that makes those same tests GREEN. Every command +listed above must then run with an exact exit code. If browser semantics cannot be +proved by the existing test environment without adding an owned fixture, add the test +under the listed fixture boundary; do not substitute string snapshots for DOM identity, +event or live-value assertions. + +## Measurement evidence + +Write deterministic measurement output into assertions or the structured implementer +result. At minimum report generated bytes, instruction count, an allocation count or +documented deterministic proxy, and activation operations for the compiled fixture and +the frozen 0.43-equivalent fixture. Do not claim general performance superiority from +one spike. + +## Stop conditions + +Return `BLOCKED` rather than editing outside owned paths, changing public surface, +choosing a new architecture, weakening an existing gate, importing private paths across +packages, using workspace aliases, or adding any compatibility/fallback renderer. Do +not commit, push, update GitHub, invoke another agent or alter control-plane files. + +## Output contract + +Write the structured headings required by `.agents/v044-kimi-implementer.md` to +`docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md` and return the same +content in the final response. Include changed files, tests, RED evidence, implementation +summary, every command with exact exit code, residual risks and scope confirmation. diff --git a/docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md b/docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md new file mode 100644 index 000000000..26e7c0720 --- /dev/null +++ b/docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md @@ -0,0 +1,169 @@ +# Implementer result — a0-001 (#1160 TSX-to-Part Program vertical compiler spike) + +STATUS: PASS + +PACKET: +docs/evidence/v0.44.0-agent-loops/a0-001/dispatch.md (loopId a0-001, issue #1160, +candidate 0.44.0-alpha.0, acceptanceSlice one-component-vite-part-program-vertical-spike) + +BASE_SHA: +e31ab34eb8cea52fe07d6d138d0b0cfbba9d0f17 (verified: `git rev-parse HEAD` matched before +editing; pre-existing dirty state was only orchestrator-owned +docs/current/v0.44.0-EXECUTION-STATE.json plus this untracked evidence directory) + +CHANGED_FILES: + +- packages/adapter-vite/src/internal/compiler/program.ts (new — spike Part Program types, + self-contained mirror of the element-side copy; no cross-package import by design) +- packages/adapter-vite/src/internal/compiler/compile.ts (new — TSX-to-Part Program spike + compiler on the TypeScript AST; fail-closed OEC9xx source-located diagnostics) +- packages/adapter-vite/src/internal/compiler/plugin.ts (new — open:compiled-element Vite + plugin; activates only on .tsx files containing the `@element(` marker) +- packages/adapter-vite/src/plugin.ts (modified — additive opt-in `compiledSpike?: boolean` + option on createOpenPlugin; default pipeline composition and plugin count unchanged) +- packages/adapter-vite/**tests**/compiled-element-spike.test.ts (new) +- packages/adapter-vite/**fixtures**/compiled-element-spike/counter.tsx (new fixture) +- packages/adapter-vite/**fixtures**/compiled-element-spike/unsupported-spread.tsx (new + negative fixture) +- packages/adapter-vite/**fixtures**/compiled-element-spike/expected-program.json (new frozen + program artifact consumed by BOTH package test suites — the single cross-package source + of truth for "that same program") +- packages/element/src/internal/compiled/program.ts (new — spike program types + + validateSpikeProgram) +- packages/element/src/internal/compiled/runtime.ts (new — serializeToHtml / createFreshDom + / claimExistingDom, PartProgramClaimError) +- packages/element/**tests**/compiled-part-program-spike.test.ts (new) +- docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md (this file) + +TESTS_ADDED: + +- packages/adapter-vite/**tests**/compiled-element-spike.test.ts — 4 tests / 12 steps: + fixture loads; plugin absent by default and registered via the opt-in flag on + createOpenPlugin; the counter.tsx fixture is transformed through the actual Vite + `transform` hook; the emitted program deep-equals expected-program.json and carries + exactly 5 typed Part/Region instructions (text, prop, event, when, each); generated code + contains no VNode/BindingDescriptor/hydration-scope/fallback vocabulary and the emitted + render() throws; re-running the transform is byte-identical; non-marker files pass + through as null; spread attributes, unknown decorators and non-OpenElement bases fail at + transform time with `file:line:character - error OEC9xx` diagnostics. +- packages/element/**tests**/compiled-part-program-spike.test.ts — 2 tests / 9 steps: + SSR serialization emits the exact deterministic HTML; fresh DOM creation from the same + program produces structurally identical observable output (toHtml equality with the SSR + string); claim of parsed SSR DOM allocates zero nodes, preserves element/text node + identity and a live user-typed input value, attaches the click listener and text-part + subscription; a Signal write to `count` mutates only its text Part and conditional + Region (unrelated `label` property sink records 0 writes) and vice versa; keyed list + Region preserves li identity across reorder/update/insert/remove; claim mismatch throws + PartProgramClaimError with the template path; dispose() detaches all subscriptions; + measurements are asserted and logged. + +RED_EVIDENCE: +Captured against the base tree before implementation existed (both exit code 1): + +- `deno test -A packages/adapter-vite/__tests__/compiled-element-spike.test.ts` → exit=1. + Harness step "fixtures load" and "default pipeline does not register the spike compiler" + PASSED; the three behavior steps FAILED with `TypeError: Module not found + ".../packages/adapter-vite/src/internal/compiler/plugin.ts"` — i.e. the vertical behavior + is absent, not a syntax/dependency/harness failure (fixtures, plugin.ts and vite imports + all resolved; the only unresolved specifier is the new compiler module). +- `deno test -A packages/element/__tests__/compiled-part-program-spike.test.ts` → exit=1. + Harness-sanity test PASSED (fixture JSON loads, fake DOM parse/serialize round-trips); + the behavior test FAILED with `TypeError: Module not found + ".../packages/element/src/internal/compiled/program.ts"` — the runtime vertical is absent. + (One harness-only import defect — WritableSignal imported from the wrong internal module — + was fixed and the RED rerun reproduced the same module-not-found failure before any + implementation existed.) + +IMPLEMENTATION: + +- Compiler (adapter-vite/src/internal/compiler): parses TSX with the existing `typescript` + npm dependency; recognizes exactly the fixture grammar — one `@element('oe-spike-counter')` + class extending OpenElement, `@property({ reflect: })` fields with initializers, + plain methods, and render() returning one JSX tree with static elements/attributes, + `{this.field}` text parts, `value={this.field}` on `` (prop part), `onX={this.method}` + (event part), `{this.field > N ? a : b}` (conditional Region, static branches) and + `{this.field.map((item) =>
  • {item.text}
  • )}` (keyed list Region). + Everything else fails closed with OEC9xx diagnostics carrying file/line/character. + Emission is deterministic: one serializable `__partProgram` JSON literal (verified + byte-identical across runs) plus the class with decorators stripped and render() replaced + by a throwing stub — no runtime fallback is reachable. assertPathSafety fails closed when + a path-addressed part would be preceded by a dynamic anchor sibling. +- Runtime (element/src/internal/compiled): one Part Program is the sole authority for all + three modes. serializeToHtml renders static structure plus `` / + `` region anchors; createFreshDom builds the identical tree; claimExistingDom + verifies exact structure (structured PartProgramClaimError with template path on drift), + attaches events/subscriptions without recreating nodes, and suppresses only a synchronous + subscription-time echo equal to the pre-subscription snapshot so live input values survive + claim while lazy-delivery engines keep their first real write (repair-1, R1). Signal + writes reach only the subscribed Part/Region; the keyed list Region reconciles by key with + identity preserved. dispose() releases every subscription and listener. +- Wiring: createOpenPlugin gained an additive opt-in `compiledSpike?: boolean`; the default + pipeline is unchanged (existing plugin.test.ts 43/43 pass, plugin count 9 preserved). + +MEASUREMENTS (frozen alpha.0 fixture; evidence, not a performance GO claim): + +- adapter-compiler: generatedBytes=3404 (repair-1 R2 added `__compiledProperties` metadata; + previously 3189), programBytes=2541 (embedded literal; fixture file + 2542 with trailing newline), instructionCount=5. +- element-runtime compiled path: freshAllocations=19 (8 elements + 6 texts + 5 comments), + claimAllocations=0, activationSubscriptions=4, activationListeners=1, + countUpdateAllocations=2, countUpdateWalkVisits=0. +- frozen 0.43-equivalent proxy (full re-render + full-tree marker walk per update; includes + its own host root, +1 vs compiled which mounts into the platform-provided root): + buildAllocations=20, buildWalkVisits=20, updateAllocations=20, updateWalkVisits=20, + updateListeners=1. + +COMMANDS_AND_EXIT_CODES: + +- `deno test -A packages/adapter-vite/__tests__/compiled-element-spike.test.ts` → exit=0 + (ok | 4 passed (12 steps) | 0 failed) +- `deno test -A packages/element/__tests__/compiled-part-program-spike.test.ts` → exit=0 + (ok | 2 passed (9 steps) | 0 failed) +- `deno task --cwd packages/adapter-vite typecheck` → exit=0 +- `deno task --cwd packages/element typecheck` → exit=0 +- `deno lint packages/adapter-vite/src/internal/compiler packages/adapter-vite/__tests__/compiled-element-spike.test.ts packages/element/src/internal/compiled packages/element/__tests__/compiled-part-program-spike.test.ts` → exit=0 (Checked 7 files) +- `deno fmt --check packages/adapter-vite/src/internal/compiler packages/adapter-vite/src/plugin.ts packages/adapter-vite/__tests__/compiled-element-spike.test.ts packages/adapter-vite/__fixtures__/compiled-element-spike packages/element/src/internal/compiled packages/element/__tests__/compiled-part-program-spike.test.ts` → exit=0 (Checked 11 files) +- `deno task arch:check` → exit=0 (Architecture contract check passed) +- `git diff --check` → exit=0 +- Regression (not packet-required, run for safety): `deno test -A packages/adapter-vite/__tests__/plugin.test.ts` → exit=0 (43 passed, 0 failed) +- Regression: `deno test -A packages/adapter-vite/__tests__` (full adapter suite) → exit=0 + (638 passed (70 steps), 0 failed) +- Regression: `deno test --allow-read --allow-write --allow-env --allow-net --allow-run packages/element/__tests__` (full element suite) → exit=0 (294 passed (9 steps), 0 failed) + +RESIDUAL_RISKS: + +- Browser-only semantics (focus, selection, captured pre-upgrade events, closed-shadow DSD) + are exercised against a minimal instrumented fake DOM in Deno, not real browsers; + cross-browser qualification remains with #1168/#1169 and the alpha gate. +- Path-addressed parts (prop/event) use template child indices and fail closed + (OEC9015) when a preceding sibling is dynamic; general dynamic addressing is #1161's + frozen-grammar work. +- Claim mismatch produces the structured diagnostic only; bounded element-local recovery + is intentionally absent (#1169). +- The spike program type is duplicated between packages by design (no private + cross-package import allowed); expected-program.json is the shared artifact both test + suites pin, and #1161 should replace the duplication with the frozen schema. +- Corrected in repair-1 (R1/R4): the original text here wrongly claimed a lazy-delivery + Signal engine was harmless. It was not: the unconditional first-callback skip would have + lost a conforming lazy engine's first real update. `subscribeWrites` now suppresses only a + synchronous subscription-time echo whose value equals the snapshot read immediately before + subscribing, and a protocol-only lazy-delivery Signal test proves the first write updates + exactly its subscribed Part. Initial-echo semantics (immediate vs lazy delivery) should + still be pinned in the #723 conformance suite. +- Generated code is validated structurally/by determinism but not executed by a bundler in + this slice; end-to-end Vite build integration is #1163. + +SCOPE_CONFIRMATION: +All edits are inside packet-owned paths. No forbidden path was touched (no deno.json, no +public index.ts, no internal/core, no docs/adr, docs/current, governance, roadmap, .agents, +.github). The pre-existing modification of docs/current/v0.44.0-EXECUTION-STATE.json is +orchestrator-owned and was left untouched. No commits, pushes, PR/issue updates, or other +agents were performed/invoked. No public export, package topology, version or consumer +configuration changed; the 0.43 renderer is unmodified. + +NEXT_REQUIRED_ACTION: +Release verifier: independently re-run the eight requiredCommands at this worktree state, +verify RED/GREEN evidence and the scope claims, then feed the GO/NO-GO evidence (including +the residual risks above) back to the #1160 issue and the alpha.0 gate. Follow-up issues +#1161 (frozen Part Program v1 grammar) and #1163 (Vite build/HMR/source-map integration) +own the hardening this spike deliberately deferred. diff --git a/docs/evidence/v0.44.0-agent-loops/a0-001/repair-1-result.md b/docs/evidence/v0.44.0-agent-loops/a0-001/repair-1-result.md new file mode 100644 index 000000000..31775d352 --- /dev/null +++ b/docs/evidence/v0.44.0-agent-loops/a0-001/repair-1-result.md @@ -0,0 +1,134 @@ +# Implementer result — a0-001 repair-1 (engine-neutral subscription and decorator evidence) + +STATUS: PASS + +PACKET: +docs/evidence/v0.44.0-agent-loops/a0-001/repair-1.md (loopId a0-001-repair-1, parentLoop +a0-001, issue #1160, candidate 0.44.0-alpha.0). Sol review (decision NO-GO-REPAIR, findings +R1–R3) was read first: docs/evidence/v0.44.0-agent-loops/a0-001/review.md. + +BASE_SHA: +e31ab34eb8cea52fe07d6d138d0b0cfbba9d0f17 (verified: `git rev-parse HEAD` matched; the only +pre-existing tracked modification remains the Sol-owned +docs/current/v0.44.0-EXECUTION-STATE.json) + +CHANGED_FILES: + +- packages/element/src/internal/compiled/runtime.ts (R1 — subscribeWrites is now + engine-neutral: suppresses only a synchronous pre-return echo whose value Object.is-equals + the snapshot read immediately before subscribing) +- packages/element/**tests**/compiled-part-program-spike.test.ts (R1 — protocol-only + LazySignal class + makeLazyHost + new step 'lazy-delivery Signal: first write reaches only + its subscribed Part'; all immediate-delivery Preact and live-input claim assertions kept) +- packages/adapter-vite/src/internal/compiler/compile.ts (R2 — deterministic + `__compiledProperties` metadata emission preserving @property reflect decisions in source + field order; `static __compiledProperties` on the generated class; no runtime reflection) +- packages/adapter-vite/**tests**/compiled-element-spike.test.ts (R2 — new step asserting + the transform output represents count as { reflect: true } and label/items as + { reflect: false }, and that no runtime reflection behavior is emitted; R3 — the opt-in + pipeline assertion now runs BEFORE the dynamic import of the compiler module) +- docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md (R4 — corrected the wrong + "lazy engine is harmless" claim; updated generatedBytes measurement 3189 → 3404; runtime + description updated to the engine-neutral wording) +- docs/evidence/v0.44.0-agent-loops/a0-001/repair-1-result.md (this file) + +TESTS_ADDED: + +- element spike test, new step: 'lazy-delivery Signal: first write reaches only its + subscribed Part' — with a protocol-only lazy-delivery WritableSignal (subscribe() delivers + nothing at subscription time), the FIRST write to `count` produces exactly one text-Part + write with zero writes on the unrelated property sink and zero listener adds; a first + write to `label` produces exactly one property write with zero text writes. +- adapter spike test, new step: 'emitted metadata preserves @property reflection decisions + (R2)' — parses the `const __compiledProperties = {...}` literal out of the actual Vite + transform output and deep-equals it against + `{ count: { reflect: true }, label: { reflect: false }, items: { reflect: false } }`; + also asserts `attributeChangedCallback` is absent (no runtime reflection). + No existing assertion was weakened or removed. + +RED_EVIDENCE: +Original a0-001 RED evidence is preserved verbatim in implementer-result.md (historical +record: both spike tests exit=1 on module-not-found for the absent vertical modules). +Supplemental behavior-first RED probe (repair-1, R3), run in an isolated immutable base-SHA +checkout: + +- Setup: `git worktree add /tmp/a0-001-base-probe e31ab34eb8cea52fe07d6d138d0b0cfbba9d0f17` + plus a node_modules symlink; probe file red-probe.test.ts written inside the temporary + worktree only, containing the FINAL assertion logic of the reordered step (createOpenPlugin + with `compiledSpike: true`, assert the pipeline contains `open:compiled-element`). +- Command: `cd /tmp/a0-001-base-probe && deno test -A red-probe.test.ts` → exit=1. +- Failure: `AssertionError: Values are not equal. [Diff] Actual / Expected - false + true` + at red-probe.test.ts:16 — createOpenPlugin imported, executed and logged its workspace + aliases (module resolution and pipeline construction both worked); the assertion failed + because the opt-in pipeline has no `open:compiled-element` plugin. This is behavior-first + RED, not a resolution failure. +- Cleanup: probe worktree removed (`git worktree remove --force`); no repo files touched. + Post-repair GREEN of the same assertion logic: the reordered step passes in + compiled-element-spike.test.ts (see commands below). + +IMPLEMENTATION: + +- R1: `subscribeWrites` reads `const snapshot = signal.value`, subscribes with a + `subscribeReturned` flag, and suppresses a callback only while it arrives synchronously + before `subscribe()` returns AND its value `Object.is`-equals the snapshot. Any callback + after `subscribe()` returns is applied as a real update. This keeps the Preact + immediate-echo suppression (live-input claim assertions still pass) while a conforming + lazy-delivery engine's first write now reaches its Part (proved by the new LazySignal + step). +- R2: the compiler emits `const __compiledProperties = { count: { reflect: true }, ... }` + (JSON, 2-space, source field order) and `static __compiledProperties = __compiledProperties;` + on the generated class. This is internal generated data only: no runtime reflection, no + public export, no package config, no frozen schema commitment (#1161/#1162 own those). +- R3: the opt-in step now asserts the pipeline composition before dynamically importing + `src/internal/compiler/plugin.ts`; the base-SHA probe above records the behavior-first RED. +- R4: implementer-result.md corrected; measurement updated (generatedBytes 3189 → 3404, + programBytes 2541 and instructionCount 5 unchanged). + +COMMANDS_AND_EXIT_CODES: +Parent dispatch commands: + +- `deno test -A packages/adapter-vite/__tests__/compiled-element-spike.test.ts` → exit=0 + (ok | 4 passed (13 steps) | 0 failed) +- `deno test -A packages/element/__tests__/compiled-part-program-spike.test.ts` → exit=0 + (ok | 2 passed (10 steps) | 0 failed) +- `deno task --cwd packages/adapter-vite typecheck` → exit=0 +- `deno task --cwd packages/element typecheck` → exit=0 +- `deno lint packages/adapter-vite/src/internal/compiler packages/adapter-vite/__tests__/compiled-element-spike.test.ts packages/element/src/internal/compiled packages/element/__tests__/compiled-part-program-spike.test.ts` → exit=0 (Checked 7 files) +- `deno fmt --check packages/adapter-vite/src/internal/compiler packages/adapter-vite/src/plugin.ts packages/adapter-vite/__tests__/compiled-element-spike.test.ts packages/adapter-vite/__fixtures__/compiled-element-spike packages/element/src/internal/compiled packages/element/__tests__/compiled-part-program-spike.test.ts` → exit=0 (Checked 11 files) +- `deno task arch:check` → exit=0 (Architecture contract check passed) +- `git diff --check` → exit=0 + Repair-packet additional commands: +- `deno test -A packages/adapter-vite/__tests__/plugin.test.ts` → exit=0 (43 passed, 0 failed) +- `deno test -A packages/adapter-vite/__tests__` → exit=0 (638 passed (71 steps), 0 failed) +- `deno test --allow-read --allow-write --allow-env --allow-net --allow-run packages/element/__tests__` → exit=0 (294 passed (10 steps), 0 failed) + R3 probe command: +- `cd /tmp/a0-001-base-probe && deno test -A red-probe.test.ts` → exit=1 (expected RED; + AssertionError false !== true at red-probe.test.ts:16) + +RESIDUAL_RISKS: + +- Initial-echo suppression still assumes a synchronous subscription-time callback is an echo + only when its value equals the pre-subscription snapshot; engines with asynchronous echo + of an unchanged snapshot value would apply one redundant same-value write (observably + idempotent for all spike part kinds). Protocol-level echo semantics belong in the #723 + conformance suite. +- `__compiledProperties` is emitted and asserted but has no consumer yet; #1162 owns the + canonical decorator lowering and #1164 the kernel that would consume reflection metadata. +- All parent-slice residual risks not addressed by this repair remain as recorded in + implementer-result.md (fake-DOM-only browser semantics, template-index path addressing, + claim recovery deferred to #1169, bundler execution deferred to #1163). + +SCOPE_CONFIRMATION: +All edits are inside repair-packet-owned paths (the two compiler/runtime trees, the two spike +test files, the fixture dir, and the two evidence files). No Sol-owned dispatch/review/ +execution-state file was modified; the pre-existing EXECUTION-STATE.json modification is +Sol-owned and untouched. No public entry point, package config, version, workspace alias or +private cross-package import was added; no VNode/BindingDescriptor/hydration-walker/ +compatibility-renderer/interpreter was introduced; no existing assertion was weakened or +removed. No commit, push or GitHub update was performed; the temporary probe worktree was +created outside the repo and removed. + +NEXT_REQUIRED_ACTION: +Sol re-review of a0-001-repair-1 against findings R1–R3 (and the R4 record correction): +independently re-run the eleven commands above and the R3 base-SHA probe, then issue the +GO/NO-GO decision for #1160 alpha.0 evidence. diff --git a/docs/evidence/v0.44.0-agent-loops/a0-001/repair-1.md b/docs/evidence/v0.44.0-agent-loops/a0-001/repair-1.md new file mode 100644 index 000000000..b2a889171 --- /dev/null +++ b/docs/evidence/v0.44.0-agent-loops/a0-001/repair-1.md @@ -0,0 +1,63 @@ +# a0-001 repair 1 — engine-neutral subscription and decorator evidence + +```yaml +loopId: a0-001-repair-1 +parentLoop: a0-001 +kind: repair +candidate: 0.44.0-alpha.0 +issue: 1160 +baseSha: e31ab34eb8cea52fe07d6d138d0b0cfbba9d0f17 +implementerSession: session_5998df53-7c93-43ef-a13c-424763ba16cd +risk: critical +ownedPaths: + - packages/adapter-vite/src/internal/compiler/ + - packages/adapter-vite/src/plugin.ts + - packages/adapter-vite/__tests__/compiled-element-spike.test.ts + - packages/adapter-vite/__fixtures__/compiled-element-spike/ + - packages/element/src/internal/compiled/ + - packages/element/__tests__/compiled-part-program-spike.test.ts + - docs/evidence/v0.44.0-agent-loops/a0-001/implementer-result.md + - docs/evidence/v0.44.0-agent-loops/a0-001/repair-1-result.md +maxRepairAttempts: 5 +``` + +## Required repairs + +1. Replace the unconditional first-callback skip in `subscribeWrites()` with engine-neutral + behavior: suppress only a synchronous initial callback whose value equals the snapshot + read immediately before subscription. Any callback delivered after `subscribe()` returns + is a real update. Add a minimal protocol-only lazy-delivery Signal in the element test and + prove its first write updates exactly its subscribed Part while an unrelated sink records + no write. Keep the existing immediate-delivery Preact and live-input claim assertions. +2. Preserve the exact fixture's `@property` reflection decisions in deterministic internal + generated metadata. Assert transform output represents `count` as reflected and `label` + and `items` as non-reflected. Do not add runtime reflection, public API, exports, package + configuration or a frozen schema decision. +3. Reorder the adapter opt-in test so `createOpenPlugin({ compiledSpike: true })` is asserted + before dynamically importing `src/internal/compiler/plugin.ts`. In an isolated immutable + base-SHA checkout, run a self-contained behavior-first RED probe that uses the final test + assertion logic and fails because the opt-in pipeline has no `open:compiled-element` + plugin. Record the exact command, assertion failure and exit code in + `repair-1-result.md`. Preserve the original RED evidence as historical evidence. +4. Correct the implementer result's claim that a non-immediate Signal engine is harmless. + Update measurements if deterministic generated byte counts change. + +## Forbidden changes + +- No public entry point, package config, version, workspace alias or private cross-package + import. +- No VNode, BindingDescriptor, hydration walker, compatibility renderer or interpreter. +- No edits to Sol-owned dispatch, review, execution state, governance, ADR or roadmap files. +- No weakening/removal of existing assertions. No production changes outside the two + identified repairs. + +## Required commands + +Run every command from the parent dispatch and report exact exit codes. Also run: + +- `deno test -A packages/adapter-vite/__tests__/plugin.test.ts` +- `deno test -A packages/adapter-vite/__tests__` +- `deno test --allow-read --allow-write --allow-env --allow-net --allow-run packages/element/__tests__` + +Write `repair-1-result.md` with the implementer profile's required headings and return the +same result. Do not commit, push or update GitHub. diff --git a/docs/evidence/v0.44.0-agent-loops/a0-001/review-repair-1.md b/docs/evidence/v0.44.0-agent-loops/a0-001/review-repair-1.md new file mode 100644 index 000000000..0cae3bf9d --- /dev/null +++ b/docs/evidence/v0.44.0-agent-loops/a0-001/review-repair-1.md @@ -0,0 +1,68 @@ +# Sol review — a0-001 repair 1 + +```yaml +reviewer: gpt-5.6-sol/low +candidate: 0.44.0-alpha.0 +issue: 1160 +loopId: a0-001-repair-1 +baseSha: e31ab34eb8cea52fe07d6d138d0b0cfbba9d0f17 +implementerSession: session_5998df53-7c93-43ef-a13c-424763ba16cd +implementerExitCode: 0 +decision: GO +reviewedAt: 2026-08-27T12:23:48Z +``` + +## Repair findings + +- **R1 closed:** `subscribeWrites()` now snapshots before subscription and suppresses only + a synchronous, snapshot-equal echo. A protocol-only lazy-delivery Signal test proves its + first real write updates the subscribed Part and does not touch an unrelated sink. The + original Preact immediate-delivery and successful-claim live-value assertions remain. +- **R2 closed:** the compiler emits deterministic, internal `__compiledProperties` metadata + preserving `count.reflect=true` and `label/items.reflect=false`. The actual Vite transform + output is parsed and asserted. No runtime reflection, public export or schema freeze was + introduced. +- **R3 closed:** the adapter opt-in assertion executes before the compiler-module import. + Sol independently created an immutable `e31ab34e` worktree and ran the assertion probe; + it exited 1 with `Actual false / Expected true`, proving absent behavior rather than a + module, dependency or harness failure. The temporary worktree was removed. +- **R4 closed:** the original result corrects the lazy-engine claim and updates generated + bytes to 3404. + +## Scope and architecture + +- Product edits remain within the original packet paths. No package entry point, package + configuration, version or public API was changed. `compiledSpike` exists only on the + documented internal plugin factory and is not present in the package entry configuration. +- No workspace alias or private cross-package import was added. +- Searches found no VNode, BindingDescriptor, HydrationScope, generic discovery walker, + compatibility renderer or interpreter in the production implementation. Matching words + occur only in explanatory comments and negative assertions. +- The 0.43 renderer and all forbidden paths remain untouched by K3. Sol-owned execution + state and evidence are the only control-plane changes. + +## Independent verification + +Sol reran the following on the repaired worktree: + +- adapter spike test: exit 0, 4 tests / 13 steps; +- element spike test: exit 0, 2 tests / 10 steps; +- adapter and element typechecks: exit 0; +- packet lint and format check: exit 0; +- architecture check and `git diff --check`: exit 0; +- existing plugin regression: exit 0, 43 tests; +- full adapter suite: exit 0, 638 tests / 71 steps; +- full element suite: exit 0, 294 tests / 10 steps; +- repository `deno task test`: exit 0, including the independent starter package tests; +- immutable base-SHA behavior RED probe: expected exit 1 with assertion `false != true`. + +## Decision + +The one-component TSX-to-Part Program spike satisfies the a0-001 dispatch and #1160 +acceptance slice. The result is an internal alpha.0 proof, not a frozen schema, decorator +contract, browser matrix or general performance claim. Those explicitly remain with +#1161/#1162/#1163 and later runtime/claim issues. + +Sol decision: **GO** for committing the #1160 slice and entering PR/CI review against `dev`. +This is not the alpha.0 version-closure GO; the remaining alpha.0 issues must still land and +a fresh K3 release verifier is required at the scheduled version boundary. diff --git a/docs/evidence/v0.44.0-agent-loops/a0-001/review.md b/docs/evidence/v0.44.0-agent-loops/a0-001/review.md new file mode 100644 index 000000000..58bde7ffe --- /dev/null +++ b/docs/evidence/v0.44.0-agent-loops/a0-001/review.md @@ -0,0 +1,82 @@ +# Sol review — a0-001 initial implementation + +```yaml +reviewer: gpt-5.6-sol/low +candidate: 0.44.0-alpha.0 +issue: 1160 +loopId: a0-001 +baseSha: e31ab34eb8cea52fe07d6d138d0b0cfbba9d0f17 +implementerSession: session_5998df53-7c93-43ef-a13c-424763ba16cd +implementerExitCode: 0 +decision: NO-GO-REPAIR +reviewedAt: 2026-08-27T11:58:57Z +``` + +## Accepted evidence + +- The implementation stayed inside packet-owned product paths. The existing + `docs/current/v0.44.0-EXECUTION-STATE.json` change and dispatch packet are Sol-owned. +- The compiler uses the TypeScript AST, fails closed for tested unsupported syntax and + emits deterministic program data without a VNode, BindingDescriptor, hydration walker + or fallback interpreter. +- The same frozen program artifact is structurally pinned by the adapter test and consumed + by the element runtime test for server serialization, fresh DOM and claim. +- K3 reported all eight packet gates with exit code 0. Sol independently reran all eight; + every command also exited 0. Sol additionally confirmed the package entry point does not + expose `compiledSpike`; `createOpenPlugin` is the documented internal factory. +- Full K3 regressions passed: adapter-vite 638 tests and element 294 tests. + +## Rejected findings + +### R1 — SignalEngine-dependent first-write loss + +`subscribeWrites()` unconditionally drops the first callback. That preserves live claim +values for the current Preact engine because its subscription immediately delivers the +current value, but the public Signal protocol does not require immediate delivery. A +conforming lazy-delivery engine would lose its first real update. This contradicts the +ADR-0143 replaceable-engine boundary and the implementer result incorrectly calls the +case harmless. + +Required correction: suppress only a synchronous initial delivery matching the value +observed immediately before subscribing. A callback delivered after subscription returns +must be treated as a real update. Add a protocol-only lazy-delivery Signal test proving its +first write updates the subscribed Part and does not touch an unrelated sink. + +### R2 — reflected-property lowering is parsed but discarded + +The fixture contains `@property({ reflect: true }) count`, and the compiler records the +boolean in `SpikeField`, but generated output and assertions do not preserve or expose it. +The evidence therefore proves decorator syntax acceptance, not compilation of the reflected +property requested by #1160. + +Required correction: emit deterministic internal compiled-property metadata on the +generated class (or another equally bounded, non-public generated artifact) that preserves +`count: { reflect: true }` and `label/items: { reflect: false }`. Assert the metadata in the +actual Vite transform output. Do not add reflection runtime semantics, public exports or a +new Part Program grammar commitment; #1161/#1162 own those later contracts. + +### R3 — RED evidence needs a behavior-first adapter assertion + +The recorded harness checks passed, but the adapter RED ultimately failed when the new +compiler module was absent. Strengthen the final test ordering so the opt-in pipeline +assertion checks `createOpenPlugin({ compiledSpike: true })` before importing the new module. +Document a reproducible base-SHA RED command using an isolated temporary worktree or +equivalent immutable checkout: the assertion must fail because the opt-in pipeline lacks the +compiled plugin, not because a module cannot be resolved. Do not rewrite the original RED +record; add the stronger supplemental evidence. + +## Independent commands + +All exited 0 on the initial K3 result: + +- `deno test -A packages/adapter-vite/__tests__/compiled-element-spike.test.ts` +- `deno test -A packages/element/__tests__/compiled-part-program-spike.test.ts` +- `deno task --cwd packages/adapter-vite typecheck` +- `deno task --cwd packages/element typecheck` +- packet-scoped `deno lint` +- packet-scoped `deno fmt --check` +- `deno task arch:check` +- `git diff --check` + +Passing gates do not override R1–R3. The candidate remains NO-GO until the repair packet is +implemented and independently re-reviewed. diff --git a/packages/adapter-vite/__fixtures__/compiled-element-spike/counter.tsx b/packages/adapter-vite/__fixtures__/compiled-element-spike/counter.tsx new file mode 100644 index 000000000..80e64fb62 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/compiled-element-spike/counter.tsx @@ -0,0 +1,43 @@ +/** + * v0.44.0-alpha.0 compiler spike fixture (#1160). + * + * This is the exact TSX grammar recognized by the open:compiled-element spike + * transform. The file is transformed through the Vite plugin hook; it is never + * executed directly. The local decorator declarations exist only so the fixture + * is self-contained - the canonical decorator contract is owned by #1162. + */ +import { OpenElement } from '@openelement/element'; + +declare function element(tag: string): ClassDecorator; +declare function property(options: { reflect: boolean }): PropertyDecorator; + +@element('oe-spike-counter') +export class SpikeCounter extends OpenElement { + @property({ reflect: true }) + count = 0; + + @property({ reflect: false }) + label = 'ready'; + + @property({ reflect: false }) + items: Array<{ id: string; text: string }> = [{ id: 'a', text: 'alpha' }, { + id: 'b', + text: 'beta', + }]; + + increment(): void { + this.count++; + } + + render() { + return ( +
    +

    Count: {this.count}

    + + + {this.count > 0 ?

    positive

    :

    zero

    } +
      {this.items.map((item) =>
    • {item.text}
    • )}
    +
    + ); + } +} diff --git a/packages/adapter-vite/__fixtures__/compiled-element-spike/expected-program.json b/packages/adapter-vite/__fixtures__/compiled-element-spike/expected-program.json new file mode 100644 index 000000000..db72b39b0 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/compiled-element-spike/expected-program.json @@ -0,0 +1,153 @@ +{ + "version": 1, + "tag": "oe-spike-counter", + "template": [ + { + "k": "el", + "tag": "div", + "attrs": [ + [ + "class", + "spike" + ] + ], + "children": [ + { + "k": "el", + "tag": "h1", + "attrs": [], + "children": [ + { + "k": "text", + "value": "Count: " + }, + { + "k": "part", + "index": 0 + } + ] + }, + { + "k": "el", + "tag": "input", + "attrs": [], + "children": [] + }, + { + "k": "el", + "tag": "button", + "attrs": [], + "children": [ + { + "k": "text", + "value": "+" + } + ] + }, + { + "k": "part", + "index": 3 + }, + { + "k": "el", + "tag": "ul", + "attrs": [], + "children": [ + { + "k": "part", + "index": 4 + } + ] + } + ] + } + ], + "parts": [ + { + "k": "text", + "index": 0, + "signal": "count" + }, + { + "k": "prop", + "index": 1, + "signal": "label", + "name": "value", + "path": [ + 0, + 1 + ] + }, + { + "k": "event", + "index": 2, + "event": "click", + "handler": "increment", + "path": [ + 0, + 2 + ] + }, + { + "k": "when", + "index": 3, + "signal": "count", + "gt": 0, + "on": [ + { + "k": "el", + "tag": "p", + "attrs": [ + [ + "class", + "parity" + ] + ], + "children": [ + { + "k": "text", + "value": "positive" + } + ] + } + ], + "off": [ + { + "k": "el", + "tag": "p", + "attrs": [ + [ + "class", + "parity" + ] + ], + "children": [ + { + "k": "text", + "value": "zero" + } + ] + } + ] + }, + { + "k": "each", + "index": 4, + "signal": "items", + "key": "id", + "field": "text", + "item": [ + { + "k": "el", + "tag": "li", + "attrs": [], + "children": [ + { + "k": "ival" + } + ] + } + ] + } + ] +} diff --git a/packages/adapter-vite/__fixtures__/compiled-element-spike/unsupported-spread.tsx b/packages/adapter-vite/__fixtures__/compiled-element-spike/unsupported-spread.tsx new file mode 100644 index 000000000..4fe50f905 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/compiled-element-spike/unsupported-spread.tsx @@ -0,0 +1,18 @@ +/** + * v0.44.0-alpha.0 compiler spike negative fixture (#1160). + * + * Spread attributes are outside the spike grammar. The transform must fail + * closed with a source-located diagnostic and must never fall back to the + * legacy runtime path. + */ +import { OpenElement } from '@openelement/element'; + +declare function element(tag: string): ClassDecorator; + +@element('oe-spike-unsupported') +export class UnsupportedSpike extends OpenElement { + render() { + const props = { id: 'spread' }; + return
    nope
    ; + } +} diff --git a/packages/adapter-vite/__tests__/compiled-element-spike.test.ts b/packages/adapter-vite/__tests__/compiled-element-spike.test.ts new file mode 100644 index 000000000..26749fc9c --- /dev/null +++ b/packages/adapter-vite/__tests__/compiled-element-spike.test.ts @@ -0,0 +1,249 @@ +/** + * @openelement/adapter-vite — #1160 TSX-to-Part Program vertical compiler spike. + * + * Behavior-first coverage for the open:compiled-element spike transform: + * - the one-component fixture is transformed through the actual Vite plugin + * transform hook registered by createOpenPlugin() + * - the emitted module embeds one deterministic, serializable Part Program + * (static structure + typed Part/Region instructions) that deep-equals the + * frozen expected-program.json fixture consumed by the element runtime test + * - re-running the transform yields byte-identical output + * - unsupported syntax fails closed at transform time with a source-located + * diagnostic; no runtime fallback is emitted + * - measurement evidence (generated bytes, instruction count) is recorded + * + * The compiler modules are loaded via dynamic import so a RED run proves the + * harness and fixtures work while the new vertical behavior is absent. + */ + +import { assert, assertEquals, assertStringIncludes } from '@std/assert'; +import { createOpenPlugin } from '../src/plugin.ts'; +import type { Plugin } from 'vite'; + +const FIXTURE_DIR = new URL('../__fixtures__/compiled-element-spike/', import.meta.url); + +async function readFixture(name: string): Promise { + return await Deno.readTextFile(new URL(name, FIXTURE_DIR)); +} + +type PluginModule = typeof import('../src/internal/compiler/plugin.ts'); + +async function loadPluginModule(): Promise { + return await import('../src/internal/compiler/plugin.ts'); +} + +interface TransformContext { + error(message: string): never; +} + +function failingContext(): TransformContext & { messages: string[] } { + const messages: string[] = []; + return { + messages, + error(message: string): never { + messages.push(message); + throw new Error(message); + }, + }; +} + +type TransformHook = (this: TransformContext, code: string, id: string) => string | null; + +function transformOf(plugin: Plugin): TransformHook { + assert(typeof plugin.transform === 'function', 'plugin must expose a transform hook'); + return plugin.transform as unknown as TransformHook; +} + +Deno.test('compiled-element spike - harness sanity (fixtures load)', async () => { + const counter = await readFixture('counter.tsx'); + const expected = await readFixture('expected-program.json'); + assert(counter.includes("@element('oe-spike-counter')")); + assertEquals(JSON.parse(expected).tag, 'oe-spike-counter'); +}); + +Deno.test('compiled-element spike - plugin registration through createOpenPlugin', async (t) => { + await t.step('default pipeline does not register the spike compiler', () => { + const plugins = createOpenPlugin(); + assertEquals(plugins.some((p) => p.name === 'open:compiled-element'), false); + }); + + await t.step('opt-in spike flag registers the compiler plugin', async () => { + // Behavior-first ordering (repair-1, R3): the pipeline assertion runs + // BEFORE the new compiler module is imported, so on a base-SHA checkout + // this step fails because the opt-in pipeline lacks the plugin — not + // because a module cannot be resolved. The cast keeps the RED-authored + // test compiling against the not-yet-widened signature. + const spikeOptions = { compiledSpike: true } as unknown as Parameters< + typeof createOpenPlugin + >[0]; + const plugins = createOpenPlugin(spikeOptions); + assertEquals(plugins.some((p) => p.name === 'open:compiled-element'), true); + const { compiledElementPlugin } = await loadPluginModule(); + assertEquals(compiledElementPlugin().name, 'open:compiled-element'); + }); +}); + +Deno.test('compiled-element spike - fixture transforms through the Vite hook', async (t) => { + const { compiledElementPlugin } = await loadPluginModule(); + const plugin = compiledElementPlugin(); + const transform = transformOf(plugin); + const source = await readFixture('counter.tsx'); + const id = '/project/app/islands/counter.tsx'; + + let emitted: string | null = null; + await t.step('transform returns generated code', () => { + emitted = transform.call(failingContext(), source, id); + assert(typeof emitted === 'string'); + assertStringIncludes(emitted, '__partProgram'); + }); + + const programLiteral = (code: string): string => { + const marker = 'const __partProgram = '; + const start = code.indexOf(marker); + assert(start >= 0, 'generated code must embed the Part Program literal'); + const end = code.indexOf(';', start); + return code.slice(start + marker.length, end); + }; + + await t.step('emitted program deep-equals the frozen expected program', async () => { + const expected = JSON.parse(await readFixture('expected-program.json')); + assertEquals(JSON.parse(programLiteral(emitted!)), expected); + }); + + await t.step('emitted metadata preserves @property reflection decisions (R2)', () => { + const marker = 'const __compiledProperties = '; + const start = emitted!.indexOf(marker); + assert(start >= 0, 'generated code must embed compiled-property metadata'); + const end = emitted!.indexOf(';', start); + const metadata = JSON.parse(emitted!.slice(start + marker.length, end)); + // Exact fixture decisions: count reflected; label and items not reflected. + assertEquals(metadata, { + count: { reflect: true }, + label: { reflect: false }, + items: { reflect: false }, + }); + // Compile-time data only: no runtime reflection behavior is emitted. + assertEquals(emitted!.includes('attributeChangedCallback'), false); + assertStringIncludes(emitted!, 'static __compiledProperties = __compiledProperties;'); + }); + + await t.step('program carries static structure plus typed Part/Region instructions', () => { + const program = JSON.parse(programLiteral(emitted!)); + assertEquals(program.version, 1); + const kinds = program.parts.map((p: { k: string }) => p.k).sort(); + assertEquals(kinds, ['each', 'event', 'prop', 'text', 'when']); + // instruction count = typed parts; static structure lives in the template + assertEquals(program.parts.length, 5); + }); + + await t.step('emitted code contains no VNode/binding/hydration fallback vocabulary', () => { + for ( + const token of [ + 'VNode', + 'BindingDescriptor', + 'hydrationScope', + 'HydrationScope', + 'createElement(', + ] + ) { + assertEquals(emitted!.includes(token), false, `generated code must not contain ${token}`); + } + assertStringIncludes(emitted!, 'not available in 0.44'); + }); + + await t.step('re-running the transform yields byte-identical output', () => { + const again = transform.call(failingContext(), source, id); + assertEquals(again, emitted); + }); + + await t.step('measurement evidence is recorded', () => { + const programJson = programLiteral(emitted!); + const generatedBytes = new TextEncoder().encode(emitted!).length; + const programBytes = new TextEncoder().encode(programJson).length; + const instructionCount = JSON.parse(programJson).parts.length; + // Frozen spike evidence (alpha.0 fixture): regenerated only by changing the + // fixture grammar. These are evidence, not a performance claim. + assertEquals(instructionCount, 5); + console.log( + JSON.stringify({ + spike: 'adapter-compiler', + generatedBytes, + programBytes, + instructionCount, + }), + ); + }); + + await t.step('files outside the spike grammar marker pass through untouched', () => { + const result = transform.call( + failingContext(), + 'export class Plain { render() { return null; } }', + '/project/app/components/plain.tsx', + ); + assertEquals(result, null); + }); +}); + +Deno.test('compiled-element spike - unsupported syntax fails closed with located diagnostics', async (t) => { + const { compiledElementPlugin } = await loadPluginModule(); + const transform = transformOf(compiledElementPlugin()); + + await t.step('spread attributes are rejected with file/line location', async () => { + const source = await readFixture('unsupported-spread.tsx'); + const ctx = failingContext(); + let thrown: Error | null = null; + try { + transform.call(ctx, source, '/project/app/islands/unsupported-spread.tsx'); + } catch (err) { + thrown = err as Error; + } + assert(thrown, 'transform must throw for unsupported syntax'); + assertEquals(ctx.messages.length, 1); + const message = ctx.messages[0]; + assertStringIncludes(message, 'unsupported-spread.tsx:'); + assertStringIncludes(message, 'OEC9'); + assertStringIncludes(message.toLowerCase(), 'spread'); + }); + + await t.step('unknown decorators on an OpenElement class fail closed', () => { + const source = [ + "import { OpenElement } from '@openelement/element';", + 'declare function element(tag: string): ClassDecorator;', + 'declare function mystery(): ClassDecorator;', + "@element('oe-spike-mystery')", + '@mystery()', + 'export class Mystery extends OpenElement {', + ' render() { return
    ok
    ; }', + '}', + ].join('\n'); + const ctx = failingContext(); + let thrown: Error | null = null; + try { + transform.call(ctx, source, '/project/app/islands/mystery.tsx'); + } catch (err) { + thrown = err as Error; + } + assert(thrown, 'transform must throw for unknown decorators'); + assertStringIncludes(ctx.messages[0], 'mystery.tsx:'); + assertStringIncludes(ctx.messages[0].toLowerCase(), 'decorator'); + }); + + await t.step('classes not extending OpenElement fail closed', () => { + const source = [ + 'declare function element(tag: string): ClassDecorator;', + "@element('oe-spike-alien')", + 'export class Alien extends HTMLElement {', + ' render() { return null; }', + '}', + ].join('\n'); + const ctx = failingContext(); + let thrown: Error | null = null; + try { + transform.call(ctx, source, '/project/app/islands/alien.tsx'); + } catch (err) { + thrown = err as Error; + } + assert(thrown, 'transform must throw for non-OpenElement base classes'); + assertStringIncludes(ctx.messages[0], 'OpenElement'); + }); +}); diff --git a/packages/adapter-vite/src/internal/compiler/compile.ts b/packages/adapter-vite/src/internal/compiler/compile.ts new file mode 100644 index 000000000..47e3e03ee --- /dev/null +++ b/packages/adapter-vite/src/internal/compiler/compile.ts @@ -0,0 +1,615 @@ +/** + * @openelement/adapter-vite — TSX-to-Part Program spike compiler (#1160). + * + * Recognizes exactly the alpha.0 fixture grammar: + * - one `@element('tag')` class extending OpenElement + * - `@property({ reflect: boolean })` class fields with literal initializers + * - a render() returning one JSX tree with static elements/attributes, + * `{this.field}` text, `value={this.field}` on , `onX={this.method}` + * events, `{this.field > N ? a : b}` conditional Regions and + * `{this.field.map((item) =>
  • {item.text}
  • )}` keyed + * list Regions + * + * Every other construct fails closed with a source-located diagnostic + * (OEC9xx). There is no runtime fallback: the emitted render() throws. + */ + +import ts from 'typescript'; +import type { PartProgramSpike, SpikePart, SpikeTreeNode } from './program.ts'; + +export interface SpikeDiagnostic { + code: string; + message: string; + file: string; + line: number; + character: number; +} + +export class CompiledSpikeError extends Error { + readonly diagnostics: SpikeDiagnostic[]; + constructor(diagnostics: SpikeDiagnostic[]) { + super( + diagnostics + .map((d) => `${d.file}:${d.line}:${d.character} - error ${d.code}: ${d.message}`) + .join('\n'), + ); + this.name = 'CompiledSpikeError'; + this.diagnostics = diagnostics; + } +} + +export interface CompileSpikeResult { + code: string; + program: PartProgramSpike; +} + +interface SpikeField { + name: string; + reflect: boolean; + typeText: string; + initializerText: string; +} + +/** Distributive input union for addPart (Omit collapses; do not use). */ +type SpikePartInput = + | { k: 'text'; signal: string } + | { k: 'prop'; signal: string; name: string; path: number[] } + | { k: 'event'; event: string; handler: string; path: number[] } + | { k: 'when'; signal: string; gt: number; on: SpikeTreeNode[]; off: SpikeTreeNode[] } + | { k: 'each'; signal: string; key: string; field: string; item: SpikeTreeNode[] }; + +class Lowering { + readonly parts: SpikePart[] = []; + readonly fieldNames: Set; + readonly methodNames: Set; + + constructor( + private readonly sf: ts.SourceFile, + fields: SpikeField[], + methodNames: string[], + ) { + this.fieldNames = new Set(fields.map((field) => field.name)); + this.methodNames = new Set(methodNames); + } + + fail(node: ts.Node, code: string, message: string): never { + const { line, character } = this.sf.getLineAndCharacterOfPosition(node.getStart(this.sf)); + throw new CompiledSpikeError([{ + code, + message, + file: this.sf.fileName, + line: line + 1, + character: character + 1, + }]); + } + + private addPart(part: SpikePartInput): number { + const index = this.parts.length; + this.parts.push({ ...part, index } as SpikePart); + return index; + } + + /** `this.` referencing a compiled @property field, or null. */ + private fieldAccess(expr: ts.Expression): string | null { + if ( + ts.isPropertyAccessExpression(expr) && + expr.expression.kind === ts.SyntaxKind.ThisKeyword && + this.fieldNames.has(expr.name.text) + ) { + return expr.name.text; + } + return null; + } + + private unwrap(expr: ts.Expression): ts.Expression { + return ts.isParenthesizedExpression(expr) ? this.unwrap(expr.expression) : expr; + } + + lowerRoot(expr: ts.Expression): SpikeTreeNode { + const root = this.unwrap(expr); + if (ts.isJsxElement(root)) { + return this.lowerElement(root.openingElement.tagName, root.openingElement.attributes, [ + ...root.children, + ], [0]); + } + if (ts.isJsxSelfClosingElement(root)) { + return this.lowerElement(root.tagName, root.attributes, [], [0]); + } + return this.fail(root, 'OEC9007', 'render() must return a single JSX element'); + } + + private lowerElement( + tagNameNode: ts.JsxTagNameExpression, + attributes: ts.JsxAttributes, + children: ts.JsxChild[], + path: number[], + staticOnly = false, + ): SpikeTreeNode { + const tag = tagNameNode.getText(this.sf); + if (!/^[a-z][a-z0-9]*$/.test(tag)) { + this.fail( + tagNameNode, + 'OEC9010', + `component tag <${tag}> is outside the spike grammar (intrinsic lowercase elements only)`, + ); + } + const attrs: Array<[string, string]> = []; + for (const prop of attributes.properties) { + if (ts.isJsxSpreadAttribute(prop)) { + this.fail(prop, 'OEC9011', 'spread attributes are not supported by the spike grammar'); + } + const name = prop.name.getText(this.sf); + const init = prop.initializer; + if (!init) { + this.fail(prop, 'OEC9011', `boolean attribute "${name}" is not supported by the spike`); + } + if (ts.isStringLiteral(init)) { + attrs.push([name, init.text]); + continue; + } + if (!ts.isJsxExpression(init) || !init.expression) { + this.fail(prop, 'OEC9011', `attribute "${name}" must be a string literal`); + } + const expr = this.unwrap(init.expression); + if (/^on[A-Z]/.test(name)) { + if (staticOnly) this.fail(prop, 'OEC9012', 'region branches must be fully static'); + if ( + ts.isPropertyAccessExpression(expr) && + expr.expression.kind === ts.SyntaxKind.ThisKeyword && + this.methodNames.has(expr.name.text) + ) { + this.addPart({ + k: 'event', + event: name.slice(2).toLowerCase(), + handler: expr.name.text, + path, + }); + continue; + } + this.fail(prop, 'OEC9011', `event attribute "${name}" must reference this.`); + } + if (name === 'value' && tag === 'input') { + if (staticOnly) this.fail(prop, 'OEC9012', 'region branches must be fully static'); + const field = this.fieldAccess(expr); + if (!field) { + this.fail(prop, 'OEC9011', 'input value must be a compiled this. reference'); + } + this.addPart({ k: 'prop', signal: field, name: 'value', path }); + continue; + } + this.fail( + prop, + 'OEC9011', + `attribute "${name}" expression is outside the spike grammar ` + + '(only on={this.method} and value={this.property} on are supported)', + ); + } + + const lowered: SpikeTreeNode[] = []; + for (const child of children) { + if (ts.isJsxText(child)) { + const value = child.getText(this.sf).replace(/\s+/g, ' '); + if (value.trim().length === 0) continue; + lowered.push({ k: 'text', value }); + continue; + } + if (ts.isJsxExpression(child)) { + const loweredChild = this.lowerExpressionChild(child, staticOnly); + if (loweredChild) lowered.push(loweredChild); + continue; + } + if (ts.isJsxElement(child)) { + lowered.push( + this.lowerElement( + child.openingElement.tagName, + child.openingElement.attributes, + [ + ...child.children, + ], + [...path, lowered.length], + staticOnly, + ), + ); + continue; + } + if (ts.isJsxSelfClosingElement(child)) { + lowered.push( + this.lowerElement( + child.tagName, + child.attributes, + [], + [...path, lowered.length], + staticOnly, + ), + ); + continue; + } + this.fail(child, 'OEC9013', 'JSX fragments and spreads are outside the spike grammar'); + } + return { k: 'el', tag, attrs, children: lowered }; + } + + private lowerExpressionChild( + child: ts.JsxExpression, + staticOnly: boolean, + ): SpikeTreeNode | null { + if (!child.expression) return null; // {} formatting whitespace + const expr = this.unwrap(child.expression); + + const textField = this.fieldAccess(expr); + if (textField) { + if (staticOnly) this.fail(child, 'OEC9012', 'region branches must be fully static'); + const index = this.addPart({ k: 'text', signal: textField }); + return { k: 'part', index }; + } + + if (ts.isConditionalExpression(expr)) { + if (staticOnly) this.fail(child, 'OEC9012', 'nested regions are outside the spike grammar'); + const condition = expr.condition; + if ( + !ts.isBinaryExpression(condition) || + condition.operatorToken.kind !== ts.SyntaxKind.GreaterThanToken || + !ts.isNumericLiteral(condition.right) + ) { + this.fail( + child, + 'OEC9013', + 'conditional Regions support exactly `this. > ` in the spike', + ); + } + const field = this.fieldAccess(condition.left); + if (!field) { + this.fail(child, 'OEC9013', 'conditional Region test must reference this.'); + } + const on = this.lowerStaticBranch(expr.whenTrue, child); + const off = this.lowerStaticBranch(expr.whenFalse, child); + const index = this.addPart({ + k: 'when', + signal: field, + gt: Number(condition.right.getText(this.sf)), + on: [on], + off: [off], + }); + return { k: 'part', index }; + } + + if (ts.isCallExpression(expr)) { + if (staticOnly) this.fail(child, 'OEC9012', 'nested regions are outside the spike grammar'); + return this.lowerEach(expr, child); + } + + this.fail( + child, + 'OEC9013', + 'unsupported dynamic expression; the spike grammar supports {this.}, ' + + '{this. > ? a : b} and {this..map((item) =>
  • {item.text}
  • )}', + ); + } + + private lowerStaticBranch(expr: ts.Expression, near: ts.Node): SpikeTreeNode { + const branch = this.unwrap(expr); + const lower = ( + tagName: ts.JsxTagNameExpression, + attributes: ts.JsxAttributes, + children: ts.JsxChild[], + ): SpikeTreeNode => this.lowerElement(tagName, attributes, children, [], true); + if (ts.isJsxElement(branch)) { + return lower(branch.openingElement.tagName, branch.openingElement.attributes, [ + ...branch.children, + ]); + } + if (ts.isJsxSelfClosingElement(branch)) { + return lower(branch.tagName, branch.attributes, []); + } + this.fail(near, 'OEC9012', 'conditional Region branches must be single static JSX elements'); + } + + private lowerEach(expr: ts.CallExpression, near: ts.Node): SpikeTreeNode { + const callee = expr.expression; + if ( + !ts.isPropertyAccessExpression(callee) || callee.name.text !== 'map' || + expr.arguments.length !== 1 + ) { + this.fail(near, 'OEC9013', 'list Regions support exactly this..map(...)'); + } + const field = this.fieldAccess(callee.expression); + if (!field) this.fail(near, 'OEC9013', 'list Regions must map over this.'); + const arrow = expr.arguments[0]; + if (!ts.isArrowFunction(arrow) || arrow.parameters.length !== 1) { + this.fail(near, 'OEC9013', 'list Region mapper must be a single-parameter arrow function'); + } + const param = arrow.parameters[0].name.getText(this.sf); + const body = this.unwrap(arrow.body as ts.Expression); + if (!ts.isJsxElement(body) && !ts.isJsxSelfClosingElement(body)) { + this.fail(near, 'OEC9013', 'list Region mapper must return one JSX element'); + } + const tagName = ts.isJsxElement(body) ? body.openingElement.tagName : body.tagName; + const attributes = ts.isJsxElement(body) ? body.openingElement.attributes : body.attributes; + const itemChildren = ts.isJsxElement(body) ? [...body.children] : []; + const tag = tagName.getText(this.sf); + if (!/^[a-z][a-z0-9]*$/.test(tag)) { + this.fail(tagName, 'OEC9010', 'list Region item must be an intrinsic lowercase element'); + } + + let key: string | null = null; + for (const prop of attributes.properties) { + if (!ts.isJsxAttribute(prop)) { + this.fail(prop, 'OEC9011', 'spread attributes are not supported by the spike grammar'); + } + const name = prop.name.getText(this.sf); + if (name !== 'key') { + this.fail(prop, 'OEC9011', 'list Region items support only the key attribute'); + } + if ( + !prop.initializer || !ts.isJsxExpression(prop.initializer) || + !prop.initializer.expression + ) { + this.fail(prop, 'OEC9014', 'key must be key={.}'); + } + const keyExpr = this.unwrap(prop.initializer.expression); + if ( + ts.isPropertyAccessExpression(keyExpr) && + ts.isIdentifier(keyExpr.expression) && + keyExpr.expression.text === param + ) { + key = keyExpr.name.text; + } else { + this.fail(prop, 'OEC9014', `key must reference ${param}.`); + } + } + if (!key) this.fail(body, 'OEC9014', 'list Region items require key={.}'); + + if (itemChildren.length !== 1) { + this.fail(body, 'OEC9013', 'list Region items support exactly one {.} child'); + } + const onlyChild = itemChildren[0]; + if (!ts.isJsxExpression(onlyChild) || !onlyChild.expression) { + this.fail(onlyChild, 'OEC9013', 'list Region item child must be {.}'); + } + const valueExpr = this.unwrap(onlyChild.expression!); + let textField: string | null = null; + if ( + ts.isPropertyAccessExpression(valueExpr) && + ts.isIdentifier(valueExpr.expression) && + valueExpr.expression.text === param + ) { + textField = valueExpr.name.text; + } + if (!textField) this.fail(onlyChild, 'OEC9013', `item child must be {${param}.}`); + + const index = this.addPart({ + k: 'each', + signal: field!, + key: key!, + field: textField, + item: [{ k: 'el', tag, attrs: [], children: [{ k: 'ival' }] }], + }); + return { k: 'part', index }; + } +} + +/** + * Verify path-addressed parts are not preceded by dynamic anchors. Path parts + * resolve by template child index, which matches the DOM child index only when + * no preceding sibling is a dynamic anchor (anchors expand to multiple DOM + * nodes). Fail closed otherwise; general dynamic addressing is owned by #1161. + */ +function assertPathSafety(program: PartProgramSpike, file: string): void { + for (const part of program.parts) { + if (part.k !== 'prop' && part.k !== 'event') continue; + let nodes = program.template; + for (let depth = 0; depth < part.path.length; depth++) { + const target = part.path[depth]; + for (let sibling = 0; sibling < target; sibling++) { + if (nodes[sibling].k === 'part') { + throw new CompiledSpikeError([{ + code: 'OEC9015', + message: + `${part.k} part path [${part.path.join(',')}] is preceded by a dynamic anchor; ` + + 'path-addressed parts must appear before any Region sibling in the spike grammar', + file, + line: 1, + character: 1, + }]); + } + } + const next = nodes[target]; + nodes = next.k === 'el' ? next.children : []; + } + } +} + +export function compileElementSpike(source: string, fileName: string): CompileSpikeResult { + const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX); + + // Function declaration (not an arrow const): TS 6 only narrows after calls + // to never-returning functions declared with the `function` keyword. + function fail(node: ts.Node, code: string, message: string): never { + const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + throw new CompiledSpikeError([{ + code, + message, + file: fileName, + line: line + 1, + character: character + 1, + }]); + } + + const classes = sf.statements.filter(ts.isClassDeclaration); + const decorated = classes.filter((node) => + (ts.getDecorators(node) ?? []).some((decorator) => { + const expr = decorator.expression; + return ts.isCallExpression(expr) && expr.expression.getText(sf) === 'element'; + }) + ); + if (decorated.length !== 1) { + fail(sf, 'OEC9001', 'expected exactly one @element(...) class per compiled module'); + } + const classNode = decorated[0]; + + // Class decorators: exactly one @element(''), nothing else. + let tag = ''; + for (const decorator of ts.getDecorators(classNode) ?? []) { + const expr = decorator.expression; + if (ts.isCallExpression(expr) && expr.expression.getText(sf) === 'element') { + const arg = expr.arguments[0]; + if (!arg || !ts.isStringLiteral(arg)) { + fail(decorator, 'OEC9002', '@element requires a single string tag name'); + } + if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(arg.text)) { + fail(decorator, 'OEC9002', `@element tag "${arg.text}" is not a custom-element name`); + } + tag = arg.text; + continue; + } + fail( + decorator, + 'OEC9004', + 'unknown decorator on an OpenElement class; the spike supports only @element and @property', + ); + } + + const heritage = classNode.heritageClauses?.find((clause) => + clause.token === ts.SyntaxKind.ExtendsKeyword + ); + const baseName = heritage?.types[0]?.expression.getText(sf); + if (baseName !== 'OpenElement') { + fail( + classNode.name ?? classNode, + 'OEC9003', + `compiled classes must extend OpenElement (found ${baseName ?? 'no base class'})`, + ); + } + if (!classNode.name) fail(classNode, 'OEC9003', 'compiled classes must be named'); + const className = classNode.name.text; + + // Members: @property fields, plain methods, render(). Nothing else. + const fields: SpikeField[] = []; + const methodNames: string[] = []; + const methodTexts: string[] = []; + let renderMethod: ts.MethodDeclaration | null = null; + + for (const member of classNode.members) { + if (ts.isPropertyDeclaration(member)) { + const decorators = ts.getDecorators(member) ?? []; + if (decorators.length !== 1) { + fail(member, 'OEC9005', 'compiled fields must carry exactly one @property decorator'); + } + const call = decorators[0].expression; + const isPropertyDecorator = ts.isCallExpression(call) && + call.expression.getText(sf) === 'property'; + if (!isPropertyDecorator) { + fail( + decorators[0], + 'OEC9004', + 'unknown decorator on an OpenElement member; the spike supports only @property', + ); + } + const options = (call as ts.CallExpression).arguments[0]; + if (!options || !ts.isObjectLiteralExpression(options)) { + fail(decorators[0], 'OEC9005', '@property requires an options object literal'); + } + let reflect = false; + for (const entry of options.properties) { + if ( + !ts.isPropertyAssignment(entry) || entry.name.getText(sf) !== 'reflect' || + (entry.initializer.kind !== ts.SyntaxKind.TrueKeyword && + entry.initializer.kind !== ts.SyntaxKind.FalseKeyword) + ) { + fail(entry, 'OEC9005', '@property options support only `reflect: `'); + } + reflect = entry.initializer.kind === ts.SyntaxKind.TrueKeyword; + } + if (!member.initializer) { + fail(member, 'OEC9005', 'compiled fields require an initializer for deterministic codegen'); + } + if (!ts.isIdentifier(member.name)) fail(member, 'OEC9005', 'field names must be identifiers'); + fields.push({ + name: member.name.text, + reflect, + typeText: member.type ? `: ${member.type.getText(sf)}` : '', + initializerText: member.initializer.getText(sf), + }); + continue; + } + if (ts.isMethodDeclaration(member)) { + if ((ts.getDecorators(member) ?? []).length > 0) { + fail(member, 'OEC9004', 'methods may not carry decorators in the spike grammar'); + } + const name = member.name.getText(sf); + if (name === 'render') { + renderMethod = member; + continue; + } + methodNames.push(name); + methodTexts.push(member.getText(sf)); + continue; + } + fail(member, 'OEC9006', 'constructors and accessors are outside the spike grammar'); + } + + if (!renderMethod) fail(classNode, 'OEC9007', 'compiled classes must declare render()'); + const renderBody = renderMethod.body; + const returns = renderBody?.statements ?? []; + if (returns.length !== 1 || !ts.isReturnStatement(returns[0]) || !returns[0].expression) { + fail(renderMethod, 'OEC9007', 'render() must be a single return of one JSX element'); + } + + const lowering = new Lowering(sf, fields, methodNames); + const root = lowering.lowerRoot(returns[0].expression!); + + const program: PartProgramSpike = { + version: 1, + tag, + template: [root], + parts: lowering.parts, + }; + assertPathSafety(program, fileName); + + // ─── Deterministic emission ──────────────────────────────────────── + const programJson = JSON.stringify(program, null, 2); + // Preserve the fixture's @property reflection decisions as deterministic + // internal generated metadata (source field order). This is compile-time + // data only: no runtime reflection, no public export, no frozen schema — + // #1161/#1162 own those contracts. + const propertiesJson = JSON.stringify( + Object.fromEntries(fields.map((field) => [field.name, { reflect: field.reflect }])), + null, + 2, + ); + const memberLines: string[] = []; + memberLines.push(' static __partProgram = __partProgram;'); + memberLines.push(' static __compiledProperties = __compiledProperties;'); + for (const field of fields) { + memberLines.push(` ${field.name}${field.typeText} = ${field.initializerText};`); + } + for (const text of methodTexts) { + memberLines.push(...text.split('\n').map((line) => ` ${line}`)); + } + memberLines.push(' render(): never {'); + memberLines.push(' throw new Error('); + memberLines.push( + ` '[open:compiled-element] ${tag} is compiled to a Part Program; ` + + "the runtime JSX render path is not available in 0.44.',", + ); + memberLines.push(' );'); + memberLines.push(' }'); + + const code = [ + '// ', + "import { OpenElement } from '@openelement/element';", + '', + `const __partProgram = ${programJson};`, + '', + `const __compiledProperties = ${propertiesJson};`, + '', + `export class ${className} extends OpenElement {`, + ...memberLines, + '}', + '', + 'export { __partProgram };', + '', + ].join('\n'); + + return { code, program }; +} diff --git a/packages/adapter-vite/src/internal/compiler/plugin.ts b/packages/adapter-vite/src/internal/compiler/plugin.ts new file mode 100644 index 000000000..d1177d9ce --- /dev/null +++ b/packages/adapter-vite/src/internal/compiler/plugin.ts @@ -0,0 +1,35 @@ +/** + * @openelement/adapter-vite — open:compiled-element spike plugin (#1160). + * + * Vite integration boundary for the alpha.0 TSX-to-Part Program compiler. + * The hook activates only for .tsx modules that opt into the compiled model + * via the `@element(` marker; everything else passes through untouched. + * Unsupported grammar fails the build through this.error() with a + * source-located OEC9xx diagnostic — there is no runtime fallback. + * + * Internal alpha.0 spike only: not part of the public adapter API. + */ + +import type { Plugin } from 'vite'; +import { CompiledSpikeError, compileElementSpike } from './compile.ts'; + +export const COMPILED_ELEMENT_MARKER = '@element('; + +export function compiledElementPlugin(): Plugin { + return { + name: 'open:compiled-element', + + transform(code, id) { + if (!/\.tsx$/.test(id)) return null; + if (!code.includes(COMPILED_ELEMENT_MARKER)) return null; + try { + return compileElementSpike(code, id).code; + } catch (error) { + if (error instanceof CompiledSpikeError) { + this.error(error.message); + } + throw error; + } + }, + }; +} diff --git a/packages/adapter-vite/src/internal/compiler/program.ts b/packages/adapter-vite/src/internal/compiler/program.ts new file mode 100644 index 000000000..f11390c8d --- /dev/null +++ b/packages/adapter-vite/src/internal/compiler/program.ts @@ -0,0 +1,44 @@ +/** + * @openelement/adapter-vite — spike Part Program types (v0.44.0-alpha.0, #1160). + * + * Structural mirror of packages/element/src/internal/compiled/program.ts. The + * two copies intentionally share no import: the generated program must remain + * a self-contained serializable artifact with no private cross-package path + * and no workspace alias (dispatch a0-001 boundary). The adapter test asserts + * the emitted program deep-equals the frozen expected-program.json fixture + * that the element runtime consumes, which keeps both copies honest. + */ + +export type SpikeTreeNode = + | { k: 'el'; tag: string; attrs: Array<[string, string]>; children: SpikeTreeNode[] } + | { k: 'text'; value: string } + | { k: 'part'; index: number } + | { k: 'ival' }; + +export type SpikePart = + | { k: 'text'; index: number; signal: string } + | { k: 'prop'; index: number; signal: string; name: string; path: number[] } + | { k: 'event'; index: number; event: string; handler: string; path: number[] } + | { + k: 'when'; + index: number; + signal: string; + gt: number; + on: SpikeTreeNode[]; + off: SpikeTreeNode[]; + } + | { + k: 'each'; + index: number; + signal: string; + key: string; + field: string; + item: SpikeTreeNode[]; + }; + +export interface PartProgramSpike { + version: 1; + tag: string; + template: SpikeTreeNode[]; + parts: SpikePart[]; +} diff --git a/packages/adapter-vite/src/plugin.ts b/packages/adapter-vite/src/plugin.ts index d20bd4940..a4a8e2217 100644 --- a/packages/adapter-vite/src/plugin.ts +++ b/packages/adapter-vite/src/plugin.ts @@ -35,6 +35,7 @@ import { } from './internal/ssg/index.ts'; import { buildHeadExtras } from './head-injection.ts'; import { islandTransformPlugin } from './island-transform.ts'; +import { compiledElementPlugin } from './internal/compiler/plugin.ts'; import { devIslandClientPlugin, RESOLVED_CLIENT_ENTRY_ID } from './dev-island-client.ts'; import { createGeneratedDataResolverPlugin } from './generated-data-resolver.ts'; import { @@ -117,7 +118,7 @@ export function optionalPackageStubsPlugin(): Plugin { * @internal */ export function createOpenPlugin( - options: FrameworkOptions & { ssg?: SsgBehaviorOptions } = {}, + options: FrameworkOptions & { ssg?: SsgBehaviorOptions; compiledSpike?: boolean } = {}, externalCtx?: OpenElementBuildContext, ): Plugin[] { // Build head extras (validated HTML fragments, stylesheets, scripts) @@ -126,6 +127,7 @@ export function createOpenPlugin( const resolvedOptions: FrameworkOptions & { allowHeadExtrasScripts?: boolean; ssg?: SsgBehaviorOptions; + compiledSpike?: boolean; } = { ...options, routesDir: options.routesDir || DEFAULT_ROUTES_DIR, @@ -639,5 +641,12 @@ export function createOpenPlugin( devIslandClientPlugin(resolvedOptions, ctx), ); + // #1160 (v0.44.0-alpha.0 spike): opt-in compiled-element transform. The + // flag keeps the internal spike isolated from the default 0.43 pipeline; + // plugin ordering/count for the default pipeline is unchanged. + if (resolvedOptions.compiledSpike) { + plugins.push(compiledElementPlugin()); + } + return plugins; } diff --git a/packages/element/__tests__/compiled-part-program-spike.test.ts b/packages/element/__tests__/compiled-part-program-spike.test.ts new file mode 100644 index 000000000..a09bb450e --- /dev/null +++ b/packages/element/__tests__/compiled-part-program-spike.test.ts @@ -0,0 +1,683 @@ +/** + * @openelement/element — #1160 compiled Part Program spike (runtime vertical). + * + * Behavior-first coverage proving the ADR-0143 replacement path on the frozen + * alpha.0 fixture program (packages/adapter-vite/__fixtures__/ + * compiled-element-spike/expected-program.json — the same artifact the Vite + * transform test asserts structurally): + * - server serialization, fresh DOM creation and existing-DOM claim consume + * one Part Program and produce equivalent observable structure + * - successful claim preserves node identity and a live input value while + * attaching the event and reactive sinks + * - a Signal write mutates only its subscribed Part/Region; unrelated sinks + * record no write + * - keyed list reconciliation preserves element identity across reorders + * - claim mismatch fails with a structured, source-located diagnostic + * - measurement evidence (allocations, subscriptions, activation ops) is + * recorded against a frozen 0.43-equivalent proxy (evidence, not a + * performance GO claim) + * + * The runtime modules are loaded via dynamic import so a RED run proves the + * harness works while the new vertical behavior is absent. The minimal fake + * DOM below implements only the standard DOM surface the compiled runtime is + * allowed to touch (Deno's runner provides no browser DOM). + */ + +import { assertEquals, assertStrictEquals, assertStringIncludes, assertThrows } from '@std/assert'; +import { signal } from '../src/internal/signal/framework.ts'; +import type { WritableSignal } from '../src/internal/signal/types.ts'; +import { SIGNAL_BRAND } from '../src/internal/protocol/signal.ts'; + +// ─── Minimal instrumented DOM harness ──────────────────────────────── + +interface DomCounts { + elements: number; + texts: number; + comments: number; + textWrites: number; + valueWrites: number; + listenerAdds: number; + removals: number; + walkVisits: number; +} + +type FNode = FElement | FText | FComment; + +abstract class FNodeBase { + readonly ownerDocument: FDocument; + parentNode: FElement | null = null; + childNodes: FNode[] = []; + + constructor(ownerDocument: FDocument) { + this.ownerDocument = ownerDocument; + } + + get nextSibling(): FNode | null { + if (!this.parentNode) return null; + const siblings = this.parentNode.childNodes; + const index = siblings.indexOf(this as unknown as FNode); + return index >= 0 && index + 1 < siblings.length ? siblings[index + 1] : null; + } + + appendChild(node: FNode): FNode { + this.detachForMove(node); + node.parentNode = this as unknown as FElement; + this.childNodes.push(node); + return node; + } + + insertBefore(node: FNode, ref: FNode): FNode { + this.detachForMove(node); + const index = this.childNodes.indexOf(ref); + if (index < 0) throw new Error('insertBefore: reference node not found'); + node.parentNode = this as unknown as FElement; + this.childNodes.splice(index, 0, node); + return node; + } + + /** Re-parenting during insertBefore/appendChild is a move, not a removal. */ + private detachForMove(node: FNode): void { + const parent = node.parentNode; + if (!parent) return; + const index = parent.childNodes.indexOf(node); + parent.childNodes.splice(index, 1); + node.parentNode = null; + } + + removeChild(node: FNode): FNode { + const index = this.childNodes.indexOf(node); + if (index < 0) throw new Error('removeChild: node not found'); + this.childNodes.splice(index, 1); + node.parentNode = null; + this.ownerDocument.counts.removals++; + return node; + } +} + +class FText extends FNodeBase { + readonly nodeType = 3; + #data: string; + constructor(ownerDocument: FDocument, data: string) { + super(ownerDocument); + this.#data = data; + } + get data(): string { + return this.#data; + } + set data(value: string) { + this.#data = value; + this.ownerDocument.counts.textWrites++; + } +} + +class FComment extends FNodeBase { + readonly nodeType = 8; + constructor(ownerDocument: FDocument, readonly data: string) { + super(ownerDocument); + } +} + +type FListener = (event: { type: string }) => void; + +class FElement extends FNodeBase { + readonly nodeType = 1; + readonly tagName: string; + readonly attributes = new Map(); + readonly listeners = new Map>(); + #value = ''; + + constructor(ownerDocument: FDocument, tagName: string) { + super(ownerDocument); + this.tagName = tagName.toUpperCase(); + } + + get value(): string { + return this.#value; + } + set value(next: string) { + this.#value = next; + this.ownerDocument.counts.valueWrites++; + } + + /** Test-only live-value mutation that bypasses the instrumentation. */ + simulateUserInput(next: string): void { + this.#value = next; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + getAttribute(name: string): string | null { + return this.attributes.has(name) ? this.attributes.get(name)! : null; + } + addEventListener(type: string, listener: FListener): void { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(listener); + this.ownerDocument.counts.listenerAdds++; + } + removeEventListener(type: string, listener: FListener): void { + this.listeners.get(type)?.delete(listener); + } + dispatch(type: string): void { + for (const listener of this.listeners.get(type) ?? []) listener({ type }); + } +} + +const VOID_TAGS = new Set(['input', 'br', 'img', 'hr', 'meta', 'link']); + +class FDocument { + readonly counts: DomCounts = { + elements: 0, + texts: 0, + comments: 0, + textWrites: 0, + valueWrites: 0, + listenerAdds: 0, + removals: 0, + walkVisits: 0, + }; + createElement(tagName: string): FElement { + this.counts.elements++; + return new FElement(this, tagName); + } + createTextNode(data: string): FText { + this.counts.texts++; + return new FText(this, data); + } + createComment(data: string): FComment { + this.counts.comments++; + return new FComment(this, data); + } + resetCounts(): void { + for (const key of Object.keys(this.counts) as Array) this.counts[key] = 0; + } +} + +function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} +function unescapeText(value: string): string { + return value.replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll( + '&', + '&', + ); +} + +/** Serialize the fake tree with the same observable shape as SSR output. */ +function toHtml(node: FNode): string { + if (node instanceof FText) return escapeText(node.data); + if (node instanceof FComment) return ``; + const el = node as FElement; + const tag = el.tagName.toLowerCase(); + const attrs = Array.from(el.attributes.entries()) + .map(([name, value]) => + ` ${name}="${value.replaceAll('&', '&').replaceAll('"', '"')}"` + ) + .join(''); + if (VOID_TAGS.has(tag)) return `<${tag}${attrs}>`; + return `<${tag}${attrs}>${el.childNodes.map(toHtml).join('')}`; +} + +/** Parse the serializer output into an existing-DOM tree for claim tests. */ +function parseHtml(doc: FDocument, html: string): FElement { + const host = doc.createElement('host'); + const stack: FElement[] = [host]; + for (const match of html.matchAll(/|<[^>]+>|[^<]+/g)) { + const token = match[0]; + const parent = stack[stack.length - 1]; + if (token.startsWith('0', + ); + assertEquals(host.childNodes.length, 1); + assertEquals( + toHtml(host), + '

    Count: 0

    ', + ); +}); + +Deno.test('compiled part program spike - one program, three execution modes', async (t) => { + const { programModule, runtime } = await loadSpike(); + const programJson = await Deno.readTextFile(PROGRAM_URL); + const program = programModule.validateSpikeProgram(JSON.parse(programJson)); + + await t.step('program evidence: bytes and instruction count', () => { + const programBytes = new TextEncoder().encode(programJson).length; + assertEquals(program.parts.length, 5); + console.log( + JSON.stringify({ spike: 'element-program', programBytes, instructionCount: 5 }), + ); + }); + + let ssrHtml = ''; + await t.step('server serialization emits the deterministic HTML', () => { + const counters = { subs: 0 }; + const { host } = makeHost(counters); + ssrHtml = runtime.serializeToHtml(program, host); + assertEquals( + ssrHtml, + '
    ' + + '

    Count: 0

    ' + + '' + + '' + + '

    zero

    ' + + '
    • alpha
    • beta
    ' + + '
    ', + ); + }); + + const FRESH_ALLOCATIONS = { elements: 8, texts: 6, comments: 5 }; + const FRESH_ACTIVATION = { subs: 4, listenerAdds: 1 }; + + await t.step('fresh DOM creation from the same program matches SSR structure', () => { + const freshDoc = new FDocument(); + const counters = { subs: 0 }; + const { host } = makeHost(counters); + const freshRoot = freshDoc.createElement('host'); + freshDoc.resetCounts(); + runtime.createFreshDom(program, host, freshRoot); + assertEquals(toHtml(freshRoot), `${ssrHtml}`); + assertEquals(freshDoc.counts.elements, FRESH_ALLOCATIONS.elements); + assertEquals(freshDoc.counts.texts, FRESH_ALLOCATIONS.texts); + assertEquals(freshDoc.counts.comments, FRESH_ALLOCATIONS.comments); + assertEquals(counters.subs, FRESH_ACTIVATION.subs); + assertEquals(freshDoc.counts.listenerAdds, FRESH_ACTIVATION.listenerAdds); + }); + + await t.step('existing-DOM claim preserves identity, live value and attaches sinks', () => { + const claimDoc = new FDocument(); + const claimRoot = parseHtml(claimDoc, ssrHtml); + claimDoc.resetCounts(); + const input = (claimRoot.childNodes[0] as FElement).childNodes[1] as FElement; + const h1TextBefore = ((claimRoot.childNodes[0] as FElement).childNodes[0] as FElement) + .childNodes[2] as FText; + input.simulateUserInput('typed by user'); + const counters = { subs: 0 }; + const { host, count } = makeHost(counters); + runtime.claimExistingDom(program, host, claimRoot); + + // no allocation, no live-value overwrite, structure equivalent + assertEquals(claimDoc.counts.elements, 0); + assertEquals(claimDoc.counts.texts, 0); + assertEquals(claimDoc.counts.comments, 0); + assertEquals(claimDoc.counts.valueWrites, 0); + assertEquals(input.value, 'typed by user'); + assertEquals(toHtml(claimRoot), `${ssrHtml}`); + assertEquals(counters.subs, FRESH_ACTIVATION.subs); + assertEquals(claimDoc.counts.listenerAdds, 1); + + // the claimed event and text part are live; node identity is preserved + const button = (claimRoot.childNodes[0] as FElement).childNodes[2] as FElement; + button.dispatch('click'); + assertEquals(count.value, 1); + const h1TextAfter = ((claimRoot.childNodes[0] as FElement).childNodes[0] as FElement) + .childNodes[2] as FText; + assertStrictEquals(h1TextBefore, h1TextAfter); + assertEquals(h1TextAfter.data, '1'); + }); + + await t.step('a Signal write touches only its subscribed Part/Region', () => { + const doc = new FDocument(); + const root = doc.createElement('host'); + const counters = { subs: 0 }; + const { host, count, label } = makeHost(counters); + runtime.createFreshDom(program, host, root); + doc.resetCounts(); + + label.value = 'edited'; + assertEquals(doc.counts.valueWrites, 1); + assertEquals(doc.counts.textWrites, 0); + assertEquals(doc.counts.elements, 0); + + doc.resetCounts(); + count.value = 1; + // the subscribed text Part wrote once; the conditional Region swapped branches + assertEquals(doc.counts.textWrites, 1); + assertEquals(doc.counts.elements, 1); // replacement

    + assertEquals(doc.counts.texts, 1); // replacement text + assertEquals(doc.counts.removals, 1); // old

    (its text leaves with it) + // the unrelated property sink recorded no write + assertEquals(doc.counts.valueWrites, 0); + assertEquals(doc.counts.listenerAdds, 0); + }); + + await t.step('lazy-delivery Signal: first write reaches only its subscribed Part', () => { + const doc = new FDocument(); + const root = doc.createElement('host'); + const counters = { subs: 0 }; + const { host, count, label } = makeLazyHost(counters); + runtime.createFreshDom(program, host, root); + doc.resetCounts(); + + // The lazy engine delivered nothing at subscription time; its FIRST write + // must be treated as a real update on the subscribed text Part, while the + // unrelated property sink records no write. + count.value = 1; + assertEquals(doc.counts.textWrites, 1); + assertEquals(doc.counts.valueWrites, 0); + assertEquals(doc.counts.listenerAdds, 0); + + doc.resetCounts(); + label.value = 'lazy edit'; + assertEquals(doc.counts.valueWrites, 1); + assertEquals(doc.counts.textWrites, 0); + assertEquals(doc.counts.elements, 0); + }); + + await t.step('keyed list Region preserves element identity across reorders', () => { + const doc = new FDocument(); + const root = doc.createElement('host'); + const counters = { subs: 0 }; + const { host, items } = makeHost(counters); + runtime.createFreshDom(program, host, root); + // div children: h1(0) input(1) button(2) oe:p3(3)

    (4) oe:/p3(5) ul(6) + const ul = (root.childNodes[0] as FElement).childNodes[6] as FElement; + const liA = ul.childNodes[1] as FElement; + const liB = ul.childNodes[2] as FElement; + + doc.resetCounts(); + items.value = [ + { id: 'b', text: 'beta' }, + { id: 'a', text: 'ALPHA' }, + { id: 'c', text: 'gamma' }, + ]; + const keptB = ul.childNodes[1] as FElement; + const keptA = ul.childNodes[2] as FElement; + const addedC = ul.childNodes[3] as FElement; + assertStrictEquals(keptB, liB); + assertStrictEquals(keptA, liA); + assertEquals((keptA.childNodes[0] as FText).data, 'ALPHA'); + assertEquals((addedC.childNodes[0] as FText).data, 'gamma'); + assertEquals(doc.counts.elements, 1); // only the new item + assertEquals(doc.counts.texts, 1); + assertEquals(doc.counts.removals, 0); + + doc.resetCounts(); + items.value = [{ id: 'a', text: 'ALPHA' }]; + assertEquals(ul.childNodes.length, 3); // anchors + one item + assertStrictEquals(ul.childNodes[1], liA); + assertEquals(doc.counts.removals, 2); // b and c elements + }); + + await t.step('claim mismatch fails with a structured located diagnostic', () => { + const claimDoc = new FDocument(); + const claimRoot = parseHtml(claimDoc, ssrHtml); + const staticText = ((claimRoot.childNodes[0] as FElement).childNodes[0] as FElement) + .childNodes[0] as FText; + staticText.data = 'Count? '; + const counters = { subs: 0 }; + const { host } = makeHost(counters); + const error = assertThrows( + () => runtime.claimExistingDom(program, host, claimRoot), + runtime.PartProgramClaimError, + ); + assertStringIncludes(error.message, 'template[0].children[0].children[0]'); + }); + + await t.step('dispose detaches every subscription', () => { + const doc = new FDocument(); + const root = doc.createElement('host'); + const counters = { subs: 0 }; + const { host, count, label } = makeHost(counters); + const instance = runtime.createFreshDom(program, host, root); + instance.dispose(); + doc.resetCounts(); + count.value = 9; + label.value = 'gone'; + assertEquals(doc.counts.textWrites, 0); + assertEquals(doc.counts.valueWrites, 0); + assertEquals(doc.counts.elements, 0); + }); + + await t.step('measurement evidence against the frozen 0.43-equivalent proxy', () => { + // 0.43-equivalent: full subtree re-allocation + full marker walk per update. + const proxyDoc = new FDocument(); + build043Equivalent(proxyDoc, INITIAL_STATE); + const proxyBuildAllocations = proxyDoc.counts.elements + proxyDoc.counts.texts + + proxyDoc.counts.comments; + const proxyBuildWalk = proxyDoc.counts.walkVisits; + proxyDoc.resetCounts(); + build043Equivalent(proxyDoc, { ...INITIAL_STATE, count: 1 }); + const proxyUpdateAllocations = proxyDoc.counts.elements + proxyDoc.counts.texts + + proxyDoc.counts.comments; + const proxyUpdateWalk = proxyDoc.counts.walkVisits; + + const summary = { + spike: 'element-runtime-measurements', + compiled: { + instructionCount: 5, + freshAllocations: FRESH_ALLOCATIONS.elements + FRESH_ALLOCATIONS.texts + + FRESH_ALLOCATIONS.comments, + claimAllocations: 0, + activationSubscriptions: FRESH_ACTIVATION.subs, + activationListeners: FRESH_ACTIVATION.listenerAdds, + countUpdateAllocations: 2, + countUpdateWalkVisits: 0, + }, + frozen043EquivalentProxy: { + buildAllocations: proxyBuildAllocations, + buildWalkVisits: proxyBuildWalk, + updateAllocations: proxyUpdateAllocations, + updateWalkVisits: proxyUpdateWalk, + updateListeners: 1, + }, + }; + // Frozen evidence pins (alpha.0 fixture). The proxy counts include its own + // host root (+1 allocation, +1 walk visit) because it must allocate the + // container the compiled element receives from the platform. + assertEquals(summary.compiled.freshAllocations, 19); + assertEquals(proxyBuildAllocations, 20); + assertEquals(proxyUpdateAllocations, 20); + assertEquals(proxyBuildWalk, 20); + assertEquals(proxyUpdateWalk, 20); + console.log(JSON.stringify(summary)); + }); +}); diff --git a/packages/element/src/internal/compiled/program.ts b/packages/element/src/internal/compiled/program.ts new file mode 100644 index 000000000..26845e5bd --- /dev/null +++ b/packages/element/src/internal/compiled/program.ts @@ -0,0 +1,259 @@ +/** + * @openelement/element — compiled Part Program spike types (v0.44.0-alpha.0, #1160). + * + * Internal alpha.0 spike grammar for the ADR-0143 replacement architecture. + * One Part Program is the sole structural/mutation authority for server + * serialization, fresh browser DOM creation and existing-DOM claim. This is + * NOT the frozen Part Program v1 schema — #1161 owns the frozen grammar and + * #1162 owns the canonical decorator lowering. + * + * The adapter-vite spike compiler + * (packages/adapter-vite/src/internal/compiler/) emits exactly this shape as + * serializable JSON; the two copies intentionally share no import because the + * generated program must never depend on a private cross-package path. + */ + +export const PART_PROGRAM_SPIKE_VERSION = 1; + +/** Static element node. `attrs` preserves source order for determinism. */ +export interface SpikeElementNode { + k: 'el'; + tag: string; + attrs: Array<[string, string]>; + children: SpikeTreeNode[]; +} + +/** Static text node. */ +export interface SpikeTextNode { + k: 'text'; + value: string; +} + +/** Dynamic anchor: semantics are owned by `program.parts[index]`. */ +export interface SpikePartAnchorNode { + k: 'part'; + index: number; +} + +/** Item-value text slot, valid only inside an `each` item template. */ +export interface SpikeItemValueNode { + k: 'ival'; +} + +export type SpikeTreeNode = + | SpikeElementNode + | SpikeTextNode + | SpikePartAnchorNode + | SpikeItemValueNode; + +/** Dynamic text bound one-to-one to a Signal. */ +export interface SpikeTextPart { + k: 'text'; + index: number; + signal: string; +} + +/** DOM property sink bound to a Signal; `path` addresses the target element. */ +export interface SpikePropPart { + k: 'prop'; + index: number; + signal: string; + name: string; + path: number[]; +} + +/** Event sink on a path-addressed element; `handler` names a host handler. */ +export interface SpikeEventPart { + k: 'event'; + index: number; + event: string; + handler: string; + path: number[]; +} + +/** + * Conditional Region. The spike grammar supports exactly + * `signal > `; `gt` is the serialized threshold. Branches are + * fully static structure. + */ +export interface SpikeWhenPart { + k: 'when'; + index: number; + signal: string; + gt: number; + on: SpikeTreeNode[]; + off: SpikeTreeNode[]; +} + +/** + * Keyed list Region. `signal` holds an array of records; `key` selects the + * identity field and `field` the rendered text field of each item. + */ +export interface SpikeEachPart { + k: 'each'; + index: number; + signal: string; + key: string; + field: string; + item: SpikeTreeNode[]; +} + +export type SpikePart = + | SpikeTextPart + | SpikePropPart + | SpikeEventPart + | SpikeWhenPart + | SpikeEachPart; + +/** The one serializable program consumed by all three execution modes. */ +export interface PartProgramSpike { + version: typeof PART_PROGRAM_SPIKE_VERSION; + tag: string; + template: SpikeTreeNode[]; + parts: SpikePart[]; +} + +/** Anchor marker payloads shared by serialization, creation and claim. */ +export function partAnchorMarker(index: number): string { + return `oe:p${index}`; +} +export function partAnchorEndMarker(index: number): string { + return `oe:/p${index}`; +} + +function fail(reason: string): never { + throw new Error(`[compiled-program] invalid Part Program spike: ${reason}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateTreeNodes( + nodes: unknown, + where: string, + allowAnchors: boolean, + allowItemValue: boolean, +): asserts nodes is SpikeTreeNode[] { + if (!Array.isArray(nodes)) fail(`${where} must be an array`); + for (const node of nodes) { + if (!isRecord(node)) fail(`${where} entries must be objects`); + switch (node.k) { + case 'el': + if (typeof node.tag !== 'string' || node.tag.length === 0) fail(`${where}.el needs tag`); + if (!Array.isArray(node.attrs)) fail(`${where}.el(${node.tag}) attrs must be an array`); + for (const attr of node.attrs) { + if ( + !Array.isArray(attr) || attr.length !== 2 || typeof attr[0] !== 'string' || + typeof attr[1] !== 'string' + ) { + fail(`${where}.el(${node.tag}) attrs must be [name, value] pairs`); + } + } + validateTreeNodes(node.children, `${where}.${node.tag}`, allowAnchors, allowItemValue); + break; + case 'text': + if (typeof node.value !== 'string') fail(`${where}.text needs a string value`); + break; + case 'part': + if (!allowAnchors) fail(`${where} may not contain part anchors`); + if (!Number.isInteger(node.index)) fail(`${where}.part needs an integer index`); + break; + case 'ival': + if (!allowItemValue) fail(`${where} may not contain item value slots`); + break; + default: + fail(`${where} has unknown node kind ${String(node.k)}`); + } + } +} + +/** + * Validate an unknown value as a spike Part Program. Throws on any structural + * violation; returns the value narrowed to PartProgramSpike on success. + */ +export function validateSpikeProgram(raw: unknown): PartProgramSpike { + if (!isRecord(raw)) fail('program must be an object'); + if (raw.version !== PART_PROGRAM_SPIKE_VERSION) fail('version must be 1'); + if (typeof raw.tag !== 'string' || !/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(raw.tag)) { + fail('tag must be a custom-element tag name'); + } + validateTreeNodes(raw.template, 'template', true, false); + if (!Array.isArray(raw.parts)) fail('parts must be an array'); + const parts = raw.parts as SpikePart[]; + + parts.forEach((part, position) => { + if (!isRecord(part)) fail(`parts[${position}] must be an object`); + if (part.index !== position) fail(`parts[${position}].index must equal its position`); + switch (part.k) { + case 'text': + if (typeof part.signal !== 'string') fail(`parts[${position}].signal must be a string`); + break; + case 'prop': + if (typeof part.signal !== 'string' || typeof part.name !== 'string') { + fail(`parts[${position}] prop needs signal and name`); + } + if (!Array.isArray(part.path) || part.path.some((i) => !Number.isInteger(i))) { + fail(`parts[${position}].path must be integer child indices`); + } + break; + case 'event': + if (typeof part.event !== 'string' || typeof part.handler !== 'string') { + fail(`parts[${position}] event needs event and handler`); + } + if (!Array.isArray(part.path) || part.path.some((i) => !Number.isInteger(i))) { + fail(`parts[${position}].path must be integer child indices`); + } + break; + case 'when': + if (typeof part.signal !== 'string' || typeof part.gt !== 'number') { + fail(`parts[${position}] when needs signal and numeric gt`); + } + validateTreeNodes(part.on, `parts[${position}].on`, false, false); + validateTreeNodes(part.off, `parts[${position}].off`, false, false); + break; + case 'each': + if ( + typeof part.signal !== 'string' || typeof part.key !== 'string' || + typeof part.field !== 'string' + ) { + fail(`parts[${position}] each needs signal, key and field`); + } + validateTreeNodes(part.item, `parts[${position}].item`, false, true); + break; + default: + fail(`parts[${position}] has unknown part kind`); + } + }); + + // Anchor nodes must reference anchor-bearing parts; path parts must resolve + // to static elements inside the template. + const resolveTemplate = (path: number[]): SpikeTreeNode => { + let nodes = raw.template as SpikeTreeNode[]; + let node: SpikeTreeNode | undefined; + for (const index of path) { + node = nodes[index]; + if (!node) fail(`path [${path.join(',')}] does not resolve inside the template`); + nodes = node.k === 'el' ? node.children : []; + } + return node!; + }; + const walkAnchors = (nodes: SpikeTreeNode[]): void => { + for (const node of nodes) { + if (node.k === 'el') walkAnchors(node.children); + if (node.k !== 'part') continue; + const part = parts[node.index]; + if (!part || (part.k !== 'text' && part.k !== 'when' && part.k !== 'each')) { + fail(`anchor ${node.index} must reference a text/when/each part`); + } + } + }; + walkAnchors(raw.template as SpikeTreeNode[]); + for (const part of parts) { + if ((part.k === 'prop' || part.k === 'event') && resolveTemplate(part.path).k !== 'el') { + fail(`${part.k} part ${part.index} path must target an element`); + } + } + + return raw as unknown as PartProgramSpike; +} diff --git a/packages/element/src/internal/compiled/runtime.ts b/packages/element/src/internal/compiled/runtime.ts new file mode 100644 index 000000000..05fb7618d --- /dev/null +++ b/packages/element/src/internal/compiled/runtime.ts @@ -0,0 +1,712 @@ +/** + * @openelement/element — compiled Part Program spike runtime (#1160). + * + * Executes the alpha.0 spike Part Program in the three ADR-0143 modes: + * - serializeToHtml: server serialization (pure string output) + * - createFreshDom: fresh browser DOM creation + * - claimExistingDom: attach to existing SSR DOM, preserving node identity + * + * Signal -> Part/Region is the only reactive path: a Signal write reaches + * exactly its subscribed Part or Region. There is no VNode tree, no binding + * discovery walk and no interpreter fallback here. + * + * Internal alpha.0 spike only — not exported from the package entry points. + */ + +import type { SignalLike, Unsubscribe } from '../protocol/signal.ts'; +import { + partAnchorEndMarker, + partAnchorMarker, + type PartProgramSpike, + type SpikeEachPart, + type SpikeElementNode, + type SpikePropPart, + type SpikeTreeNode, + type SpikeWhenPart, +} from './program.ts'; + +/** Structured diagnostic for claim-time structure/identity drift (#631-class). */ +export class PartProgramClaimError extends Error { + readonly code = 'OPEN_ELEMENT_COMPILED_CLAIM_MISMATCH'; + readonly path: string; + constructor(path: string, message: string) { + super(`[compiled-claim] ${path}: ${message}`); + this.name = 'PartProgramClaimError'; + this.path = path; + } +} + +/** Host-provided reactive state and behavior, keyed by compiled names. */ +export interface CompiledSpikeHost { + signals: Record>; + handlers: Record void>; +} + +export interface CompiledSpikeInstance { + dispose(): void; +} + +interface MountContext { + program: PartProgramSpike; + host: CompiledSpikeHost; + unsubs: Unsubscribe[]; + listeners: Array<{ el: Element; type: string; fn: EventListener }>; + /** Prop parts keyed by `path.join('.')` for attribute/serialization lookup. */ + propPartsByPath: Map; +} + +function createContext(program: PartProgramSpike, host: CompiledSpikeHost): MountContext { + const propPartsByPath = new Map(); + for (const part of program.parts) { + if (part.k !== 'prop') continue; + const key = part.path.join('.'); + const list = propPartsByPath.get(key) ?? []; + list.push(part); + propPartsByPath.set(key, list); + } + return { program, host, unsubs: [], listeners: [], propPartsByPath }; +} + +function signalOf(ctx: MountContext, name: string): SignalLike { + const signal = ctx.host.signals[name]; + if (!signal) throw new Error(`[compiled-runtime] missing host signal "${name}"`); + return signal; +} + +/** + * Subscribe to future writes only, engine-neutrally. Build and claim have + * already applied the current value, so a subscription-time echo would double + * write — and, for claim, would clobber live DOM state such as a user-edited + * input value. The public Signal protocol does not require immediate delivery + * (preact-engine delivers immediately via effect(); a conforming engine may be + * lazy), so suppression is precise: only a callback delivered synchronously + * before `subscribe()` returns, whose value equals the snapshot read + * immediately before subscribing, is treated as the echo. Any callback + * delivered after `subscribe()` returns is a real update and is applied — + * including a first write from a lazy-delivery engine. + */ +function subscribeWrites( + ctx: MountContext, + name: string, + fn: (value: unknown) => void, +): void { + const signal = signalOf(ctx, name); + const snapshot = signal.value; + let subscribeReturned = false; + const unsub = signal.subscribe((value) => { + if (!subscribeReturned && Object.is(value, snapshot)) return; // sync initial echo + fn(value); + }); + subscribeReturned = true; + ctx.unsubs.push(unsub); +} + +function isComment(node: Node): node is Comment { + return node.nodeType === 8; +} +function isText(node: Node): node is Text { + return node.nodeType === 3; +} +function isElement(node: Node): node is Element { + return node.nodeType === 1; +} + +// ─── Shared node construction ──────────────────────────────────────── + +function buildStaticNodes(doc: Document, nodes: SpikeTreeNode[], where: string): Node[] { + const out: Node[] = []; + for (const node of nodes) { + if (node.k === 'text') { + out.push(doc.createTextNode(node.value)); + } else if (node.k === 'el') { + const el = doc.createElement(node.tag); + for (const [name, value] of node.attrs) el.setAttribute(name, value); + for (const child of buildStaticNodes(doc, node.children, where)) el.appendChild(child); + out.push(el); + } else { + throw new Error(`[compiled-runtime] ${where} must be fully static (found "${node.k}")`); + } + } + return out; +} + +interface BuiltItem { + nodes: Node[]; + texts: Text[]; +} + +function buildItem(doc: Document, part: SpikeEachPart, item: Record): BuiltItem { + const texts: Text[] = []; + const buildItemNodes = (nodes: SpikeTreeNode[]): Node[] => { + const out: Node[] = []; + for (const node of nodes) { + if (node.k === 'ival') { + const text = doc.createTextNode(String(item[part.field])); + texts.push(text); + out.push(text); + } else if (node.k === 'text') { + out.push(doc.createTextNode(node.value)); + } else if (node.k === 'el') { + const el = doc.createElement(node.tag); + for (const [name, value] of node.attrs) el.setAttribute(name, value); + for (const child of buildItemNodes(node.children)) el.appendChild(child); + out.push(el); + } else { + throw new Error('[compiled-runtime] item templates may not contain part anchors'); + } + } + return out; + }; + return { nodes: buildItemNodes(part.item), texts }; +} + +// ─── Regions ───────────────────────────────────────────────────────── + +interface WhenRegion { + part: SpikeWhenPart; + anchor: Comment; + end: Comment; + current: boolean; +} + +interface EachEntry extends BuiltItem { + key: string; +} + +interface EachRegion { + part: SpikeEachPart; + anchor: Comment; + end: Comment; + byKey: Map; +} + +function whenActive(part: SpikeWhenPart, value: unknown): boolean { + return Number(value) > part.gt; +} + +function updateWhen(region: WhenRegion, value: unknown): void { + const next = whenActive(region.part, value); + if (next === region.current) return; + const parent = region.end.parentNode; + if (!parent) return; + let node = region.anchor.nextSibling; + while (node && node !== region.end) { + const after = node.nextSibling; + parent.removeChild(node); + node = after; + } + const branch = next ? region.part.on : region.part.off; + for (const built of buildStaticNodes(region.end.ownerDocument, branch, 'when branch')) { + parent.insertBefore(built, region.end); + } + region.current = next; +} + +function updateEach(region: EachRegion, value: unknown): void { + if (!Array.isArray(value)) { + throw new Error(`[compiled-runtime] each part ${region.part.index} expects an array signal`); + } + const items = value as Array>; + const parent = region.end.parentNode; + if (!parent) return; + const seen = new Set(); + // `current` walks the live DOM inside the Region; the end anchor bounds it. + let current: Node = region.anchor.nextSibling ?? region.end; + for (const item of items) { + const key = String(item[region.part.key]); + seen.add(key); + let entry = region.byKey.get(key); + if (!entry) { + const built = buildItem(region.end.ownerDocument, region.part, item); + entry = { key, nodes: built.nodes, texts: built.texts }; + region.byKey.set(key, entry); + } else { + const nextText = String(item[region.part.field]); + if (entry.texts.length > 0 && entry.texts[0].data !== nextText) { + entry.texts[0].data = nextText; + } + } + for (const node of entry.nodes) { + if (node === current) { + current = current.nextSibling ?? region.end; + continue; + } + parent.insertBefore(node, current); + } + } + for (const [key, entry] of region.byKey) { + if (seen.has(key)) continue; + for (const node of entry.nodes) node.parentNode?.removeChild(node); + region.byKey.delete(key); + } +} + +// ─── Fresh DOM creation ────────────────────────────────────────────── + +function buildNodes(ctx: MountContext, doc: Document, nodes: SpikeTreeNode[]): Node[] { + const out: Node[] = []; + for (const node of nodes) { + if (node.k === 'text') { + out.push(doc.createTextNode(node.value)); + continue; + } + if (node.k === 'el') { + const el = doc.createElement(node.tag); + for (const [name, value] of node.attrs) el.setAttribute(name, value); + for (const child of buildNodes(ctx, doc, node.children)) el.appendChild(child); + out.push(el); + continue; + } + if (node.k !== 'part') { + throw new Error('[compiled-runtime] item value slot outside an each Region'); + } + const part = ctx.program.parts[node.index]; + if (part.k === 'text') { + const text = doc.createTextNode(String(signalOf(ctx, part.signal).value)); + subscribeWrites(ctx, part.signal, (value) => { + text.data = String(value); + }); + out.push(doc.createComment(partAnchorMarker(part.index)), text); + continue; + } + if (part.k === 'when') { + const anchor = doc.createComment(partAnchorMarker(part.index)); + const end = doc.createComment(partAnchorEndMarker(part.index)); + const active = whenActive(part, signalOf(ctx, part.signal).value); + const region: WhenRegion = { part, anchor, end, current: active }; + subscribeWrites(ctx, part.signal, (value) => updateWhen(region, value)); + out.push(anchor, ...buildStaticNodes(doc, active ? part.on : part.off, 'when branch'), end); + continue; + } + if (part.k === 'each') { + const anchor = doc.createComment(partAnchorMarker(part.index)); + const end = doc.createComment(partAnchorEndMarker(part.index)); + const region: EachRegion = { part, anchor, end, byKey: new Map() }; + const items = signalOf(ctx, part.signal).value as Array>; + const itemNodes: Node[] = []; + for (const item of items) { + const built = buildItem(doc, part, item); + region.byKey.set(String(item[part.key]), { key: String(item[part.key]), ...built }); + itemNodes.push(...built.nodes); + } + subscribeWrites(ctx, part.signal, (value) => updateEach(region, value)); + out.push(anchor, ...itemNodes, end); + continue; + } + throw new Error(`[compiled-runtime] part ${node.index} has no anchor representation`); + } + return out; +} + +function resolvePath(root: Node, path: number[], where: string): Element { + let node: Node = root; + for (const index of path) { + const child: ChildNode | undefined = node.childNodes[index]; + if (!child) throw new Error(`[compiled-runtime] ${where}: path [${path.join(',')}] unresolved`); + node = child; + } + if (!isElement(node)) { + throw new Error(`[compiled-runtime] ${where}: path [${path.join(',')}] is not an element`); + } + return node; +} + +/** Attach path-addressed prop/event parts. */ +function attachPathParts(ctx: MountContext, root: Node, mode: 'fresh' | 'claim'): void { + for (const part of ctx.program.parts) { + if (part.k === 'prop') { + const el = resolvePath(root, part.path, 'prop part'); + if (mode === 'fresh') { + const initial = signalOf(ctx, part.signal).value; + el.setAttribute(part.name, String(initial)); + (el as unknown as Record)[part.name] = initial; + } + // claim deliberately does not write the initial value: live DOM state + // (e.g. a user-edited input value) survives a successful claim. + subscribeWrites(ctx, part.signal, (value) => { + (el as unknown as Record)[part.name] = value; + }); + continue; + } + if (part.k === 'event') { + const el = resolvePath(root, part.path, 'event part'); + const handler = ctx.host.handlers[part.handler]; + if (!handler) { + throw new Error(`[compiled-runtime] missing host handler "${part.handler}"`); + } + const fn: EventListener = (event) => handler(event); + el.addEventListener(part.event, fn); + ctx.listeners.push({ el, type: part.event, fn }); + } + } +} + +function instance(ctx: MountContext): CompiledSpikeInstance { + let disposed = false; + return { + dispose(): void { + if (disposed) return; + disposed = true; + for (const unsub of ctx.unsubs.splice(0)) unsub(); + for (const { el, type, fn } of ctx.listeners.splice(0)) el.removeEventListener(type, fn); + }, + }; +} + +/** Fresh browser DOM creation: build the program under `root`. */ +export function createFreshDom( + program: PartProgramSpike, + host: CompiledSpikeHost, + root: Node, +): CompiledSpikeInstance { + const ctx = createContext(program, host); + const doc = root.ownerDocument; + if (!doc) throw new Error('[compiled-runtime] root must have an ownerDocument'); + for (const node of buildNodes(ctx, doc, program.template)) root.appendChild(node); + attachPathParts(ctx, root, 'fresh'); + return instance(ctx); +} + +// ─── Server serialization ──────────────────────────────────────────── + +const VOID_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'source', + 'track', + 'wbr', +]); + +function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} +function escapeAttr(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"'); +} + +function serializeElement( + ctx: MountContext, + node: SpikeElementNode, + programPath: number[], +): string { + const attrs = node.attrs.map(([name, value]) => ` ${name}="${escapeAttr(value)}"`); + for (const part of ctx.propPartsByPath.get(programPath.join('.')) ?? []) { + attrs.push(` ${part.name}="${escapeAttr(String(signalOf(ctx, part.signal).value))}"`); + } + const open = `<${node.tag}${attrs.join('')}`; + if (VOID_TAGS.has(node.tag)) return `${open}>`; + const children = node.children + .map((child, index) => serializeNode(ctx, child, [...programPath, index])) + .join(''); + return `${open}>${children}`; +} + +function serializeItemNodes( + nodes: SpikeTreeNode[], + part: SpikeEachPart, + item: Record, +): string { + return nodes + .map((node) => { + if (node.k === 'ival') return escapeText(String(item[part.field])); + if (node.k === 'text') return escapeText(node.value); + if (node.k === 'el') { + const attrs = node.attrs.map(([name, value]) => ` ${name}="${escapeAttr(value)}"`).join(''); + const inner = serializeItemNodes(node.children, part, item); + return VOID_TAGS.has(node.tag) + ? `<${node.tag}${attrs}>` + : `<${node.tag}${attrs}>${inner}`; + } + throw new Error('[compiled-runtime] item templates may not contain part anchors'); + }) + .join(''); +} + +function serializeNode(ctx: MountContext, node: SpikeTreeNode, programPath: number[]): string { + switch (node.k) { + case 'text': + return escapeText(node.value); + case 'el': + return serializeElement(ctx, node, programPath); + case 'part': { + const part = ctx.program.parts[node.index]; + const open = ``; + if (part.k === 'text') { + return open + escapeText(String(signalOf(ctx, part.signal).value)); + } + const close = ``; + if (part.k === 'when') { + const active = whenActive(part, signalOf(ctx, part.signal).value); + const branch = (active ? part.on : part.off) + .map((branchNode, index) => serializeNode(ctx, branchNode, [...programPath, index])) + .join(''); + return open + branch + close; + } + if (part.k === 'each') { + const items = signalOf(ctx, part.signal).value as Array>; + return open + items.map((item) => serializeItemNodes(part.item, part, item)).join('') + + close; + } + throw new Error(`[compiled-runtime] part ${part.index} has no serialized anchor`); + } + case 'ival': + throw new Error('[compiled-runtime] item value slot outside an each Region'); + } +} + +/** Server serialization: the same program rendered to deterministic HTML. */ +export function serializeToHtml(program: PartProgramSpike, host: CompiledSpikeHost): string { + const ctx = createContext(program, host); + return program.template.map((node, index) => serializeNode(ctx, node, [index])).join(''); +} + +// ─── Existing-DOM claim ────────────────────────────────────────────── + +function claimFailure(path: string, message: string): never { + throw new PartProgramClaimError(path, message); +} + +function expectComment(node: Node, marker: string, path: string): Comment { + if (!isComment(node) || node.data !== marker) { + claimFailure(path, `expected anchor`); + } + return node; +} + +function claimItemNodes( + part: SpikeEachPart, + nodes: SpikeTreeNode[], + parent: Node, + cursor: number, + item: Record, + path: string, +): { consumed: number; entryNodes: Node[]; texts: Text[] } { + const entryNodes: Node[] = []; + const texts: Text[] = []; + let used = 0; + for (const node of nodes) { + const dom = parent.childNodes[cursor + used]; + if (!dom) claimFailure(path, 'missing item node'); + if (node.k === 'ival') { + if (!isText(dom)) claimFailure(path, 'expected item value text'); + const expected = String(item[part.field]); + if (dom.data !== expected) { + claimFailure(path, `item text drift: expected ${JSON.stringify(expected)}`); + } + texts.push(dom); + entryNodes.push(dom); + used++; + continue; + } + if (node.k === 'text') { + if (!isText(dom) || dom.data !== node.value) claimFailure(path, 'item text drift'); + entryNodes.push(dom); + used++; + continue; + } + if (node.k === 'el') { + if (!isElement(dom) || dom.tagName.toLowerCase() !== node.tag) { + claimFailure(path, `expected item element <${node.tag}>`); + } + for (const [name, value] of node.attrs) { + if ((dom as Element).getAttribute(name) !== value) { + claimFailure(path, `item attribute "${name}" drift`); + } + } + texts.push(...claimItemChildren(part, node, dom, item, path)); + entryNodes.push(dom); + used++; + continue; + } + claimFailure(path, 'item templates may not contain part anchors'); + } + return { consumed: used, entryNodes, texts }; +} + +function claimItemChildren( + part: SpikeEachPart, + node: SpikeElementNode, + el: Node, + item: Record, + path: string, +): Text[] { + const texts: Text[] = []; + if (el.childNodes.length !== node.children.length) { + claimFailure(path, 'item element child count drift'); + } + node.children.forEach((child, index) => { + const dom = el.childNodes[index]; + if (child.k === 'ival') { + if (!isText(dom)) claimFailure(path, 'expected item value text'); + const expected = String(item[part.field]); + if (dom.data !== expected) { + claimFailure(path, `item text drift: expected ${JSON.stringify(expected)}`); + } + texts.push(dom); + return; + } + if (child.k === 'text') { + if (!isText(dom) || dom.data !== child.value) claimFailure(path, 'item text drift'); + return; + } + if (child.k === 'el') { + if (!isElement(dom) || dom.tagName.toLowerCase() !== child.tag) { + claimFailure(path, `expected item element <${child.tag}>`); + } + texts.push(...claimItemChildren(part, child, dom, item, path)); + return; + } + claimFailure(path, 'item templates may not contain part anchors'); + }); + return texts; +} + +/** + * Claim `nodes` against `parent`'s existing children starting at `cursor`. + * Returns the cursor after the consumed children. `programPath` tracks + * template child indices so prop-driven attributes can skip verification. + */ +function claimChildren( + ctx: MountContext, + parent: Node, + cursor: number, + nodes: SpikeTreeNode[], + path: string, + programPath: number[], +): number { + const kids = parent.childNodes; + const at = (index: number): Node => { + const node = kids[index]; + if (!node) claimFailure(path, `missing child at DOM index ${index}`); + return node; + }; + + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index]; + const nodePath = `${path}[${index}]`; + const nodeProgramPath = [...programPath, index]; + + if (node.k === 'text') { + const dom = at(cursor++); + if (!isText(dom)) claimFailure(nodePath, 'expected a text node'); + if (dom.data !== node.value) { + claimFailure( + nodePath, + `text drift: expected ${JSON.stringify(node.value)}, found ${JSON.stringify(dom.data)}`, + ); + } + continue; + } + + if (node.k === 'el') { + const dom = at(cursor++); + if (!isElement(dom)) claimFailure(nodePath, 'expected an element'); + if (dom.tagName.toLowerCase() !== node.tag) { + claimFailure(nodePath, `expected <${node.tag}>, found <${dom.tagName.toLowerCase()}>`); + } + const propDriven = new Set( + (ctx.propPartsByPath.get(nodeProgramPath.join('.')) ?? []).map((part) => part.name), + ); + for (const [name, value] of node.attrs) { + if (propDriven.has(name)) continue; // live property state wins over SSR drift + if (dom.getAttribute(name) !== value) { + claimFailure(nodePath, `attribute drift on "${name}": expected ${JSON.stringify(value)}`); + } + } + const consumed = claimChildren( + ctx, + dom, + 0, + node.children, + `${nodePath}.children`, + nodeProgramPath, + ); + if (consumed !== dom.childNodes.length) { + claimFailure(`${nodePath}.children`, 'unexpected trailing nodes'); + } + continue; + } + + if (node.k === 'part') { + const part = ctx.program.parts[node.index]; + if (part.k === 'text') { + expectComment(at(cursor++), partAnchorMarker(part.index), nodePath); + const text = at(cursor++); + if (!isText(text)) claimFailure(nodePath, 'expected a text node after the part anchor'); + const expected = String(signalOf(ctx, part.signal).value); + if (text.data !== expected) { + claimFailure( + nodePath, + `part text drift: expected ${JSON.stringify(expected)}, found ${ + JSON.stringify(text.data) + }`, + ); + } + subscribeWrites(ctx, part.signal, (value) => { + text.data = String(value); + }); + continue; + } + if (part.k === 'when') { + const anchor = expectComment(at(cursor++), partAnchorMarker(part.index), nodePath); + const active = whenActive(part, signalOf(ctx, part.signal).value); + const branch = active ? part.on : part.off; + cursor = claimChildren(ctx, parent, cursor, branch, `${nodePath}.branch`, []); + const end = expectComment(at(cursor++), partAnchorEndMarker(part.index), nodePath); + const region: WhenRegion = { part, anchor, end, current: active }; + subscribeWrites(ctx, part.signal, (value) => updateWhen(region, value)); + continue; + } + if (part.k === 'each') { + const anchor = expectComment(at(cursor++), partAnchorMarker(part.index), nodePath); + const region: EachRegion = { part, anchor, end: anchor, byKey: new Map() }; + const items = signalOf(ctx, part.signal).value as Array>; + for (const item of items) { + const claimed = claimItemNodes(part, part.item, parent, cursor, item, `${nodePath}.item`); + cursor += claimed.consumed; + region.byKey.set(String(item[part.key]), { + key: String(item[part.key]), + nodes: claimed.entryNodes, + texts: claimed.texts, + }); + } + region.end = expectComment(at(cursor++), partAnchorEndMarker(part.index), nodePath); + subscribeWrites(ctx, part.signal, (value) => updateEach(region, value)); + continue; + } + claimFailure(nodePath, `part ${node.index} has no claimable anchor`); + } + + claimFailure(nodePath, 'item value slot outside an each Region'); + } + + return cursor; +} + +/** + * Claim existing SSR DOM: verify exact structure and attach Parts, Regions, + * events and subscriptions without recreating nodes. Structural drift fails + * with a PartProgramClaimError carrying the template path; bounded recovery of + * the owning element range is owned by #1169 and is intentionally absent here. + */ +export function claimExistingDom( + program: PartProgramSpike, + host: CompiledSpikeHost, + root: Node, +): CompiledSpikeInstance { + const ctx = createContext(program, host); + const consumed = claimChildren(ctx, root, 0, program.template, 'template', []); + if (consumed !== root.childNodes.length) { + claimFailure('template', 'unexpected trailing nodes'); + } + attachPathParts(ctx, root, 'claim'); + return instance(ctx); +}