@hyperfixi/testing-framework 2.5.1 → 2.7.0

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.
@@ -0,0 +1,510 @@
1
+ /**
2
+ * R2 — execution smoke validator (CORRECTNESS_RELIABILITY_PLAN.md §8).
3
+ *
4
+ * Parse-level ratchets (R0 action-set fidelity, R1 role fidelity) cannot see a
5
+ * parse that EXECUTES wrongly: the session-5 probe found translations whose
6
+ * parse looks plausible but whose runtime behavior diverges from the English
7
+ * reference — a dropped destination role lands the effect on `me` instead of
8
+ * `#menu`, a mis-built AST throws `Unknown command: into`, a quoted literal
9
+ * keeps its quotes in the DOM. This validator executes a CURATED subset of
10
+ * corpus patterns in jsdom (semantic parse → buildAST → runtime.execute →
11
+ * dispatch the trigger event) and compares the resulting DOM effects against
12
+ * the English reference's effects.
13
+ *
14
+ * Effect signature: a before/after diff of every element's classes, attributes,
15
+ * inline style, and leaf text. Selectors/classes/attribute names are code (not
16
+ * translated), so signatures are directly comparable across languages. A
17
+ * pattern scores 1 when its signature EXACTLY matches the en reference's
18
+ * (binary — extra effects are as wrong as missing ones). Trapped runtime
19
+ * errors are recorded for diagnostics but are NOT part of the match: their
20
+ * attribution depends on unhandled-rejection timing (racy), while the effect
21
+ * snapshot is synchronous and deterministic. A mis-built AST that damages
22
+ * behavior diverges in its effects regardless.
23
+ *
24
+ * Determinism: a fresh JSDOM document and a fresh Runtime per execution, no
25
+ * network, no timers beyond a fixed settle window; the probe measured two full
26
+ * sweeps byte-identical. Patterns whose en reference errors or produces no
27
+ * effects are excluded (no usable reference — same semantics as R0/R1).
28
+ */
29
+
30
+ import { JSDOM } from 'jsdom';
31
+ import { parseSemantic, buildAST } from '@lokascript/semantic';
32
+ import { getAllPatterns, getTranslationsByLanguage } from '@hyperfixi/patterns-reference';
33
+ import type { LanguageCode } from '../types';
34
+
35
+ /**
36
+ * The curated execution subset: simple, deterministic, fixture-friendly
37
+ * single-handler click patterns. Expand only after the loop is proven stable
38
+ * (an unstable ratchet is worse than none). `set-text-basic` (#id.innerText)
39
+ * is deliberately absent: jsdom does not implement innerText, so its en
40
+ * reference write is an inert expando (empty signature) — a harness
41
+ * limitation, not a runtime gap. `toggle-visibility` and `set-attribute`
42
+ * joined in wave 2b once @attr emitted canonical attributeAccess nodes.
43
+ */
44
+ export const EXECUTION_SUBSET: readonly string[] = [
45
+ 'add-class-basic',
46
+ 'add-class-to-other',
47
+ 'remove-class-basic',
48
+ 'remove-class-from-all',
49
+ 'toggle-class-basic',
50
+ 'toggle-class-on-other',
51
+ 'put-content-basic',
52
+ 'show-element',
53
+ 'hide-element',
54
+ 'increment-counter',
55
+ 'decrement-counter',
56
+ 'modal-open',
57
+ 'tabs-basic',
58
+ 'set-text-possessive-dot',
59
+ 'set-inner-html-possessive-dot',
60
+ 'set-style',
61
+ 'closest-ancestor',
62
+ // Expansion wave 1 (session 8): multi-command click patterns — sync,
63
+ // network/timer-free sequences of 2–4 commands. Same eligibility bar as the
64
+ // original subset: the en reference must execute with a non-empty effect
65
+ // signature in the current runtime. Eleven candidates were probed; the two
66
+ // then-excluded for unusable EN references (modal-close-button,
67
+ // make-toast-element) joined in waves 3/3b once their en parses were fixed.
68
+ 'tabs-content',
69
+ 'accordion-exclusive',
70
+ // Expansion wave 2 (session 9, post en-conditional work): control-flow click
71
+ // patterns whose en reference now parses (the if/else conditional fold) and
72
+ // executes (propertyAccess evaluator; matches/exists/is-empty condition
73
+ // forms). Probed through this validator: each produces a non-empty,
74
+ // deterministic effect signature. `unless-condition` stays out — `unless` is
75
+ // deliberately NOT folded (see semantic-parser.tryParseConditionalBlock), so
76
+ // its flat parse still errors at runtime.
77
+ 'if-condition',
78
+ 'if-matches',
79
+ 'if-exists',
80
+ 'modal-close-backdrop',
81
+ // Expansion wave 2b (session 9): the @attr family — the semantic value
82
+ // converter / expression parser now emit canonical attributeAccess nodes for
83
+ // `@attr`, so these en references execute (set → setAttribute, toggle →
84
+ // attribute toggle). set-text-basic stays out: its en reference writes
85
+ // `innerText`, which jsdom does not implement (the write becomes an inert
86
+ // expando, empty signature) — a harness limitation, not a runtime gap; it
87
+ // works in real browsers.
88
+ 'set-attribute',
89
+ 'toggle-visibility',
90
+ 'tabs-aria',
91
+ // Expansion wave 2c (session 9): `halt the event` no longer collapses to a
92
+ // bare halt (the semantic halt mapper preserves its patient, so the handler
93
+ // CONTINUES after preventDefault/stopPropagation), and `make a <div.card/>`
94
+ // executes (element literals carry their markup on `raw`; #container added
95
+ // to the fixture as the put destination).
96
+ 'halt-propagation',
97
+ 'make-element',
98
+ // Expansion wave 2d (session 9): `next .dropdown-menu` folds to a positional
99
+ // call expression (next('.dropdown-menu')) the runtime's positional
100
+ // expressions evaluate — previously it mangled into `next.dropdown - menu`
101
+ // and the toggle target evaluated to NaN. `.dropdown-menu` added to the
102
+ // fixture (appended last; existing snapshot indexes unchanged).
103
+ 'dropdown-toggle',
104
+ // Expansion wave 3 (session 10): positional-phrase patients/destinations —
105
+ // the pattern matcher now captures `closest <sel>` and article-led
106
+ // `the next <sel>` as positional expressions (previously the hide/show
107
+ // command dropped from the body entirely). The en reference for
108
+ // modal-close-button now hides the enclosing .modal (PATTERN_SETUP gives
109
+ // #btn a .modal ancestor, mirroring the real-page structure).
110
+ 'modal-close-button',
111
+ // Expansion wave 3b (session 10): the at-end-of positional put — en put
112
+ // patterns for `at end of` / `at start of`, the parseBodyWithClauses
113
+ // end-noun guard (the `end` in `at end of` is a position noun, not the
114
+ // block terminator), putMapper reads `manner` (also fixing the latent
115
+ // before/after→into bug), core PutCommand accepts the multi-word modifier
116
+ // keys, and contextReference body/document/window resolve. The en
117
+ // reference appends the toast at end of body: one clean effect line.
118
+ 'make-toast-element',
119
+ // Expansion wave 4 (the deferred S1 follow-up): the first non-click,
120
+ // event-reading cell. `on success put event.detail.message into #sr-announce
121
+ // set @role to "alert" on #sr-announce` exercises (1) a custom event name
122
+ // carried as an expression role — buildEventHandler now binds it instead of
123
+ // defaulting to `click`; (2) the `set @attr … on <scope>` plumb landed in S1;
124
+ // (3) per-cell trigger support (PATTERN_TRIGGER) dispatching a CustomEvent
125
+ // with a `detail` payload. The en reference writes the announcement text into
126
+ // #sr-announce and sets role=alert on it: two clean effect lines.
127
+ 'announce-screen-reader',
128
+ // Expansion wave 5 (session 12): `remove me` — the bare self-removal positional.
129
+ // Discovery probe (every non-subset, non-network/timer/behavior pattern executed
130
+ // against this fixture, then all 23 translations checked against the en effect
131
+ // signature) found ten patterns with a clean non-empty en effect.
132
+ 'remove-element',
133
+ // Expansion wave 6 (session 13): the six wave-5 "worklist" candidates that, when
134
+ // RE-GROUNDED against a freshly `populate`d patterns.db, in fact match the en effect
135
+ // signature in ALL 23 languages — so each keeps avgExecutionFidelity at 1.0. The
136
+ // wave-5 worklist's per-language divergence counts were measured against a STALE
137
+ // committed patterns.db snapshot (the committed copy lags the current dicts — see
138
+ // patterns-reference/CLAUDE.md); e.g. its ms `next-element` carried untranslated
139
+ // `to`/`next` (`apabila click … to next <li/>`), but a fresh populate emits the
140
+ // localized `apabila click tambah .highlight ke seterusnya <li/>`, which executes
141
+ // identically to en. Re-grounded counts (fresh db, all 24 priority langs): these six
142
+ // diverge in 0/23; the wave-5 worklist said next-element 1, toggle-aria-expanded 2,
143
+ // set-opacity 4, set-transform 4, accordion-toggle 6, caret-var-on-target 23 — all
144
+ // stale. No fixture/setup/trigger change needed; each en reference produces a clean
145
+ // non-empty signature against the existing fixture (next/closest positionals fall
146
+ // back to `me` consistently across every language; set *opacity/*transform write
147
+ // inline style; caret-var-on-target clears #btn text — the undefined `^count` resolves
148
+ // the same way in every language).
149
+ 'next-element',
150
+ 'toggle-aria-expanded',
151
+ 'set-opacity',
152
+ 'set-transform',
153
+ 'accordion-toggle',
154
+ 'caret-var-on-target',
155
+ // Expansion wave 7 (session 13): `multiple-events` — `on click or
156
+ // keypress[key=="Enter"] toggle .active`. The third wave-5 worklist divergence,
157
+ // now FIXED (semantic): a scoped or-clause excision pre-pass + the ja `または`→or
158
+ // tokenizer fix + hi/bn OR_KEYWORDS entries. It diverged in 7 languages
159
+ // (ja,ko,it,hi,tr,bn,qu — the translated `or` became a phantom body command or
160
+ // mangled into a selector); now all 23 match the en click effect (toggles .active
161
+ // on #btn). R2 stays 1.0.
162
+ 'multiple-events',
163
+ // Expansion wave 8 (session 14): `put-before` / `put-after` — `put "<p>New</p>"
164
+ // before/after me`, the last two wave-5 worklist divergences (14 langs each), now
165
+ // FIXED. The position word (`before`/`after` + translations) is now captured as the
166
+ // put command's `manner` role (→ the DOM-insert modifier): per-language put-before/
167
+ // after patterns for SVO/SOV (#516) + handcrafted high-priority VSO put-event
168
+ // patterns for ar/tl/uk (the fused VSO event pattern consumed the position word as
169
+ // a plain destination marker and dropped manner). All 23 langs now insert the <p>
170
+ // before/after #btn (matching en); R2 stays 1.0.
171
+ 'put-before',
172
+ 'put-after',
173
+ // Expansion wave 9 (R2-coverage sweep): a fresh discovery probe (every
174
+ // non-subset, non-async pattern's en reference executed against the fixture,
175
+ // then all 23 translations checked against the en signature) surfaced three
176
+ // patterns whose en reference produces a clean, deterministic, non-empty
177
+ // effect AND that already match in ALL 23 languages — pure coverage, no
178
+ // parser/dict fix needed:
179
+ // - chained-access-possessive-dot: `set my.parentElement.style.display to
180
+ // "none"` — a synchronous chained-member write on the parent (.card).
181
+ // - hide-with-transition / show-with-transition: `hide me with *opacity` /
182
+ // `show #modal with *opacity`. Despite the names these are SYNCHRONOUS
183
+ // hide/show STRATEGIES (the runtime writes display + a data-original-display
184
+ // marker on hide, adds the visibility class on show) — no timer, no
185
+ // animation frame, so the signature is deterministic (verified across two
186
+ // back-to-back runs). Distinct from the async `transition … over Nms`
187
+ // family the subset still excludes.
188
+ 'chained-access-possessive-dot',
189
+ 'hide-with-transition',
190
+ 'show-with-transition',
191
+ // Expansion wave 10 (the SOV literal-role-extraction arc, PRs #560/#561):
192
+ // the two R2 blockers whose SOV translations silently dropped a bare literal
193
+ // role, now capturing it in all 23 languages:
194
+ // - append-content: `append "<li>Item</li>" to #list` — the fronted content
195
+ // literal bound to a bogus `event` role in the body-clause marker lookup
196
+ // (ja/tr runtime "append requires content", bn silent no-op). Fixture adds
197
+ // `<ul id="list">` (appended last, indices preserved).
198
+ // - increment-by-amount: `increment #score by 10` — the trailing bare amount
199
+ // was unconsumed by every fused event pattern and defaulted to 1 in the
200
+ // SOV 6 + th (invisible to R0/R1: fillSchemaDefaults injects
201
+ // quantity:literal=1 into the role signature — only R2 sees the value).
202
+ // Fixture adds `<div id="score">0</div>` (appended last).
203
+ 'append-content',
204
+ 'increment-by-amount',
205
+ ];
206
+
207
+ /**
208
+ * Shared fixture: every element the curated subset's selectors reference.
209
+ * The SAME fixture (plus the same per-pattern setup) is used for the en
210
+ * reference and every translation, so signatures are comparable.
211
+ */
212
+ const FIXTURE_HTML = `<!DOCTYPE html><html><body>
213
+ <div class="card"><button id="btn">Click</button></div>
214
+ <div id="item"></div>
215
+ <div id="menu"></div>
216
+ <div id="panel"></div>
217
+ <div id="modal"></div>
218
+ <div id="output"></div>
219
+ <div id="counter">0</div>
220
+ <ul><li class="items active"></li><li class="items active"></li></ul>
221
+ <div class="tab active"></div>
222
+ <div class="tab"></div>
223
+ <div class="tab-panel"></div>
224
+ <div class="tab-panel"></div>
225
+ <div class="accordion-item open"></div>
226
+ <div id="container"></div>
227
+ <div class="dropdown-menu"></div>
228
+ <div id="sr-announce"></div>
229
+ <ul id="list"></ul>
230
+ <div id="score">0</div>
231
+ </body></html>`;
232
+
233
+ /** Per-pattern fixture preconditions (applied identically for every language). */
234
+ const PATTERN_SETUP: Record<string, (doc: Document) => void> = {
235
+ // `remove .highlight from me` needs the class present to have an effect.
236
+ 'remove-class-basic': doc => doc.getElementById('btn')!.classList.add('highlight'),
237
+ // `show #modal` needs the modal hidden.
238
+ 'show-element': doc => {
239
+ (doc.getElementById('modal') as HTMLElement).style.display = 'none';
240
+ },
241
+ // `add .open to closest .accordion-item` needs #btn inside one; the
242
+ // fixture's standalone `.accordion-item.open` is the exclusivity victim.
243
+ 'accordion-exclusive': doc => doc.querySelector('.card')!.classList.add('accordion-item'),
244
+ // `if target matches .modal-backdrop hide .modal-backdrop` — the dispatched
245
+ // click's target is #btn, so the condition only fires when #btn IS the
246
+ // backdrop (the real-page case is a click landing on the backdrop itself).
247
+ 'modal-close-backdrop': doc => doc.getElementById('btn')!.classList.add('modal-backdrop'),
248
+ // `hide closest .modal` needs #btn inside a .modal (the real-page case is a
249
+ // close button inside the modal it closes), and `remove .modal-open from
250
+ // body` needs the class present so the body write is scoreable.
251
+ 'modal-close-button': doc => {
252
+ doc.querySelector('.card')!.classList.add('modal');
253
+ doc.body.classList.add('modal-open');
254
+ },
255
+ };
256
+
257
+ /**
258
+ * Per-pattern trigger override. The subset is overwhelmingly `on click`, so the
259
+ * default (dispatched in `executeInner`) is a bare `Event('click')`. A handler
260
+ * that listens for a different event — or that READS the triggering event (e.g.
261
+ * `on success put event.detail.message …`) — declares the event name and, when
262
+ * needed, a `detail` payload here. With a `detail`, a `CustomEvent` is
263
+ * dispatched so `event.detail.*` resolves; the payload is identical for the en
264
+ * reference and every translation (it is harness-supplied, not translated), so
265
+ * effect signatures stay comparable across languages.
266
+ */
267
+ interface PatternTrigger {
268
+ readonly event: string;
269
+ readonly detail?: unknown;
270
+ }
271
+ const PATTERN_TRIGGER: Record<string, PatternTrigger> = {
272
+ // `on success put event.detail.message into #sr-announce set @role to "alert"
273
+ // on #sr-announce` — a custom event carrying the announcement text. The fixed
274
+ // message lands in #sr-announce's text; role=alert is set on the same node.
275
+ 'announce-screen-reader': { event: 'success', detail: { message: 'Saved successfully' } },
276
+ };
277
+
278
+ /** Result of executing one pattern translation. */
279
+ export interface ExecutionResult {
280
+ codeExampleId: string;
281
+ language: LanguageCode;
282
+ /** Sorted effect lines (see snapshot/diff). Empty on error or no effect. */
283
+ effects: string[];
284
+ /** Parse/build/runtime error, when any. */
285
+ error?: string;
286
+ /** 1 = exact signature match vs the en reference, 0 = mismatch. Undefined for en itself or when no usable reference exists. */
287
+ executionFidelity?: number;
288
+ }
289
+
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
+ }
337
+
338
+ /** How long to let the dispatched handler settle (ms). The subset contains no
339
+ * waits/transitions/fetches, so this only needs to drain micro/macrotasks. */
340
+ const SETTLE_MS = 20;
341
+
342
+ /** Per-execution hard timeout — a hung execution must not hang the sweep. */
343
+ const EXECUTION_TIMEOUT_MS = 5000;
344
+
345
+ /**
346
+ * R2 execution validator. `initialize()` MUST complete before `execute()`:
347
+ * it bootstraps jsdom globals and only then loads `@hyperfixi/core`, whose
348
+ * module evaluation touches `Element`/`document`.
349
+ */
350
+ export class ExecutionValidator {
351
+ private core: {
352
+ Runtime: new () => { execute(ast: unknown, ctx: unknown): Promise<unknown> };
353
+ createContext: (el: HTMLElement) => unknown;
354
+ } | null = null;
355
+
356
+ /** Errors thrown inside dispatched listeners surface as unhandled rejections
357
+ * (the handler is async); trap them into the current execution's sink. */
358
+ private listenerErrors: string[] = [];
359
+ private trapInstalled = false;
360
+
361
+ async initialize(): Promise<void> {
362
+ if (this.core) return;
363
+
364
+ // Bootstrap DOM globals BEFORE importing @hyperfixi/core (its dist
365
+ // evaluates `Element` at module load). The per-execution fresh document
366
+ // replaces these for each run.
367
+ this.installGlobals(new JSDOM(FIXTURE_HTML));
368
+
369
+ const core = await import('@hyperfixi/core');
370
+ this.core = {
371
+ Runtime: core.Runtime as unknown as new () => {
372
+ execute(ast: unknown, ctx: unknown): Promise<unknown>;
373
+ },
374
+ createContext: core.createContext as unknown as (el: HTMLElement) => unknown,
375
+ };
376
+
377
+ if (!this.trapInstalled) {
378
+ process.on('unhandledRejection', (reason: unknown) => {
379
+ const msg = reason instanceof Error ? reason.message : String(reason);
380
+ this.listenerErrors.push(msg);
381
+ });
382
+ this.trapInstalled = true;
383
+ }
384
+ }
385
+
386
+ getName(): string {
387
+ return 'ExecutionValidator';
388
+ }
389
+
390
+ private installGlobals(dom: JSDOM): void {
391
+ const g = globalThis as Record<string, unknown>;
392
+ g.window = dom.window;
393
+ g.document = dom.window.document;
394
+ g.Event = dom.window.Event;
395
+ g.CustomEvent = dom.window.CustomEvent;
396
+ g.HTMLElement = dom.window.HTMLElement;
397
+ g.Element = dom.window.Element;
398
+ g.Node = dom.window.Node;
399
+ g.MutationObserver = dom.window.MutationObserver;
400
+ g.getComputedStyle = dom.window.getComputedStyle;
401
+ }
402
+
403
+ /**
404
+ * Execute one pattern translation in a fresh fixture and return its effect
405
+ * signature. Never throws; failures come back as `error`.
406
+ */
407
+ async execute(codeExampleId: string, code: string, lang: string): Promise<ExecutionResult> {
408
+ const run = this.executeInner(codeExampleId, code, lang);
409
+ const timeout = new Promise<ExecutionResult>(resolve =>
410
+ setTimeout(
411
+ () =>
412
+ resolve({
413
+ codeExampleId,
414
+ language: lang as LanguageCode,
415
+ effects: [],
416
+ error: `execution timed out (${EXECUTION_TIMEOUT_MS}ms)`,
417
+ }),
418
+ EXECUTION_TIMEOUT_MS
419
+ )
420
+ );
421
+ return Promise.race([run, timeout]);
422
+ }
423
+
424
+ private async executeInner(
425
+ codeExampleId: string,
426
+ code: string,
427
+ lang: string
428
+ ): Promise<ExecutionResult> {
429
+ if (!this.core) await this.initialize();
430
+ const base: ExecutionResult = {
431
+ codeExampleId,
432
+ language: lang as LanguageCode,
433
+ effects: [],
434
+ };
435
+
436
+ const dom = new JSDOM(FIXTURE_HTML);
437
+ this.installGlobals(dom);
438
+ const document = dom.window.document;
439
+ PATTERN_SETUP[codeExampleId]?.(document);
440
+ const btn = document.getElementById('btn')!;
441
+ this.listenerErrors = [];
442
+
443
+ // The runtime logs every failing command via console — across a full
444
+ // multi-language sweep that is pure noise; silence it for the execution.
445
+ const saved = { log: console.log, warn: console.warn, error: console.error };
446
+ console.log = console.warn = console.error = () => {};
447
+ try {
448
+ const parsed = parseSemantic(code, lang);
449
+ if (!parsed.node || parsed.confidence < 0.5) {
450
+ return { ...base, error: `parse failed (confidence ${parsed.confidence?.toFixed(2)})` };
451
+ }
452
+ const built = buildAST(parsed.node);
453
+ if (!built.ast) return { ...base, error: 'buildAST returned no AST' };
454
+
455
+ const runtime = new this.core!.Runtime();
456
+ const ctx = this.core!.createContext(btn as unknown as HTMLElement);
457
+ // Installs the event handler (or runs bare commands immediately).
458
+ await runtime.execute(built.ast, ctx);
459
+
460
+ const before = snapshot(document);
461
+ const trigger = PATTERN_TRIGGER[codeExampleId];
462
+ const triggerEvent =
463
+ trigger?.detail !== undefined
464
+ ? new dom.window.CustomEvent(trigger.event, { bubbles: true, detail: trigger.detail })
465
+ : new dom.window.Event(trigger?.event ?? 'click', { bubbles: true });
466
+ btn.dispatchEvent(triggerEvent);
467
+ await new Promise(r => setTimeout(r, SETTLE_MS));
468
+ const after = snapshot(document);
469
+
470
+ const effects = diffSnapshots(before, after);
471
+ if (this.listenerErrors.length > 0) {
472
+ return { ...base, effects, error: `runtime: ${this.listenerErrors.join('; ')}` };
473
+ }
474
+ return { ...base, effects };
475
+ } catch (e: unknown) {
476
+ return { ...base, error: e instanceof Error ? e.message : String(e) };
477
+ } finally {
478
+ console.log = saved.log;
479
+ console.warn = saved.warn;
480
+ console.error = saved.error;
481
+ dom.window.close();
482
+ }
483
+ }
484
+ }
485
+
486
+ /** The subset's source text per language: en from code_examples.raw_code,
487
+ * other languages from pattern_translations. */
488
+ export async function loadExecutionSubset(
489
+ languages: readonly LanguageCode[]
490
+ ): Promise<Map<LanguageCode, Map<string, string>>> {
491
+ const subset = new Set(EXECUTION_SUBSET);
492
+ const result = new Map<LanguageCode, Map<string, string>>();
493
+
494
+ for (const lang of languages) {
495
+ const byId = new Map<string, string>();
496
+ if (lang === 'en') {
497
+ const patterns = await getAllPatterns();
498
+ for (const p of patterns) {
499
+ if (subset.has(p.id)) byId.set(p.id, p.rawCode);
500
+ }
501
+ } else {
502
+ const translations = await getTranslationsByLanguage(lang, 1000);
503
+ for (const t of translations) {
504
+ if (subset.has(t.codeExampleId)) byId.set(t.codeExampleId, t.hyperscript);
505
+ }
506
+ }
507
+ result.set(lang, byId);
508
+ }
509
+ return result;
510
+ }
@@ -4,7 +4,9 @@
4
4
 
