@hyperfixi/testing-framework 2.9.0 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixi/testing-framework",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "Cross-platform behavior testing suite for LokaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -40,7 +40,8 @@
40
40
  "analyze-failures": "tsx src/multilingual/tools/analyze-failures.ts",
41
41
  "typecheck": "tsc --noEmit",
42
42
  "test:check": "VITEST_QUIET=1 bash ../../scripts/vitest-run.sh --reporter=dot",
43
- "test:canonical": "FOREIGN_CANONICAL_VALIDITY=1 VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/canonical-validity.test.ts src/multilingual/foreign-canonical-validity.test.ts"
43
+ "test:canonical": "FOREIGN_CANONICAL_VALIDITY=1 VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/canonical-validity.test.ts src/multilingual/foreign-canonical-validity.test.ts",
44
+ "test:shipped-sources": "VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/shipped-sources-validity.test.ts"
44
45
  },
45
46
  "keywords": [
46
47
  "hyperscript",
@@ -56,10 +57,10 @@
56
57
  "author": "LokaScript Contributors",
57
58
  "license": "MIT",
58
59
  "dependencies": {
59
- "@hyperfixi/core": "^2.9.0",
60
- "@hyperfixi/patterns-reference": "^2.9.0",
61
- "@lokascript/i18n": "^2.9.0",
62
- "@lokascript/semantic": "^2.9.0",
60
+ "@hyperfixi/core": "^2.9.2",
61
+ "@hyperfixi/patterns-reference": "^2.9.2",
62
+ "@lokascript/i18n": "^2.9.2",
63
+ "@lokascript/semantic": "^2.9.2",
63
64
  "diff": "^8.0.3",
64
65
  "esbuild": "^0.28.0",
65
66
  "happy-dom": "^20.10.6",
@@ -15,8 +15,9 @@
15
15
  *
16
16
  * The FOREIGN-side sibling (foreign-canonical-validity.ts) is folded into
17
17
  * `multilingual/cli.ts`'s `--regression` gate as the R4 canonical-validity
18
- * ratchet. This en-side check remains vitest-only (its allowlist holds exactly
19
- * one entry, pick-text-range). The same loader recipe now also ships in the
18
+ * ratchet. This en-side check remains vitest-only; its allowlist is currently
19
+ * EMPTY (134/134 corpus references render valid — it formerly held one entry,
20
+ * pick-text-range). The same loader recipe now also ships in the
20
21
  * build-time `@hyperscript-tools/i18n` transpiler (`src/validate.ts`, CLI
21
22
  * `--check`, Eleventy `parseCheck`) to gate its ENGLISH side (input + foreign→
22
23
  * English output). Faithful foreign-OUTPUT gating still awaits the v2
@@ -0,0 +1,92 @@
1
+ /**
2
+ * DOM effect signatures — shared by the R2 execution validator (corpus
3
+ * patterns vs the en reference) and the shipped-examples execution gate
4
+ * (shipped handlers vs the upstream engine).
5
+ *
6
+ * A signature is a before/after diff of every element's identity-relevant
7
+ * state: classes, attributes, inline style, and leaf text. Selectors, classes,
8
+ * and attribute names are code (not translated), so signatures are directly
9
+ * comparable across languages — and across engines, which is what makes the
10
+ * upstream-oracle comparison possible at all.
11
+ *
12
+ * Extracted verbatim from validators/execution-validator.ts (R2), which now
13
+ * imports from here. Any change to the serialization changes BOTH gates'
14
+ * signatures at once, deliberately: the two must never disagree about what a
15
+ * DOM effect is.
16
+ */
17
+
18
+ /**
19
+ * Attributes that are ENGINE bookkeeping, not page behavior: hyperscript
20
+ * source (`_`), hyperfixi's processed-marker, show/hide display memo, and the
21
+ * shipped-examples harness's identity keys. Excluded from signatures so a
22
+ * cross-engine comparison measures what the handler DID to the page, not how
23
+ * each engine annotates its own work. For R2 this changed exactly one locked
24
+ * signature (`hide-with-transition`, which loses its data-original-display
25
+ * memo) and no fidelity numbers — translations are compared against a same-run
26
+ * en reference, so both sides drop the memo together.
27
+ */
28
+ const ENGINE_ATTRS = new Set([
29
+ '_',
30
+ 'data-hyperscript-powered',
31
+ 'data-original-display',
32
+ 'data-exec-key',
33
+ ]);
34
+
35
+ /**
36
+ * Serialize the identity-relevant state of one element.
37
+ * Leaf text only — container text would duplicate every child mutation.
38
+ */
39
+ export function serializeElement(el: Element): string {
40
+ const attrs = Array.from(el.attributes)
41
+ .filter(a => a.name !== 'class' && a.name !== 'style' && !ENGINE_ATTRS.has(a.name))
42
+ .map(a => `${a.name}=${a.value}`)
43
+ .sort()
44
+ .join(',');
45
+ const classes = Array.from(el.classList).sort().join(' ');
46
+ const style = (el as HTMLElement).getAttribute('style') ?? '';
47
+ const text =
48
+ el.childNodes.length === 0 || (el.childNodes.length === 1 && el.firstChild?.nodeType === 3)
49
+ ? (el.textContent ?? '')
50
+ : '';
51
+ return `cls[${classes}] attr[${attrs}] style[${style}] text[${text}]`;
52
+ }
53
+
54
+ /**
55
+ * Snapshot every element under <body>. <body> participates under its own
56
+ * stable key (body-targeted effects must be visible).
57
+ *
58
+ * Keying: `data-exec-key` when present (stamped by the shipped-examples
59
+ * harness BEFORE the engine runs, so the key is the element's IDENTITY — an
60
+ * engine inserting or removing one element cannot shift every later element's
61
+ * key, which flat indices did: one injected node turned a one-line diff into a
62
+ * whole-page churn storm). Elements without a key (added during the run, and
63
+ * the whole R2 corpus fixture, which is never stamped) fall back to `#id` /
64
+ * `tag[document-order-index]` — R2's original keying, byte-identical for its
65
+ * signatures.
66
+ */
67
+ export function snapshot(document: Document): Map<string, string> {
68
+ const out = new Map<string, string>();
69
+ out.set('body', serializeElement(document.body));
70
+ document.body.querySelectorAll('*').forEach((el, i) => {
71
+ const execKey = el.getAttribute?.('data-exec-key');
72
+ const key = execKey
73
+ ? `${el.tagName.toLowerCase()}:${execKey}${el.id ? `#${el.id}` : ''}`
74
+ : el.id
75
+ ? `#${el.id}`
76
+ : `${el.tagName.toLowerCase()}[${i}]`;
77
+ out.set(key, serializeElement(el));
78
+ });
79
+ return out;
80
+ }
81
+
82
+ /** Sorted, stable list of per-element changes between two snapshots. */
83
+ export function diffSnapshots(before: Map<string, string>, after: Map<string, string>): string[] {
84
+ const effects: string[] = [];
85
+ for (const [k, v] of after) {
86
+ const b = before.get(k);
87
+ if (b === undefined) effects.push(`+${k} ${v}`);
88
+ else if (b !== v) effects.push(`Δ${k} ${v}`);
89
+ }
90
+ for (const k of before.keys()) if (!after.has(k)) effects.push(`-${k}`);
91
+ return effects.sort();
92
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Shipped-examples execution gate (see shipped-examples-execution.ts for the
3
+ * why and the execution model).
4
+ *
5
+ * Executes every eligible `_="…"` handler shipped in `examples/**` on BOTH
6
+ * hyperfixi and the real `hyperscript.org` engine, in jsdom, and ratchets on
7
+ * divergence of their DOM effect signatures. Upstream is the behavioral
8
+ * oracle — the same role R4 gives it for validity. This is the gate that would
9
+ * have caught the #785 defect (a conditional body running unconditionally on a
10
+ * shipped page) on BEHAVIOR: every parse-level gate stayed green while it
11
+ * shipped.
12
+ *
13
+ * Assertions, matching the shipped-sources gate:
14
+ * 1. sanity — pages walked, handlers extracted, comparisons actually ran
15
+ * (guards the silent-zero failure mode);
16
+ * 2. no NEW divergence appears outside the committed allowlist;
17
+ * 3. no allowlisted key has silently converged (stale entries must be
18
+ * removed so the list only ever ratchets down).
19
+ *
20
+ * To update after an intentional change: re-run and regenerate
21
+ * `baselines/shipped-examples-execution.json` (the allowlist key embeds a
22
+ * source hash, so FIXING a handler changes its key and assertion 3 forces the
23
+ * entry's removal).
24
+ *
25
+ * Node-only (walks the repo, imports hyperscript.org off disk). The sweep
26
+ * swaps jsdom globals per handler execution — safe because vitest isolates
27
+ * test files per worker.
28
+ *
29
+ * @vitest-environment node
30
+ * The node environment is REQUIRED, not a preference: under the suite default
31
+ * (happy-dom) the DOM constructors already exist on globalThis, the harness's
32
+ * globals bootstrap refuses to overwrite what it does not own, and both
33
+ * engines then bind happy-dom's constructors — every instanceof against a
34
+ * jsdom element fails and every hyperfixi signature comes back empty
35
+ * (measured: 0 real matches under happy-dom vs 74 under node).
36
+ */
37
+
38
+ import { readFileSync } from 'node:fs';
39
+ import { fileURLToPath } from 'node:url';
40
+ import path from 'node:path';
41
+ import { describe, it, expect, beforeAll } from 'vitest';
42
+ import {
43
+ runShippedExamplesExecution,
44
+ triggerEventOf,
45
+ keyFor,
46
+ type ExecutionParityResult,
47
+ } from './shipped-examples-execution';
48
+
49
+ interface AllowlistDoc {
50
+ allowedDivergences: Array<{
51
+ key: string;
52
+ file: string;
53
+ event: string;
54
+ excerpt: string;
55
+ reason: string;
56
+ }>;
57
+ }
58
+
59
+ const baselinePath = path.resolve(
60
+ path.dirname(fileURLToPath(import.meta.url)),
61
+ '../../baselines/shipped-examples-execution.json'
62
+ );
63
+ const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
64
+ const allowed = new Set(allowlist.allowedDivergences.map(e => e.key));
65
+
66
+ describe('shipped-examples execution gate', () => {
67
+ let result: ExecutionParityResult;
68
+
69
+ beforeAll(async () => {
70
+ result = await runShippedExamplesExecution();
71
+
72
+ // Visibility, not assertions: what the sweep could not compare, and why.
73
+ // A silently shrinking denominator is this gate's own blind spot.
74
+ const reasons = new Map<string, number>();
75
+ for (const s of result.skipped) {
76
+ const r = s.reason.split(':')[0] ?? s.reason;
77
+ reasons.set(r, (reasons.get(r) ?? 0) + 1);
78
+ }
79
+ const vacuous = result.compared.filter(c => c.vacuous).length;
80
+ console.log(
81
+ `[shipped-examples-execution] pages=${result.pages} handlers=${result.handlers} ` +
82
+ `compared=${result.compared.length} (vacuous=${vacuous}) skipped=${result.skipped.length}`
83
+ );
84
+ for (const [r, n] of [...reasons].sort((a, b) => b[1] - a[1])) {
85
+ console.log(`[shipped-examples-execution] skip ×${n}: ${r}`);
86
+ }
87
+ }, 240_000);
88
+
89
+ it('walks pages and compares handlers (sanity: extraction and both engines working)', () => {
90
+ // Floors well below current values (55 / 333 / 162 / 74) but far above
91
+ // zero: a broken walk, extractor, or engine bootstrap fails loudly here
92
+ // instead of making assertions 2-3 vacuously pass.
93
+ expect(result.pages).toBeGreaterThan(40);
94
+ expect(result.handlers).toBeGreaterThan(250);
95
+ expect(result.compared.length).toBeGreaterThan(120);
96
+ // Vacuous (empty-vs-empty) pairs are NOT parity evidence — the floor is on
97
+ // real, non-empty signature matches.
98
+ const realMatches = result.compared.filter(c => c.match && !c.vacuous).length;
99
+ expect(realMatches).toBeGreaterThan(60);
100
+ });
101
+
102
+ it('has no NEW divergence from upstream outside the allowlist', () => {
103
+ const unexpected = result.compared.filter(c => !c.match && !allowed.has(c.key));
104
+ expect(
105
+ unexpected,
106
+ unexpected.length
107
+ ? `\nShipped handlers whose DOM effect DIVERGES from the hyperscript.org engine ` +
108
+ `(fix the behavior, or allowlist with a family reason):\n` +
109
+ unexpected
110
+ .map(
111
+ f =>
112
+ ` [${f.key}]\n` +
113
+ ` "${f.excerpt}"\n` +
114
+ ` hyperfixi: ${JSON.stringify(f.hyperfixiEffects).slice(0, 300)}\n` +
115
+ ` upstream : ${JSON.stringify(f.upstreamEffects).slice(0, 300)}`
116
+ )
117
+ .join('\n') +
118
+ `\n\nTriage guidance: an EMPTY hyperfixi signature with a non-empty upstream one usually\n` +
119
+ `means hyperfixi silently dropped behavior (the #785 class). The reverse often means a\n` +
120
+ `deliberate hyperfixi extension or a jsdom limitation on the upstream side — check the\n` +
121
+ `existing family reasons in baselines/shipped-examples-execution.json before adding a new one.`
122
+ : ''
123
+ ).toEqual([]);
124
+ });
125
+
126
+ it('has no stale allowlist entries (a now-converged handler must be removed so the list ratchets down)', () => {
127
+ const stillDiverging = new Set(result.compared.filter(c => !c.match).map(c => c.key));
128
+ const stale = allowlist.allowedDivergences.map(e => e.key).filter(k => !stillDiverging.has(k));
129
+ expect(
130
+ stale,
131
+ stale.length
132
+ ? `\nThese allowlisted handlers no longer diverge (fixed, or edited — the key embeds a\n` +
133
+ `source hash; or no longer eligible, in which case the coverage loss should be deliberate).\n` +
134
+ `Remove them from baselines/shipped-examples-execution.json:\n ${stale.join('\n ')}`
135
+ : ''
136
+ ).toEqual([]);
137
+ });
138
+ });
139
+
140
+ describe('harness pieces', () => {
141
+ it('extracts the trigger event from the leading on-clause', () => {
142
+ expect(triggerEventOf('on click add .a to me')).toBe('click');
143
+ expect(triggerEventOf(' on every click log me')).toBe('click');
144
+ expect(triggerEventOf("on keydown[key=='Escape'] from window hide .x")).toBe('keydown');
145
+ expect(triggerEventOf('on draggable:start add .drag')).toBe('draggable:start');
146
+ expect(triggerEventOf('on click or keyup toggle .a')).toBe('click');
147
+ expect(triggerEventOf('install Draggable')).toBeNull();
148
+ expect(triggerEventOf('init set x to 1')).toBeNull();
149
+ });
150
+
151
+ it('keys embed the source hash, so an edited handler changes key', () => {
152
+ const a = keyFor({ file: 'f.html', source: 'on click add .a', event: 'click' });
153
+ const b = keyFor({ file: 'f.html', source: 'on click add .b', event: 'click' });
154
+ expect(a).not.toBe(b);
155
+ expect(a).toMatch(/^f\.html::[0-9a-f]{10}::click$/);
156
+ });
157
+ });
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Shipped-examples execution gate — upstream as the behavioral oracle
3
+ * -------------------------------------------------------------------
4
+ * Every parse-level gate in this repo went green while `native-dialog.html`
5
+ * shipped with a conditional body running unconditionally (#785), and again
6
+ * while five upstream-valid `if` shapes regressed on a PR branch (#786). The
7
+ * class they cannot see is BEHAVIOR: a handler that parses cleanly and then
8
+ * does the wrong thing on the page.
9
+ *
10
+ * This gate executes the handlers we actually ship. For each `_="…"` attribute
11
+ * in `examples/**`, the full page is loaded into two jsdom instances — one
12
+ * processed by hyperfixi (parse + a fresh Runtime with the element as context,
13
+ * the same shape the browser attribute processor uses), one by the real
14
+ * `hyperscript.org` engine (`processNode`) — the handler's trigger event is
15
+ * dispatched, and the resulting DOM effect signatures (../effect-signature.ts,
16
+ * shared with the R2 execution ratchet) are diffed against each other.
17
+ * Upstream plays the role R4 gives it for validity: the oracle. A divergence
18
+ * means hyperfixi's runtime behavior differs from upstream's ON A SHIPPED
19
+ * PAGE — exactly the #785/#786 failure mode, caught on behavior instead of by
20
+ * luck.
21
+ *
22
+ * ## Fair denominator (mirrors R4)
23
+ * A handler is only compared when BOTH engines accept its source: hyperfixi
24
+ * must compile it clean (`ok: true`, no recovered errors — recovering sources
25
+ * are the shipped-sources gate's business) and upstream must parse it (a
26
+ * hyperfixi-only extension has no oracle). It must also be deterministically
27
+ * executable in jsdom: triggered by a dispatchable event, free of network /
28
+ * timer / navigation constructs. Every exclusion is recorded with a reason —
29
+ * the skip list is part of the result, never silent (`no silent caps`).
30
+ *
31
+ * ## Execution model
32
+ * Per handler and engine: a FRESH jsdom of the whole page, every eligible
33
+ * handler installed (page-like — bubbling into sibling handlers stays real and
34
+ * symmetric), then ONLY the probed handler's event dispatched, with a
35
+ * before/after snapshot around it. See runHandlerOnEngine for why isolation is
36
+ * worth its cost.
37
+ *
38
+ * ## Node-only
39
+ * Imports the real `hyperscript.org` build off disk and swaps jsdom globals
40
+ * per handler execution (both engines resolve `document` lazily through
41
+ * globalThis — the same mechanism the R2 validator relies on). Cannot run in a
42
+ * browser suite, and under vitest REQUIRES the node environment (see the test
43
+ * file's docblock).
44
+ */
45
+
46
+ import fs from 'node:fs';
47
+ import path from 'node:path';
48
+ import { createHash } from 'node:crypto';
49
+ import { createRequire } from 'node:module';
50
+ import { fileURLToPath, pathToFileURL } from 'node:url';
51
+ import { JSDOM } from 'jsdom';
52
+ import { snapshot, diffSnapshots } from './effect-signature';
53
+
54
+ /** Repo root, from `packages/testing-framework/src/multilingual/`. */
55
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
56
+
57
+ /**
58
+ * Only `examples/**` — unlike the shipped-sources validity gate, execution
59
+ * needs the surrounding PAGE (the handler's selectors resolve against it), and
60
+ * doc snippets have none.
61
+ */
62
+ const DEFAULT_ROOTS = ['examples'];
63
+
64
+ /**
65
+ * Events the harness can dispatch deterministically. `on load` is excluded
66
+ * (fires through engine-specific initialization, not a dispatchable event);
67
+ * anything not listed is skipped with a reason.
68
+ */
69
+ const SAFE_EVENTS = new Set([
70
+ 'click',
71
+ 'dblclick',
72
+ 'mousedown',
73
+ 'mouseup',
74
+ 'mouseenter',
75
+ 'mouseleave',
76
+ 'mouseover',
77
+ 'mouseout',
78
+ 'pointerdown',
79
+ 'pointerup',
80
+ 'input',
81
+ 'change',
82
+ 'keydown',
83
+ 'keyup',
84
+ 'focus',
85
+ 'blur',
86
+ ]);
87
+
88
+ /**
89
+ * Constructs that make an execution nondeterministic in jsdom (network,
90
+ * timers, animation, navigation, module-level installs) — with, for each, the
91
+ * reason it is excluded. Matched against the whole source.
92
+ */
93
+ const DISQUALIFIERS: Array<{ pattern: RegExp; reason: string }> = [
94
+ { pattern: /\bfetch\b/, reason: 'network (fetch)' },
95
+ { pattern: /\bwait\b/, reason: 'timers (wait)' },
96
+ { pattern: /\bsettle\b/, reason: 'animation settling' },
97
+ { pattern: /\btransition\b/, reason: 'animation (transition)' },
98
+ { pattern: /\brepeat\s+forever\b/, reason: 'unbounded loop' },
99
+ { pattern: /\bgo\s+to\s+url\b/i, reason: 'navigation' },
100
+ { pattern: /\binstall\s/, reason: 'behavior install (registries differ by design)' },
101
+ { pattern: /(^|\s)js(\s|\()/, reason: 'js interop block' },
102
+ { pattern: /\beventsource\b/i, reason: 'SSE' },
103
+ { pattern: /\bsocket\b/i, reason: 'WebSocket' },
104
+ { pattern: /\bnavigator\b/, reason: 'browser API jsdom does not implement' },
105
+ { pattern: /\bbeep!/, reason: 'debug construct' },
106
+ {
107
+ pattern: /\bnew Date\b|\bMath\.random\b|toLocaleTimeString|Date\.now/,
108
+ reason: 'nondeterministic value (time/random) — signatures would flake across the two runs',
109
+ },
110
+ ];
111
+
112
+ /** A handler extracted from a shipped page. */
113
+ export interface ShippedHandler {
114
+ /** Repo-relative path of the page. */
115
+ file: string;
116
+ /** Document-order index among the page's `[_]` elements — the element's identity across jsdom instances. */
117
+ index: number;
118
+ /** The hyperscript source. */
119
+ source: string;
120
+ /** The trigger event parsed from the leading `on` clause, when any. */
121
+ event: string | null;
122
+ }
123
+
124
+ export interface SkippedHandler extends ShippedHandler {
125
+ reason: string;
126
+ }
127
+
128
+ /** One compared handler: both engines ran it; signatures either match or not. */
129
+ export interface ComparedHandler extends ShippedHandler {
130
+ /** Stable key: file + source hash + event. Fixing a source changes its key. */
131
+ key: string;
132
+ event: string;
133
+ hyperfixiEffects: string[];
134
+ upstreamEffects: string[];
135
+ match: boolean;
136
+ /**
137
+ * Both signatures empty. NOT evidence of parity — a genuinely effect-free
138
+ * handler (bare `halt`, a `log`) and a both-engines-broken handler look the
139
+ * same. Counted separately; never let vacuous pairs inflate the match count.
140
+ */
141
+ vacuous: boolean;
142
+ /** Single-line excerpt, for reading the baseline without opening the file. */
143
+ excerpt: string;
144
+ }
145
+
146
+ export interface ExecutionParityResult {
147
+ /** Pages walked. */
148
+ pages: number;
149
+ /** Handlers found (before eligibility). */
150
+ handlers: number;
151
+ /** Handlers executed on both engines. */
152
+ compared: ComparedHandler[];
153
+ /** Handlers excluded, each with its reason. */
154
+ skipped: SkippedHandler[];
155
+ }
156
+
157
+ /** Stable key for one handler execution. */
158
+ export function keyFor(h: { file: string; source: string; event: string }): string {
159
+ const hash = createHash('sha1').update(h.source).digest('hex').slice(0, 10);
160
+ return `${h.file}::${hash}::${h.event}`;
161
+ }
162
+
163
+ /** First `on <event>` name, if the source is an event handler. */
164
+ export function triggerEventOf(source: string): string | null {
165
+ // `on every click`, `on click[filter]`, `on click from #x`, `on click or keyup`
166
+ // all yield their first event name; modifiers/filters are dropped.
167
+ const m = source.match(/^\s*on\s+(?:every\s+)?([\w-]+(?::[\w-]+)?)/);
168
+ return m ? (m[1] ?? null) : null;
169
+ }
170
+
171
+ function walkHtml(dir: string, acc: string[] = []): string[] {
172
+ let entries: fs.Dirent[];
173
+ try {
174
+ entries = fs.readdirSync(dir, { withFileTypes: true });
175
+ } catch {
176
+ return acc;
177
+ }
178
+ for (const entry of entries) {
179
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) {
180
+ continue;
181
+ }
182
+ const full = path.join(dir, entry.name);
183
+ if (entry.isDirectory()) walkHtml(full, acc);
184
+ else if (entry.name.endsWith('.html')) acc.push(full);
185
+ }
186
+ return acc;
187
+ }
188
+
189
+ /** Extract the page's `[_]` handlers in document order. */
190
+ export function extractHandlers(file: string, html: string): ShippedHandler[] {
191
+ const dom = new JSDOM(html);
192
+ const out: ShippedHandler[] = [];
193
+ dom.window.document.querySelectorAll('[_]').forEach((el: Element, index: number) => {
194
+ const source = el.getAttribute('_') ?? '';
195
+ out.push({ file, index, source, event: triggerEventOf(source) });
196
+ });
197
+ return out;
198
+ }
199
+
200
+ /** Minimal engine surfaces, injected by `initEngines`. */
201
+ export interface Engines {
202
+ /** hyperfixi compile check (fair-denominator side 1). */
203
+ compileClean(source: string): boolean;
204
+ /** upstream parse check (fair-denominator side 2). Returns error strings; [] = valid. */
205
+ upstreamErrors(source: string): string[];
206
+ /** Install a handler on an element via hyperfixi (the public eval surface). */
207
+ hyperfixiInstall(source: string, el: Element): Promise<void>;
208
+ /** Install a handler on an element via the upstream engine. */
209
+ upstreamInstall(el: Element): void;
210
+ }
211
+
212
+ /**
213
+ * Keys this module has installed onto globalThis from a jsdom window. Tracked
214
+ * so every later `installGlobals` RE-points them at the new page's window —
215
+ * merely skipping keys that already exist left every DOM constructor
216
+ * (`HTMLElement`, `Element`, …) bound to the FIRST page forever, and both
217
+ * engines' `instanceof` checks then silently failed for later pages' elements
218
+ * (measured: upstream produced empty signatures for `on click put 'Hello' into
219
+ * #output` — the simplest possible handler — on every page after the first).
220
+ */
221
+ const installedGlobalKeys = new Set<string>();
222
+
223
+ /**
224
+ * Point the process globals at a page's jsdom (jsdom-global style: everything
225
+ * the window owns that node does not, plus the DOM-critical names node has
226
+ * opinions about). Both engines resolve `document` lazily through globalThis,
227
+ * so this is what "switching pages" means.
228
+ */
229
+ export function installGlobals(dom: JSDOM): void {
230
+ const g = globalThis as Record<string, unknown>;
231
+ const win = dom.window as unknown as Record<string, unknown>;
232
+ for (const key of Object.getOwnPropertyNames(win)) {
233
+ if (key in globalThis && !installedGlobalKeys.has(key)) continue; // node's own — leave it
234
+ try {
235
+ g[key] = win[key];
236
+ installedGlobalKeys.add(key);
237
+ } catch {
238
+ /* read-only */
239
+ }
240
+ }
241
+ for (const key of ['window', 'document', 'Event', 'CustomEvent', 'navigator']) {
242
+ try {
243
+ Object.defineProperty(g, key, {
244
+ value: key === 'window' ? dom.window : win[key],
245
+ configurable: true,
246
+ writable: true,
247
+ });
248
+ installedGlobalKeys.add(key);
249
+ } catch {
250
+ /* read-only */
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Errors thrown inside dispatched listeners surface as unhandled rejections
257
+ * (handlers are async) and would crash the sweep. Trapped, not asserted: a
258
+ * runtime error that damages behavior diverges in its effect signature, which
259
+ * is the comparison — same stance as the R2 validator.
260
+ */
261
+ let rejectionTrapInstalled = false;
262
+ function installRejectionTrap(): void {
263
+ if (rejectionTrapInstalled) return;
264
+ process.on('unhandledRejection', () => {
265
+ /* swallowed — the effect signature is the assertion */
266
+ });
267
+ rejectionTrapInstalled = true;
268
+ }
269
+
270
+ /**
271
+ * Load both engines ONCE, after bootstrapping jsdom globals (both packages
272
+ * touch DOM constructors at module evaluation). Callers then swap pages via
273
+ * `installGlobals`.
274
+ */
275
+ export async function initEngines(): Promise<Engines> {
276
+ installRejectionTrap();
277
+ installGlobals(new JSDOM('<!doctype html><html><body></body></html>'));
278
+
279
+ // Install goes through parse + a FRESH Runtime per handler with the element
280
+ // as context — the same shape the browser attribute processor uses, and the
281
+ // same shape the R2 validator proved stable across per-page document swaps.
282
+ // (The api singleton `hyperscript.eval` binds document-dependent state at
283
+ // first use, which is correct in a browser — document identity never changes
284
+ // within a realm — but silently resolves later PAGES' selectors against the
285
+ // first page here. Measured: a two-page probe no-opped page 2's toggle.)
286
+ const core = (await import('@hyperfixi/core')) as unknown as {
287
+ hyperscript: {
288
+ compileSync(code: string): { ok: boolean; errors?: Array<{ message: string }> };
289
+ };
290
+ parse(code: string): { success: boolean; node?: unknown };
291
+ Runtime: new () => { execute(ast: unknown, ctx: unknown): Promise<unknown> };
292
+ createContext(el: HTMLElement): unknown;
293
+ };
294
+
295
+ const require = createRequire(import.meta.url);
296
+ const esm = path.join(path.dirname(require.resolve('hyperscript.org')), '_hyperscript.esm.js');
297
+ const hs = (await import(pathToFileURL(esm).href)).default as {
298
+ parse(src: string): { errors?: Array<{ message: string }> };
299
+ processNode(el: Node): void;
300
+ };
301
+
302
+ return {
303
+ compileClean(source) {
304
+ try {
305
+ const r = core.hyperscript.compileSync(source);
306
+ return r.ok && (r.errors ?? []).length === 0;
307
+ } catch {
308
+ return false;
309
+ }
310
+ },
311
+ upstreamErrors(source) {
312
+ try {
313
+ return (hs.parse(source)?.errors ?? []).map(e => e.message);
314
+ } catch (e) {
315
+ return ['threw: ' + (e as Error).message.split('\n')[0]];
316
+ }
317
+ },
318
+ async hyperfixiInstall(source, el) {
319
+ const parsed = core.parse(source);
320
+ if (!parsed.success || !parsed.node) {
321
+ throw new Error('parse failed at install (eligibility should have caught this)');
322
+ }
323
+ const runtime = new core.Runtime();
324
+ await runtime.execute(parsed.node, core.createContext(el as HTMLElement));
325
+ },
326
+ upstreamInstall(el) {
327
+ hs.processNode(el);
328
+ },
329
+ };
330
+ }
331
+
332
+ /** Drain micro/macrotasks after a dispatch (same window the R2 validator uses). */
333
+ const SETTLE_MS = 20;
334
+ const settle = () => new Promise(r => setTimeout(r, SETTLE_MS));
335
+
336
+ /**
337
+ * Execute ONE handler on one engine, in ISOLATION: a fresh jsdom of the page,
338
+ * every eligible handler installed (as on a real page — bubbling into sibling
339
+ * handlers stays page-like and symmetric across engines), then ONLY the probed
340
+ * handler's event dispatched, with a before/after snapshot around it.
341
+ *
342
+ * Isolation is deliberate, and worth its jsdom-per-handler cost: a sequential
343
+ * dispatch-them-all model was measured amplifying ONE real divergence into a
344
+ * page's worth of cascades (upstream's inert element-`decrement` made the
345
+ * following `put 0 into #count` write 0-over-0 — invisible — so every later
346
+ * handler on the page diverged too), and letting double-failures hide as
347
+ * empty-vs-empty "matches". Fresh state per handler makes each comparison
348
+ * independently triageable.
349
+ *
350
+ * Runtime errors do not abort — the effect signature is the comparison, and an
351
+ * error that damages behavior diverges in its effects.
352
+ */
353
+ async function runHandlerOnEngine(
354
+ html: string,
355
+ eligible: Array<ShippedHandler & { event: string }>,
356
+ target: ShippedHandler & { event: string },
357
+ install: (h: ShippedHandler & { event: string }, el: Element) => Promise<void> | void
358
+ ): Promise<string[]> {
359
+ // No pretendToBeVisual: nothing eligible needs rAF (animation constructs are
360
+ // disqualified), and its frame timer would keep node alive after the sweep.
361
+ const dom = new JSDOM(html);
362
+ try {
363
+ installGlobals(dom);
364
+ const doc = dom.window.document;
365
+ const els = doc.querySelectorAll('[_]');
366
+
367
+ // Stamp identity keys BEFORE anything runs: same page → same stamping on
368
+ // both engines, so snapshot keys are the element's identity and an
369
+ // engine-inserted node cannot shift them (see snapshot()).
370
+ doc.body
371
+ .querySelectorAll('*')
372
+ .forEach((el: Element, i: number) => el.setAttribute('data-exec-key', String(i)));
373
+
374
+ for (const h of eligible) {
375
+ const el = els[h.index];
376
+ if (!el) continue;
377
+ try {
378
+ await install(h, el);
379
+ } catch {
380
+ /* recorded via the empty signature */
381
+ }
382
+ }
383
+
384
+ const el = els[target.index];
385
+ if (!el) return ['<element not found>'];
386
+ if ((target.event === 'input' || target.event === 'change') && 'value' in el) {
387
+ (el as HTMLInputElement).value = 'test';
388
+ }
389
+ const before = snapshot(doc);
390
+ el.dispatchEvent(new dom.window.Event(target.event, { bubbles: true, cancelable: true }));
391
+ await settle();
392
+ return diffSnapshots(before, snapshot(doc));
393
+ } finally {
394
+ // Release the page's timers/listeners so the process can exit; the next
395
+ // page (or the caller's bootstrap dom) re-points the globals.
396
+ dom.window.close();
397
+ }
398
+ }
399
+
400
+ /**
401
+ * The sweep: walk `examples/**`, extract handlers, apply the fair-denominator
402
+ * filters (each skip reasoned), and execute every eligible handler on both
403
+ * engines.
404
+ */
405
+ export async function runShippedExamplesExecution(opts?: {
406
+ roots?: string[];
407
+ repoRoot?: string;
408
+ engines?: Engines;
409
+ }): Promise<ExecutionParityResult> {
410
+ const repoRoot = opts?.repoRoot ?? REPO_ROOT;
411
+ const roots = opts?.roots ?? DEFAULT_ROOTS;
412
+ const engines = opts?.engines ?? (await initEngines());
413
+
414
+ const compared: ComparedHandler[] = [];
415
+ const skipped: SkippedHandler[] = [];
416
+ let pages = 0;
417
+ let handlers = 0;
418
+
419
+ for (const root of roots) {
420
+ for (const full of walkHtml(path.join(repoRoot, root))) {
421
+ const rel = path.relative(repoRoot, full);
422
+ const html = fs.readFileSync(full, 'utf8');
423
+ const pageHandlers = extractHandlers(rel, html);
424
+ if (pageHandlers.length === 0) continue;
425
+ pages++;
426
+ handlers += pageHandlers.length;
427
+
428
+ const eligible: Array<ShippedHandler & { event: string }> = [];
429
+ for (const h of pageHandlers) {
430
+ if (!h.event) {
431
+ skipped.push({ ...h, reason: 'not an `on <event>` handler' });
432
+ continue;
433
+ }
434
+ if (!SAFE_EVENTS.has(h.event)) {
435
+ skipped.push({ ...h, reason: `event not dispatchable deterministically (${h.event})` });
436
+ continue;
437
+ }
438
+ const disq = DISQUALIFIERS.find(d => d.pattern.test(h.source));
439
+ if (disq) {
440
+ skipped.push({ ...h, reason: disq.reason });
441
+ continue;
442
+ }
443
+ if (!engines.compileClean(h.source)) {
444
+ skipped.push({
445
+ ...h,
446
+ reason: 'hyperfixi does not compile it clean (shipped-sources gate territory)',
447
+ });
448
+ continue;
449
+ }
450
+ const upstreamErrs = engines.upstreamErrors(h.source);
451
+ if (upstreamErrs.length > 0) {
452
+ skipped.push({ ...h, reason: `upstream rejects it (no oracle): ${upstreamErrs[0]}` });
453
+ continue;
454
+ }
455
+ eligible.push(h as ShippedHandler & { event: string });
456
+ }
457
+ if (eligible.length === 0) continue;
458
+
459
+ // Both engines log runtime errors to the console during dispatch
460
+ // (COMMAND FAILED etc.). That is expected data here — the effect
461
+ // signature carries the consequence — so silence the console for the
462
+ // engine runs only, restoring even on a throw.
463
+ const saved = {
464
+ log: console.log,
465
+ warn: console.warn,
466
+ error: console.error,
467
+ debug: console.debug,
468
+ };
469
+ const noop = () => {};
470
+ console.log = console.warn = console.error = console.debug = noop;
471
+ try {
472
+ for (const h of eligible) {
473
+ const ours = await runHandlerOnEngine(html, eligible, h, (hh, el) =>
474
+ engines.hyperfixiInstall(hh.source, el)
475
+ );
476
+ const theirs = await runHandlerOnEngine(html, eligible, h, (_hh, el) =>
477
+ engines.upstreamInstall(el)
478
+ );
479
+ compared.push({
480
+ ...h,
481
+ key: keyFor(h),
482
+ hyperfixiEffects: ours,
483
+ upstreamEffects: theirs,
484
+ match: JSON.stringify(ours) === JSON.stringify(theirs),
485
+ vacuous: ours.length === 0 && theirs.length === 0,
486
+ excerpt: h.source.replace(/\s+/g, ' ').trim().slice(0, 100),
487
+ });
488
+ }
489
+ } finally {
490
+ console.log = saved.log;
491
+ console.warn = saved.warn;
492
+ console.error = saved.error;
493
+ console.debug = saved.debug;
494
+ }
495
+ }
496
+ }
497
+
498
+ return { pages, handlers, compared, skipped };
499
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Shipped-sources validity gate (see shipped-sources-validity.ts for the why).
3
+ *
4
+ * Compiles every hyperscript source we ship in `examples/` and the doc trees,
5
+ * and ratchets on the recovers-with-errors state (`ok: true` with a non-empty
6
+ * `errors`). Three assertions, matching the canonical-validity gates:
7
+ * 1. sanity — sources were actually found and mostly compile clean;
8
+ * 2. no NEW recovering source appears outside the committed allowlist;
9
+ * 3. no allowlisted key has silently become clean (stale entries must be
10
+ * removed so the list only ever shrinks).
11
+ *
12
+ * To update after an intentional fix: re-run and rewrite
13
+ * `baselines/shipped-sources-validity.json`. Note the allowlist key embeds a
14
+ * hash of the source, so FIXING a source changes its key — the entry goes
15
+ * stale and assertion 3 makes removing it mandatory.
16
+ */
17
+
18
+ import { readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import path from 'node:path';
21
+ import { JSDOM } from 'jsdom';
22
+ import { describe, it, expect, beforeAll } from 'vitest';
23
+ import {
24
+ checkShippedSourcesValidity,
25
+ type ShippedSourcesResult,
26
+ type CompileForValidity,
27
+ } from './shipped-sources-validity';
28
+
29
+ interface AllowlistDoc {
30
+ allowedRecovered: Array<{
31
+ key: string;
32
+ file: string;
33
+ error: string;
34
+ upstream: string;
35
+ reason: string;
36
+ }>;
37
+ }
38
+
39
+ const baselinePath = path.resolve(
40
+ path.dirname(fileURLToPath(import.meta.url)),
41
+ '../../baselines/shipped-sources-validity.json'
42
+ );
43
+ const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
44
+ const allowed = new Set(allowlist.allowedRecovered.map(e => e.key));
45
+
46
+ describe('shipped-sources validity gate', () => {
47
+ let result: ShippedSourcesResult;
48
+
49
+ beforeAll(async () => {
50
+ // Import core through its built entry, the same surface a consumer gets.
51
+ const core = (await import('@hyperfixi/core')) as unknown as {
52
+ hyperscript: { compileSync: CompileForValidity };
53
+ };
54
+ const dom = new JSDOM('<!doctype html><html><body></body></html>');
55
+ result = checkShippedSourcesValidity(
56
+ code => core.hyperscript.compileSync(code),
57
+ dom.window.document as unknown as Parameters<typeof checkShippedSourcesValidity>[1]
58
+ );
59
+ }, 120_000);
60
+
61
+ it('finds and compiles the shipped sources (sanity: trees walked, extraction working)', () => {
62
+ // Guards the silent-zero failure mode: a broken walk or extractor would
63
+ // make every other assertion vacuously pass.
64
+ expect(result.checked).toBeGreaterThan(100);
65
+ expect(result.clean).toBeGreaterThan(100);
66
+ });
67
+
68
+ it('has no NEW source that parses with recovered errors outside the allowlist', () => {
69
+ const unexpected = result.findings.filter(f => !allowed.has(f.key));
70
+ expect(
71
+ unexpected,
72
+ unexpected.length
73
+ ? `\nNew shipped sources that parse ok:true WITH errors (fix the source, or allowlist with a reason):\n` +
74
+ unexpected
75
+ .map(f => ` [${f.key}]\n "${f.excerpt}"\n -> ${f.error}`)
76
+ .join('\n') +
77
+ `\n\nAsk the real hyperscript.org engine for a second opinion before deciding:\n` +
78
+ `upstream REJECTING means the source is malformed; upstream ACCEPTING means this is a hyperfixi parser defect.\n` +
79
+ `See loadCanonicalParser() in canonical-validity.ts.`
80
+ : ''
81
+ ).toEqual([]);
82
+ });
83
+
84
+ it('has no stale allowlist entries (a now-clean source must be removed so the list ratchets down)', () => {
85
+ const stillFailing = new Set(result.findings.map(f => f.key));
86
+ const stale = allowlist.allowedRecovered.map(e => e.key).filter(key => !stillFailing.has(key));
87
+ expect(
88
+ stale,
89
+ stale.length
90
+ ? `\nThese allowlisted sources no longer recover-with-errors (fixed, or edited — the key embeds a source hash).\n` +
91
+ `Remove them from baselines/shipped-sources-validity.json:\n ${stale.join('\n ')}`
92
+ : ''
93
+ ).toEqual([]);
94
+ });
95
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Shipped-sources validity gate
3
+ * -----------------------------
4
+ * Every hyperscript source we ship in `examples/` and the doc trees, parsed
5
+ * with hyperfixi, must not land in the *recovers-with-errors* state:
6
+ * `ok === true` with a non-empty `errors`.
7
+ *
8
+ * That state is the blind spot this gate exists for. The parser is
9
+ * deliberately resilient — it recovers from some malformed input and returns a
10
+ * usable-but-degraded AST plus diagnostics — so genuinely broken sources
11
+ * neither throw nor report `ok: false`. Nothing in the existing suites looks at
12
+ * the combination, which is how five malformed examples shipped undetected
13
+ * (an `if` with no `end`, a `put` with no target, `{id}` where `${id}` was
14
+ * meant, and a `*--css-var` neither engine supports). The same sweep is what
15
+ * caught the `send`-vs-`trigger` divergence and the `parseTriggerCommand` hang
16
+ * in #780, neither of which was reachable from the existing tests.
17
+ *
18
+ * Deliberately NOT gated: `ok === false`. That is a much larger and mostly
19
+ * legitimate class — non-English sources (which need the multilingual path,
20
+ * not `compileSync`), plugin syntax whose feature is not installed, and
21
+ * intentionally-broken "this does not work" doc snippets. Gating it would
22
+ * drown the signal. A source that fails outright is also loud; one that
23
+ * silently recovers is not, and that asymmetry is the whole point.
24
+ *
25
+ * Node-only: the second-opinion path needs `loadCanonicalParser()`, which
26
+ * imports the real `hyperscript.org` build off disk. Cannot run in a browser
27
+ * suite.
28
+ */
29
+
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { createHash } from 'node:crypto';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { extractHyperscriptFromMarkup } from '@hyperfixi/patterns-reference';
35
+
36
+ /** Repo root, from `packages/testing-framework/src/multilingual/`. */
37
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
38
+
39
+ /** Trees whose hyperscript we ship to users. */
40
+ const DEFAULT_ROOTS = ['examples', 'docs', 'packages/core/docs'];
41
+
42
+ /**
43
+ * Paths excluded from the sweep, each with the reason it is not a shipped
44
+ * source. Keep this list short and justified — every entry is coverage lost.
45
+ */
46
+ const EXCLUDED = [
47
+ {
48
+ match: (rel: string) => rel.includes(`${path.sep}archive${path.sep}`),
49
+ reason: 'historical phase/summary docs, not shipped guidance',
50
+ },
51
+ ];
52
+
53
+ export interface ShippedSource {
54
+ /** Repo-relative path of the file the source came from. */
55
+ file: string;
56
+ /** How it was extracted, for triage. */
57
+ kind: 'html-attribute' | 'markdown-html-block';
58
+ /** The hyperscript source itself. */
59
+ source: string;
60
+ }
61
+
62
+ export interface ShippedSourceFinding extends ShippedSource {
63
+ /** Stable allowlist key: file plus a hash of the source. */
64
+ key: string;
65
+ /** First hyperfixi diagnostic. */
66
+ error: string;
67
+ /** Single-line excerpt, for reading the baseline without opening the file. */
68
+ excerpt: string;
69
+ }
70
+
71
+ export interface ShippedSourcesResult {
72
+ /** Sources extracted and compiled. */
73
+ checked: number;
74
+ /** Sources that compiled with no diagnostics. */
75
+ clean: number;
76
+ /** Sources in the recovers-with-errors state. */
77
+ findings: ShippedSourceFinding[];
78
+ }
79
+
80
+ /** Minimal shape of `hyperscript.compileSync`, injected so this stays testable. */
81
+ export type CompileForValidity = (code: string) => {
82
+ ok: boolean;
83
+ errors?: Array<{ message: string }>;
84
+ };
85
+
86
+ /**
87
+ * A stable key for an individual snippet.
88
+ *
89
+ * Hashing the source (rather than using its index in the file) means the key
90
+ * survives reordering, and — the point — CHANGES when the snippet is fixed, so
91
+ * a stale allowlist entry cannot silently keep covering a source that has been
92
+ * edited. The stale-entry test turns that into a hard failure.
93
+ */
94
+ function keyFor(file: string, source: string): string {
95
+ return `${file}::${createHash('sha1').update(source).digest('hex').slice(0, 10)}`;
96
+ }
97
+
98
+ function walk(dir: string, acc: string[] = []): string[] {
99
+ let entries: fs.Dirent[];
100
+ try {
101
+ entries = fs.readdirSync(dir, { withFileTypes: true });
102
+ } catch {
103
+ return acc;
104
+ }
105
+ for (const entry of entries) {
106
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) {
107
+ continue;
108
+ }
109
+ const full = path.join(dir, entry.name);
110
+ if (entry.isDirectory()) walk(full, acc);
111
+ else if (/\.(html|md)$/.test(entry.name)) acc.push(full);
112
+ }
113
+ return acc;
114
+ }
115
+
116
+ /**
117
+ * Collect every hyperscript source we ship.
118
+ *
119
+ * HTML files contribute their `_=` / `hx-live` attributes and
120
+ * `<script type="text/hyperscript">` bodies, read through the DOM (see
121
+ * `extractHyperscriptFromMarkup` for why not a regex). Markdown contributes
122
+ * the same, extracted from its fenced ```html blocks.
123
+ *
124
+ * Bare fenced ```hyperscript blocks are deliberately NOT collected: in this
125
+ * repo they are overwhelmingly syntax *notation* rather than code
126
+ * (`send <event> to <target>`, `copy <source> as <format>`), which no parser
127
+ * can accept and which it would be wrong to demand parses. The sources that
128
+ * actually run on a page are the attributes.
129
+ */
130
+ export function collectShippedSources(
131
+ doc: Parameters<typeof extractHyperscriptFromMarkup>[0],
132
+ opts?: { roots?: string[]; repoRoot?: string }
133
+ ): ShippedSource[] {
134
+ const repoRoot = opts?.repoRoot ?? REPO_ROOT;
135
+ const roots = opts?.roots ?? DEFAULT_ROOTS;
136
+ const out: ShippedSource[] = [];
137
+
138
+ for (const root of roots) {
139
+ for (const full of walk(path.join(repoRoot, root))) {
140
+ const rel = path.relative(repoRoot, full);
141
+ if (EXCLUDED.some(e => e.match(rel))) continue;
142
+ const text = fs.readFileSync(full, 'utf8');
143
+
144
+ if (full.endsWith('.html')) {
145
+ for (const source of extractHyperscriptFromMarkup(doc, text).snippets) {
146
+ out.push({ file: rel, kind: 'html-attribute', source });
147
+ }
148
+ } else {
149
+ for (const block of text.matchAll(/```html\n([\s\S]*?)```/g)) {
150
+ for (const source of extractHyperscriptFromMarkup(doc, block[1] ?? '').snippets) {
151
+ out.push({ file: rel, kind: 'markdown-html-block', source });
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /**
161
+ * Compile every shipped source and report the ones that recovered with errors.
162
+ */
163
+ export function checkShippedSourcesValidity(
164
+ compile: CompileForValidity,
165
+ doc: Parameters<typeof extractHyperscriptFromMarkup>[0],
166
+ opts?: { roots?: string[]; repoRoot?: string }
167
+ ): ShippedSourcesResult {
168
+ const sources = collectShippedSources(doc, opts);
169
+ const findings: ShippedSourceFinding[] = [];
170
+ let clean = 0;
171
+
172
+ for (const s of sources) {
173
+ let result: ReturnType<CompileForValidity>;
174
+ try {
175
+ result = compile(s.source);
176
+ } catch {
177
+ // A throw is the loud failure mode, not this gate's business.
178
+ continue;
179
+ }
180
+ const errors = result.errors ?? [];
181
+ if (result.ok && errors.length > 0) {
182
+ findings.push({
183
+ ...s,
184
+ key: keyFor(s.file, s.source),
185
+ error: errors[0]?.message ?? 'recovered with errors',
186
+ excerpt: s.source.replace(/\s+/g, ' ').trim().slice(0, 100),
187
+ });
188
+ } else if (result.ok) {
189
+ clean++;
190
+ }
191
+ }
192
+
193
+ return { checked: sources.length, clean, findings };
194
+ }
@@ -339,8 +339,10 @@ describe('R2 execution validator (lock)', () => {
339
339
  // The three wave-9 additions. Each en reference must produce a non-empty,
340
340
  // deterministic signature against the existing fixture. The `*opacity`
341
341
  // hide/show STRATEGIES are synchronous (no timer): hide writes display:none
342
- // + a data-original-display marker on #btn; show adds the visibility class
343
- // on #modal. chained-access-possessive-dot writes the parent (.card) display.
342
+ // on #btn (its data-original-display memo is engine bookkeeping, excluded
343
+ // from signatures since the shared effect-signature module — see
344
+ // ENGINE_ATTRS there); show adds the visibility class on #modal.
345
+ // chained-access-possessive-dot writes the parent (.card) display.
344
346
  const cases: ReadonlyArray<[string, string, string[]]> = [
345
347
  [
346
348
  'chained-access-possessive-dot',
@@ -350,7 +352,7 @@ describe('R2 execution validator (lock)', () => {
350
352
  [
351
353
  'hide-with-transition',
352
354
  'on click hide me with *opacity',
353
- ['Δ#btn cls[] attr[data-original-display=,id=btn] style[display: none;] text[Click]'],
355
+ ['Δ#btn cls[] attr[id=btn] style[display: none;] text[Click]'],
354
356
  ],
355
357
  [
356
358
  'show-with-transition',
@@ -31,6 +31,7 @@ import { JSDOM } from 'jsdom';
31
31
  import { parseSemantic, buildAST } from '@lokascript/semantic';
32
32
  import { getAllPatterns, getTranslationsByLanguage } from '@hyperfixi/patterns-reference';
33
33
  import type { LanguageCode } from '../types';
34
+ import { snapshot, diffSnapshots } from '../effect-signature';
34
35
 
35
36
  /**
36
37
  * The curated execution subset: simple, deterministic, fixture-friendly
@@ -287,53 +288,12 @@ export interface ExecutionResult {
287
288
  executionFidelity?: number;
288
289
  }
289
290
 
290
- /**
291
- * Serialize the identity-relevant state of every element under <body>.
292
- * Keyed by #id when present, else tag[document-order-index]; the fixture is
293
- * identical across languages, so keys are comparable.
294
- */
295
- function serializeElement(el: Element): string {
296
- const attrs = Array.from(el.attributes)
297
- .filter(a => a.name !== 'class' && a.name !== 'style')
298
- .map(a => `${a.name}=${a.value}`)
299
- .sort()
300
- .join(',');
301
- const classes = Array.from(el.classList).sort().join(' ');
302
- const style = (el as HTMLElement).getAttribute('style') ?? '';
303
- // Leaf text only — container text would duplicate every child mutation.
304
- const text =
305
- el.childNodes.length === 0 || (el.childNodes.length === 1 && el.firstChild?.nodeType === 3)
306
- ? (el.textContent ?? '')
307
- : '';
308
- return `cls[${classes}] attr[${attrs}] style[${style}] text[${text}]`;
309
- }
310
-
311
- function snapshot(document: Document): Map<string, string> {
312
- const out = new Map<string, string>();
313
- // body participates under its own stable key: body-targeted effects
314
- // (`add .modal-open to body`, modal-open/modal-close-button) must be
315
- // visible in the signature now that the runtime resolves `body`. Before
316
- // wave 3b these writes fell back to `me` (visible by accident); a correct
317
- // body write was invisible and a dropped one unscoreable.
318
- out.set('body', serializeElement(document.body));
319
- document.body.querySelectorAll('*').forEach((el, i) => {
320
- const key = el.id ? `#${el.id}` : `${el.tagName.toLowerCase()}[${i}]`;
321
- out.set(key, serializeElement(el));
322
- });
323
- return out;
324
- }
325
-
326
- /** Sorted, stable list of per-element changes between two snapshots. */
327
- function diffSnapshots(before: Map<string, string>, after: Map<string, string>): string[] {
328
- const effects: string[] = [];
329
- for (const [k, v] of after) {
330
- const b = before.get(k);
331
- if (b === undefined) effects.push(`+${k} ${v}`);
332
- else if (b !== v) effects.push(`Δ${k} ${v}`);
333
- }
334
- for (const k of before.keys()) if (!after.has(k)) effects.push(`-${k}`);
335
- return effects.sort();
336
- }
291
+ // Effect-signature machinery (serializeElement/snapshot/diffSnapshots) moved to
292
+ // ../effect-signature.ts (imported at the top), shared with the
293
+ // shipped-examples execution gate so the two can never disagree about what a
294
+ // DOM effect is. Notes that used to live on the local copies (leaf-text-only
295
+ // rationale; body's own stable key, added in wave 3b when the runtime learned
296
+ // to resolve `body`) moved with them.
337
297
 
338
298
  /** How long to let the dispatched handler settle (ms). The subset contains no
339
299
  * waits/transitions/fetches, so this only needs to drain micro/macrotasks. */