@hyperfixi/testing-framework 3.1.1 → 3.3.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +186 -1
  2. package/dist/index.js +5 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +5 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/runner.js +5 -1
  7. package/dist/runner.js.map +1 -1
  8. package/dist/runner.mjs +5 -1
  9. package/dist/runner.mjs.map +1 -1
  10. package/package.json +21 -19
  11. package/src/multilingual/README.md +39 -0
  12. package/src/multilingual/en-reference-equivalences.test.ts +321 -0
  13. package/src/multilingual/en-reference-preservation.test.ts +124 -0
  14. package/src/multilingual/en-reference-preservation.ts +495 -0
  15. package/src/multilingual/engine-parser-parity.test.ts +67 -0
  16. package/src/multilingual/pattern-loader.test.ts +43 -0
  17. package/src/multilingual/pattern-loader.ts +7 -2
  18. package/src/multilingual/shipped-examples-execution.test.ts +76 -1
  19. package/src/multilingual/shipped-examples-execution.ts +73 -3
  20. package/src/multilingual/shipped-sources-engine.test.ts +106 -0
  21. package/src/multilingual/shipped-sources-validity.ts +65 -0
  22. package/src/multilingual/validators/execution-validator.test.ts +32 -3
  23. package/src/multilingual/validators/execution-validator.ts +22 -5
  24. package/src/multilingual/value-matrix-gate.ts +118 -0
  25. package/src/multilingual/value-matrix.accepted.test.ts +65 -0
  26. package/src/multilingual/value-matrix.assign.test.ts +13 -0
  27. package/src/multilingual/value-matrix.chain-phrases.test.ts +13 -0
  28. package/src/multilingual/value-matrix.chain.test.ts +13 -0
  29. package/src/multilingual/value-matrix.count.test.ts +13 -0
  30. package/src/multilingual/value-matrix.get-phrases.test.ts +13 -0
  31. package/src/multilingual/value-matrix.get.test.ts +13 -0
  32. package/src/multilingual/value-matrix.if-phrases.test.ts +13 -0
  33. package/src/multilingual/value-matrix.if.test.ts +13 -0
  34. package/src/multilingual/value-matrix.increment.test.ts +13 -0
  35. package/src/multilingual/value-matrix.isolation.test.ts +57 -0
  36. package/src/multilingual/value-matrix.names.test.ts +62 -0
  37. package/src/multilingual/value-matrix.put-phrases.test.ts +13 -0
  38. package/src/multilingual/value-matrix.put.test.ts +13 -0
  39. package/src/multilingual/value-matrix.set-phrases.test.ts +13 -0
  40. package/src/multilingual/value-matrix.set.test.ts +13 -0
  41. package/src/multilingual/value-matrix.times.test.ts +13 -0
  42. package/src/multilingual/value-matrix.ts +1208 -0
  43. package/src/multilingual/value-matrix.while.test.ts +13 -0
  44. package/src/runner.ts +7 -1
