@hyperfixi/testing-framework 2.11.1 → 3.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,126 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.0.0] - 2026-09-03
11
+
12
+ Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v3.0.0).
13
+
14
+ One major, two themes. `@lokascript/i18n`'s grammar transformer is gone,
15
+ because `@lokascript/semantic` overtook it on every row of the translation
16
+ corpus (#973–#1001). And the engine-migration plan
17
+ (`docs-internal/ENGINE_MIGRATION_PLAN.md`) is complete: its last arc deleted
18
+ the exported dead code it had been carrying and collapsed the browser bundle
19
+ lineup to two names (#1099–#1105).
20
+
21
+ ### ⚠ BREAKING
22
+
23
+ - **`@lokascript/i18n` no longer translates** (#1001). `translate()`, `toLocale()`,
24
+ `toEnglish()`, `GrammarTransformer` and `createTransformer` are deleted; the
25
+ package is per-language vocabulary and grammar profiles. Migrate to
26
+ `import { translate } from '@lokascript/semantic'` (same signature; it throws
27
+ on unparseable input where the transformer returned nonsense). The classic-i18n
28
+ browser bundle, `@hyperscript-tools/i18n` and `@lokascript/types-browser` drop
29
+ the same members (#998, #999); the patterns corpus writer is semantic-only (#1000).
30
+ - **The prebuilt browser bundles are two names** (#1105): `hyperfixi-hx.js`
31
+ (small: hybrid parser, blocks, expressions, htmx v1/v2 attributes) and
32
+ `hyperfixi.js` (everything); `hyperfixi-hx-v4.js` and
33
+ `hyperfixi-multilingual.js` stay as separate products. `hyperfixi-lite.js`,
34
+ `-lite-plus.js`, `-minimal.js`, `-standard.js` and their `./browser/*`
35
+ exports are gone — use `hyperfixi-hx.js`, `hyperfixi.js`, or the Vite
36
+ plugin, which picks the tier itself. `./browser/hybrid-complete` remains as
37
+ the plugin's internal fallback.
38
+ - **Exported dead code deleted from `@hyperfixi/core`** — none had a
39
+ production caller: the six `@deprecated` `features/` families and the
40
+ modular bundle's `features` namespace (#1099); `Lexer`/`Tokens` (#1100 —
41
+ use `tokenize`); the `unified-types` `Validator`, the `types.d.ts` shim and
42
+ `registry/multilingual` (#1101); the `async` command (#1102 — it was
43
+ unreachable from parsed hyperscript; the manifest is 58 commands);
44
+ `ContextProviderRegistry`, the context-provider `Proxy`, the registry's
45
+ `context` slot and the plugin `contextProviders` field (#1104 — set
46
+ request-scoped values as `context.locals`).
47
+
48
+ ### Fixed
49
+
50
+ - **A small bundle fails loudly on a command it lacks and names `hyperfixi.js`**
51
+ (#1103). The hybrid parser had been dropping an unrecognised word silently;
52
+ making it loud also exposed and fixed three silent mis-parses in the hybrid
53
+ bundles: unquoted `fetch /api/data` fetched `/`, `send custom:event` sent
54
+ `custom` to `me`, and `repeat forever` ran zero iterations.
55
+ - **English→foreign rendering is gated** (#931–#972, #953): 3105/3105 corpus
56
+ renders parse on the canonical engine; the bare (handler-less) surface has its
57
+ own gate. `as JSON` stays part of the value (#991); a flattened loop header
58
+ closes (#992).
59
+ - Plugins report their real versions (#926); `analyze_content` is unshadowed in
60
+ the MCP server (#927); four dependency advisories resolved; the `domain-flow`
61
+ → `server-bridge` route contract is pinned (#1000s).
62
+
63
+ ### Changed
64
+
65
+ - `hyperfixi.js` and `hyperfixi-hx-v4.js` carry the 24-language render
66
+ vocabulary (~19 KB gzip each) so `translate` works in the browser (#931).
67
+ - Deprecated `@lokascript/domain-*` references repoint to `@lokascript/domains`.
68
+
69
+ ## [2.11.1] - 2026-08-25
70
+
71
+ Patch release. Fixes the release workflow itself: the build now runs **after**
72
+ the version sync, so a published tarball carries its own version rather than
73
+ the previous one, and `version.ts` is committed (#925). Also lands the
74
+ agent-loop A/B benchmark with an isolated generator (#924).
75
+
76
+ ## [2.11.0] - 2026-08-24
77
+
78
+ Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v2.11.0).
79
+
80
+ ### Changed
81
+
82
+ - **The domain-DSL family moved out** (#909). Twelve packages left this
83
+ monorepo: the ten publishable `@lokascript/domain-*` packages (bdd,
84
+ behaviorspec, config, flow, jsx, learn, llm, sql, todo, voice) plus the
85
+ private `domain-toolkit` and `mcp-multilingual-intent`. They now ship as
86
+ **`@lokascript/domains`**, one subpath export per domain, from the
87
+ [lokascript-domains](https://github.com/codetalcott/lokascript-domains)
88
+ repository.
89
+
90
+ All ten published names are **deprecated on npm**, each naming its
91
+ replacement subpath (`@lokascript/domain-sql` → "import from
92
+ `@lokascript/domains/sql`"). Existing installs keep working — deprecation is
93
+ a warning, not a removal — they simply stop receiving updates. The
94
+ pre-deletion tree is preserved at the `moved/domain-family` tag.
95
+
96
+ _Migration:_ replace nine dependencies with one, and change
97
+ `from '@lokascript/domain-x'` to `from '@lokascript/domains/x'`. The root
98
+ entry absorbed `domain-config` and exports `createDomainRegistry`,
99
+ `registerAllDomains` and `DOMAIN_PRIORITY`.
100
+
101
+ ### Added
102
+
103
+ - **"Show in My Language"** — LSP request plus VSCode command (#921).
104
+ - **Verified-translation badge** in the compilation service (#920).
105
+ - **`scoreFidelity` / `@lokascript/semantic/fidelity`** (#919).
106
+ - **Inert-shape warnings** (#918) and surfaced unconsumed-input warnings (#916)
107
+ in the compilation service.
108
+ - **The agent-loop benchmark** and the silent-failure finding it produced
109
+ (#915), and the MCP server's agent-era roadmap (#914).
110
+ - **Element-collection write-back** for hypermedia tables, with gallery
111
+ examples and four system fixes (#904).
112
+ - **The comprehensive Playwright tier now runs in CI** (#908) alongside
113
+ `quick`. It had never run: 122 specs, hiding four real bugs — a detached
114
+ `startViewTransition`, untracked reads of unset globals, concurrent effects
115
+ clobbering dependency capture, and `put` stringifying DocumentFragments
116
+ (#905).
117
+
118
+ ### Fixed
119
+
120
+ - **The `hyperscript-adapter` review arc** (#895–#902): whole-string
121
+ translation first, taking canonical validity from 2849/3105 to **3105/3105**
122
+ (#899); a host-parser validity gate so an invalid render falls back to the
123
+ author's text (#900); the dead split-statement fallback deleted after
124
+ measuring 0 outputs (#901); and the slim divergence set burned 6 → 1 via
125
+ schema marker data, repairing 39 body-dropping corpus rows (#902).
126
+ - **`qu take.recipient`** — the R1 tail's last `take` row (#910).
127
+ - **The release workflows** no longer reference the moved-out domain family
128
+ (#922).
129
+
10
130
  ## [2.10.0] - 2026-08-01
11
131
 
12
132
  Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v2.10.0).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixi/testing-framework",
3
- "version": "2.11.1",
3
+ "version": "3.0.0",
4
4
  "description": "Cross-platform behavior testing suite for LokaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -40,7 +40,7 @@
40
40
  "analyze-failures": "tsx src/multilingual/tools/analyze-failures.ts",
41
41
  "typecheck": "tsc --noEmit",
42
42
  "test:check": "VITEST_QUIET=1 bash ../../scripts/vitest-run.sh --reporter=dot",
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",
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",
44
44
  "test:shipped-sources": "VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/shipped-sources-validity.test.ts"
45
45
  },
46
46
  "keywords": [
@@ -57,17 +57,17 @@
57
57
  "author": "LokaScript Contributors",
58
58
  "license": "MIT",
59
59
  "dependencies": {
60
- "@hyperfixi/core": "^2.11.1",
61
- "@hyperfixi/patterns-reference": "^2.11.1",
62
- "@lokascript/compilation-service": "^2.11.1",
63
- "@lokascript/i18n": "^2.11.1",
64
- "@lokascript/semantic": "^2.11.1",
60
+ "@hyperfixi/core": "^3.0.0",
61
+ "@hyperfixi/patterns-reference": "^3.0.0",
62
+ "@lokascript/compilation-service": "^3.0.0",
63
+ "@lokascript/i18n": "^3.0.0",
64
+ "@lokascript/semantic": "^3.0.0",
65
65
  "diff": "^8.0.3",
66
66
  "esbuild": "^0.28.0",
67
67
  "happy-dom": "^20.10.6",
68
68
  "jsdom": "^30.0.0",
69
69
  "playwright": "^1.40.0",
70
- "puppeteer": "^24.26.1",
70
+ "puppeteer": "^25.9.0",
71
71
  "tsx": "^4.23.1",
72
72
  "url-parse": "^1.5.10",
73
73
  "vite": "^8.1.4"
@@ -0,0 +1,100 @@
1
+ /**
2
+ * BARE-surface en→foreign render-fidelity ratchet (see bare-render-fidelity.ts
3
+ * for why this exists and why it is not redundant with the wrapped gate).
4
+ *
5
+ * Identical assertions to `render-fidelity.test.ts`, over handler-STRIPPED
6
+ * corpus bodies. The allowlist is seeded at the level measured when the gate
7
+ * landed, so it starts green; it is a record of what is known-broken, not a
8
+ * target, and completing a fix means deleting entries.
9
+ *
10
+ * Needs FOREIGN_CANONICAL_VALIDITY=1 and a freshly populated patterns.db, the
11
+ * same contract the two sibling gates carry.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import path from 'node:path';
16
+ import { describe, it, expect, beforeAll } from 'vitest';
17
+ import { checkBareRenderFidelity } from './bare-render-fidelity';
18
+ import { groupFailuresByPattern, type RenderFidelityResult } from './render-fidelity';
19
+
20
+ interface AllowlistDoc {
21
+ checked: number;
22
+ clean: number;
23
+ cleanPct: number;
24
+ allowedFailures: Record<string, string[]>;
25
+ }
26
+
27
+ const baselinePath = path.resolve(
28
+ path.dirname(fileURLToPath(import.meta.url)),
29
+ '../../baselines/bare-render-fidelity.json'
30
+ );
31
+ const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
32
+ const key = (id: string, language: string) => `${id} ${language}`;
33
+ const allowed = new Set(
34
+ Object.entries(allowlist.allowedFailures).flatMap(([id, langs]) => langs.map(l => key(id, l)))
35
+ );
36
+
37
+ const DB_FRESHLY_POPULATED = process.env.FOREIGN_CANONICAL_VALIDITY === '1';
38
+
39
+ describe.skipIf(!DB_FRESHLY_POPULATED)('bare-surface english→foreign render-fidelity gate', () => {
40
+ let result: RenderFidelityResult;
41
+
42
+ beforeAll(async () => {
43
+ result = await checkBareRenderFidelity();
44
+ }, 300_000);
45
+
46
+ it('scores a non-empty bare corpus in every language (sanity: guards a false green)', () => {
47
+ // A derivation that stopped finding handler bodies — a changed handler head
48
+ // shape, a broken strip — would otherwise report zero failures and pass.
49
+ expect(result.checked).toBeGreaterThan(2500);
50
+ expect(result.clean).toBeGreaterThan(0);
51
+ });
52
+
53
+ it('does not regress the measured clean rate', () => {
54
+ const cleanPct = (100 * result.clean) / result.checked;
55
+ expect(
56
+ cleanPct,
57
+ `clean rate fell to ${cleanPct.toFixed(2)}% from the committed ${allowlist.cleanPct}%`
58
+ ).toBeGreaterThanOrEqual(allowlist.cleanPct - 0.01);
59
+ });
60
+
61
+ it('produces no NEW failing (pattern, language) bare render outside the allowlist', () => {
62
+ const unexpected = result.failures.filter(f => !allowed.has(key(f.id, f.language)));
63
+ expect(
64
+ unexpected,
65
+ unexpected.length
66
+ ? `\nNew BARE render-fidelity failures (fix it, or allowlist the pair):\n` +
67
+ unexpected
68
+ .map(
69
+ f =>
70
+ ` [${f.id}] (${f.language})\n en: ${f.english.split('\n')[0]}\n` +
71
+ ` out: ${f.rendered.split('\n')[0] || '<render threw>'}\n` +
72
+ ` lost: ${[...f.missingActions.map(a => `action:${a}`), ...f.missingRoles].join(', ')}`
73
+ )
74
+ .join('\n')
75
+ : ''
76
+ ).toEqual([]);
77
+ });
78
+
79
+ it('has no stale allowlist pairs (a now-passing pair must be removed so the list ratchets down)', () => {
80
+ const stillFailing = new Set(result.failures.map(f => key(f.id, f.language)));
81
+ const stale: string[] = [];
82
+ for (const [id, langs] of Object.entries(allowlist.allowedFailures)) {
83
+ for (const language of langs) {
84
+ if (!stillFailing.has(key(id, language))) stale.push(`${id}/${language}`);
85
+ }
86
+ }
87
+ expect(
88
+ stale,
89
+ stale.length
90
+ ? `\nThese allowlisted pairs now render faithfully bare — prune them from ` +
91
+ `baselines/bare-render-fidelity.json (regenerate with ` +
92
+ `tools/regen-bare-render-fidelity-baseline.ts):\n ${stale.join('\n ')}`
93
+ : ''
94
+ ).toEqual([]);
95
+ });
96
+
97
+ it('keeps the committed allowlist grouping in sync with the live failure set', () => {
98
+ expect(groupFailuresByPattern(result.failures)).toEqual(allowlist.allowedFailures);
99
+ });
100
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * BARE-surface en→foreign render-fidelity gate.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Every corpus row that exercises a command wraps it in an event handler, so
7
+ * the eleven multilingual signals, `render-fidelity`, and 8,800 unit tests are
8
+ * all blind to the PLAIN form of the same command. That blindness is not
9
+ * theoretical: measured 2026-08-27, `hyperfixi.translate('toggle .active on
10
+ * #panel', 'en', 'bn')` produced a surface that did not parse back AT ALL — the
11
+ * plainest two-role toggle there is — and neither did tl/vi's bare `set the
12
+ * *background-color of #theme to "#ff6600"`. Both were pre-existing, both were
13
+ * found by hand, and nothing in CI could have reported either.
14
+ *
15
+ * It matters because the bare form is a first-class public surface: MCP
16
+ * `translate_code`, `hyperfixi.translate`/`getAllTranslations`, core's
17
+ * `MultilingualHyperscript` and the VS Code "Show in my language" badge are all
18
+ * routinely handed a single command with no handler around it.
19
+ *
20
+ * WHAT IT ASSERTS
21
+ * ---------------
22
+ * Exactly what `render-fidelity` asserts — render the English into each
23
+ * language, parse it back, require that no action and no role went missing —
24
+ * but over the HANDLER-STRIPPED body of each corpus pattern instead of the
25
+ * whole row. Same ratchet shape, same allowlist contract, and it delegates to
26
+ * `checkRenderFidelity` so the two can never drift in how they score.
27
+ *
28
+ * NOT REDUNDANT. Of the 69 failing pairs this gate sees today, **37 are
29
+ * invisible to the wrapped gate** — they pass wrapped and fail bare, because a
30
+ * fused per-command handler pattern binds a role the standalone pattern misses.
31
+ * Four of the 37 do not parse back at all.
32
+ *
33
+ * DERIVED, NOT HAND-PICKED. The corpus supplies the constructs; there is no
34
+ * command list to maintain here. A hand-written one would drift exactly the way
35
+ * `scripts/test-check-all.sh` and the ci.yml job lists did.
36
+ *
37
+ * DB DEPENDENCY. Same as the wrapped gate: the SET of corpus rows depends on a
38
+ * fresh `populate`, so this runs only when the caller asserts one.
39
+ */
40
+ import { getAllPatterns } from '@hyperfixi/patterns-reference';
41
+ import { parseSemantic } from '@lokascript/semantic';
42
+ import { checkRenderFidelity, type RenderFidelityResult } from './render-fidelity';
43
+
44
+ /** A corpus row reduced to the body of its event handler. */
45
+ export interface BareBody {
46
+ readonly id: string;
47
+ readonly rawCode: string;
48
+ }
49
+
50
+ /**
51
+ * Strip a leading `on <event> [from <source>] ` handler head.
52
+ *
53
+ * Deliberately only the simple head: a row whose handler carries modifiers this
54
+ * does not recognize keeps its head, still parses as a handler, and is dropped
55
+ * by the guard in {@link deriveBareBodies} rather than being scored wrongly.
56
+ */
57
+ function stripHandlerHead(english: string): string | null {
58
+ const match = /^on\s+\S+(\s+from\s+\S+)?\s+/.exec(english);
59
+ if (!match) return null;
60
+ const body = english.slice(match[0].length).trim();
61
+ return body.length > 0 ? body : null;
62
+ }
63
+
64
+ /**
65
+ * Reduce the corpus to the handler bodies that are meaningful bare.
66
+ *
67
+ * Three exclusions, each because the row has no bare surface to score:
68
+ * - no handler head (a `behavior`/`socket`/`eventsource` block, or already bare);
69
+ * - the stripped body does not parse as English at all;
70
+ * - the stripped body STILL parses as an event handler. A block-shaped body
71
+ * (`if … end`) re-anchors as one, which leaks a phantom `on` action into the
72
+ * reference that then "goes missing" on the round trip for reasons that have
73
+ * nothing to do with the bare surface. Measured: 7 rows, and they accounted
74
+ * for 24 of the 61 apparent bare-only failures before the guard existed.
75
+ */
76
+ export function deriveBareBodies(
77
+ patterns: ReadonlyArray<{ id: string; rawCode: string }>
78
+ ): BareBody[] {
79
+ const bodies: BareBody[] = [];
80
+ for (const pattern of patterns) {
81
+ const body = stripHandlerHead(pattern.rawCode);
82
+ if (!body) continue;
83
+ let reference;
84
+ try {
85
+ reference = parseSemantic(body, 'en')?.node ?? null;
86
+ } catch {
87
+ continue;
88
+ }
89
+ if (!reference) continue;
90
+ if ((reference as { action?: string }).action === 'on') continue;
91
+ bodies.push({ id: pattern.id, rawCode: body });
92
+ }
93
+ return bodies;
94
+ }
95
+
96
+ /** Score every corpus handler BODY, bare, in every language. */
97
+ export async function checkBareRenderFidelity(opts?: {
98
+ languages?: readonly string[];
99
+ }): Promise<RenderFidelityResult> {
100
+ const patterns = await getAllPatterns({ limit: 1000 });
101
+ const bodies = deriveBareBodies(patterns);
102
+ return opts?.languages
103
+ ? checkRenderFidelity({ languages: opts.languages, patterns: bodies })
104
+ : checkRenderFidelity({ patterns: bodies });
105
+ }
@@ -19,5 +19,6 @@ export {
19
19
  computePrecision,
20
20
  spuriousActions,
21
21
  collectRoleSignature,
22
+ collectRoleSignatureStrict,
22
23
  collectRoleValueSignature,
23
24
  } from '@lokascript/semantic/fidelity';
@@ -0,0 +1,126 @@
1
+ /**
2
+ * English→foreign render-fidelity ratchet (see render-fidelity.ts for why).
3
+ *
4
+ * Renders every corpus pattern's English source into each of the 23 languages,
5
+ * parses it back, and requires that no action and no role from the English
6
+ * reference went missing. Two ratchet assertions, at (pattern, language)
7
+ * granularity, mirroring the foreign→English gate:
8
+ * 1. no NEW failing pair appears outside the committed allowlist;
9
+ * 2. no allowlisted pair has silently started passing (stale entries must be
10
+ * removed, so the list only ever shrinks).
11
+ *
12
+ * The allowlist was seeded at 75.97% clean — the level measured when the gate
13
+ * landed — so it starts green. It is a record of what is known-broken, not a
14
+ * target: completing a renderer fix means deleting entries from it.
15
+ *
16
+ * DB DEPENDENCY — corrected 2026-08-26 after CI disagreed with local.
17
+ * The gate renders `rawCode`, and that text is stable, but the SET of rows is
18
+ * not: `populate` re-runs `discoverPatterns` and finds examples the committed
19
+ * (frozen) patterns.db lacks — 3588 pairs against a fresh DB versus 3542
20
+ * against the committed one, which moved the clean rate 75.89% vs 75.97% and
21
+ * failed this gate on its own first CI run. So it carries the same guard as the
22
+ * foreign gate: it runs only when the caller asserts a fresh populate, and the
23
+ * baseline is seeded from that state.
24
+ *
25
+ * Regenerate after an intentional renderer change with
26
+ * `npm run populate --prefix packages/patterns-reference` followed by
27
+ * `npx tsx tools/regen-render-fidelity-baseline.ts`, and commit the result.
28
+ */
29
+ import { readFileSync } from 'node:fs';
30
+ import { fileURLToPath } from 'node:url';
31
+ import path from 'node:path';
32
+ import { describe, it, expect, beforeAll } from 'vitest';
33
+ import {
34
+ checkRenderFidelity,
35
+ groupFailuresByPattern,
36
+ type RenderFidelityResult,
37
+ } from './render-fidelity';
38
+
39
+ interface AllowlistDoc {
40
+ checked: number;
41
+ clean: number;
42
+ cleanPct: number;
43
+ allowedFailures: Record<string, string[]>;
44
+ }
45
+
46
+ const baselinePath = path.resolve(
47
+ path.dirname(fileURLToPath(import.meta.url)),
48
+ '../../baselines/render-fidelity.json'
49
+ );
50
+ const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
51
+ const key = (id: string, language: string) => `${id} ${language}`;
52
+ const allowed = new Set(
53
+ Object.entries(allowlist.allowedFailures).flatMap(([id, langs]) => langs.map(l => key(id, l)))
54
+ );
55
+
56
+ // Same contract as the foreign gate: a plain `vitest run` on a stale checkout
57
+ // skips rather than reporting phantom drift. `npm run test:canonical` and the CI
58
+ // multilingual job both set this, after populating.
59
+ const DB_FRESHLY_POPULATED = process.env.FOREIGN_CANONICAL_VALIDITY === '1';
60
+
61
+ describe.skipIf(!DB_FRESHLY_POPULATED)('english→foreign render-fidelity gate', () => {
62
+ let result: RenderFidelityResult;
63
+
64
+ beforeAll(async () => {
65
+ result = await checkRenderFidelity();
66
+ }, 300_000);
67
+
68
+ it('scores a non-empty corpus in every language (sanity: guards a false green)', () => {
69
+ // A corpus that failed to load would otherwise report zero failures and pass.
70
+ expect(result.checked).toBeGreaterThan(3000);
71
+ expect(result.clean).toBeGreaterThan(0);
72
+ });
73
+
74
+ it('does not regress the measured clean rate', () => {
75
+ // The headline number, kept honest independently of the pair-level lists:
76
+ // it may rise freely, but a drop means a render got worse somewhere the
77
+ // per-pair assertions might net out to zero.
78
+ const cleanPct = (100 * result.clean) / result.checked;
79
+ expect(
80
+ cleanPct,
81
+ `clean rate fell to ${cleanPct.toFixed(2)}% from the committed ${allowlist.cleanPct}%`
82
+ ).toBeGreaterThanOrEqual(allowlist.cleanPct - 0.01);
83
+ });
84
+
85
+ it('produces no NEW failing (pattern, language) render outside the allowlist', () => {
86
+ const unexpected = result.failures.filter(f => !allowed.has(key(f.id, f.language)));
87
+ expect(
88
+ unexpected,
89
+ unexpected.length
90
+ ? `\nNew render-fidelity failures (fix the renderer, or allowlist the pair):\n` +
91
+ unexpected
92
+ .map(
93
+ f =>
94
+ ` [${f.id}] (${f.language})\n en: ${f.english.split('\n')[0]}\n` +
95
+ ` out: ${f.rendered.split('\n')[0] || '<render threw>'}\n` +
96
+ ` lost: ${[...f.missingActions.map(a => `action:${a}`), ...f.missingRoles].join(', ')}`
97
+ )
98
+ .join('\n')
99
+ : ''
100
+ ).toEqual([]);
101
+ });
102
+
103
+ it('has no stale allowlist pairs (a now-passing pair must be removed so the list ratchets down)', () => {
104
+ const stillFailing = new Set(result.failures.map(f => key(f.id, f.language)));
105
+ const stale: string[] = [];
106
+ for (const [id, langs] of Object.entries(allowlist.allowedFailures)) {
107
+ for (const language of langs) {
108
+ if (!stillFailing.has(key(id, language))) stale.push(`${id}/${language}`);
109
+ }
110
+ }
111
+ expect(
112
+ stale,
113
+ stale.length
114
+ ? `\nThese allowlisted pairs now render faithfully — prune them from ` +
115
+ `baselines/render-fidelity.json (regenerate with ` +
116
+ `tools/regen-render-fidelity-baseline.ts):\n ${stale.join('\n ')}`
117
+ : ''
118
+ ).toEqual([]);
119
+ });
120
+
121
+ it('keeps the committed allowlist grouping in sync with the live failure set', () => {
122
+ // Structural cross-check: redundant with the two assertions above, but it
123
+ // yields a single clear diff when regeneration is needed.
124
+ expect(groupFailuresByPattern(result.failures)).toEqual(allowlist.allowedFailures);
125
+ });
126
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * English→foreign RENDER-fidelity gate.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Nothing gated this direction. The multilingual ratchet scores the STORED
7
+ * `pattern_translations` rows, which @lokascript/i18n's GrammarTransformer
8
+ * writes — it never calls `render(node, L)` at all. `canonical-validity` is
9
+ * en→en and `foreign-canonical-validity` is foreign→en, both of which pass.
10
+ * So `render(parse(en), L)` — the function behind MCP `translate_code`,
11
+ * `hyperfixi.translate`, `getAllTranslations`, core's `MultilingualHyperscript`
12
+ * and the VS Code "Show in my language" badge — was measured by nothing.
13
+ *
14
+ * When it was finally measured (2026-08-26) it was 73.3% structurally clean
15
+ * against the English reference where the i18n-written corpus was 97.0%.
16
+ * `README.md`, `AGENTS.md` and the MCP server instructions all attach a
17
+ * structural-fidelity guarantee to exactly this direction.
18
+ *
19
+ * WHAT IT ASSERTS
20
+ * ---------------
21
+ * For every corpus pattern and every non-English language: render the English
22
+ * source into that language, parse it back, and require that no ACTION and no
23
+ * ROLE from the English reference went missing. Failures are recorded per
24
+ * (pattern, language) against a committed allowlist that may only shrink —
25
+ * the same ratchet shape as `foreign-canonical-validity`.
26
+ *
27
+ * The allowlist is seeded at the level measured when the gate landed, so it
28
+ * lands green and every later improvement is a deletion from it. It is a
29
+ * record of what is known-broken, not a target.
30
+ *
31
+ * DB DEPENDENCY. The rendered text comes from `rawCode`, which is stable, but
32
+ * the SET of rows is not: `populate` re-runs `discoverPatterns` and finds
33
+ * examples the committed (frozen) patterns.db lacks. Measured 2026-08-26:
34
+ * 3588 pairs against a fresh DB, 3542 against the committed one — enough to
35
+ * move the clean rate (75.89% vs 75.97%) and fail the ratchet spuriously. So
36
+ * this gate needs a freshly populated DB, exactly like the foreign one, and
37
+ * its test carries the matching guard.
38
+ *
39
+ * STRICT ROLE SIGNATURES. Scoring uses `collectRoleSignatureStrict`, which
40
+ * ignores roles the matcher injected as schema defaults. Without that, a
41
+ * render that drops `to me` and a parser that puts `destination: me` back are
42
+ * indistinguishable, and a role-dropping render scores as faithful. Both sides
43
+ * are filtered, so a role implicit in the reference and in the candidate
44
+ * cancels out.
45
+ */
46
+ import { getAllPatterns } from '@hyperfixi/patterns-reference';
47
+ import { parseSemantic, render, type SemanticNode } from '@lokascript/semantic';
48
+ // Via the local shim, the path every other gate in this directory uses.
49
+ import { collectActions, collectRoleSignatureStrict } from './fidelity';
50
+
51
+ /**
52
+ * The 23 non-English corpus languages. Mirrors `FOREIGN_LANGUAGES` in
53
+ * foreign-canonical-validity.ts; kept as its own list because this gate can run
54
+ * over a language the corpus has no authored translations for.
55
+ */
56
+ export const RENDER_LANGUAGES = [
57
+ 'ar',
58
+ 'bn',
59
+ 'de',
60
+ 'es',
61
+ 'fr',
62
+ 'he',
63
+ 'hi',
64
+ 'id',
65
+ 'it',
66
+ 'ja',
67
+ 'ko',
68
+ 'ms',
69
+ 'pl',
70
+ 'pt',
71
+ 'qu',
72
+ 'ru',
73
+ 'sw',
74
+ 'th',
75
+ 'tl',
76
+ 'tr',
77
+ 'uk',
78
+ 'vi',
79
+ 'zh',
80
+ ] as const;
81
+
82
+ export interface RenderFidelityFailure {
83
+ readonly id: string;
84
+ readonly language: string;
85
+ readonly english: string;
86
+ readonly rendered: string;
87
+ /** Actions present in the English reference and absent after the round trip. */
88
+ readonly missingActions: readonly string[];
89
+ /** `action.role:valueType` entries present in the reference and absent after. */
90
+ readonly missingRoles: readonly string[];
91
+ /** Set when the rendered surface could not be parsed back at all. */
92
+ readonly unparseable?: boolean;
93
+ }
94
+
95
+ export interface RenderFidelityResult {
96
+ readonly checked: number;
97
+ readonly clean: number;
98
+ readonly failures: readonly RenderFidelityFailure[];
99
+ }
100
+
101
+ function safeParse(code: string, language: string): SemanticNode | null {
102
+ try {
103
+ return parseSemantic(code, language)?.node ?? null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Render every corpus pattern into every language and score the round trip.
111
+ *
112
+ * A pattern whose English source does not itself parse is skipped rather than
113
+ * counted as a failure — it has no reference to compare against, and that is a
114
+ * parser question this gate does not ask.
115
+ */
116
+ export async function checkRenderFidelity(opts?: {
117
+ languages?: readonly string[];
118
+ patterns?: ReadonlyArray<{ id: string; rawCode: string }>;
119
+ }): Promise<RenderFidelityResult> {
120
+ const languages = opts?.languages ?? RENDER_LANGUAGES;
121
+ const patterns = opts?.patterns ?? (await getAllPatterns({ limit: 1000 }));
122
+
123
+ const failures: RenderFidelityFailure[] = [];
124
+ let checked = 0;
125
+ let clean = 0;
126
+
127
+ for (const pattern of patterns) {
128
+ // A pattern whose English source does not parse has no reference to compare
129
+ // against, so it is skipped rather than counted as a failure — that is a
130
+ // parser question, asked by other gates, not this one. This is also what
131
+ // excludes the non-translatable HTML-markup rows: they are not hyperscript,
132
+ // so they never parse, and there is no rendering decision to score.
133
+ const reference = safeParse(pattern.rawCode, 'en');
134
+ if (!reference) continue;
135
+
136
+ const refActions = collectActions(reference);
137
+ const refRoles = collectRoleSignatureStrict(reference);
138
+
139
+ for (const language of languages) {
140
+ checked++;
141
+ let rendered = '';
142
+ try {
143
+ rendered = render(reference, language);
144
+ } catch {
145
+ failures.push({
146
+ id: pattern.id,
147
+ language,
148
+ english: pattern.rawCode,
149
+ rendered: '',
150
+ missingActions: refActions,
151
+ missingRoles: refRoles,
152
+ unparseable: true,
153
+ });
154
+ continue;
155
+ }
156
+
157
+ const roundTripped = safeParse(rendered, language);
158
+ if (!roundTripped) {
159
+ failures.push({
160
+ id: pattern.id,
161
+ language,
162
+ english: pattern.rawCode,
163
+ rendered,
164
+ missingActions: refActions,
165
+ missingRoles: refRoles,
166
+ unparseable: true,
167
+ });
168
+ continue;
169
+ }
170
+
171
+ const gotActions = new Set(collectActions(roundTripped));
172
+ const gotRoles = new Set(collectRoleSignatureStrict(roundTripped));
173
+ const missingActions = refActions.filter(a => !gotActions.has(a));
174
+ const missingRoles = refRoles.filter(r => !gotRoles.has(r));
175
+
176
+ if (missingActions.length === 0 && missingRoles.length === 0) {
177
+ clean++;
178
+ } else {
179
+ failures.push({
180
+ id: pattern.id,
181
+ language,
182
+ english: pattern.rawCode,
183
+ rendered,
184
+ missingActions,
185
+ missingRoles,
186
+ });
187
+ }
188
+ }
189
+ }
190
+
191
+ return { checked, clean, failures };
192
+ }
193
+
194
+ /** Group failures as `{ patternId: [language, …] }` — the committed allowlist shape. */
195
+ export function groupFailuresByPattern(
196
+ failures: readonly RenderFidelityFailure[]
197
+ ): Record<string, string[]> {
198
+ const grouped: Record<string, string[]> = {};
199
+ for (const failure of failures) {
200
+ (grouped[failure.id] ??= []).push(failure.language);
201
+ }
202
+ for (const languages of Object.values(grouped)) languages.sort();
203
+ return Object.fromEntries(Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)));
204
+ }
@@ -29,6 +29,7 @@
29
29
 
30
30
  import fs from 'node:fs';
31
31
  import path from 'node:path';
32
+ import { execFileSync } from 'node:child_process';
32
33
  import { createHash } from 'node:crypto';
33
34
  import { fileURLToPath } from 'node:url';
34
35
  import { extractHyperscriptFromMarkup } from '@hyperfixi/patterns-reference';
@@ -95,7 +96,40 @@ function keyFor(file: string, source: string): string {
95
96
  return `${file}::${createHash('sha1').update(source).digest('hex').slice(0, 10)}`;
96
97
  }
97
98
 
98
- function walk(dir: string, acc: string[] = []): string[] {
99
+ /**
100
+ * The files git actually tracks under `repoRoot`, as absolute paths.
101
+ *
102
+ * This gate walks the working TREE, and a working tree is not a clean checkout:
103
+ * `examples/vite-plugin-multilingual/` is GITIGNORED, so a local run saw a
104
+ * source CI could never see. That is unfixable from the allowlist — with an
105
+ * entry for it the gate failed in CI as a STALE entry, and without one it
106
+ * failed locally as a NEW finding. No allowlist state satisfies both, so the
107
+ * DENOMINATOR is what has to agree.
108
+ *
109
+ * The sibling `shipped-examples-execution` gate already derives its corpus this
110
+ * way, and its comment records that the lesson cost **#862** — it "failed on
111
+ * every clean checkout the first time CI ran it" for exactly this reason. This
112
+ * gate was simply never brought into line; it cost another CI round-trip on
113
+ * 2026-08-31 before anyone noticed the two disagreed.
114
+ *
115
+ * git being unavailable THROWS rather than falling back to the full tree, which
116
+ * is the sibling's convention and for its stated reason: a silent fallback
117
+ * would resurrect the drift this exists to kill.
118
+ */
119
+ function trackedFiles(repoRoot: string): Set<string> {
120
+ const out = execFileSync('git', ['-C', repoRoot, 'ls-files', '-z'], {
121
+ encoding: 'utf8',
122
+ maxBuffer: 64 * 1024 * 1024,
123
+ });
124
+ return new Set(
125
+ out
126
+ .split('\0')
127
+ .filter(Boolean)
128
+ .map(rel => path.join(repoRoot, rel))
129
+ );
130
+ }
131
+
132
+ function walk(dir: string, acc: string[] = [], tracked?: Set<string>): string[] {
99
133
  let entries: fs.Dirent[];
100
134
  try {
101
135
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -107,8 +141,12 @@ function walk(dir: string, acc: string[] = []): string[] {
107
141
  continue;
108
142
  }
109
143
  const full = path.join(dir, entry.name);
110
- if (entry.isDirectory()) walk(full, acc);
111
- else if (/\.(html|md)$/.test(entry.name)) acc.push(full);
144
+ if (entry.isDirectory()) walk(full, acc, tracked);
145
+ else if (/\.(html|md)$/.test(entry.name)) {
146
+ // Skip anything git does not track, so local and CI score the same set.
147
+ if (tracked && !tracked.has(full)) continue;
148
+ acc.push(full);
149
+ }
112
150
  }
113
151
  return acc;
114
152
  }
@@ -135,8 +173,9 @@ export function collectShippedSources(
135
173
  const roots = opts?.roots ?? DEFAULT_ROOTS;
136
174
  const out: ShippedSource[] = [];
137
175
 
176
+ const tracked = trackedFiles(repoRoot);
138
177
  for (const root of roots) {
139
- for (const full of walk(path.join(repoRoot, root))) {
178
+ for (const full of walk(path.join(repoRoot, root), [], tracked)) {
140
179
  const rel = path.relative(repoRoot, full);
141
180
  if (EXCLUDED.some(e => e.match(rel))) continue;
142
181
  const text = fs.readFileSync(full, 'utf8');
@@ -22,8 +22,7 @@
22
22
  */
23
23
 
24
24
  import { describe, expect, it } from 'vitest';
25
- import { parseSemantic } from '@lokascript/semantic';
26
- import { GrammarTransformer } from '@lokascript/i18n';
25
+ import { parseSemantic, translate } from '@lokascript/semantic';
27
26
 
28
27
  /** Verbatim (action, role, value) triples from a parse tree. */
29
28
  function triples(node: unknown, acc: string[] = [], depth = 0): string[] {
@@ -51,12 +50,40 @@ function triples(node: unknown, acc: string[] = [], depth = 0): string[] {
51
50
  return acc;
52
51
  }
53
52
 
53
+ /**
54
+ * Renders with @lokascript/semantic, not @lokascript/i18n's `GrammarTransformer`
55
+ * (retired 2026-08-28). The assertions are unchanged and still pass: what they
56
+ * pin is the VOCABULARY, and `lexicon-parity.test.ts` gates semantic's lexicons
57
+ * against i18n's dictionaries, so the two renderers agree on exactly these words
58
+ * — verified on this file's cases before the swap (de `markieren`, ar `ظلل`,
59
+ * bn `#row কে ক্লোন`, id `tutupkan #modal`, vi `thêm vào đầu "x" vào #list`:
60
+ * byte-identical from both).
61
+ */
54
62
  function renderAndParse(en: string, lang: string): { render: string; triples: string[] } {
55
- const render = new GrammarTransformer('en', lang).transform(en);
63
+ const render = translate(en, 'en', lang);
56
64
  const result = parseSemantic(render, lang);
57
65
  return { render, triples: result.node ? triples(result.node) : [] };
58
66
  }
59
67
 
68
+ /**
69
+ * The three EVENT-NAME classes that used to live here — reset, submit and
70
+ * qu change — are gone with the renderer that made them observable.
71
+ *
72
+ * They asserted that i18n's DICTIONARY event word appears in the rendered
73
+ * surface (it `reimpostare`, ko `재설정`, pl `zresetuj`, ru `сбросить`,
74
+ * qu `musuqchay`, …). @lokascript/semantic deliberately does NOT localize an
75
+ * event name: `localizeEventName` keeps a curated denylist of events that must
76
+ * stay English to round-trip, so it renders `on reset`, `on submit`, `on change`
77
+ * verbatim. Migrated as-is, those tests would have asserted only that an English
78
+ * event name comes back as itself — true, and about nothing.
79
+ *
80
+ * The dictionary words themselves still ship (they feed the keyword providers on
81
+ * the PARSE side) and are still gated, by the V1-V4 vocab consistency check
82
+ * (`testing-framework/src/vocab/cli.ts validate`) and `lexicon-parity.test.ts`.
83
+ * What is no longer covered is their appearance in a RENDER, because nothing
84
+ * renders from them any more.
85
+ */
86
+
60
87
  describe('Batch 3 — select class (dict word was the pick keyword)', () => {
61
88
  it('de: `select #note` renders markieren and parses back as select', () => {
62
89
  const { render, triples: t } = renderAndParse('select #note', 'de');
@@ -97,51 +124,6 @@ describe('Batch 3 — wrong-verb class (dict word was another command)', () => {
97
124
  });
98
125
  });
99
126
 
100
- describe('Batch 3 — reset class (broken/wrong-event listener)', () => {
101
- it.each([
102
- ['it', 'reimpostare'],
103
- ['ko', '재설정'],
104
- ['pl', 'zresetuj'],
105
- ['pt', 'redefinir'],
106
- ['ru', 'сбросить'],
107
- ['uk', 'скинути'],
108
- ['qu', 'musuqchay'],
109
- ])('%s: on-reset render captures on.event="reset" (canonical)', (langCode, word) => {
110
- const { render, triples: t } = renderAndParse('on reset log "done"', langCode);
111
- expect(render).toContain(word);
112
- expect(t).toContain('on.event=reset');
113
- });
114
- });
115
-
116
- describe('Batch 3 — submit class (dict word was the send verb)', () => {
117
- it.each([
118
- ['es', 'envío'],
119
- ['pl', 'wysłaniu'],
120
- ['tr', 'gönderme'],
121
- ['vi', 'nộp'],
122
- ['qu', 'apaykachay'],
123
- ])(
124
- '%s: corpus-shaped on-submit render captures on.event="submit", not "send"',
125
- (langCode, word) => {
126
- const { render, triples: t } = renderAndParse(
127
- 'on submit add @disabled to <button/> in me put "Submitting..." into <button/> in me',
128
- langCode
129
- );
130
- expect(render).toContain(word);
131
- expect(t).toContain('on.event=submit');
132
- expect(t).not.toContain('on.event=send');
133
- }
134
- );
135
- });
136
-
137
- describe('Batch 3 — qu change (dict word was the toggle verb)', () => {
138
- it('qu: on-change render captures on.event="change", not "toggle"', () => {
139
- const { render, triples: t } = renderAndParse('on change log "x"', 'qu');
140
- expect(render).toContain('kambiay');
141
- expect(t).toContain('on.event=change');
142
- });
143
- });
144
-
145
127
  describe('Batch 3 — it blur (noun form dropped the command patient)', () => {
146
128
  it('it: blur command render captures blur.patient', () => {
147
129
  const { render, triples: t } = renderAndParse('on keydown[key=="Escape"] blur me', 'it');