@hyperfixi/testing-framework 3.2.0 → 4.0.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 (29) hide show
  1. package/CHANGELOG.md +203 -1
  2. package/package.json +9 -9
  3. package/src/agent-bench/README.md +48 -1
  4. package/src/agent-bench/agent-bench.test.ts +7 -0
  5. package/src/agent-bench/harness.ts +39 -50
  6. package/src/multilingual/README.md +30 -11
  7. package/src/multilingual/bare-render-fidelity.ts +1 -1
  8. package/src/multilingual/cli.ts +5 -3
  9. package/src/multilingual/direct-path-shapes-gate.ts +56 -0
  10. package/src/multilingual/direct-path-shapes.1.test.ts +12 -0
  11. package/src/multilingual/direct-path-shapes.2.test.ts +12 -0
  12. package/src/multilingual/direct-path-shapes.3.test.ts +12 -0
  13. package/src/multilingual/direct-path-shapes.cases.json +2621 -0
  14. package/src/multilingual/direct-path-shapes.ts +459 -0
  15. package/src/multilingual/engine-parser-parity.test.ts +67 -0
  16. package/src/multilingual/orchestrator.ts +2 -1
  17. package/src/multilingual/render-fidelity.ts +1 -1
  18. package/src/multilingual/shipped-examples-execution.test.ts +44 -33
  19. package/src/multilingual/shipped-examples-execution.ts +55 -75
  20. package/src/multilingual/shipped-sources-engine.test.ts +106 -0
  21. package/src/multilingual/shipped-sources-localized.test.ts +87 -0
  22. package/src/multilingual/shipped-sources-validity.ts +127 -74
  23. package/src/multilingual/validators/execution-validator.test.ts +81 -23
  24. package/src/multilingual/validators/execution-validator.ts +140 -94
  25. package/src/multilingual/validators/parse-validator.ts +7 -20
  26. package/src/multilingual/value-matrix.accepted.test.ts +8 -11
  27. package/src/multilingual/value-matrix.isolation.test.ts +7 -6
  28. package/src/multilingual/value-matrix.ts +72 -86
  29. package/src/multilingual/shipped-sources-validity.test.ts +0 -95
@@ -42,17 +42,29 @@
42
42
  * Each cell runs its English source on the real `hyperscript.org` engine: that
43
43
  * result is the ORACLE. Then, on the same fixture:
44
44
  *
45
- * - `en` hyperfixi's English path (core's parser and runtime);
46
45
  * - `en-rt` semantic's English parse rendered back to English, on upstream:
47
46
  * 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.
47
+ * - `<L>/up` each of 23 languages — `render(parse_en(src), L)` — through
48
+ * `@lokascript/hyperscript-adapter` (`preprocess`, back to
49
+ * English) on upstream: the multilingual product for original
50
+ * _hyperscript users;
51
+ * - `eng` the English source on `@hyperfixi/engine`, the engine that
52
+ * replaces core's;
53
+ * - `<L>/eng` the adapter's English (the string the `/up` lane runs) on
54
+ * that engine: where it differs from `<L>/up`, the two ENGINES
55
+ * differ, since they were given the same text.
53
56
  *
54
57
  * A (cell, lane) pair FAILS when its result differs from the oracle's.
55
58
  *
59
+ * A `<L>` lane ran each translation on core's direct path (compiled with
60
+ * `{ language: L }`) until Phase C2 of the engine cutover, which made text the
61
+ * multilingual interchange. It retired after the measurement the plan asked
62
+ * for: on the committed baseline no cell failed `<L>/eng` while passing `<L>`
63
+ * (the only foreign failures, three Italian `di` cells, failed all three).
64
+ * An `en` lane ran the English source on `@hyperfixi/core`'s parser and runtime
65
+ * until Phase C4, when it left with core's engine; its only failures were the
66
+ * eight accepted cells of core's `the X of Y as T` reading, which `eng` passes.
67
+ *
56
68
  * ## The ratchet
57
69
  * `baselines/value-matrix.json` lists every failing pair, per cell. The gate