@@ -0,0 +1,1208 @@
1
+ /**
2
+ * Value matrix — a generated, executed test of every value shape
3
+ * ----------------------------------------------------------------
4
+ * Fifty PRs fixed values that lost their tail or their whole command: a
5
+ * possessive operand, an `of` path, a parenthesized group, a translated `and`.
6
+ * Each was found by a battery written by hand, and each fix was locked in only
7
+ * by its own test table. Nothing counted what was still broken, because nothing
8
+ * enumerated the space.
9
+ *
10
+ * This does. A CELL is one value expression in one position:
11
+ *
12
+ * - OPERANDS: eleven kinds (literal, variable, selector, possessive, `of`,
13
+ * dotted, call, array, parens, a reference's property: `event's type`,
14
+ * `the id of target`, and a sigil variable: `$n`), each with instances
15
+ * of the value types it produces;
16
+ * - OPERATORS: arithmetic, comparison, equality, logic, membership, and the
17
+ * prefix and postfix forms (`not`, `-`, `no`, `is empty`, `is null`,
18
+ * `exists`, `as`), and core's operator PHRASES (`is greater than or equal
19
+ * to`, `does not include`, `is an Element`, …);
20
+ * - POSITIONS: a `put` value, a `set` value, an `if` condition, a `repeat
21
+ * while` condition, a loop's count (`repeat … times`), an `increment …
22
+ * by` amount, a `set` value in the second command of a chain (`chain`),
23
+ * what a `get` reads (`get`), and two written targets: what a `set`
24
+ * writes (`assign`) and what an `increment` counts (`count`).
25
+ *
26
+ * The generator crosses them as a covering design, not a full product: every
27
+ * operand alone; every operator with literal and variable operands on each
28
+ * side; every other operand kind with a representative operator of each class
29
+ * it fits, on each side; and a few multi-operator expressions.
30
+ *
31
+ * Two smaller axes test what a value is written to, and what it is called:
32
+ *
33
+ * - TARGETS: a plain variable, a property and an attribute as `assign` and
34
+ * `count` targets;
35
+ * - NAMES: variables spelled like another language's structure word (es
36
+ * `a`, pl `w`, tr `de`, de `um`), as a whole value in eight positions and
37
+ * as an operand in four expressions (NAME_EXPRESSIONS). A translation
38
+ * writes a variable verbatim, so its reader has to tell the variable from
39
+ * the word by where it stands (see collidingNames).
40
+ *
41
+ * ## Lanes
42
+ * Each cell runs its English source on the real `hyperscript.org` engine: that
43
+ * result is the ORACLE. Then, on the same fixture:
44
+ *
45
+ * - `en` hyperfixi's English path (core's parser and runtime);
46
+ * - `en-rt` semantic's English parse rendered back to English, on upstream:
47
+ * where it fails, every translation inherits the loss;
48
+ * - `<L>` each of 23 languages on hyperfixi's direct path —
49
+ * `render(parse_en(src), L)`, compiled with `{ language: L }`;
50
+ * - `<L>/up` the same translation through `@lokascript/hyperscript-adapter`
51
+ * (`preprocess`, back to English) on upstream: the multilingual
52
+ * product for original _hyperscript users;
53
+ * - `eng` the English source on `@hyperfixi/engine`, the engine that
54
+ * replaces core's;
55
+ * - `<L>/eng` the adapter's English (the string the `/up` lane runs) on
56
+ * that engine: where it differs from `<L>/up`, the two ENGINES
57
+ * differ, since they were given the same text.
58
+ *
59
+ * A (cell, lane) pair FAILS when its result differs from the oracle's.
60
+ *
61
+ * ## The ratchet
62
+ * `baselines/value-matrix.json` lists every failing pair, per cell. The gate
63
+ * fails on a failing pair it does not list AND on a listed pair that now
64
+ * passes, so the list only ever shrinks and every fix is locked in by the gate
65
+ * that counted it. Regenerate with `tools/regen-value-matrix-baseline.ts`,
66
+ * which refuses to add pairs unless told to.
67
+ *
68
+ * ## Execution model
69
+ * One jsdom window for the whole run; each lane resets `<body>` and the
70
+ * globals, installs the handler on a fresh button, clicks it, and reads
71
+ * `#out`. Variables are window globals (see GLOBALS), so no source needs a
72
+ * `set` prefix. Upstream runs synchronously; hyperfixi's handler settles
73
+ * within one macrotask.
74
+ *
75
+ * A translation that loses a loop's condition can loop forever. Core caps a
76
+ * loop at 10,000 iterations; upstream has no cap and blocks the thread, so
77
+ * every upstream run gets an evaluation budget (see EVAL_BUDGET). The new
78
+ * engine has no cap and no hook for one: a string that spent the budget on
79
+ * upstream is not run on it, and its `/eng` lane reads `✗budget` too.
80
+ *
81
+ * Node-only: imports the real `hyperscript.org` build off disk and needs the
82
+ * node vitest environment (see the shipped-examples gate for why).
83
+ */
84
+
85
+ import { createRequire } from 'node:module';
86
+ import { pathToFileURL } from 'node:url';
87
+ import { JSDOM } from 'jsdom';
88
+ import { nameCollision, tokenize } from '@lokascript/semantic';
89
+ import { installGlobals } from './shipped-examples-execution';
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // The generator
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /** The value an expression produces. `nstr` is a numeric string, as `textContent` reads: it fills a number's slot too. */
96
+ export type ValueType = 'num' | 'nstr' | 'str' | 'bool' | 'arr' | 'obj' | 'null' | 'el' | 'els';
97
+
98
+ export type OperandKind =
99
+ | 'literal'
100
+ | 'variable'
101
+ | 'selector'
102
+ | 'possessive'
103
+ | 'of'
104
+ | 'dotted'
105
+ | 'call'
106
+ | 'array'
107
+ | 'parens'
108
+ | 'reference'
109
+ | 'sigil';
110
+
111
+ export type Position =
112
+ 'put' | 'set' | 'if' | 'while' | 'times' | 'increment' | 'chain' | 'get' | 'assign' | 'count';
113
+
114
+ export const POSITIONS: readonly Position[] = [
115
+ 'put',
116
+ 'set',
117
+ 'if',
118
+ 'while',
119
+ 'times',
120
+ 'increment',
121
+ 'chain',
122
+ 'get',
123
+ 'assign',
124
+ 'count',
125
+ ];
126
+
127
+ export interface Operand {
128
+ kind: OperandKind;
129
+ type: ValueType;
130
+ text: string;
131
+ }
132
+
133
+ export interface MatrixCell {
134
+ /** `<position>|<expression>` — stable, and readable in the baseline. */
135
+ id: string;
136
+ position: Position;
137
+ expression: string;
138
+ /** The English handler. */
139
+ source: string;
140
+ group: 'operand' | 'operator' | 'operand-operator' | 'compound' | 'target' | 'name';
141
+ /** The operand kind under test, when the cell tests one. */
142
+ operand?: OperandKind;
143
+ /** The operator under test, when any. */
144
+ operator?: string;
145
+ /** The colliding name under test, in a `name` cell (see collidingNames). */
146
+ name?: string;
147
+ /**
148
+ * Languages whose two lanes do not run: the cell's variable is spelled like
149
+ * that language's pronoun, so no reader can tell them apart (see
150
+ * collidingNames).
151
+ */
152
+ skip?: readonly string[];
153
+ }
154
+
155
+ /**
156
+ * The fixture every lane runs on. `#out` starts as `∅`, which no oracle
157
+ * result is: a lane that never reaches its `put` reads `∅`.
158
+ */
159
+ export const FIXTURE =
160
+ '<div id="out">∅</div>' +
161
+ '<p id="a" class="x" title="t1">6</p>' +
162
+ '<div id="w"><p class="w">w</p><p class="w">v</p></div>' +
163
+ '<button id="b">b</button>';
164
+
165
+ /** Window globals, rebuilt before every run so no lane sees another's writes. */
166
+ export const GLOBALS: Readonly<Record<string, () => unknown>> = {
167
+ n: () => 6,
168
+ s: () => 'ab',
169
+ flag: () => true,
170
+ arr: () => [1, 2],
171
+ obj: () => ({ v: 6, w: { v: 2 } }),
172
+ // Both engines read a `$` variable off the global scope.
173
+ $n: () => 3,
174
+ };
175
+
176
+ export const OPERANDS: readonly Operand[] = [
177
+ { kind: 'literal', type: 'num', text: '2' },
178
+ { kind: 'literal', type: 'str', text: '"q"' },
179
+ { kind: 'literal', type: 'bool', text: 'true' },
180
+ { kind: 'literal', type: 'null', text: 'null' },
181
+ { kind: 'literal', type: 'obj', text: '{}' },
182
+ { kind: 'variable', type: 'num', text: 'n' },
183
+ { kind: 'variable', type: 'str', text: 's' },
184
+ { kind: 'variable', type: 'bool', text: 'flag' },
185
+ { kind: 'variable', type: 'arr', text: 'arr' },
186
+ { kind: 'variable', type: 'obj', text: 'obj' },
187
+ { kind: 'selector', type: 'el', text: '#a' },
188
+ { kind: 'selector', type: 'els', text: '.w' },
189
+ { kind: 'possessive', type: 'nstr', text: "#a's textContent" },
190
+ { kind: 'possessive', type: 'str', text: 'my id' },
191
+ { kind: 'possessive', type: 'num', text: "obj's v" },
192
+ { kind: 'possessive', type: 'num', text: "arr's length" },
193
+ { kind: 'possessive', type: 'num', text: "#a's textContent's length" },
194
+ { kind: 'of', type: 'nstr', text: 'textContent of #a' },
195
+ { kind: 'of', type: 'nstr', text: 'the textContent of #a' },
196
+ { kind: 'of', type: 'num', text: 'v of obj' },
197
+ { kind: 'of', type: 'num', text: 'length of arr' },
198
+ { kind: 'of', type: 'str', text: '@title of #a' },
199
+ { kind: 'of', type: 'num', text: 'v of w of obj' },
200
+ { kind: 'of', type: 'arr', text: 'textContent of .w' },
201
+ { kind: 'dotted', type: 'nstr', text: '#a.textContent' },
202
+ { kind: 'dotted', type: 'str', text: 'me.id' },
203
+ { kind: 'dotted', type: 'num', text: 'obj.v' },
204
+ { kind: 'dotted', type: 'num', text: 'arr.length' },
205
+ { kind: 'dotted', type: 'num', text: 'obj.w.v' },
206
+ { kind: 'call', type: 'num', text: 'Math.max(n, 1)' },
207
+ { kind: 'call', type: 'nstr', text: 'String(n)' },
208
+ { kind: 'call', type: 'str', text: 's.toUpperCase()' },
209
+ { kind: 'call', type: 'bool', text: 'Array.isArray(arr)' },
210
+ { kind: 'array', type: 'arr', text: '[1, 2]' },
211
+ { kind: 'array', type: 'arr', text: '[n, 2]' },
212
+ { kind: 'parens', type: 'num', text: '(n + 1)' },
213
+ { kind: 'parens', type: 'str', text: '(s + "c")' },
214
+ { kind: 'parens', type: 'bool', text: '(n > 1)' },
215
+ // A reference word's property, each way a property is written. The owner is
216
+ // a keyword every language translates, where a variable is written verbatim:
217
+ // PR 104 dropped a guard that kept `event's type` reading in five languages,
218
+ // with every oracle green, since no cell had a reference owner. The click is
219
+ // synthetic, so `event's detail` is 0, and jsdom never scrolls.
220
+ { kind: 'reference', type: 'str', text: "event's type" },
221
+ { kind: 'reference', type: 'str', text: 'the type of event' },
222
+ { kind: 'reference', type: 'str', text: 'event.type' },
223
+ { kind: 'reference', type: 'str', text: "target's id" },
224
+ { kind: 'reference', type: 'str', text: 'the id of target' },
225
+ { kind: 'reference', type: 'str', text: "event's target's id" },
226
+ { kind: 'reference', type: 'num', text: "event's detail" },
227
+ { kind: 'reference', type: 'num', text: 'the scrollY of window' },
228
+ // A variable with its scope's sigil. A translation writes it verbatim, like
229
+ // a variable, but no cell had one: semantic's English dropped a whole loop
230
+ // counted by one (`repeat $n times`), and every translation with it.
231
+ { kind: 'sigil', type: 'num', text: '$n' },
232
+ ];
233
+
234
+ /** An operator slot, and the value types that fill it. */
235
+ type Slot =
236
+ 'num' | 'str' | 'text' | 'bool' | 'arr' | 'empty' | 'any' | 'el' | 'one' | 'els' | 'none';
237
+
238
+ const FITS: Record<Slot, readonly ValueType[]> = {
239
+ num: ['num', 'nstr'],
240
+ str: ['str', 'nstr'],
241
+ text: ['str'],
242
+ bool: ['bool'],
243
+ arr: ['arr'],
244
+ empty: ['str', 'nstr', 'arr', 'obj', 'null'],
245
+ any: ['num', 'nstr', 'str', 'bool', 'arr', 'obj', 'null', 'el', 'els'],
246
+ el: ['el', 'els'],
247
+ one: ['el'],
248
+ els: ['els'],
249
+ none: [],
250
+ };
251
+
252
+ interface Signature {
253
+ left: Slot;
254
+ right: Slot;
255
+ /** The operands that fill the other slot while one side is under test. */
256
+ leftAnchor: string;
257
+ rightAnchor: string;
258
+ result: ValueType;
259
+ }
260
+
261
+ interface BinaryOperator {
262
+ op: string;
263
+ signatures: Signature[];
264
+ /** Representative of its class: crossed with every operand kind, not only literals and variables. */
265
+ representative?: boolean;
266
+ }
267
+
268
+ interface UnaryOperator {
269
+ op: string;
270
+ fix: 'prefix' | 'postfix';
271
+ slot: Slot;
272
+ anchor: string;
273
+ result: ValueType;
274
+ representative?: boolean;
275
+ }
276
+
277
+ const sig = (
278
+ left: Slot,
279
+ right: Slot,
280
+ leftAnchor: string,
281
+ rightAnchor: string,
282
+ result: ValueType
283
+ ): Signature => ({ left, right, leftAnchor, rightAnchor, result });
284
+
285
+ export const BINARY_OPERATORS: readonly BinaryOperator[] = [
286
+ {
287
+ op: '+',
288
+ representative: true,
289
+ signatures: [sig('num', 'num', '2', '2', 'num'), sig('text', 'text', '"q"', '"q"', 'str')],
290
+ },
291
+ { op: '-', signatures: [sig('num', 'num', '20', '2', 'num')] },
292
+ { op: '*', signatures: [sig('num', 'num', '2', '2', 'num')] },
293
+ { op: '/', signatures: [sig('num', 'num', '12', '2', 'num')] },
294
+ { op: 'mod', signatures: [sig('num', 'num', '13', '4', 'num')] },
295
+ { op: '<', representative: true, signatures: [sig('num', 'num', '1', '4', 'bool')] },
296
+ { op: '>', signatures: [sig('num', 'num', '1', '4', 'bool')] },
297
+ { op: '<=', signatures: [sig('num', 'num', '1', '4', 'bool')] },
298
+ { op: '>=', signatures: [sig('num', 'num', '1', '4', 'bool')] },
299
+ {
300
+ op: 'is',
301
+ representative: true,
302
+ signatures: [sig('num', 'num', '6', '6', 'bool'), sig('text', 'text', '"ab"', '"ab"', 'bool')],
303
+ },
304
+ {
305
+ op: 'is not',
306
+ signatures: [sig('num', 'num', '6', '6', 'bool'), sig('text', 'text', '"ab"', '"ab"', 'bool')],
307
+ },
308
+ { op: '==', signatures: [sig('num', 'num', '6', '6', 'bool')] },
309
+ { op: '!=', signatures: [sig('num', 'num', '6', '6', 'bool')] },
310
+ { op: 'is greater than', signatures: [sig('num', 'num', '1', '4', 'bool')] },
311
+ { op: 'is less than', signatures: [sig('num', 'num', '1', '4', 'bool')] },
312
+ { op: 'and', representative: true, signatures: [sig('bool', 'bool', 'true', 'true', 'bool')] },
313
+ { op: 'or', signatures: [sig('bool', 'bool', 'false', 'false', 'bool')] },
314
+ {
315
+ op: 'contains',
316
+ representative: true,
317
+ signatures: [
318
+ sig('str', 'str', '"xab6"', '"a"', 'bool'),
319
+ sig('arr', 'num', '[1, 2, 6]', '2', 'bool'),
320
+ ],
321
+ },
322
+ {
323
+ op: 'is in',
324
+ representative: true,
325
+ signatures: [sig('num', 'arr', '2', '[1, 2, 6]', 'bool')],
326
+ },
327
+ { op: 'matches', representative: true, signatures: [sig('one', 'none', '#a', '.x', 'bool')] },
328
+ // Operator PHRASES (the after-85 handoff, part 2): the words each language
329
+ // renders for them are read back by the join and the sense table, which no
330
+ // single-word operator exercises. One of each class is representative, so the
331
+ // class meets every operand kind without every phrase multiplying the cells.
332
+ {
333
+ op: 'is equal to',
334
+ representative: true,
335
+ signatures: [sig('num', 'num', '6', '6', 'bool'), sig('text', 'text', '"ab"', '"ab"', 'bool')],
336
+ },
337
+ {
338
+ op: 'is not equal to',
339
+ signatures: [sig('num', 'num', '6', '6', 'bool'), sig('text', 'text', '"ab"', '"ab"', 'bool')],
340
+ },
341
+ { op: 'is really equal to', signatures: [sig('num', 'num', '6', '6', 'bool')] },
342
+ { op: 'is not really equal to', signatures: [sig('num', 'num', '6', '6', 'bool')] },
343
+ { op: 'really equals', signatures: [sig('num', 'num', '6', '6', 'bool')] },
344
+ { op: 'equals', signatures: [sig('num', 'num', '6', '6', 'bool')] },
345
+ { op: 'is really', signatures: [sig('num', 'num', '6', '6', 'bool')] },
346
+ {
347
+ op: 'is greater than or equal to',
348
+ representative: true,
349
+ signatures: [sig('num', 'num', '1', '4', 'bool')],
350
+ },
351
+ { op: 'is less than or equal to', signatures: [sig('num', 'num', '1', '4', 'bool')] },
352
+ { op: 'is not in', signatures: [sig('num', 'arr', '2', '[1, 2, 6]', 'bool')] },
353
+ {
354
+ op: 'includes',
355
+ representative: true,
356
+ signatures: [
357
+ sig('str', 'str', '"xab6"', '"a"', 'bool'),
358
+ sig('arr', 'num', '[1, 2, 6]', '2', 'bool'),
359
+ ],
360
+ },
361
+ {
362
+ op: 'does not include',
363
+ signatures: [
364
+ sig('str', 'str', '"xab6"', '"a"', 'bool'),
365
+ sig('arr', 'num', '[1, 2, 6]', '2', 'bool'),
366
+ ],
367
+ },
368
+ {
369
+ op: 'does not contain',
370
+ signatures: [
371
+ sig('str', 'str', '"xab6"', '"a"', 'bool'),
372
+ sig('arr', 'num', '[1, 2, 6]', '2', 'bool'),
373
+ ],
374
+ },
375
+ { op: 'does not match', signatures: [sig('one', 'none', '#a', '.x', 'bool')] },
376
+ { op: 'precedes', signatures: [sig('one', 'one', '#a', '#b', 'bool')] },
377
+ { op: 'follows', signatures: [sig('one', 'one', '#a', '#b', 'bool')] },
378
+ ];
379
+
380
+ export const UNARY_OPERATORS: readonly UnaryOperator[] = [
381
+ { op: 'not', fix: 'prefix', slot: 'bool', anchor: 'false', result: 'bool', representative: true },
382
+ { op: '-', fix: 'prefix', slot: 'num', anchor: '2', result: 'num', representative: true },
383
+ { op: 'no', fix: 'prefix', slot: 'els', anchor: '.w', result: 'bool', representative: true },
384
+ {
385
+ op: 'is empty',
386
+ fix: 'postfix',
387
+ slot: 'empty',
388
+ anchor: '""',
389
+ result: 'bool',
390
+ representative: true,
391
+ },
392
+ { op: 'is not empty', fix: 'postfix', slot: 'empty', anchor: '""', result: 'bool' },
393
+ // Its own pair: languages that spell `null` and `empty` alike read one of
394
+ // them back as the other, and only `"" is null` tells them apart.
395
+ { op: 'is null', fix: 'postfix', slot: 'any', anchor: '""', result: 'bool' },
396
+ { op: 'is not null', fix: 'postfix', slot: 'any', anchor: '""', result: 'bool' },
397
+ { op: 'exists', fix: 'postfix', slot: 'el', anchor: '#a', result: 'bool', representative: true },
398
+ { op: 'as Int', fix: 'postfix', slot: 'num', anchor: '"7"', result: 'num', representative: true },
399
+ { op: 'as String', fix: 'postfix', slot: 'num', anchor: '7', result: 'str' },
400
+ // Operator phrases (see BINARY_OPERATORS): existence and type checks.
401
+ {
402
+ op: 'does not exist',
403
+ fix: 'postfix',
404
+ slot: 'el',
405
+ anchor: '#a',
406
+ result: 'bool',
407
+ representative: true,
408
+ },
409
+ {
410
+ op: 'is a Number',
411
+ fix: 'postfix',
412
+ slot: 'any',
413
+ anchor: '6',
414
+ result: 'bool',
415
+ representative: true,
416
+ },
417
+ { op: 'is not a Number', fix: 'postfix', slot: 'any', anchor: '6', result: 'bool' },
418
+ { op: 'is a String', fix: 'postfix', slot: 'any', anchor: '"q"', result: 'bool' },
419
+ { op: 'is not a String', fix: 'postfix', slot: 'any', anchor: '"q"', result: 'bool' },
420
+ { op: 'is an Element', fix: 'postfix', slot: 'any', anchor: '#a', result: 'bool' },
421
+ { op: 'is not an Element', fix: 'postfix', slot: 'any', anchor: '#a', result: 'bool' },
422
+ ];
423
+
424
+ /**
425
+ * The operator phrases the after-85 handoff's part 2 added. They add half
426
+ * again to the matrix, so each position's phrase cells run in a shard of
427
+ * their own (`value-matrix.<position>-phrases.test.ts`), which the position's
428
+ * shard leaves them to.
429
+ */
430
+ export const PHRASE_OPERATORS: ReadonlySet<string> = new Set([
431
+ 'is equal to',
432
+ 'is not equal to',
433
+ 'is really equal to',
434
+ 'is not really equal to',
435
+ 'really equals',
436
+ 'equals',
437
+ 'is really',
438
+ 'is greater than or equal to',
439
+ 'is less than or equal to',
440
+ 'is not in',
441
+ 'includes',
442
+ 'does not include',
443
+ 'does not contain',
444
+ 'does not match',
445
+ 'precedes',
446
+ 'follows',
447
+ 'does not exist',
448
+ 'is a Number',
449
+ 'is not a Number',
450
+ 'is a String',
451
+ 'is not a String',
452
+ 'is an Element',
453
+ 'is not an Element',
454
+ ]);
455
+
456
+ /** Is this cell one of the phrase shard's? */
457
+ export const isPhraseCell = (cell: MatrixCell): boolean =>
458
+ cell.operator !== undefined && PHRASE_OPERATORS.has(cell.operator);
459
+
460
+ /**
461
+ * Multi-operator expressions: precedence, chaining, and mixed operand kinds.
462
+ * Upstream requires parentheses between different math operators.
463
+ */
464
+ export const COMPOUND_EXPRESSIONS: ReadonlyArray<{ text: string; result: ValueType }> = [
465
+ { text: 'n + (2 * 3)', result: 'num' },
466
+ { text: '(n * 2) + 3', result: 'num' },
467
+ { text: '(n + 2) * 3', result: 'num' },
468
+ { text: 'n - 2 - 1', result: 'num' },
469
+ { text: 'n + 1 < 10', result: 'bool' },
470
+ { text: 'n > 1 and n < 10', result: 'bool' },
471
+ { text: 'n < 1 or n > 5', result: 'bool' },
472
+ { text: 'not flag or n is 6', result: 'bool' },
473
+ { text: 'flag and not (n is 6)', result: 'bool' },
474
+ { text: "#a's textContent as Int + 1", result: 'num' },
475
+ { text: 'length of arr + n', result: 'num' },
476
+ { text: 'obj.v * obj.w.v', result: 'num' },
477
+ { text: 's + " " + my id', result: 'str' },
478
+ ];
479
+
480
+ /**
481
+ * The `assign` and `count` targets besides the colliding names: a variable, a
482
+ * property three ways and in an element, and an attribute of the button,
483
+ * which has none (an increment reads it as 0).
484
+ */
485
+ export const TARGETS: readonly string[] = [
486
+ 'n',
487
+ 'obj.v',
488
+ "obj's v",
489
+ 'v of obj',
490
+ "#a's textContent",
491
+ 'the textContent of #a',
492
+ '@title',
493
+ ];
494
+
495
+ /** The value every colliding name holds (see collidingNames). */
496
+ export const NAME_VALUE = 7;
497
+
498
+ /**
499
+ * A colliding name's positions as a whole value. Not the two loops, `while`
500
+ * and `times`: probing every name in each, it failed in no lane that another
501
+ * position did not.
502
+ */
503
+ const NAME_POSITIONS: readonly Position[] = [
504
+ 'put',
505
+ 'set',
506
+ 'if',
507
+ 'increment',
508
+ 'chain',
509
+ 'get',
510
+ 'assign',
511
+ 'count',
512
+ ];
513
+
514
+ /**
515
+ * A colliding name as an operand: before an operator and after one, in a
516
+ * comparison, and after `not`. The rules that tell a name from a structure
517
+ * word read its neighbours, and a whole value has none: the extended names
518
+ * oracle misreads a tenth of these names in these shapes on main, where the
519
+ * whole-value cells all pass. Each condition is false for the name's value,
520
+ * so the misreadings that drop the operator (`if si`) read as true.
521
+ */
522
+ export const NAME_EXPRESSIONS: ReadonlyArray<{
523
+ position: Position;
524
+ form: (name: string) => string;
525
+ }> = [
526
+ { position: 'put', form: name => `${name} + 1` },
527
+ { position: 'set', form: name => `1 + ${name}` },
528
+ { position: 'if', form: name => `${name} < 3` },
529
+ { position: 'if', form: name => `not ${name}` },
530
+ ];
531
+
532
+ export interface CollidingName {
533
+ name: string;
534
+ /** Languages whose tokenizer reads the name as a pronoun: their lanes do not run. */
535
+ pronounIn: readonly string[];
536
+ }
537
+
538
+ let collidingNamesMemo: readonly CollidingName[] | undefined;
539
+
540
+ /**
541
+ * Longer names, as code writes them, that a tokenizer splits: qu reads the end
542
+ * of a camelCase word as a case marker (`userData` → `userDa` + `ta`, its
543
+ * accusative), and the render lost the whole variable (PR 111).
544
+ */
545
+ const CODE_NAMES: readonly string[] = ['userData'];
546
+
547
+ /**
548
+ * Variables spelled like a structure word of a language the matrix translates
549
+ * into: every one- or two-letter name semantic's `nameCollision` finds
550
+ * colliding with one — a particle, a connective, a copula, a verb, a role
551
+ * marker (de `um`, which the tokenizer leaves an identifier), a connective the
552
+ * join reads (tl `o`, fr `ou`), an English article. A translation writes the
553
+ * variable verbatim, so the reader has to tell it from the word by where it
554
+ * stands, as PR 59 did for a conjunction, PR 64 for tr's particle `i` and PR 75
555
+ * for an article. Derived, and from the definition the validator and the
556
+ * editor warn with, so a vocabulary change that makes a new word collide adds
557
+ * its cells.
558
+ *
559
+ * Left out: English keywords, which hyperfixi's own English cannot take as a
560
+ * variable either (except the articles, which it can, PR 75); `no`, which
561
+ * upstream reads as its operator; the templates' `i` and `x` and the other
562
+ * globals; and a name that only ever collides with a pronoun: pt `eu` is `me`,
563
+ * and `colocar eu em #out` really does say "put me into #out". A name that is
564
+ * a pronoun in one language and a structure word in another keeps its cells,
565
+ * without the pronoun's lanes (MatrixCell.skip).
566
+ */
567
+ export function collidingNames(): readonly CollidingName[] {
568
+ if (collidingNamesMemo) return collidingNamesMemo;
569
+ const letters = [...'abcdefghijklmnopqrstuvwxyz'];
570
+ const candidates = [...letters, ...letters.flatMap(a => letters.map(b => a + b)), ...CODE_NAMES];
571
+ const reserved = new Set(['i', 'x', 'no', ...Object.keys(GLOBALS)]);
572
+ const englishKeyword = (word: string): boolean => {
573
+ if (word === 'a' || word === 'an') return false;
574
+ const tokens = tokenize(word, 'en').tokens;
575
+ return tokens.length === 1 && tokens[0]?.kind !== 'identifier';
576
+ };
577
+ collidingNamesMemo = candidates
578
+ .filter(word => !reserved.has(word) && !englishKeyword(word))
579
+ .flatMap(name => {
580
+ const readings = FOREIGN_LANGUAGES.map(language => ({
581
+ language,
582
+ collision: nameCollision(name, language),
583
+ }));
584
+ if (!readings.some(reading => reading.collision === 'structure')) return [];
585
+ const pronounIn = readings.filter(r => r.collision === 'pronoun').map(r => r.language);
586
+ return [{ name, pronounIn }];
587
+ });
588
+ return collidingNamesMemo;
589
+ }
590
+
591
+ const TEMPLATES: Record<Position, (expression: string) => string> = {
592
+ put: e => `on click put ${e} into #out`,
593
+ set: e => `on click set x to ${e} then put x into #out`,
594
+ if: e => `on click if ${e} then put "Y" into #out else put "N" into #out end`,
595
+ while: e => `on click set i to 0 then repeat while i < ${e} increment i end then put i into #out`,
596
+ times: e => `on click set i to 0 then repeat ${e} times increment i end then put i into #out`,
597
+ increment: e => `on click set i to 1 then increment i by ${e} then put i into #out`,
598
+ chain: e => `on click set p to 1 then set x to ${e} then put x into #out`,
599
+ get: e => `on click get ${e} then put it into #out`,
600
+ assign: e => `on click set ${e} to 5 then put ${e} into #out`,
601
+ count: e => `on click increment ${e} then put ${e} into #out`,
602
+ };
603
+
604
+ /**
605
+ * The positions an expression fills: any value is put or set; a condition
606
+ * takes a boolean, or an operand alone (its truthiness); a loop bound, a
607
+ * loop's count and an increment take a number. A reader that loses a count
608
+ * loops forever: es read `repetir n veces` as `repeat forever`.
609
+ *
610
+ * A value the second command of a chain sets (`chain`) is every `set` value
611
+ * but an operand kind's cross with the operators: the cells that meet each
612
+ * operator with a variable and a literal on each side, each operand alone, the
613
+ * compounds and the names. A command after `then` is read where a handler can
614
+ * start: de reads `setze x auf n + 3` there as `on n` (`auf` is also `on`),
615
+ * and every cell had one command before the value's.
616
+ *
617
+ * What a `get` reads (`get`) is the same set: `get` takes any value and sets
618
+ * `it`, but its role took no literal, so English dropped `get "hello"`, `get
619
+ * 3` and `get true` whole (and every translation with them) while `get n` and
620
+ * `get #a's textContent` read.
621
+ */
622
+ function positionsFor(type: ValueType, bare: boolean, group: MatrixCell['group']): Position[] {
623
+ const out: Position[] = ['put', 'set'];
624
+ if (type === 'bool' || bare) out.push('if');
625
+ if (type === 'num' || type === 'nstr') out.push('while', 'times', 'increment');
626
+ if (group !== 'operand-operator') out.push('chain', 'get');
627
+ return out;
628
+ }
629
+
630
+ const fits = (operand: Operand, slot: Slot): boolean => FITS[slot].includes(operand.type);
631
+ const isSimple = (operand: Operand): boolean =>
632
+ operand.kind === 'literal' || operand.kind === 'variable';
633
+
634
+ /** Every cell, in a stable order. */
635
+ export function generateCells(): MatrixCell[] {
636
+ const cells = new Map<string, MatrixCell>();
637
+ const add = (
638
+ expression: string,
639
+ result: ValueType,
640
+ meta: Pick<MatrixCell, 'group'> & Partial<Pick<MatrixCell, 'operand' | 'operator'>>
641
+ ): void => {
642
+ for (const position of positionsFor(result, meta.group === 'operand', meta.group)) {
643
+ const id = `${position}|${expression}`;
644
+ if (cells.has(id)) continue;
645
+ cells.set(id, { id, position, expression, source: TEMPLATES[position](expression), ...meta });
646
+ }
647
+ };
648
+
649
+ for (const o of OPERANDS) add(o.text, o.type, { group: 'operand', operand: o.kind });
650
+
651
+ for (const b of BINARY_OPERATORS) {
652
+ for (const s of b.signatures) {
653
+ add(`${s.leftAnchor} ${b.op} ${s.rightAnchor}`, s.result, {
654
+ group: 'operator',
655
+ operator: b.op,
656
+ });
657
+ for (const o of OPERANDS) {
658
+ if (!isSimple(o) && !b.representative) continue;
659
+ const group = isSimple(o) ? 'operator' : 'operand-operator';
660
+ const meta = { group, operand: o.kind, operator: b.op } as const;
661
+ if (fits(o, s.left)) add(`${o.text} ${b.op} ${s.rightAnchor}`, s.result, meta);
662
+ if (fits(o, s.right)) add(`${s.leftAnchor} ${b.op} ${o.text}`, s.result, meta);
663
+ }
664
+ }
665
+ }
666
+
667
+ for (const u of UNARY_OPERATORS) {
668
+ const form = (text: string): string =>
669
+ u.fix === 'postfix' ? `${text} ${u.op}` : u.op === '-' ? `-${text}` : `${u.op} ${text}`;
670
+ add(form(u.anchor), u.result, { group: 'operator', operator: u.op });
671
+ for (const o of OPERANDS) {
672
+ if (!isSimple(o) && !u.representative) continue;
673
+ if (!fits(o, u.slot)) continue;
674
+ // Upstream reads no article after a unary minus.
675
+ if (u.op === '-' && o.text.startsWith('the ')) continue;
676
+ add(form(o.text), u.result, {
677
+ group: isSimple(o) ? 'operator' : 'operand-operator',
678
+ operand: o.kind,
679
+ operator: u.op,
680
+ });
681
+ }
682
+ }
683
+
684
+ for (const c of COMPOUND_EXPRESSIONS) add(c.text, c.result, { group: 'compound' });
685
+
686
+ const place = (
687
+ position: Position,
688
+ expression: string,
689
+ meta: Omit<MatrixCell, 'id' | 'position' | 'expression' | 'source'>
690
+ ): void => {
691
+ const id = `${position}|${expression}`;
692
+ if (!cells.has(id)) {
693
+ cells.set(id, { id, position, expression, source: TEMPLATES[position](expression), ...meta });
694
+ }
695
+ };
696
+ for (const target of TARGETS) {
697
+ place('assign', target, { group: 'target' });
698
+ place('count', target, { group: 'target' });
699
+ }
700
+ for (const { name, pronounIn } of collidingNames()) {
701
+ const meta = { group: 'name', name, ...(pronounIn.length ? { skip: pronounIn } : {}) } as const;
702
+ for (const position of NAME_POSITIONS) place(position, name, meta);
703
+ for (const { position, form } of NAME_EXPRESSIONS) place(position, form(name), meta);
704
+ }
705
+
706
+ return [...cells.values()];
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // Lanes and engines
711
+ // ---------------------------------------------------------------------------
712
+
713
+ export const FOREIGN_LANGUAGES = [
714
+ 'ar',
715
+ 'bn',
716
+ 'de',
717
+ 'es',
718
+ 'fr',
719
+ 'he',
720
+ 'hi',
721
+ 'id',
722
+ 'it',
723
+ 'ja',
724
+ 'ko',
725
+ 'ms',
726
+ 'pl',
727
+ 'pt',
728
+ 'qu',
729
+ 'ru',
730
+ 'sw',
731
+ 'th',
732
+ 'tl',
733
+ 'tr',
734
+ 'uk',
735
+ 'vi',
736
+ 'zh',
737
+ ] as const;
738
+
739
+ /** Every lane, in report order. */
740
+ export const LANES: readonly string[] = [
741
+ 'en',
742
+ 'en-rt',
743
+ 'eng',
744
+ ...FOREIGN_LANGUAGES.flatMap(language => [language, `${language}/up`, `${language}/eng`]),
745
+ ];
746
+
747
+ /**
748
+ * Upstream evaluations allowed per run. A cell uses well under a hundred; a
749
+ * loop whose condition a translation lost uses them all and stops, where it
750
+ * would otherwise block the thread for good.
751
+ */
752
+ const EVAL_BUDGET = 20_000;
753
+
754
+ /** What a lane needs of a host that reads scripts off attributes: upstream, or the new engine. */
755
+ interface ScriptHost {
756
+ parse(source: string): { errors?: Array<{ message: string }> } | undefined;
757
+ processNode(element: Element): void;
758
+ }
759
+
760
+ /** The upstream surface this uses (`hyperscript.org`'s ESM default export). */
761
+ interface UpstreamEngine extends ScriptHost {
762
+ internals: {
763
+ runtime: { unifiedEval(parseElement: unknown, context: unknown): unknown };
764
+ };
765
+ }
766
+
767
+ /** One (cell, lane) outcome: what the lane put in `#out`, or why it could not run. */
768
+ export type LaneResult = string;
769
+
770
+ export interface CellResult {
771
+ id: string;
772
+ /** The oracle: upstream's result for the English source. */
773
+ want: string;
774
+ /** Why the oracle is unusable, when it is (the generator should never produce one). */
775
+ invalid?: string;
776
+ lanes: Record<string, LaneResult>;
777
+ }
778
+
779
+ export interface MatrixEngines {
780
+ runCell(cell: MatrixCell): Promise<CellResult>;
781
+ /** Restore the console and the process's rejection listeners. */
782
+ close(): Promise<void>;
783
+ }
784
+
785
+ /**
786
+ * Load both engines, once, on a single jsdom window, and return a cell runner.
787
+ *
788
+ * Until `close()`, the console is silenced and unhandled rejections are
789
+ * trapped: thousands of lanes fail by design, both engines report a failure
790
+ * on the console, and hyperfixi's handler is an async listener, so an error
791
+ * in it rejects a promise nobody holds. The process's own rejection
792
+ * listeners (vitest's, under test) are set aside for the run and restored.
793
+ */
794
+ export async function initMatrixEngines(): Promise<MatrixEngines> {
795
+ // Errors either engine reports. jsdom's default virtual console forwards an
796
+ // exception thrown in a listener (an upstream handler's) to console.error.
797
+ let reportedErrors = 0;
798
+ const dom = new JSDOM('<!doctype html><html><body></body></html>', {
799
+ url: 'http://localhost/',
800
+ });
801
+ installGlobals(dom);
802
+
803
+ const saved = { log: console.log, warn: console.warn, error: console.error, info: console.info };
804
+ const quiet = (): void => {};
805
+ console.log = quiet;
806
+ console.warn = quiet;
807
+ console.info = quiet;
808
+ console.error = () => {
809
+ reportedErrors++;
810
+ };
811
+ const rejectionListeners = process.listeners('unhandledRejection');
812
+ process.removeAllListeners('unhandledRejection');
813
+ const trap = (): void => {
814
+ reportedErrors++;
815
+ };
816
+ process.on('unhandledRejection', trap);
817
+
818
+ const { hyperscript } = await import('@hyperfixi/core');
819
+ const { parseSemantic, render } = await import('@lokascript/semantic');
820
+ const { preprocess } = await import('@lokascript/hyperscript-adapter');
821
+ const require = createRequire(import.meta.url);
822
+ const esm = require.resolve('hyperscript.org').replace(/[^/\\]+$/, '_hyperscript.esm.js');
823
+ const upstream: UpstreamEngine = (await import(pathToFileURL(esm).href)).default;
824
+ const engineModule = await import('@hyperfixi/engine');
825
+ engineModule.register(...engineModule.everything);
826
+ const engine: ScriptHost = engineModule.api;
827
+
828
+ // The evaluation budget: every upstream evaluation goes through unifiedEval.
829
+ const runtime = upstream.internals.runtime;
830
+ const unifiedEval = runtime.unifiedEval.bind(runtime);
831
+ let evaluations = 0;
832
+ runtime.unifiedEval = (parseElement, context) => {
833
+ if (++evaluations > EVAL_BUDGET) throw new Error('value matrix: evaluation budget spent');
834
+ return unifiedEval(parseElement, context);
835
+ };
836
+
837
+ const window = dom.window;
838
+ const document = window.document;
839
+ type Ast = Parameters<typeof hyperscript.execute>[0];
840
+
841
+ // hyperfixi keeps its global variables in one Map that every context shares,
842
+ // and writes a window global there (`increment n`). It outlives the run, and
843
+ // it shadows window's copy, so every later lane would read the write.
844
+ const coreGlobals = hyperscript.createContext().globals;
845
+ const coreGlobalsAtStart = new Map(coreGlobals);
846
+ const headAtStart = document.head.innerHTML;
847
+ const names = collidingNames();
848
+
849
+ /**
850
+ * A fresh body and fresh globals; returns the button. A global goes on both
851
+ * the jsdom window and node's globalThis — one object in a browser, two
852
+ * here, and the engines' lookups reach one or the other.
853
+ */
854
+ const reset = (): HTMLElement => {
855
+ // A lane can take the body with it: tr read `increment i by 2 * 2` as
856
+ // `increment *`, which writes the text of every element, <html> included.
857
+ if (!document.body) {
858
+ const html = document.documentElement ?? document.appendChild(document.createElement('html'));
859
+ html.innerHTML = `<head>${headAtStart}</head><body></body>`;
860
+ }
861
+ document.body.innerHTML = FIXTURE;
862
+ coreGlobals.clear();
863
+ for (const [name, value] of coreGlobalsAtStart) coreGlobals.set(name, value);
864
+ for (const [name, make] of Object.entries(GLOBALS)) {
865
+ const value = make();
866
+ Reflect.set(window, name, value);
867
+ Reflect.set(globalThis, name, value);
868
+ }
869
+ for (const { name } of names) {
870
+ Reflect.set(window, name, NAME_VALUE);
871
+ Reflect.set(globalThis, name, NAME_VALUE);
872
+ }
873
+ return document.getElementById('b') as HTMLElement;
874
+ };
875
+ const click = (button: HTMLElement): void => {
876
+ button.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
877
+ };
878
+ const read = (): string => document.getElementById('out')?.textContent ?? '✗no #out';
879
+
880
+ /** Run English on a host that reads the script off the button. */
881
+ const onHost = (host: ScriptHost, source: string): string => {
882
+ const errors = host.parse(source)?.errors ?? [];
883
+ if (errors.length) return `✗parse: ${errors[0]?.message.split('\n')[0] ?? ''}`;
884
+ const button = reset();
885
+ button.setAttribute('_', source);
886
+ evaluations = 0;
887
+ const before = reportedErrors;
888
+ host.processNode(button);
889
+ click(button);
890
+ if (evaluations > EVAL_BUDGET) return '✗budget';
891
+ const got = read();
892
+ return reportedErrors > before && got === '∅' ? '✗threw' : got;
893
+ };
894
+ const onUpstream = (source: string): string => onHost(upstream, source);
895
+
896
+ /** Install a compiled handler on hyperfixi, click, and settle. */
897
+ const onHyperfixi = async (ast: Ast): Promise<string> => {
898
+ const button = reset();
899
+ await hyperscript.execute(ast, hyperscript.createContext(button));
900
+ click(button);
901
+ await new Promise(resolve => setTimeout(resolve, 0));
902
+ return read();
903
+ };
904
+
905
+ const guard = async (run: () => Promise<string> | string): Promise<string> => {
906
+ try {
907
+ return await run();
908
+ } catch (e) {
909
+ return `✗threw: ${(e as Error).message?.split('\n')[0] ?? String(e)}`;
910
+ }
911
+ };
912
+
913
+ return {
914
+ async runCell(cell) {
915
+ const want = onUpstream(cell.source);
916
+ const result: CellResult = { id: cell.id, want, lanes: {} };
917
+ if (want.startsWith('✗') || want === '∅') {
918
+ result.invalid = want;
919
+ return result;
920
+ }
921
+ const lanes = result.lanes;
922
+
923
+ lanes.en = await guard(async () => {
924
+ const compiled = hyperscript.compileSync(cell.source);
925
+ if (!compiled.ok || !compiled.ast) return '✗compile';
926
+ return onHyperfixi(compiled.ast);
927
+ });
928
+
929
+ const english = parseSemantic(cell.source, 'en').node;
930
+ lanes['en-rt'] = await guard(() =>
931
+ english ? onUpstream(render(english, 'en')) : '✗untranslatable'
932
+ );
933
+ lanes.eng = await guard(() => onHost(engine, cell.source));
934
+
935
+ for (const language of FOREIGN_LANGUAGES) {
936
+ if (cell.skip?.includes(language)) continue;
937
+ let code: string | null = null;
938
+ try {
939
+ code = english ? render(english, language) : null;
940
+ } catch {
941
+ code = null;
942
+ }
943
+ if (code === null) {
944
+ lanes[language] = '✗untranslatable';
945
+ lanes[`${language}/up`] = '✗untranslatable';
946
+ lanes[`${language}/eng`] = '✗untranslatable';
947
+ continue;
948
+ }
949
+ const translated = code;
950
+ lanes[language] = await guard(async () => {
951
+ const compiled = await hyperscript.compile(translated, { language });
952
+ if (!compiled.ok || !compiled.ast) return '✗compile';
953
+ return onHyperfixi(compiled.ast);
954
+ });
955
+ // The adapter's English, once, for both hosts.
956
+ const adapted = await guard(() => preprocess(translated, language));
957
+ const up = (lanes[`${language}/up`] = adapted.startsWith('✗')
958
+ ? adapted
959
+ : await guard(() => onUpstream(adapted)));
960
+ lanes[`${language}/eng`] =
961
+ adapted.startsWith('✗') || up === '✗budget'
962
+ ? up
963
+ : await guard(() => onHost(engine, adapted));
964
+ }
965
+ return result;
966
+ },
967
+ async close() {
968
+ // A rejection from the last lane is reported after its macrotask.
969
+ await new Promise(resolve => setTimeout(resolve, 0));
970
+ process.off('unhandledRejection', trap);
971
+ for (const listener of rejectionListeners) process.on('unhandledRejection', listener);
972
+ Object.assign(console, saved);
973
+ },
974
+ };
975
+ }
976
+
977
+ /** Run the given cells, in order. */
978
+ export async function runValueMatrix(cells: readonly MatrixCell[]): Promise<CellResult[]> {
979
+ const engines = await initMatrixEngines();
980
+ try {
981
+ const results: CellResult[] = [];
982
+ for (const cell of cells) results.push(await engines.runCell(cell));
983
+ return results;
984
+ } finally {
985
+ await engines.close();
986
+ }
987
+ }
988
+
989
+ // ---------------------------------------------------------------------------
990
+ // The baseline
991
+ // ---------------------------------------------------------------------------
992
+
993
+ export interface BaselineEntry {
994
+ /**
995
+ * The failing lanes, space-separated, in LANES order, with three shorthands:
996
+ * `*direct` for all 23 languages on hyperfixi, `*up` for all 23 on upstream,
997
+ * `*eng` for all 23 on the new engine.
998
+ */
999
+ lanes: string;
1000
+ /** Where the loss sits, for reading the burn-down (see familyOf); not asserted. */
1001
+ family: string;
1002
+ /** Why the failure is kept, when the owner accepted every failing lane (see ACCEPTED). */
1003
+ accepted?: string;
1004
+ }
1005
+
1006
+ export interface ValueMatrixBaseline {
1007
+ description: string;
1008
+ /** Cells and (cell, lane) pairs the run covered, and how many failed. */
1009
+ cells: number;
1010
+ pairs: number;
1011
+ failing: number;
1012
+ /** Of the failing pairs, how many the owner accepted (see ACCEPTED). */
1013
+ accepted?: number;
1014
+ entries: Record<string, BaselineEntry>;
1015
+ }
1016
+
1017
+ const DIRECT_LANES: readonly string[] = FOREIGN_LANGUAGES;
1018
+ const ADAPTER_LANES: readonly string[] = FOREIGN_LANGUAGES.map(language => `${language}/up`);
1019
+ const ENGINE_LANES: readonly string[] = FOREIGN_LANGUAGES.map(language => `${language}/eng`);
1020
+
1021
+ /** Lanes (in LANES order) as a baseline string, with a shorthand for each full group. */
1022
+ export function compressLanes(lanes: readonly string[]): string {
1023
+ const set = new Set(lanes);
1024
+ const out: string[] = [];
1025
+ const direct = DIRECT_LANES.every(l => set.has(l));
1026
+ const adapter = ADAPTER_LANES.every(l => set.has(l));
1027
+ const engine = ENGINE_LANES.every(l => set.has(l));
1028
+ for (const lane of LANES) {
1029
+ if (!set.has(lane)) continue;
1030
+ if (direct && DIRECT_LANES.includes(lane)) continue;
1031
+ if (adapter && ADAPTER_LANES.includes(lane)) continue;
1032
+ if (engine && ENGINE_LANES.includes(lane)) continue;
1033
+ out.push(lane);
1034
+ }
1035
+ if (direct) out.push('*direct');
1036
+ if (adapter) out.push('*up');
1037
+ if (engine) out.push('*eng');
1038
+ return out.join(' ');
1039
+ }
1040
+
1041
+ /** The lanes a baseline string names. */
1042
+ export function expandLanes(text: string): string[] {
1043
+ const out: string[] = [];
1044
+ for (const token of text.split(' ').filter(Boolean)) {
1045
+ if (token === '*direct') out.push(...DIRECT_LANES);
1046
+ else if (token === '*up') out.push(...ADAPTER_LANES);
1047
+ else if (token === '*eng') out.push(...ENGINE_LANES);
1048
+ else out.push(token);
1049
+ }
1050
+ return out;
1051
+ }
1052
+
1053
+ /** The failing lanes of one result, in LANES order. */
1054
+ export function failingLanes(result: CellResult): string[] {
1055
+ return LANES.filter(lane => lane in result.lanes && result.lanes[lane] !== result.want);
1056
+ }
1057
+
1058
+ /**
1059
+ * Where a cell's failure sits, from which lanes fail:
1060
+ *
1061
+ * - `core` core's English run differs from upstream's;
1062
+ * - `semantic-en` semantic's English parse loses it (`en-rt`), so every
1063
+ * translation inherits the loss;
1064
+ * - `translation` some foreign lanes, on both engines;
1065
+ * - `direct-path` hyperfixi's foreign lanes only;
1066
+ * - `adapter` upstream's foreign lanes only;
1067
+ * - `engine` the new engine differs from upstream on the same text:
1068
+ * its English run, or a language's `/eng` lane without
1069
+ * its `/up` lane.
1070
+ *
1071
+ * Several can hold at once; they are joined in that order.
1072
+ */
1073
+ export function familyOf(lanes: readonly string[]): string {
1074
+ const set = new Set(lanes);
1075
+ const parts: string[] = [];
1076
+ if (set.has('en')) parts.push('core');
1077
+ if (set.has('en-rt')) parts.push('semantic-en');
1078
+ else {
1079
+ const direct = FOREIGN_LANGUAGES.filter(l => set.has(l));
1080
+ const adapter = FOREIGN_LANGUAGES.filter(l => set.has(`${l}/up`));
1081
+ const both = direct.filter(l => adapter.includes(l));
1082
+ if (both.length) parts.push('translation');
1083
+ if (direct.length > both.length && !set.has('en')) parts.push('direct-path');
1084
+ if (adapter.length > both.length) parts.push('adapter');
1085
+ }
1086
+ if (set.has('eng') || FOREIGN_LANGUAGES.some(l => set.has(`${l}/eng`) !== set.has(`${l}/up`))) {
1087
+ parts.push('engine');
1088
+ }
1089
+ return parts.join('+') || 'none';
1090
+ }
1091
+
1092
+ /**
1093
+ * Failing pairs the owner decided to keep, and why. They stay in the baseline
1094
+ * (the gate still fails when one starts passing, so a change of mind prunes
1095
+ * it); their entries carry the reason, and the report counts them apart from
1096
+ * open work.
1097
+ */
1098
+ export const ACCEPTED: ReadonlyArray<{
1099
+ cells: readonly string[];
1100
+ /** The accepted lanes, in the baseline's shorthand. */
1101
+ lanes: string;
1102
+ reason: string;
1103
+ }> = [
1104
+ {
1105
+ cells: [
1106
+ ...['put', 'set', 'while', 'times', 'increment'].map(
1107
+ p => `${p}|the textContent of #a as Int`
1108
+ ),
1109
+ // The same difference with a reference owner (PR 108); upstream's
1110
+ // `window as Int` is null, and in a loop bound both read 0 iterations.
1111
+ ...['put', 'set', 'increment'].map(p => `${p}|the scrollY of window as Int`),
1112
+ ],
1113
+ lanes: 'en *direct',
1114
+ reason:
1115
+ 'known difference: core converts the property, upstream the target (core/docs/UPSTREAM-KNOWN-DIFFS.md)',
1116
+ },
1117
+ {
1118
+ cells: [
1119
+ 'increment|#a.textContent',
1120
+ 'increment|#a.textContent + 2',
1121
+ 'increment|#a.textContent as Int',
1122
+ ],
1123
+ lanes: 'it it/up it/eng',
1124
+ reason:
1125
+ 'ambiguity: it `di` is both `by` and `of`, so `incrementare i di #a.textContent` also says `increment i of #a.textContent`',
1126
+ },
1127
+ ];
1128
+
1129
+ /** The reason a cell's failing lanes are kept, when ACCEPTED covers every one of them. */
1130
+ export function acceptedReason(id: string, lanes: readonly string[]): string | undefined {
1131
+ if (!lanes.length) return undefined;
1132
+ for (const entry of ACCEPTED) {
1133
+ if (!entry.cells.includes(id)) continue;
1134
+ const accepted = new Set(expandLanes(entry.lanes));
1135
+ if (lanes.every(lane => accepted.has(lane))) return entry.reason;
1136
+ }
1137
+ return undefined;
1138
+ }
1139
+
1140
+ /** The baseline a run implies. */
1141
+ export function baselineFrom(
1142
+ results: readonly CellResult[],
1143
+ description: string
1144
+ ): ValueMatrixBaseline {
1145
+ const entries: Record<string, BaselineEntry> = {};
1146
+ let pairs = 0;
1147
+ let failing = 0;
1148
+ let acceptedPairs = 0;
1149
+ for (const r of results) {
1150
+ pairs += Object.keys(r.lanes).length;
1151
+ const lanes = failingLanes(r);
1152
+ failing += lanes.length;
1153
+ if (!lanes.length) continue;
1154
+ const accepted = acceptedReason(r.id, lanes);
1155
+ if (accepted) acceptedPairs += lanes.length;
1156
+ entries[r.id] = {
1157
+ lanes: compressLanes(lanes),
1158
+ family: familyOf(lanes),
1159
+ ...(accepted ? { accepted } : {}),
1160
+ };
1161
+ }
1162
+ return {
1163
+ description,
1164
+ cells: results.length,
1165
+ pairs,
1166
+ failing,
1167
+ accepted: acceptedPairs,
1168
+ entries,
1169
+ };
1170
+ }
1171
+
1172
+ export interface BaselineDiff {
1173
+ /** Failing pairs the baseline does not list: regressions. */
1174
+ added: Array<{ id: string; lane: string; want: string; got: string }>;
1175
+ /** Listed pairs that pass now: fixed, and must be pruned. */
1176
+ fixed: Array<{ id: string; lane: string }>;
1177
+ }
1178
+
1179
+ /**
1180
+ * Compare results with the baseline, over the cells that ran: an entry for a
1181
+ * cell outside `results` is not judged (a shard compares only its own cells).
1182
+ */
1183
+ export function diffBaseline(
1184
+ results: readonly CellResult[],
1185
+ baseline: Pick<ValueMatrixBaseline, 'entries'>
1186
+ ): BaselineDiff {
1187
+ const diff: BaselineDiff = { added: [], fixed: [] };
1188
+ for (const r of results) {
1189
+ const listed = new Set(expandLanes(baseline.entries[r.id]?.lanes ?? ''));
1190
+ const failing = new Set(failingLanes(r));
1191
+ for (const lane of failing) {
1192
+ if (!listed.has(lane)) {
1193
+ diff.added.push({ id: r.id, lane, want: r.want, got: r.lanes[lane] ?? '' });
1194
+ }
1195
+ }
1196
+ for (const lane of listed) if (!failing.has(lane)) diff.fixed.push({ id: r.id, lane });
1197
+ }
1198
+ return diff;
1199
+ }
1200
+
1201
+ /** Entries for cells the generator no longer produces. */
1202
+ export function orphanedEntries(
1203
+ baseline: Pick<ValueMatrixBaseline, 'entries'>,
1204
+ cells: readonly MatrixCell[]
1205
+ ): string[] {
1206
+ const ids = new Set(cells.map(c => c.id));
1207
+ return Object.keys(baseline.entries).filter(id => !ids.has(id));
1208
+ }