5
5
  import { MultilingualHyperscript } from '@hyperfixi/core/multilingual';
6
6
  import type { SemanticNode } from '@lokascript/semantic';
7
+ import { fillSchemaDefaults } from '@lokascript/semantic';
7
8
  import type { PatternTranslation, ParseResult, Validator } from '../types';
9
+ import { collectActions, collectActionsMultiset, collectRoleSignature } from '../fidelity';
8
10
 
9
11
  /**
10
12
  * Parse Validator
@@ -77,6 +79,18 @@ export class ParseValidator implements Validator<ParseResult[]> {
77
79
  const command = semanticNode.action;
78
80
  const roles = this.extractRoles(semanticNode);
79
81
 
82
+ // R1 measurement normalization: materialize schema role DEFAULTS (e.g.
83
+ // increment.quantity → 1, toggle.destination → me) on this throwaway parse
84
+ // before signature collection. The en pattern path materializes them but the
85
+ // SOV path doesn't, so an SOV parse would otherwise read as dropping a role it
86
+ // defaults identically at runtime — a false-positive in role recall. Applied
87
+ // uniformly here (en reference AND every translation), it only removes that
88
+ // false-positive. NOT done in `parse()` itself, which would make the renderer
89
+ // emit the materialized defaults and break round-trips. Mutation is safe: this
90
+ // node is the validator's own and is used only for the signatures below
91
+ // (actions/multiset are role-agnostic, so they're unaffected).
92
+ fillSchemaDefaults(semanticNode);
93
+
80
94
  return {
81
95
  pattern,
82
96
  success: true,
@@ -84,6 +98,14 @@ export class ParseValidator implements Validator<ParseResult[]> {
84
98
  roles,
85
99
  confidence: pattern.confidence, // Use pattern confidence
86
100
  parser: 'semantic',
101
+ // Structural signature for cross-language fidelity scoring (see fidelity.ts).
102
+ actionSignature: collectActions(semanticNode),
103
+ // R0-precision multiset signature — duplicates preserved so a phantom
104
+ // command duplicated ahead of a real one (`[toggle, toggle]`) is visible.
105
+ actionMultisetSignature: collectActionsMultiset(semanticNode),
106
+ // R1 role signature (role name + value type per command) — collected here
107
+ // because live nodes carry roles as a ReadonlyMap that serializes to {}.
108
+ roleSignature: collectRoleSignature(semanticNode),
87
109
  duration: performance.now() - startTime,
88
110
  };
89
111
  } catch (error) {