@hyperfixi/testing-framework 2.5.1 → 2.6.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.
- package/dist/{assertions-CthVynH6.d.mts → assertions-CsGP61iW.d.mts} +1 -1
- package/dist/{assertions-CthVynH6.d.ts → assertions-CsGP61iW.d.ts} +1 -1
- package/dist/assertions.d.mts +1 -1
- package/dist/assertions.d.ts +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/package.json +6 -5
- package/src/multilingual/bundle-builder.ts +34 -16
- package/src/multilingual/cli.ts +357 -37
- package/src/multilingual/fidelity.test.ts +136 -0
- package/src/multilingual/fidelity.ts +224 -0
- package/src/multilingual/html-pattern.test.ts +39 -0
- package/src/multilingual/html-pattern.ts +24 -0
- package/src/multilingual/orchestrator.ts +177 -7
- package/src/multilingual/pattern-loader.ts +11 -2
- package/src/multilingual/reporters/console-reporter.ts +101 -0
- package/src/multilingual/reporters/regression-reporter.test.ts +406 -0
- package/src/multilingual/reporters/regression-reporter.ts +136 -1
- package/src/multilingual/types.ts +146 -0
- package/src/multilingual/validators/execution-validator.test.ts +412 -0
- package/src/multilingual/validators/execution-validator.ts +510 -0
- package/src/multilingual/validators/parse-validator.ts +22 -0
|
@@ -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) {
|