58
70
  * fails on a failing pair it does not list AND on a listed pair that now
@@ -64,12 +76,13 @@
64
76
  * One jsdom window for the whole run; each lane resets `<body>` and the
65
77
  * globals, installs the handler on a fresh button, clicks it, and reads
66
78
  * `#out`. Variables are window globals (see GLOBALS), so no source needs a
67
- * `set` prefix. Upstream runs synchronously; hyperfixi's handler settles
68
- * within one macrotask.
79
+ * `set` prefix. Both engines run synchronously unless the source waits.
69
80
  *
70
- * A translation that loses a loop's condition can loop forever. Core caps a
71
- * 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).
81
+ * A translation that loses a loop's condition can loop forever. Upstream has
82
+ * no cap and blocks the thread, so every upstream run gets an evaluation
83
+ * budget (see EVAL_BUDGET). The new engine has no cap and no hook for one: a
84
+ * string that spent the budget on upstream is not run on it, and its `/eng`
85
+ * lane reads `✗budget` too.
73
86
  *
74
87
  * Node-only: imports the real `hyperscript.org` build off disk and needs the
75
88
  * node vitest environment (see the shipped-examples gate for why).
@@ -731,11 +744,14 @@ export const FOREIGN_LANGUAGES = [
731
744
 
732
745
  /** Every lane, in report order. */
733
746
  export const LANES: readonly string[] = [
734
- 'en',
735
747
  'en-rt',
736
- ...FOREIGN_LANGUAGES.flatMap(language => [language, `${language}/up`]),
748
+ 'eng',
749
+ ...FOREIGN_LANGUAGES.flatMap(language => [`${language}/up`, `${language}/eng`]),
737
750
  ];
738
751
 
752
+ /** The lanes a cell runs per language it does not skip. */
753
+ export const LANES_PER_LANGUAGE = 2;
754
+
739
755
  /**
740
756
  * Upstream evaluations allowed per run. A cell uses well under a hundred; a
741
757
  * loop whose condition a translation lost uses them all and stops, where it
@@ -743,10 +759,14 @@ export const LANES: readonly string[] = [
743
759
  */
744
760
  const EVAL_BUDGET = 20_000;
745
761
 
746
- /** The upstream surface this uses (`hyperscript.org`'s ESM default export). */
747
- interface UpstreamEngine {
762
+ /** What a lane needs of a host that reads scripts off attributes: upstream, or the new engine. */
763
+ interface ScriptHost {
748
764
  parse(source: string): { errors?: Array<{ message: string }> } | undefined;
749
765
  processNode(element: Element): void;
766
+ }
767
+
768
+ /** The upstream surface this uses (`hyperscript.org`'s ESM default export). */
769
+ interface UpstreamEngine extends ScriptHost {
750
770
  internals: {
751
771
  runtime: { unifiedEval(parseElement: unknown, context: unknown): unknown };
752
772
  };
@@ -775,8 +795,8 @@ export interface MatrixEngines {
775
795
  *
776
796
  * Until `close()`, the console is silenced and unhandled rejections are
777
797
  * trapped: thousands of lanes fail by design, both engines report a failure
778
- * on the console, and hyperfixi's handler is an async listener, so an error
779
- * in it rejects a promise nobody holds. The process's own rejection
798
+ * on the console, and a handler that waits on something runs on after its
799
+ * click, so an error in it rejects a promise nobody holds. The process's own rejection
780
800
  * listeners (vitest's, under test) are set aside for the run and restored.
781
801
  */
782
802
  export async function initMatrixEngines(): Promise<MatrixEngines> {
@@ -803,12 +823,14 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
803
823
  };
804
824
  process.on('unhandledRejection', trap);
805
825
 
806
- const { hyperscript } = await import('@hyperfixi/core');
807
826
  const { parseSemantic, render } = await import('@lokascript/semantic');
808
827
  const { preprocess } = await import('@lokascript/hyperscript-adapter');
809
828
  const require = createRequire(import.meta.url);
810
829
  const esm = require.resolve('hyperscript.org').replace(/[^/\\]+$/, '_hyperscript.esm.js');
811
830
  const upstream: UpstreamEngine = (await import(pathToFileURL(esm).href)).default;
831
+ const engineModule = await import('@hyperfixi/engine');
832
+ engineModule.register(...engineModule.everything);
833
+ const engine: ScriptHost = engineModule.api;
812
834
 
813
835
  // The evaluation budget: every upstream evaluation goes through unifiedEval.
814
836
  const runtime = upstream.internals.runtime;
@@ -821,13 +843,6 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
821
843
 
822
844
  const window = dom.window;
823
845
  const document = window.document;
824
- type Ast = Parameters<typeof hyperscript.execute>[0];
825
-
826
- // hyperfixi keeps its global variables in one Map that every context shares,
827
- // and writes a window global there (`increment n`). It outlives the run, and
828
- // it shadows window's copy, so every later lane would read the write.
829
- const coreGlobals = hyperscript.createContext().globals;
830
- const coreGlobalsAtStart = new Map(coreGlobals);
831
846
  const headAtStart = document.head.innerHTML;
832
847
  const names = collidingNames();
833
848
 
@@ -844,8 +859,6 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
844
859
  html.innerHTML = `<head>${headAtStart}</head><body></body>`;
845
860
  }
846
861
  document.body.innerHTML = FIXTURE;
847
- coreGlobals.clear();
848
- for (const [name, value] of coreGlobalsAtStart) coreGlobals.set(name, value);
849
862
  for (const [name, make] of Object.entries(GLOBALS)) {
850
863
  const value = make();
851
864
  Reflect.set(window, name, value);
@@ -862,29 +875,21 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
862
875
  };
863
876
  const read = (): string => document.getElementById('out')?.textContent ?? '✗no #out';
864
877
 
865
- /** Run English on upstream. */
866
- const onUpstream = (source: string): string => {
867
- const errors = upstream.parse(source)?.errors ?? [];
878
+ /** Run English on a host that reads the script off the button. */
879
+ const onHost = (host: ScriptHost, source: string): string => {
880
+ const errors = host.parse(source)?.errors ?? [];
868
881
  if (errors.length) return `✗parse: ${errors[0]?.message.split('\n')[0] ?? ''}`;
869
882
  const button = reset();
870
883
  button.setAttribute('_', source);
871
884
  evaluations = 0;
872
885
  const before = reportedErrors;
873
- upstream.processNode(button);
886
+ host.processNode(button);
874
887
  click(button);
875
888
  if (evaluations > EVAL_BUDGET) return '✗budget';
876
889
  const got = read();
877
890
  return reportedErrors > before && got === '∅' ? '✗threw' : got;
878
891
  };
879
-
880
- /** Install a compiled handler on hyperfixi, click, and settle. */
881
- const onHyperfixi = async (ast: Ast): Promise<string> => {
882
- const button = reset();
883
- await hyperscript.execute(ast, hyperscript.createContext(button));
884
- click(button);
885
- await new Promise(resolve => setTimeout(resolve, 0));
886
- return read();
887
- };
892
+ const onUpstream = (source: string): string => onHost(upstream, source);
888
893
 
889
894
  const guard = async (run: () => Promise<string> | string): Promise<string> => {
890
895
  try {
@@ -904,16 +909,11 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
904
909
  }
905
910
  const lanes = result.lanes;
906
911
 
907
- lanes.en = await guard(async () => {
908
- const compiled = hyperscript.compileSync(cell.source);
909
- if (!compiled.ok || !compiled.ast) return '✗compile';
910
- return onHyperfixi(compiled.ast);
911
- });
912
-
913
912
  const english = parseSemantic(cell.source, 'en').node;
914
913
  lanes['en-rt'] = await guard(() =>
915
914
  english ? onUpstream(render(english, 'en')) : '✗untranslatable'
916
915
  );
916
+ lanes.eng = await guard(() => onHost(engine, cell.source));
917
917
 
918
918
  for (const language of FOREIGN_LANGUAGES) {
919
919
  if (cell.skip?.includes(language)) continue;
@@ -924,17 +924,20 @@ export async function initMatrixEngines(): Promise<MatrixEngines> {
924
924
  code = null;
925
925
  }
926
926
  if (code === null) {
927
- lanes[language] = '✗untranslatable';
928
927
  lanes[`${language}/up`] = '✗untranslatable';
928
+ lanes[`${language}/eng`] = '✗untranslatable';
929
929
  continue;
930
930
  }
931
931
  const translated = code;
932
- lanes[language] = await guard(async () => {
933
- const compiled = await hyperscript.compile(translated, { language });
934
- if (!compiled.ok || !compiled.ast) return '✗compile';
935
- return onHyperfixi(compiled.ast);
936
- });
937
- lanes[`${language}/up`] = await guard(() => onUpstream(preprocess(translated, language)));
932
+ // The adapter's English, once, for both hosts.
933
+ const adapted = await guard(() => preprocess(translated, language));
934
+ const up = (lanes[`${language}/up`] = adapted.startsWith('✗')
935
+ ? adapted
936
+ : await guard(() => onUpstream(adapted)));
937
+ lanes[`${language}/eng`] =
938
+ adapted.startsWith('✗') || up === '✗budget'
939
+ ? up
940
+ : await guard(() => onHost(engine, adapted));
938
941
  }
939
942
  return result;
940
943
  },
@@ -967,7 +970,7 @@ export async function runValueMatrix(cells: readonly MatrixCell[]): Promise<Cell
967
970
  export interface BaselineEntry {
968
971
  /**
969
972
  * 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.
973
+ * `*up` for all 23 languages on upstream, `*eng` for all 23 on the new engine.
971
974
  */
972
975
  lanes: string;
973
976
  /** Where the loss sits, for reading the burn-down (see familyOf); not asserted. */
@@ -987,23 +990,23 @@ export interface ValueMatrixBaseline {
987
990
  entries: Record<string, BaselineEntry>;
988
991
  }
989
992
 
990
- const DIRECT_LANES: readonly string[] = FOREIGN_LANGUAGES;
991
993
  const ADAPTER_LANES: readonly string[] = FOREIGN_LANGUAGES.map(language => `${language}/up`);
994
+ const ENGINE_LANES: readonly string[] = FOREIGN_LANGUAGES.map(language => `${language}/eng`);
992
995
 
993
996
  /** Lanes (in LANES order) as a baseline string, with a shorthand for each full group. */
994
997
  export function compressLanes(lanes: readonly string[]): string {
995
998
  const set = new Set(lanes);
996
999
  const out: string[] = [];
997
- const direct = DIRECT_LANES.every(l => set.has(l));
998
1000
  const adapter = ADAPTER_LANES.every(l => set.has(l));
1001
+ const engine = ENGINE_LANES.every(l => set.has(l));
999
1002
  for (const lane of LANES) {
1000
1003
  if (!set.has(lane)) continue;
1001
- if (direct && DIRECT_LANES.includes(lane)) continue;
1002
1004
  if (adapter && ADAPTER_LANES.includes(lane)) continue;
1005
+ if (engine && ENGINE_LANES.includes(lane)) continue;
1003
1006
  out.push(lane);
1004
1007
  }
1005
- if (direct) out.push('*direct');
1006
1008
  if (adapter) out.push('*up');
1009
+ if (engine) out.push('*eng');
1007
1010
  return out.join(' ');
1008
1011
  }
1009
1012
 
@@ -1011,8 +1014,8 @@ export function compressLanes(lanes: readonly string[]): string {
1011
1014
  export function expandLanes(text: string): string[] {
1012
1015
  const out: string[] = [];
1013
1016
  for (const token of text.split(' ').filter(Boolean)) {
1014
- if (token === '*direct') out.push(...DIRECT_LANES);
1015
- else if (token === '*up') out.push(...ADAPTER_LANES);
1017
+ if (token === '*up') out.push(...ADAPTER_LANES);
1018
+ else if (token === '*eng') out.push(...ENGINE_LANES);
1016
1019
  else out.push(token);
1017
1020
  }
1018
1021
  return out;
@@ -1026,27 +1029,23 @@ export function failingLanes(result: CellResult): string[] {
1026
1029
  /**
1027
1030
  * Where a cell's failure sits, from which lanes fail:
1028
1031
  *
1029
- * - `core` core's English run differs from upstream's;
1030
1032
  * - `semantic-en` semantic's English parse loses it (`en-rt`), so every
1031
1033
  * translation inherits the loss;
1032
- * - `translation` some foreign lanes, on both engines;
1033
- * - `direct-path` hyperfixi's foreign lanes only;
1034
- * - `adapter` upstream's foreign lanes only.
1034
+ * - `translation` some foreign lanes on upstream: the adapter's English
1035
+ * for that language says something else;
1036
+ * - `engine` the new engine differs from upstream on the same text:
1037
+ * its English run, or a language's `/eng` lane without
1038
+ * its `/up` lane.
1035
1039
  *
1036
1040
  * Several can hold at once; they are joined in that order.
1037
1041
  */
1038
1042
  export function familyOf(lanes: readonly string[]): string {
1039
1043
  const set = new Set(lanes);
1040
1044
  const parts: string[] = [];
1041
- if (set.has('en')) parts.push('core');
1042
1045
  if (set.has('en-rt')) parts.push('semantic-en');
1043
- else {
1044
- const direct = FOREIGN_LANGUAGES.filter(l => set.has(l));
1045
- const adapter = FOREIGN_LANGUAGES.filter(l => set.has(`${l}/up`));
1046
- const both = direct.filter(l => adapter.includes(l));
1047
- if (both.length) parts.push('translation');
1048
- if (direct.length > both.length && !set.has('en')) parts.push('direct-path');
1049
- if (adapter.length > both.length) parts.push('adapter');
1046
+ else if (FOREIGN_LANGUAGES.some(l => set.has(`${l}/up`))) parts.push('translation');
1047
+ if (set.has('eng') || FOREIGN_LANGUAGES.some(l => set.has(`${l}/eng`) !== set.has(`${l}/up`))) {
1048
+ parts.push('engine');
1050
1049
  }
1051
1050
  return parts.join('+') || 'none';
1052
1051
  }
@@ -1063,26 +1062,13 @@ export const ACCEPTED: ReadonlyArray<{
1063
1062
  lanes: string;
1064
1063
  reason: string;
1065
1064
  }> = [
1066
- {
1067
- cells: [
1068
- ...['put', 'set', 'while', 'times', 'increment'].map(
1069
- p => `${p}|the textContent of #a as Int`
1070
- ),
1071
- // The same difference with a reference owner (PR 108); upstream's
1072
- // `window as Int` is null, and in a loop bound both read 0 iterations.
1073
- ...['put', 'set', 'increment'].map(p => `${p}|the scrollY of window as Int`),
1074
- ],
1075
- lanes: 'en *direct',
1076
- reason:
1077
- 'known difference: core converts the property, upstream the target (core/docs/UPSTREAM-KNOWN-DIFFS.md)',
1078
- },
1079
1065
  {
1080
1066
  cells: [
1081
1067
  'increment|#a.textContent',
1082
1068
  'increment|#a.textContent + 2',
1083
1069
  'increment|#a.textContent as Int',
1084
1070
  ],
1085
- lanes: 'it it/up',
1071
+ lanes: 'it/up it/eng',
1086
1072
  reason:
1087
1073
  'ambiguity: it `di` is both `by` and `of`, so `incrementare i di #a.textContent` also says `increment i of #a.textContent`',
1088
1074
  },
@@ -1,95 +0,0 @@
1
- /**
2
- * Shipped-sources validity gate (see shipped-sources-validity.ts for the why).
3
- *
4
- * Compiles every hyperscript source we ship in `examples/` and the doc trees,
5
- * and ratchets on the recovers-with-errors state (`ok: true` with a non-empty
6
- * `errors`). Three assertions, matching the canonical-validity gates:
7
- * 1. sanity — sources were actually found and mostly compile clean;
8
- * 2. no NEW recovering source appears outside the committed allowlist;
9
- * 3. no allowlisted key has silently become clean (stale entries must be
10
- * removed so the list only ever shrinks).
11
- *
12
- * To update after an intentional fix: re-run and rewrite
13
- * `baselines/shipped-sources-validity.json`. Note the allowlist key embeds a
14
- * hash of the source, so FIXING a source changes its key — the entry goes
15
- * stale and assertion 3 makes removing it mandatory.
16
- */
17
-
18
- import { readFileSync } from 'node:fs';
19
- import { fileURLToPath } from 'node:url';
20
- import path from 'node:path';
21
- import { JSDOM } from 'jsdom';
22
- import { describe, it, expect, beforeAll } from 'vitest';
23
- import {
24
- checkShippedSourcesValidity,
25
- type ShippedSourcesResult,
26
- type CompileForValidity,
27
- } from './shipped-sources-validity';
28
-
29
- interface AllowlistDoc {
30
- allowedRecovered: Array<{
31
- key: string;
32
- file: string;
33
- error: string;
34
- upstream: string;
35
- reason: string;
36
- }>;
37
- }
38
-
39
- const baselinePath = path.resolve(
40
- path.dirname(fileURLToPath(import.meta.url)),
41
- '../../baselines/shipped-sources-validity.json'
42
- );
43
- const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
44
- const allowed = new Set(allowlist.allowedRecovered.map(e => e.key));
45
-
46
- describe('shipped-sources validity gate', () => {
47
- let result: ShippedSourcesResult;
48
-
49
- beforeAll(async () => {
50
- // Import core through its built entry, the same surface a consumer gets.
51
- const core = (await import('@hyperfixi/core')) as unknown as {
52
- hyperscript: { compileSync: CompileForValidity };
53
- };
54
- const dom = new JSDOM('<!doctype html><html><body></body></html>');
55
- result = checkShippedSourcesValidity(
56
- code => core.hyperscript.compileSync(code),
57
- dom.window.document as unknown as Parameters<typeof checkShippedSourcesValidity>[1]
58
- );
59
- }, 120_000);
60
-
61
- it('finds and compiles the shipped sources (sanity: trees walked, extraction working)', () => {
62
- // Guards the silent-zero failure mode: a broken walk or extractor would
63
- // make every other assertion vacuously pass.
64
- expect(result.checked).toBeGreaterThan(100);
65
- expect(result.clean).toBeGreaterThan(100);
66
- });
67
-
68
- it('has no NEW source that parses with recovered errors outside the allowlist', () => {
69
- const unexpected = result.findings.filter(f => !allowed.has(f.key));
70
- expect(
71
- unexpected,
72
- unexpected.length
73
- ? `\nNew shipped sources that parse ok:true WITH errors (fix the source, or allowlist with a reason):\n` +
74
- unexpected
75
- .map(f => ` [${f.key}]\n "${f.excerpt}"\n -> ${f.error}`)
76
- .join('\n') +
77
- `\n\nAsk the real hyperscript.org engine for a second opinion before deciding:\n` +
78
- `upstream REJECTING means the source is malformed; upstream ACCEPTING means this is a hyperfixi parser defect.\n` +
79
- `See loadCanonicalParser() in canonical-validity.ts.`
80
- : ''
81
- ).toEqual([]);
82
- });
83
-
84
- it('has no stale allowlist entries (a now-clean source must be removed so the list ratchets down)', () => {
85
- const stillFailing = new Set(result.findings.map(f => f.key));
86
- const stale = allowlist.allowedRecovered.map(e => e.key).filter(key => !stillFailing.has(key));
87
- expect(
88
- stale,
89
- stale.length
90
- ? `\nThese allowlisted sources no longer recover-with-errors (fixed, or edited — the key embeds a source hash).\n` +
91
- `Remove them from baselines/shipped-sources-validity.json:\n ${stale.join('\n ')}`
92
- : ''
93
- ).toEqual([]);
94
- });
95
- });