@hyperfixi/testing-framework 3.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.0] - 2026-10-02
11
+
12
+ The first publication of `@hyperfixi/engine`, the hyperscript engine meant to replace the one
13
+ in `@hyperfixi/core`, and the example gallery moved onto it.
14
+
15
+ ### Added
16
+
17
+ - **`@hyperfixi/engine` is published.** A hyperscript engine written against upstream
18
+ `_hyperscript`'s source, with upstream's own test suite as the acceptance oracle (1,401 of
19
+ 1,467 tests; the 66 known failures are upstream's internal API, sockets and workers). It
20
+ ships `dist/hyperfixi-hs.js`, a script-tag bundle of hyperscript and nothing else, 34.1 KB
21
+ gzipped, which 36 of the repository's example pages now load instead of `hyperfixi.js`. It
22
+ keeps two forms upstream lacks, `new X(...)` and `toggle <element>`; the other hyperfixi-only
23
+ forms are not in it. It is typed (`tsc --strict`, no `any`), built from modules, and exposes
24
+ upstream's public API shape plus one hook, `addSourceTransform`, which
25
+ `@lokascript/hyperscript-adapter` uses to run the 24 languages on it.
26
+
27
+ ### Changed
28
+
29
+ - **Examples and docs are written in upstream `_hyperscript`'s spelling** wherever upstream has
30
+ one: `put Y into X` for the `swap` strategies, `debounced at 300ms`, `matches`, `set X's @a`,
31
+ `increment #count's textContent` (core counted in a bare `#count`; upstream does not),
32
+ `on mutation of childList from #x`, `my offsetLeft` in place of `measure x`. The multilingual
33
+ reader still accepts the old forms; the renderer writes upstream's. Corpus rows follow.
34
+ - **`@lokascript/semantic` renders `repeat for x in xs index i`** (was `with index`) and
35
+ `tell X show` (was `tell X to show`), upstream's spellings.
36
+ - **The examples' bundle loader** takes a per-page default (`data-default="hs"`), and `?bundle=hs`
37
+ switches any page to the engine bundle.
38
+
39
+ ### Fixed
40
+
41
+ - **`@hyperfixi/core`**: `toggle *display of X` parsed and then threw.
42
+ - **Hybrid bundles (`hyperfixi-hx.js`)**: `increment` / `decrement` of a possessive wrote only
43
+ style properties; `increment #count's textContent` evaluated to the text and threw on
44
+ `querySelectorAll('0')`. They now write the property.
45
+ - **`@lokascript/semantic`**: a template-literal URL in a Korean handler was read as a custom
46
+ event name.
47
+
10
48
  ## [3.2.0] - 2026-09-30
11
49
 
12
50
  A correctness release for multilingual hyperscript. The semantic parser behind
@@ -861,7 +899,9 @@ _Synchronized version release. See git history for details._
861
899
  - npm access token stored in GitHub Secrets
862
900
  - 2FA recommended for npm organization
863
901
 
864
- [Unreleased]: https://github.com/codetalcott/hyperfixi/compare/v3.1.0...HEAD
902
+ [Unreleased]: https://github.com/codetalcott/hyperfixi/compare/v3.3.0...HEAD
903
+ [3.3.0]: https://github.com/codetalcott/hyperfixi/compare/v3.2.0...v3.3.0
904
+ [3.2.0]: https://github.com/codetalcott/hyperfixi/compare/v3.1.0...v3.2.0
865
905
  [3.1.0]: https://github.com/codetalcott/hyperfixi/compare/v3.0.0...v3.1.0
866
906
  [2.10.0]: https://github.com/codetalcott/hyperfixi/compare/v2.9.0...v2.10.0
867
907
  [2.9.0]: https://github.com/codetalcott/hyperfixi/compare/v2.8.0...v2.9.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixi/testing-framework",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Cross-platform behavior testing suite for LokaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -29,7 +29,7 @@
29
29
  "scripts": {
30
30
  "build": "tsup",
31
31
  "dev": "tsup --watch",
32
- "pretest": "../../scripts/ensure-fresh.sh ../intent ../framework ../semantic ../hyperscript-adapter ../patterns-reference ../core ../aot-compiler ../compilation-service",
32
+ "pretest": "../../scripts/ensure-fresh.sh ../intent ../framework ../semantic ../hyperscript-adapter ../engine ../patterns-reference ../core ../aot-compiler ../compilation-service",
33
33
  "test": "vitest run",
34
34
  "test:watch": "vitest",
35
35
  "test:coverage": "vitest run --coverage",
@@ -41,7 +41,7 @@
41
41
  "typecheck": "tsc --noEmit",
42
42
  "test:check": "VITEST_TIMEOUT=240 VITEST_QUIET=1 bash ../../scripts/vitest-run.sh --reporter=dot",
43
43
  "test:canonical": "FOREIGN_CANONICAL_VALIDITY=1 VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/canonical-validity.test.ts src/multilingual/foreign-canonical-validity.test.ts src/multilingual/render-fidelity.test.ts src/multilingual/bare-render-fidelity.test.ts src/multilingual/en-reference-preservation.test.ts",
44
- "test:shipped-sources": "VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/shipped-sources-validity.test.ts"
44
+ "test:shipped-sources": "VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/shipped-sources-validity.test.ts src/multilingual/shipped-sources-engine.test.ts src/multilingual/shipped-examples-execution.test.ts"
45
45
  },
46
46
  "keywords": [
47
47
  "hyperscript",
@@ -57,11 +57,11 @@
57
57
  "author": "LokaScript Contributors",
58
58
  "license": "MIT",
59
59
  "dependencies": {
60
- "@hyperfixi/core": "^3.2.0",
61
- "@hyperfixi/patterns-reference": "^3.2.0",
62
- "@lokascript/compilation-service": "^3.2.0",
63
- "@lokascript/i18n": "^3.2.0",
64
- "@lokascript/semantic": "^3.2.0",
60
+ "@hyperfixi/core": "^3.3.0",
61
+ "@hyperfixi/patterns-reference": "^3.3.0",
62
+ "@lokascript/compilation-service": "^3.3.0",
63
+ "@lokascript/i18n": "^3.3.0",
64
+ "@lokascript/semantic": "^3.3.0",
65
65
  "diff": "^8.0.3",
66
66
  "esbuild": "^0.25.12",
67
67
  "happy-dom": "^20.14.5",
@@ -73,7 +73,8 @@
73
73
  "vite": "^8.3.1"
74
74
  },
75
75
  "devDependencies": {
76
- "@lokascript/hyperscript-adapter": "^3.2.0",
76
+ "@hyperfixi/engine": "^3.3.0",
77
+ "@lokascript/hyperscript-adapter": "^3.3.0",
77
78
  "@types/diff": "^5.0.0",
78
79
  "@types/node": "^26.6.3",
79
80
  "@vitest/coverage-v8": "^5.0.0",
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Engine parser parity: the canonical-validity gates' strings on `@hyperfixi/engine`.
3
+ *
4
+ * Every English string those gates put to upstream `hyperscript.org`'s parser — each corpus
5
+ * row, its English re-render, and the English render of every authored translation — is put
6
+ * to the new engine's parser as well. The two must agree on whether it parses. The engine is
7
+ * meant to replace core's, and the multilingual product reaches an engine as English text, so
8
+ * a string upstream reads and the engine rejects (or the reverse) is a translation that works
9
+ * on one host and not the other.
10
+ *
11
+ * Unlike the foreign canonical-validity gate, this needs no fresh `populate`: it compares two
12
+ * parsers on the SAME strings, so a stale patterns.db changes which strings are asked, not
13
+ * whether the parsers agree. It always runs.
14
+ *
15
+ * On a failure, run one string on both engines:
16
+ * `npx tsx packages/engine/tools/probe.mts '<source>'`.
17
+ */
18
+ import { describe, it, expect, beforeAll } from 'vitest';
19
+ import {
20
+ checkCorpusRenderValidity,
21
+ loadCanonicalParser,
22
+ type CanonicalValidate,
23
+ } from './canonical-validity';
24
+ import { checkForeignRenderValidity } from './foreign-canonical-validity';
25
+
26
+ describe('engine parser parity (R4 strings on @hyperfixi/engine)', () => {
27
+ const asked = new Set<string>();
28
+ const disagreements: string[] = [];
29
+
30
+ beforeAll(async () => {
31
+ const { api, everything, register } = await import('@hyperfixi/engine');
32
+ register(...everything);
33
+ const upstream = await loadCanonicalParser();
34
+ const engine: CanonicalValidate = source => {
35
+ try {
36
+ return api.parse(source).errors.map(error => error.message);
37
+ } catch (e) {
38
+ return ['threw: ' + (e instanceof Error ? e.message.split('\n')[0] : String(e))];
39
+ }
40
+ };
41
+ // Upstream's verdict is the one handed back, so the gates' own logic is unchanged.
42
+ const both: CanonicalValidate = source => {
43
+ const up = upstream(source);
44
+ if (!asked.has(source)) {
45
+ asked.add(source);
46
+ const mine = engine(source);
47
+ if ((up.length === 0) !== (mine.length === 0)) {
48
+ disagreements.push(
49
+ `${JSON.stringify(source)}\n upstream: ${up[0]?.split('\n')[0] ?? 'accepts'}` +
50
+ `\n engine: ${mine[0]?.split('\n')[0] ?? 'accepts'}`
51
+ );
52
+ }
53
+ }
54
+ return up;
55
+ };
56
+ await checkCorpusRenderValidity({ validate: both });
57
+ await checkForeignRenderValidity({ validate: both });
58
+ }, 300_000);
59
+
60
+ it('asks about a real corpus (sanity: patterns.db has rows)', () => {
61
+ expect(asked.size).toBeGreaterThan(100);
62
+ });
63
+
64
+ it('the two parsers agree on every string', () => {
65
+ expect(disagreements).toEqual([]);
66
+ });
67
+ });
@@ -17,6 +17,12 @@
17
17
  * 3. no allowlisted key has silently converged (stale entries must be
18
18
  * removed so the list only ever ratchets down).
19
19
  *
20
+ * The same three run for the ENGINE LANE: `@hyperfixi/engine`, the engine meant
21
+ * to replace core's, against the same oracle on every handler upstream accepts
22
+ * (`allowedEngineDivergences` in the same baseline). It is the gate for a
23
+ * handler that parses on both engines and runs differently, which the
24
+ * parse-level list in shipped-sources-engine.test.ts cannot see.
25
+ *
20
26
  * To update after an intentional change: re-run and regenerate
21
27
  * `baselines/shipped-examples-execution.json` (the allowlist key embeds a
22
28
  * source hash, so FIXING a handler changes its key and assertion 3 forces the
@@ -54,6 +60,14 @@ interface AllowlistDoc {
54
60
  excerpt: string;
55
61
  reason: string;
56
62
  }>;
63
+ /** The engine lane: `@hyperfixi/engine` against upstream. */
64
+ allowedEngineDivergences: Array<{
65
+ key: string;
66
+ file: string;
67
+ event: string;
68
+ excerpt: string;
69
+ reason: string;
70
+ }>;
57
71
  }
58
72
 
59
73
  const baselinePath = path.resolve(
@@ -62,12 +76,17 @@ const baselinePath = path.resolve(
62
76
  );
63
77
  const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
64
78
  const allowed = new Set(allowlist.allowedDivergences.map(e => e.key));
79
+ const allowedOnEngine = new Set(allowlist.allowedEngineDivergences.map(e => e.key));
80
+
81
+ /** One sweep, shared by both lanes' tests. */
82
+ let sweeping: Promise<ExecutionParityResult> | undefined;
83
+ const sweep = (): Promise<ExecutionParityResult> => (sweeping ??= runShippedExamplesExecution());
65
84
 
66
85
  describe('shipped-examples execution gate', () => {
67
86
  let result: ExecutionParityResult;
68
87
 
69
88
  beforeAll(async () => {
70
- result = await runShippedExamplesExecution();
89
+ result = await sweep();
71
90
 
72
91
  // Visibility, not assertions: what the sweep could not compare, and why.
73
92
  // A silently shrinking denominator is this gate's own blind spot.
@@ -84,6 +103,12 @@ describe('shipped-examples execution gate', () => {
84
103
  for (const [r, n] of [...reasons].sort((a, b) => b[1] - a[1])) {
85
104
  console.log(`[shipped-examples-execution] skip ×${n}: ${r}`);
86
105
  }
106
+ const onEngine = result.engineCompared;
107
+ console.log(
108
+ `[shipped-examples-execution] engine lane: compared=${onEngine.length} ` +
109
+ `(vacuous=${onEngine.filter(c => c.vacuous).length}) ` +
110
+ `diverging=${onEngine.filter(c => !c.match).length}`
111
+ );
87
112
  }, 240_000);
88
113
 
89
114
  it('walks pages and compares handlers (sanity: extraction and both engines working)', () => {
@@ -142,6 +167,56 @@ describe('shipped-examples execution gate', () => {
142
167
  });
143
168
  });
144
169
 
170
+ describe('shipped-examples execution gate: @hyperfixi/engine against upstream', () => {
171
+ let result: ExecutionParityResult;
172
+ beforeAll(async () => {
173
+ result = await sweep();
174
+ }, 240_000);
175
+
176
+ it('compares handlers on the engine (sanity: the lane ran and mostly agrees)', () => {
177
+ expect(result.engineCompared.length).toBeGreaterThan(90);
178
+ const realMatches = result.engineCompared.filter(c => c.match && !c.vacuous).length;
179
+ expect(realMatches).toBeGreaterThan(60);
180
+ });
181
+
182
+ it('has no NEW divergence from upstream outside the allowlist', () => {
183
+ const unexpected = result.engineCompared.filter(c => !c.match && !allowedOnEngine.has(c.key));
184
+ expect(
185
+ unexpected,
186
+ unexpected.length
187
+ ? `\nShipped handlers whose DOM effect on @hyperfixi/engine DIVERGES from the hyperscript.org ` +
188
+ `engine\n(the engine follows upstream's source: fix the engine, or allowlist with a reason ` +
189
+ `under allowedEngineDivergences):\n` +
190
+ unexpected
191
+ .map(
192
+ f =>
193
+ ` [${f.key}]\n` +
194
+ ` "${f.excerpt}"\n` +
195
+ ` engine : ${JSON.stringify(f.engineEffects).slice(0, 300)}\n` +
196
+ ` upstream: ${JSON.stringify(f.upstreamEffects).slice(0, 300)}`
197
+ )
198
+ .join('\n') +
199
+ `\n\nOne source on both, on one page: npx tsx packages/engine/tools/probe.mts '<source>'`
200
+ : ''
201
+ ).toEqual([]);
202
+ });
203
+
204
+ it('has no stale allowlist entries', () => {
205
+ const stillDiverging = new Set(result.engineCompared.filter(c => !c.match).map(c => c.key));
206
+ const stale = allowlist.allowedEngineDivergences
207
+ .map(e => e.key)
208
+ .filter(k => !stillDiverging.has(k));
209
+ expect(
210
+ stale,
211
+ stale.length
212
+ ? `\nThese handlers no longer diverge on the engine (or were edited: the key embeds a\n` +
213
+ `source hash). Remove them from allowedEngineDivergences in\n` +
214
+ `baselines/shipped-examples-execution.json:\n ${stale.join('\n ')}`
215
+ : ''
216
+ ).toEqual([]);
217
+ });
218
+ });
219
+
145
220
  describe('harness pieces', () => {
146
221
  it('extracts the trigger event from the leading on-clause', () => {
147
222
  expect(triggerEventOf('on click add .a to me')).toBe('click');
@@ -35,6 +35,15 @@
35
35
  * before/after snapshot around it. See runHandlerOnEngine for why isolation is
36
36
  * worth its cost.
37
37
  *
38
+ * ## The engine lane
39
+ * `@hyperfixi/engine` is meant to replace core's engine, so the same sweep
40
+ * runs it against the same oracle: every handler upstream accepts (whether or
41
+ * not core compiles it clean) is executed on upstream and on the engine, and
42
+ * the two effect signatures are compared (`engineCompared`). The parse-level
43
+ * list of what the engine rejects (shipped-sources-engine.test.ts) cannot see
44
+ * a handler that parses on both and runs differently; this can. A handler
45
+ * upstream accepts and the engine rejects is compared too, and diverges.
46
+ *
38
47
  * ## Node-only
39
48
  * Imports the real `hyperscript.org` build off disk and swaps jsdom globals
40
49
  * per handler execution (both engines resolve `document` lazily through
@@ -144,6 +153,18 @@ export interface ComparedHandler extends ShippedHandler {
144
153
  excerpt: string;
145
154
  }
146
155
 
156
+ /** One handler run on upstream and on `@hyperfixi/engine`. */
157
+ export interface EngineComparedHandler extends ShippedHandler {
158
+ key: string;
159
+ event: string;
160
+ engineEffects: string[];
161
+ upstreamEffects: string[];
162
+ match: boolean;
163
+ /** Both signatures empty: not evidence of parity (see ComparedHandler.vacuous). */
164
+ vacuous: boolean;
165
+ excerpt: string;
166
+ }
167
+
147
168
  export interface ExecutionParityResult {
148
169
  /** Pages walked. */
149
170
  pages: number;
@@ -153,6 +174,8 @@ export interface ExecutionParityResult {
153
174
  compared: ComparedHandler[];
154
175
  /** Handlers excluded, each with its reason. */
155
176
  skipped: SkippedHandler[];
177
+ /** Handlers upstream accepts, executed on upstream and on `@hyperfixi/engine`. */
178
+ engineCompared: EngineComparedHandler[];
156
179
  }
157
180
 
158
181
  /** Stable key for one handler execution. */
@@ -219,6 +242,10 @@ export interface Engines {
219
242
  hyperfixiInstall(source: string, el: Element): Promise<void>;
220
243
  /** Install a handler on an element via the upstream engine. */
221
244
  upstreamInstall(el: Element): void;
245
+ /** `@hyperfixi/engine`'s parse errors for a source; [] = it parses. */
246
+ engineErrors(source: string): string[];
247
+ /** Install a handler on an element via `@hyperfixi/engine`. */
248
+ engineInstall(el: Element): void;
222
249
  }
223
250
 
224
251
  /**
@@ -311,7 +338,20 @@ export async function initEngines(): Promise<Engines> {
311
338
  processNode(el: Node): void;
312
339
  };
313
340
 
341
+ const engine = await import('@hyperfixi/engine');
342
+ engine.register(...engine.everything);
343
+
314
344
  return {
345
+ engineErrors(source) {
346
+ try {
347
+ return engine.api.parse(source).errors.map(e => e.message.split('\n')[0] ?? '');
348
+ } catch (e) {
349
+ return ['threw: ' + (e as Error).message.split('\n')[0]];
350
+ }
351
+ },
352
+ engineInstall(el) {
353
+ engine.api.processNode(el);
354
+ },
315
355
  compileClean(source) {
316
356
  try {
317
357
  const r = core.hyperscript.compileSync(source);
@@ -425,6 +465,7 @@ export async function runShippedExamplesExecution(opts?: {
425
465
 
426
466
  const compared: ComparedHandler[] = [];
427
467
  const skipped: SkippedHandler[] = [];
468
+ const engineCompared: EngineComparedHandler[] = [];
428
469
  let pages = 0;
429
470
  let handlers = 0;
430
471
 
@@ -438,6 +479,9 @@ export async function runShippedExamplesExecution(opts?: {
438
479
  handlers += pageHandlers.length;
439
480
 
440
481
  const eligible: Array<ShippedHandler & { event: string }> = [];
482
+ // The engine lane's denominator: dispatchable, deterministic, and upstream
483
+ // accepts it. Core's verdict is not part of it.
484
+ const oracle: Array<ShippedHandler & { event: string }> = [];
441
485
  for (const h of pageHandlers) {
442
486
  if (!h.event) {
443
487
  skipped.push({ ...h, reason: 'not an `on <event>` handler' });
@@ -452,6 +496,8 @@ export async function runShippedExamplesExecution(opts?: {
452
496
  skipped.push({ ...h, reason: disq.reason });
453
497
  continue;
454
498
  }
499
+ const upstreamErrs = engines.upstreamErrors(h.source);
500
+ if (upstreamErrs.length === 0) oracle.push(h as ShippedHandler & { event: string });
455
501
  if (!engines.compileClean(h.source)) {
456
502
  skipped.push({
457
503
  ...h,
@@ -459,14 +505,18 @@ export async function runShippedExamplesExecution(opts?: {
459
505
  });
460
506
  continue;
461
507
  }
462
- const upstreamErrs = engines.upstreamErrors(h.source);
463
508
  if (upstreamErrs.length > 0) {
464
509
  skipped.push({ ...h, reason: `upstream rejects it (no oracle): ${upstreamErrs[0]}` });
465
510
  continue;
466
511
  }
467
512
  eligible.push(h as ShippedHandler & { event: string });
468
513
  }
469
- if (eligible.length === 0) continue;
514
+ if (eligible.length === 0 && oracle.length === 0) continue;
515
+ // Upstream's signatures from the core lane serve the engine lane too when
516
+ // the two lanes install the same handlers on the page.
517
+ const sameHandlers =
518
+ eligible.length === oracle.length && eligible.every((h, i) => h === oracle[i]);
519
+ const upstreamRun = new Map<ShippedHandler, string[]>();
470
520
 
471
521
  // Both engines log runtime errors to the console during dispatch
472
522
  // (COMMAND FAILED etc.). That is expected data here — the effect
@@ -488,6 +538,7 @@ export async function runShippedExamplesExecution(opts?: {
488
538
  const theirs = await runHandlerOnEngine(html, eligible, h, (_hh, el) =>
489
539
  engines.upstreamInstall(el)
490
540
  );
541
+ upstreamRun.set(h, theirs);
491
542
  compared.push({
492
543
  ...h,
493
544
  key: keyFor(h),
@@ -498,6 +549,25 @@ export async function runShippedExamplesExecution(opts?: {
498
549
  excerpt: h.source.replace(/\s+/g, ' ').trim().slice(0, 100),
499
550
  });
500
551
  }
552
+ for (const h of oracle) {
553
+ const rejected = engines.engineErrors(h.source)[0];
554
+ const theirs =
555
+ (sameHandlers && upstreamRun.get(h)) ||
556
+ (await runHandlerOnEngine(html, oracle, h, (_hh, el) => engines.upstreamInstall(el)));
557
+ const ours =
558
+ rejected !== undefined
559
+ ? [`<engine rejects the source: ${rejected}>`]
560
+ : await runHandlerOnEngine(html, oracle, h, (_hh, el) => engines.engineInstall(el));
561
+ engineCompared.push({
562
+ ...h,
563
+ key: keyFor(h),
564
+ engineEffects: ours,
565
+ upstreamEffects: theirs,
566
+ match: JSON.stringify(ours) === JSON.stringify(theirs),
567
+ vacuous: ours.length === 0 && theirs.length === 0,
568
+ excerpt: h.source.replace(/\s+/g, ' ').trim().slice(0, 100),
569
+ });
570
+ }
501
571
  } finally {
502
572
  console.log = saved.log;
503
573
  console.warn = saved.warn;
@@ -507,5 +577,5 @@ export async function runShippedExamplesExecution(opts?: {
507
577
  }
508
578
  }
509
579
 
510
- return { pages, handlers, compared, skipped };
580
+ return { pages, handlers, compared, skipped, engineCompared };
511
581
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shipped sources on `@hyperfixi/engine`: what replacing core's engine would break.
3
+ *
4
+ * `packages/engine` is meant to replace the engine in `packages/core`. Every
5
+ * hyperscript source this repository ships (`examples/` and the doc trees, the
6
+ * validity gate's collection) that core compiles clean is put to the engine's
7
+ * parser. The ones it rejects are listed in
8
+ * `baselines/shipped-sources-engine.json`, each with the reason it is still
9
+ * there. Three assertions, as the sibling gates:
10
+ * 1. sanity: sources were found and the engine reads most of them;
11
+ * 2. no NEW rejected source outside the list. A page or a doc example
12
+ * written in syntax only core has would break when core's engine goes;
13
+ * write upstream's spelling (`packages/engine/README.md` lists the forms
14
+ * that were considered and not kept), or list it with a reason;
15
+ * 3. no stale entry: a listed source the engine now reads, or one that was
16
+ * edited (the key embeds a hash of the source), must be removed, so the
17
+ * list only shrinks. It is empty when core's engine can be replaced
18
+ * without breaking a shipped page.
19
+ *
20
+ * This is a PARSE-level gate. A source can parse on both engines and run
21
+ * differently; the engine lane of `shipped-examples-execution.test.ts` is the
22
+ * gate for that.
23
+ *
24
+ * To see the list by form, and beside upstream's verdict:
25
+ * `npx tsx packages/engine/tools/shipped-sources.mts`.
26
+ */
27
+
28
+ import { readFileSync } from 'node:fs';
29
+ import { fileURLToPath } from 'node:url';
30
+ import path from 'node:path';
31
+ import { JSDOM } from 'jsdom';
32
+ import { describe, it, expect, beforeAll } from 'vitest';
33
+ import {
34
+ checkShippedSourcesOnEngine,
35
+ type CompileForValidity,
36
+ type ShippedSourcesOnEngineResult,
37
+ } from './shipped-sources-validity';
38
+
39
+ interface RejectedDoc {
40
+ rejected: Array<{ key: string; file: string; error: string; excerpt: string; reason: string }>;
41
+ }
42
+
43
+ const baselinePath = path.resolve(
44
+ path.dirname(fileURLToPath(import.meta.url)),
45
+ '../../baselines/shipped-sources-engine.json'
46
+ );
47
+ const listed = JSON.parse(readFileSync(baselinePath, 'utf8')) as RejectedDoc;
48
+ const allowed = new Set(listed.rejected.map(e => e.key));
49
+
50
+ describe('shipped sources on @hyperfixi/engine', () => {
51
+ let result: ShippedSourcesOnEngineResult;
52
+
53
+ beforeAll(async () => {
54
+ const core = (await import('@hyperfixi/core')) as unknown as {
55
+ hyperscript: { compileSync: CompileForValidity };
56
+ };
57
+ const { api, everything, register } = await import('@hyperfixi/engine');
58
+ register(...everything);
59
+ const dom = new JSDOM('<!doctype html><html><body></body></html>');
60
+ result = checkShippedSourcesOnEngine(
61
+ code => core.hyperscript.compileSync(code),
62
+ code => {
63
+ try {
64
+ return api.parse(code).errors.map(error => error.message);
65
+ } catch (e) {
66
+ return ['threw: ' + (e instanceof Error ? e.message : String(e))];
67
+ }
68
+ },
69
+ dom.window.document as unknown as Parameters<typeof checkShippedSourcesOnEngine>[2]
70
+ );
71
+ }, 120_000);
72
+
73
+ it('finds the shipped sources and the engine reads most of them (sanity)', () => {
74
+ expect(result.coreClean).toBeGreaterThan(200);
75
+ expect(result.engineAccepts).toBeGreaterThan(200);
76
+ });
77
+
78
+ it('has no NEW shipped source the engine rejects outside the list', () => {
79
+ const unexpected = result.rejections.filter(r => !allowed.has(r.key));
80
+ expect(
81
+ unexpected,
82
+ unexpected.length
83
+ ? `\nShipped sources core compiles and @hyperfixi/engine rejects (write upstream's spelling,\n` +
84
+ `or list it in baselines/shipped-sources-engine.json with a reason):\n` +
85
+ unexpected
86
+ .map(r => ` [${r.key}]\n "${r.excerpt}"\n -> ${r.error}`)
87
+ .join('\n') +
88
+ `\n\nOne source on the engine and on upstream, side by side:\n` +
89
+ ` npx tsx packages/engine/tools/probe.mts '<source>'`
90
+ : ''
91
+ ).toEqual([]);
92
+ });
93
+
94
+ it('has no stale entries (a source the engine now reads must be removed, so the list only shrinks)', () => {
95
+ const stillRejected = new Set(result.rejections.map(r => r.key));
96
+ const stale = listed.rejected.map(e => e.key).filter(key => !stillRejected.has(key));
97
+ expect(
98
+ stale,
99
+ stale.length
100
+ ? `\nThese listed sources are no longer rejected (read by the engine now, or edited: the key\n` +
101
+ `embeds a hash of the source). Remove them from baselines/shipped-sources-engine.json:\n ` +
102
+ stale.join('\n ')
103
+ : ''
104
+ ).toEqual([]);
105
+ });
106
+ });
@@ -231,3 +231,68 @@ export function checkShippedSourcesValidity(
231
231
 
232
232
  return { checked: sources.length, clean, findings };
233
233
  }
234
+
235
+ /** A shipped source `packages/core` compiles clean and `@hyperfixi/engine` rejects. */
236
+ export interface EngineRejection extends ShippedSource {
237
+ /** Stable key: file plus a hash of the source, as the validity gate's. */
238
+ key: string;
239
+ /** The engine's first parse error, first line. */
240
+ error: string;
241
+ excerpt: string;
242
+ }
243
+
244
+ export interface ShippedSourcesOnEngineResult {
245
+ /** Distinct (file, source) pairs core compiles clean. */
246
+ coreClean: number;
247
+ /** Of those, the ones the engine parses. */
248
+ engineAccepts: number;
249
+ rejections: EngineRejection[];
250
+ }
251
+
252
+ /**
253
+ * What replacing core's engine with `@hyperfixi/engine` would break in the
254
+ * material we ship: every source core compiles clean, put to the engine's
255
+ * parser. `engineErrors` returns the engine's parse errors ([] = it parses).
256
+ *
257
+ * The denominator is core-clean sources on purpose. A source core itself
258
+ * rejects or recovers from is already broken where it is shipped, and is the
259
+ * validity gate's business; the question here is only what would STOP working.
260
+ */
261
+ export function checkShippedSourcesOnEngine(
262
+ compile: CompileForValidity,
263
+ engineErrors: (code: string) => string[],
264
+ doc: Parameters<typeof extractHyperscriptFromMarkup>[0],
265
+ opts?: { roots?: string[]; repoRoot?: string }
266
+ ): ShippedSourcesOnEngineResult {
267
+ const seen = new Set<string>();
268
+ const rejections: EngineRejection[] = [];
269
+ let coreClean = 0;
270
+ let engineAccepts = 0;
271
+
272
+ for (const s of collectShippedSources(doc, opts)) {
273
+ const key = keyFor(s.file, s.source);
274
+ if (seen.has(key)) continue;
275
+ seen.add(key);
276
+ let clean = false;
277
+ try {
278
+ const result = compile(s.source);
279
+ clean = result.ok && (result.errors ?? []).length === 0;
280
+ } catch {
281
+ /* not core-clean */
282
+ }
283
+ if (!clean) continue;
284
+ coreClean++;
285
+ const errors = engineErrors(s.source);
286
+ if (errors.length === 0) {
287
+ engineAccepts++;
288
+ continue;
289
+ }
290
+ rejections.push({
291
+ ...s,
292
+ key,
293
+ error: (errors[0] ?? '').split('\n')[0] ?? '',
294
+ excerpt: s.source.replace(/\s+/g, ' ').trim().slice(0, 100),
295
+ });
296
+ }
297
+ return { coreClean, engineAccepts, rejections };
298
+ }
@@ -72,9 +72,11 @@ export const EXECUTION_SUBSET: readonly string[] = [
72
72
  // patterns whose en reference now parses (the if/else conditional fold) and
73
73
  // executes (propertyAccess evaluator; matches/exists/is-empty condition
74
74
  // forms). Probed through this validator: each produces a non-empty,
75
- // deterministic effect signature. `unless-condition` stays out — `unless` is
76
- // deliberately NOT folded (see semantic-parser.tryParseConditionalBlock), so
77
- // its flat parse still errors at runtime.
75
+ // deterministic effect signature. `unless-condition` stayed out while it was
76
+ // core's prefix `unless` (deliberately NOT folded: see
77
+ // semantic-parser.tryParseConditionalBlock; its flat parse errored at
78
+ // runtime). The row is `if I do not match …` since 2026-10-01 and has not
79
+ // been probed for this subset.
78
80
  'if-condition',
79
81
  'if-matches',
80
82
  'if-exists',
@@ -23,6 +23,9 @@ describe('value matrix: accepted pairs', () => {
23
23
  known!.reason
24
24
  );
25
25
  expect(acceptedReason('increment|#a.textContent', ['it', 'it/up'])).toBe(ambiguity!.reason);
26
+ expect(acceptedReason('increment|#a.textContent', ['it', 'it/up', 'it/eng'])).toBe(
27
+ ambiguity!.reason
28
+ );
26
29
  });
27
30
 
28
31
  it('leaves it open when one failing lane is not', () => {
@@ -49,7 +49,12 @@
49
49
  * `render(parse_en(src), L)`, compiled with `{ language: L }`;
50
50
  * - `<L>/up` the same translation through `@lokascript/hyperscript-adapter`
51
51
  * (`preprocess`, back to English) on upstream: the multilingual
52
- * product for original _hyperscript users.
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.
53
58
  *
54
59
  * A (cell, lane) pair FAILS when its result differs from the oracle's.
55
60
  *
@@ -69,7 +74,9 @@
69
74
  *
70
75
  * A translation that loses a loop's condition can loop forever. Core caps a
71
76
  * loop at 10,000 iterations; upstream has no cap and blocks the thread, so
72
- * every upstream run gets an evaluation budget (see EVAL_BUDGET).
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.
73
80
  *
74
81
  * Node-only: imports the real `hyperscript.org` build off disk and needs the
75
82
  * node vitest environment (see the shipped-examples gate for why).
@@ -733,7 +740,8 @@ export const FOREIGN_LANGUAGES = [
733
740
  export const LANES: readonly string[] = [
734
741
  'en',
735
742
  'en-rt',
736
- ...FOREIGN_LANGUAGES.flatMap(language => [language, `${language}/up`]),
743
+ 'eng',
744
+ ...FOREIGN_LANGUAGES.flatMap(language => [language, `${language}/up`, `${language}/eng`]),
737
745
  ];
738
746
 
739
747
  /**
@@ -743,10 +751,14 @@ export const LANES: readonly string[] = [
743
751
  */
744
752
  const EVAL_BUDGET = 20_000;
745
753
 
746
- /** The upstream surface this uses (`hyperscript.org`'s ESM default export). */
747
- interface UpstreamEngine {
754
+ /** What a lane needs of a host that reads scripts off attributes: upstream, or the new engine. */
755
+ interface ScriptHost {
748
756
  parse(source: string): { errors?: Array<{ message: string }> } | undefined;
749
757
  processNode(element: Element): void;
758
+ }
759
+
760
+ /** The upstream surface this uses (`hyperscript.org`'s ESM default export). */
761
+ interface UpstreamEngine extends ScriptHost {
750
762
  internals: {
751
763
  runtime: { unifiedEval(parseElement: unknown, context: unknown): unknown };
752
764
  };
@@ -809,6 +821,9 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
809
821
  const require = createRequire(import.meta.url);
810
822
  const esm = require.resolve('hyperscript.org').replace(/[^/\\]+$/, '_hyperscript.esm.js');
811
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;
812
827
 
813
828
  // The evaluation budget: every upstream evaluation goes through unifiedEval.
814
829
  const runtime = upstream.internals.runtime;
@@ -862,20 +877,21 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
862
877
  };
863
878
  const read = (): string => document.getElementById('out')?.textContent ?? '✗no #out';
864
879
 
865
- /** Run English on upstream. */
866
- const onUpstream = (source: string): string => {
867
- const errors = upstream.parse(source)?.errors ?? [];
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 ?? [];
868
883
  if (errors.length) return `✗parse: ${errors[0]?.message.split('\n')[0] ?? ''}`;
869
884
  const button = reset();
870
885
  button.setAttribute('_', source);
871
886
  evaluations = 0;
872
887
  const before = reportedErrors;
873
- upstream.processNode(button);
888
+ host.processNode(button);
874
889
  click(button);
875
890
  if (evaluations > EVAL_BUDGET) return '✗budget';
876
891
  const got = read();
877
892
  return reportedErrors > before && got === '∅' ? '✗threw' : got;
878
893
  };
894
+ const onUpstream = (source: string): string => onHost(upstream, source);
879
895
 
880
896
  /** Install a compiled handler on hyperfixi, click, and settle. */
881
897
  const onHyperfixi = async (ast: Ast): Promise<string> => {
@@ -914,6 +930,7 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
914
930
  lanes['en-rt'] = await guard(() =>
915
931
  english ? onUpstream(render(english, 'en')) : '✗untranslatable'
916
932
  );
933
+ lanes.eng = await guard(() => onHost(engine, cell.source));
917
934
 
918
935
  for (const language of FOREIGN_LANGUAGES) {
919
936
  if (cell.skip?.includes(language)) continue;
@@ -926,6 +943,7 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
926
943
  if (code === null) {
927
944
  lanes[language] = '✗untranslatable';
928
945
  lanes[`${language}/up`] = '✗untranslatable';
946
+ lanes[`${language}/eng`] = '✗untranslatable';
929
947
  continue;
930
948
  }
931
949
  const translated = code;
@@ -934,7 +952,15 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
934
952
  if (!compiled.ok || !compiled.ast) return '✗compile';
935
953
  return onHyperfixi(compiled.ast);
936
954
  });
937
- lanes[`${language}/up`] = await guard(() => onUpstream(preprocess(translated, language)));
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));
938
964
  }
939
965
  return result;
940
966
  },
@@ -966,8 +992,9 @@ export async function runValueMatrix(cells: readonly MatrixCell[]): Promise<Cell
966
992
 
967
993
  export interface BaselineEntry {
968
994
  /**
969
- * The failing lanes, space-separated, in LANES order, with two shorthands:
970
- * `*direct` for all 23 languages on hyperfixi, `*up` for all 23 on upstream.
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.
971
998
  */
972
999
  lanes: string;
973
1000
  /** Where the loss sits, for reading the burn-down (see familyOf); not asserted. */
@@ -989,6 +1016,7 @@ export interface ValueMatrixBaseline {
989
1016
 
990
1017
  const DIRECT_LANES: readonly string[] = FOREIGN_LANGUAGES;
991
1018
  const ADAPTER_LANES: readonly string[] = FOREIGN_LANGUAGES.map(language => `${language}/up`);
1019
+ const ENGINE_LANES: readonly string[] = FOREIGN_LANGUAGES.map(language => `${language}/eng`);
992
1020
 
993
1021
  /** Lanes (in LANES order) as a baseline string, with a shorthand for each full group. */
994
1022
  export function compressLanes(lanes: readonly string[]): string {
@@ -996,14 +1024,17 @@ export function compressLanes(lanes: readonly string[]): string {
996
1024
  const out: string[] = [];
997
1025
  const direct = DIRECT_LANES.every(l => set.has(l));
998
1026
  const adapter = ADAPTER_LANES.every(l => set.has(l));
1027
+ const engine = ENGINE_LANES.every(l => set.has(l));
999
1028
  for (const lane of LANES) {
1000
1029
  if (!set.has(lane)) continue;
1001
1030
  if (direct && DIRECT_LANES.includes(lane)) continue;
1002
1031
  if (adapter && ADAPTER_LANES.includes(lane)) continue;
1032
+ if (engine && ENGINE_LANES.includes(lane)) continue;
1003
1033
  out.push(lane);
1004
1034
  }
1005
1035
  if (direct) out.push('*direct');
1006
1036
  if (adapter) out.push('*up');
1037
+ if (engine) out.push('*eng');
1007
1038
  return out.join(' ');
1008
1039
  }
1009
1040
 
@@ -1013,6 +1044,7 @@ export function expandLanes(text: string): string[] {
1013
1044
  for (const token of text.split(' ').filter(Boolean)) {
1014
1045
  if (token === '*direct') out.push(...DIRECT_LANES);
1015
1046
  else if (token === '*up') out.push(...ADAPTER_LANES);
1047
+ else if (token === '*eng') out.push(...ENGINE_LANES);
1016
1048
  else out.push(token);
1017
1049
  }
1018
1050
  return out;
@@ -1031,7 +1063,10 @@ export function failingLanes(result: CellResult): string[] {
1031
1063
  * translation inherits the loss;
1032
1064
  * - `translation` some foreign lanes, on both engines;
1033
1065
  * - `direct-path` hyperfixi's foreign lanes only;
1034
- * - `adapter` upstream'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.
1035
1070
  *
1036
1071
  * Several can hold at once; they are joined in that order.
1037
1072
  */
@@ -1048,6 +1083,9 @@ export function familyOf(lanes: readonly string[]): string {
1048
1083
  if (direct.length > both.length && !set.has('en')) parts.push('direct-path');
1049
1084
  if (adapter.length > both.length) parts.push('adapter');
1050
1085
  }
1086
+ if (set.has('eng') || FOREIGN_LANGUAGES.some(l => set.has(`${l}/eng`) !== set.has(`${l}/up`))) {
1087
+ parts.push('engine');
1088
+ }
1051
1089
  return parts.join('+') || 'none';
1052
1090
  }
1053
1091
 
@@ -1082,7 +1120,7 @@ export const ACCEPTED: ReadonlyArray<{
1082
1120
  'increment|#a.textContent + 2',
1083
1121
  'increment|#a.textContent as Int',
1084
1122
  ],
1085
- lanes: 'it it/up',
1123
+ lanes: 'it it/up it/eng',
1086
1124
  reason:
1087
1125
  'ambiguity: it `di` is both `by` and `of`, so `incrementare i di #a.textContent` also says `increment i of #a.textContent`',
1088
1126
  },