@hyperfixi/testing-framework 2.11.1 → 3.1.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,305 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.0] - 2026-09-04
11
+
12
+ ### Added
13
+
14
+ - **`hyperscript.use(frontEnd)`** — register the multilingual front-end
15
+ `compile()` consults for a non-English program (and `toLSE`/`fromLSE` for
16
+ a semantic node and a renderer). The contract is `FrontEnd` in
17
+ `@hyperfixi/core` (`parseToAST`, optional `parse`/`render`);
18
+ `@lokascript/semantic` satisfies it through
19
+ `createBridgeFrontEnd(new SemanticGrammarBridge())` from
20
+ `@hyperfixi/core/multilingual`. The full browser bundle registers it at
21
+ boot. The library entry registers nothing and, through 3.x, builds the same
22
+ bridge lazily on the first non-English compile — so nothing changes for a
23
+ consumer with `@lokascript/semantic` installed. **4.0 will remove that
24
+ default:** a non-English `compile()` with no front-end registered will
25
+ return the same result the core-parser-only path returns today, and
26
+ `@lokascript/semantic` becomes an optional peer. Engine-migration plan
27
+ Arc 1, steps 2 and 3.
28
+
29
+ ### Fixed
30
+
31
+ - **Language server: valid non-English code no longer gets a `parse-error`.**
32
+ In `lokascript`/`hyperscript-i18n` mode the server ran core's English
33
+ parser on every region regardless of what the semantic front-end said, so
34
+ `al hacer clic alternar .activo` (accepted at 0.89 confidence) was published
35
+ as `Unexpected token: hacer`. Core's errors are now suppressed for a region
36
+ the front-end accepted in a non-English language. The integration suite
37
+ that claimed to cover this had been discarding every notification; it now
38
+ records `publishDiagnostics` and asserts on it.
39
+
40
+ - **Language server: settings actually reach the server.** Neither VS Code
41
+ extension set `synchronize.configurationSection`, so the client pushed
42
+ `settings: null` and every `mode`/`language`/`maxDiagnostics` value was
43
+ ignored; `initializationOptions` was ignored too. The server now reads
44
+ `initializationOptions`, a pushed `lokascript`/`hyperscript` section, or
45
+ pulls `workspace/configuration` when the push is empty — and merges partial
46
+ objects over the defaults instead of replacing them (a bare
47
+ `{ maxDiagnostics }` used to leave `language` undefined and error on every
48
+ document). Both extensions push their section, and the LokaScript one now
49
+ contributes `lokascript.mode`.
50
+
51
+ - **Both VS Code extension server bundles build and run.** The standalone
52
+ hyperscript extension's `bundle:server` had failed since #1016 (no shim for
53
+ `@hyperfixi/core/multilingual`); the LokaScript extension externalized
54
+ `@hyperfixi/core` without shipping it, so every install ran with core
55
+ absent ("diagnostics and hover degraded"). Core is bundled in now, and CI
56
+ runs both bundle scripts.
57
+
58
+ - **`@lokascript/language-server` declares what it imports.**
59
+ `@lokascript/semantic` was an optional peer but a static import (the
60
+ server crashed at startup without it); `@lokascript/framework` was
61
+ undeclared and got inlined together with `@lokascript/intent` (261 KB) —
62
+ and without `--clean`, `dist/` shipped every previous build's chunk (three
63
+ dead ones, 63% of the tarball). Both are regular dependencies now, the
64
+ build cleans, the 20-byte `--dts` output is gone, and `npm test` rebuilds
65
+ the server it spawns.
66
+
67
+ - **Hyperscript-compat mode stops flagging portable code.** `as Int`, `Float`,
68
+ `JSON`, `Date`, `Set`, `Map` are upstream conversions (the extension list
69
+ is now `Math`/`Values`, the ones core registers and upstream lacks);
70
+ `my.x`/`your.x` evaluate upstream (only `its.` is flagged); and the
71
+ extension-command scan no longer fires inside `y.replace(`, `arr.push(`,
72
+ string literals, `:process`, `--` comments, or the upstream trailing
73
+ `… unless …` modifier.
74
+
75
+ - **Language-server false positives and misses.** A `.hs` file containing a
76
+ query literal like `<li/>` was classified as HTML and lost every feature;
77
+ possessives after `)`/`]`/`>` and apostrophes in `--` comments raised
78
+ `unmatched-quote`; the outline reported the `on` in `toggle .x on me` as a
79
+ handler and `.init` as an init block; `install Foo(args)` produced
80
+ overlapping rename edits VS Code rejects; `on draggable:start` became a
81
+ variable named `:start`; and keyword boundaries were ASCII `\b`, so
82
+ definitions and symbols were silently absent in the ten non-Latin
83
+ languages. Completion-context and document-symbol logic moved out of the
84
+ entry script into `completion-context.ts` / `document-symbols.ts` so the
85
+ unit suite tests the shipped code rather than the hand-copied twins it
86
+ used to carry.
87
+
88
+ - **MCP `get_diagnostics`, `get_completions` and `get_document_symbols` read
89
+ the AST.** The bridge guarded those paths on helper names core never
90
+ exported, so all three had only ever taken their token-based fallback.
91
+ They now read the interchange: the core parser's own errors become
92
+ diagnostics, completions inside a command offer that command's argument
93
+ shapes, symbols carry their commands as children, and a document with
94
+ several `end`-terminated handlers keeps all of them.
95
+
96
+ - **`hyperscript.process()` honours the event grammar.** The API-side DOM
97
+ processor carried its own listener installer for `on …` attributes and
98
+ silently dropped what the runtime's installer handles: `on
99
+ click[event.shiftKey]` fired on a plain click, `on mouseenter or click` never
100
+ fired on mouseenter, `on click from document` never fired. Every browser
101
+ bundle already went through the runtime; `process()` now does too, and
102
+ `config.logAll` moved into the runtime so both paths log. Pinned by
103
+ `api/dom-processor.test.ts`, which runs each case on both paths.
104
+
105
+ - **The browser bundles' attribute processor now runs the element lifecycle,
106
+ and its lazy stub only takes the shapes it can run faithfully.** Measured
107
+ side by side with `hyperscript.process()`: the bundle path never dispatched
108
+ `hyperscript:before:init` / `hyperscript:after:init` and never set
109
+ `data-hyperscript-powered` — both lived only in the API processor, so a
110
+ morph engine reading the marker saw nothing on a bundle-processed page, and
111
+ canceling `before:init` was impossible there. It also compiled every
112
+ attribute as English; it now detects the element's language (`data-lang`,
113
+ closest `lang`, the document's) the way `process()` always did. And the
114
+ `lazyParsing` stub read only the event NAME from the header, so for the
115
+ first event it ignored a filter (`on click[event.shiftKey]` fired on a
116
+ plain click), listened for the first name of an `or` list only, and never
117
+ saw `from <target>` at all; a header with a filter, `(args)`, `or`, `from`,
118
+ `elsewhere`, `queue`, `debounced`, `throttled` or `in` is now processed
119
+ eagerly. `dom/processor-parity.test.ts` runs every row on all three paths
120
+ (API, eager, lazy) and is the gate the DOM-processor collapse landed under.
121
+
122
+ - **`hyperscript.process()` and the browser bundles share one DOM
123
+ processor.** `process()` and `cleanup()` now run on the attribute
124
+ processor every bundle uses, which receives its compiler and runtime from
125
+ the API by injection instead of importing it (the `dom -> api` layering
126
+ edge is gone). What changes for `process()` callers, all of it upstream
127
+ parity: an element is initialized once however many times it is passed or
128
+ scanned (it used to re-install on every call); `<script
129
+ type="text/hyperscript">` tags inside the tree are processed, with or
130
+ without `for=`, before the elements that may `install` what they define;
131
+ each element gets a non-bubbling `load` event once its program has run,
132
+ and an unparseable attribute is reported through `console.error`, the
133
+ `hyperfixi:compile-error` event and `config.onCompileError` (it was logged
134
+ at debug level only). And `cleanup(container)` now un-marks every processed
135
+ descendant — it stripped the root's `data-hyperscript-powered` only — and
136
+ drops a lazy stub still waiting on an element, so cleanup-then-process
137
+ re-initializes the tree once. A second scan in `lazyParsing` mode no longer
138
+ registers a second stub.
139
+ The API-side processor module is deleted; the multilingual bundle's own
140
+ copy of the language walk now calls the shared one, narrowed to the
141
+ languages that bundle ships; and a subtree the `MutationObserver` picks up
142
+ takes the same entry as `process()`.
143
+
144
+ - **`set *<css-property>` writes inline style in every spelling upstream
145
+ accepts.** `set *opacity to 0.5`, `set *opacity of me to 0.5` and
146
+ `set *background-color of me to "red"` were silent no-ops and
147
+ `set the *opacity of me to 0.5` threw; only the possessive `set my *opacity`
148
+ worked. The tokenizer classes `*opacity` as a selector (for `measure`), and
149
+ `set`'s parser now re-types it into the identifier property the runtime's
150
+ style rungs already handle, so one path serves all of them — bare, `of
151
+ <target>`, `the … of <target>`, `<target>'s`. Ten rows pinned against
152
+ `_hyperscript` 0.9.93.
153
+
154
+ - **`require('@hyperfixi/core')` returned `{}`, and the subpath `require`s
155
+ threw — since `"type": "module"`, on 3.0.0 too.** Every `exports.*.require`
156
+ and `main` pointed at a `.js` file built as CommonJS, which Node reads as
157
+ ESM under that flag. The CJS outputs are `.cjs` now (main entry, the 21
158
+ subpaths, the parser modules); the `import` conditions are unchanged. The
159
+ bare-Node check in CI now `require()`s the entries as well as importing
160
+ them, which is the check that would have caught this. Found by the
161
+ end-to-end probe that landed Arc 1's build half.
162
+
163
+ ### Changed
164
+
165
+ - **`@hyperfixi/core`'s library entry no longer bundles the multilingual
166
+ front-end.** `dist/index.mjs`/`index.js` had `@lokascript/semantic`,
167
+ `@lokascript/intent` and `@lokascript/framework` inlined whole (3.33 MB, zero
168
+ dynamic imports left); they are external now (1.04 MB) and load on the first
169
+ non-English `compileAsync`, the way the source always said they would. No
170
+ API change: semantic and intent stay `dependencies`, framework stays an
171
+ optional peer — and is now genuinely one. A consumer that imported both core
172
+ and semantic no longer holds two copies of semantic. `dist/index.min.js`
173
+ (UMD, not in `exports`) stays self-contained. Gated by
174
+ `scripts/check-node-import.mjs` on the sourcemap. Engine-migration plan Arc 1
175
+ step 2, build half.
176
+
177
+ - **`@lokascript/semantic` drops its `asyncSchema`.** Core deleted the `async`
178
+ command in 3.0.0 (#1102); the semantic front-end never emitted the action
179
+ anyway — its `stripAsyncModifier` pass removes the keyword (in all 24
180
+ languages) and parses the following command, so `async fetch /api as json`
181
+ is a `fetch`. The schema, its registration, the `'async'` `ActionType`
182
+ member and the AST-builder mapper row are gone. Kept on purpose: every
183
+ profile's `keywords.async` (the stripper matches on it) and the
184
+ `'then' | 'and' | 'async'` chain type (a different concept). The IR-level
185
+ `async all/race` node lives in `@lokascript/intent` and is untouched.
186
+
187
+ ## [3.0.0] - 2026-09-03
188
+
189
+ Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v3.0.0).
190
+
191
+ One major, two themes. `@lokascript/i18n`'s grammar transformer is gone,
192
+ because `@lokascript/semantic` overtook it on every row of the translation
193
+ corpus (#973–#1001). And the engine-migration plan
194
+ (`docs-internal/ENGINE_MIGRATION_PLAN.md`) closed its last arc, which deleted
195
+ the exported dead code it had been carrying and collapsed the browser bundle
196
+ lineup to two names (#1099–#1105). One arc is still open: Arc 1's steps 2
197
+ and 3 — `@hyperfixi/core`'s library entry still bundles the semantic
198
+ front-end (recorded in the plan's History, 2026-09-03).
199
+
200
+ ### ⚠ BREAKING
201
+
202
+ - **`@lokascript/i18n` no longer translates** (#1001). `translate()`, `toLocale()`,
203
+ `toEnglish()`, `GrammarTransformer` and `createTransformer` are deleted; the
204
+ package is per-language vocabulary and grammar profiles. Migrate to
205
+ `import { translate } from '@lokascript/semantic'` (same signature; it throws
206
+ on unparseable input where the transformer returned nonsense). The classic-i18n
207
+ browser bundle, `@hyperscript-tools/i18n` and `@lokascript/types-browser` drop
208
+ the same members (#998, #999); the patterns corpus writer is semantic-only (#1000).
209
+ - **The prebuilt browser bundles are two names** (#1105): `hyperfixi-hx.js`
210
+ (small: hybrid parser, blocks, expressions, htmx v1/v2 attributes) and
211
+ `hyperfixi.js` (everything); `hyperfixi-hx-v4.js` and
212
+ `hyperfixi-multilingual.js` stay as separate products. `hyperfixi-lite.js`,
213
+ `-lite-plus.js`, `-minimal.js`, `-standard.js` and their `./browser/*`
214
+ exports are gone — use `hyperfixi-hx.js`, `hyperfixi.js`, or the Vite
215
+ plugin, which picks the tier itself. `./browser/hybrid-complete` remains as
216
+ the plugin's internal fallback.
217
+ - **Exported dead code deleted from `@hyperfixi/core`** — none had a
218
+ production caller: the six `@deprecated` `features/` families and the
219
+ modular bundle's `features` namespace (#1099); `Lexer`/`Tokens` (#1100 —
220
+ use `tokenize`); the `unified-types` `Validator`, the `types.d.ts` shim and
221
+ `registry/multilingual` (#1101); the `async` command (#1102 — it was
222
+ unreachable from parsed hyperscript; the manifest is 58 commands);
223
+ `ContextProviderRegistry`, the context-provider `Proxy`, the registry's
224
+ `context` slot and the plugin `contextProviders` field (#1104 — set
225
+ request-scoped values as `context.locals`).
226
+
227
+ ### Fixed
228
+
229
+ - **A small bundle fails loudly on a command it lacks and names `hyperfixi.js`**
230
+ (#1103). The hybrid parser had been dropping an unrecognised word silently;
231
+ making it loud also exposed and fixed three silent mis-parses in the hybrid
232
+ bundles: unquoted `fetch /api/data` fetched `/`, `send custom:event` sent
233
+ `custom` to `me`, and `repeat forever` ran zero iterations.
234
+ - **English→foreign rendering is gated** (#931–#972, #953): 3105/3105 corpus
235
+ renders parse on the canonical engine; the bare (handler-less) surface has its
236
+ own gate. `as JSON` stays part of the value (#991); a flattened loop header
237
+ closes (#992).
238
+ - Plugins report their real versions (#926); `analyze_content` is unshadowed in
239
+ the MCP server (#927); four dependency advisories resolved; the `domain-flow`
240
+ → `server-bridge` route contract is pinned (#1000s).
241
+
242
+ ### Changed
243
+
244
+ - `hyperfixi.js` and `hyperfixi-hx-v4.js` carry the 24-language render
245
+ vocabulary (~19 KB gzip each) so `translate` works in the browser (#931).
246
+ - Deprecated `@lokascript/domain-*` references repoint to `@lokascript/domains`.
247
+
248
+ ## [2.11.1] - 2026-08-25
249
+
250
+ Patch release. Fixes the release workflow itself: the build now runs **after**
251
+ the version sync, so a published tarball carries its own version rather than
252
+ the previous one, and `version.ts` is committed (#925). Also lands the
253
+ agent-loop A/B benchmark with an isolated generator (#924).
254
+
255
+ ## [2.11.0] - 2026-08-24
256
+
257
+ Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v2.11.0).
258
+
259
+ ### Changed
260
+
261
+ - **The domain-DSL family moved out** (#909). Twelve packages left this
262
+ monorepo: the ten publishable `@lokascript/domain-*` packages (bdd,
263
+ behaviorspec, config, flow, jsx, learn, llm, sql, todo, voice) plus the
264
+ private `domain-toolkit` and `mcp-multilingual-intent`. They now ship as
265
+ **`@lokascript/domains`**, one subpath export per domain, from the
266
+ [lokascript-domains](https://github.com/codetalcott/lokascript-domains)
267
+ repository.
268
+
269
+ All ten published names are **deprecated on npm**, each naming its
270
+ replacement subpath (`@lokascript/domain-sql` → "import from
271
+ `@lokascript/domains/sql`"). Existing installs keep working — deprecation is
272
+ a warning, not a removal — they simply stop receiving updates. The
273
+ pre-deletion tree is preserved at the `moved/domain-family` tag.
274
+
275
+ _Migration:_ replace nine dependencies with one, and change
276
+ `from '@lokascript/domain-x'` to `from '@lokascript/domains/x'`. The root
277
+ entry absorbed `domain-config` and exports `createDomainRegistry`,
278
+ `registerAllDomains` and `DOMAIN_PRIORITY`.
279
+
280
+ ### Added
281
+
282
+ - **"Show in My Language"** — LSP request plus VSCode command (#921).
283
+ - **Verified-translation badge** in the compilation service (#920).
284
+ - **`scoreFidelity` / `@lokascript/semantic/fidelity`** (#919).
285
+ - **Inert-shape warnings** (#918) and surfaced unconsumed-input warnings (#916)
286
+ in the compilation service.
287
+ - **The agent-loop benchmark** and the silent-failure finding it produced
288
+ (#915), and the MCP server's agent-era roadmap (#914).
289
+ - **Element-collection write-back** for hypermedia tables, with gallery
290
+ examples and four system fixes (#904).
291
+ - **The comprehensive Playwright tier now runs in CI** (#908) alongside
292
+ `quick`. It had never run: 122 specs, hiding four real bugs — a detached
293
+ `startViewTransition`, untracked reads of unset globals, concurrent effects
294
+ clobbering dependency capture, and `put` stringifying DocumentFragments
295
+ (#905).
296
+
297
+ ### Fixed
298
+
299
+ - **The `hyperscript-adapter` review arc** (#895–#902): whole-string
300
+ translation first, taking canonical validity from 2849/3105 to **3105/3105**
301
+ (#899); a host-parser validity gate so an invalid render falls back to the
302
+ author's text (#900); the dead split-statement fallback deleted after
303
+ measuring 0 outputs (#901); and the slim divergence set burned 6 → 1 via
304
+ schema marker data, repairing 39 body-dropping corpus rows (#902).
305
+ - **`qu take.recipient`** — the R1 tail's last `take` row (#910).
306
+ - **The release workflows** no longer reference the moved-out domain family
307
+ (#922).
308
+
10
309
  ## [2.10.0] - 2026-08-01
11
310
 
12
311
  Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v2.10.0).
@@ -394,7 +693,8 @@ _Synchronized version release. See git history for details._
394
693
  - npm access token stored in GitHub Secrets
395
694
  - 2FA recommended for npm organization
396
695
 
397
- [Unreleased]: https://github.com/codetalcott/hyperfixi/compare/v2.10.0...HEAD
696
+ [Unreleased]: https://github.com/codetalcott/hyperfixi/compare/v3.1.0...HEAD
697
+ [3.1.0]: https://github.com/codetalcott/hyperfixi/compare/v3.0.0...v3.1.0
398
698
  [2.10.0]: https://github.com/codetalcott/hyperfixi/compare/v2.9.0...v2.10.0
399
699
  [2.9.0]: https://github.com/codetalcott/hyperfixi/compare/v2.8.0...v2.9.0
400
700
  [2.8.0]: https://github.com/codetalcott/hyperfixi/compare/v2.5.1...v2.8.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.1.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.1.0",
61
+ "@hyperfixi/patterns-reference": "^3.1.0",
62
+ "@lokascript/compilation-service": "^3.1.0",
63
+ "@lokascript/i18n": "^3.1.0",
64
+ "@lokascript/semantic": "^3.1.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
+ }
@@ -54,9 +54,16 @@ function findStaleDists(): string[] {
54
54
  for (const name of DIST_GUARD_PACKAGES) {
55
55
  const pkg = path.join(packagesRoot, name);
56
56
  const srcDir = path.join(pkg, 'src');
57
- const marker = path.join(pkg, 'dist', 'index.js');
57
+ // Whichever entry the build emits: `.js` for most packages, `.mjs` for core
58
+ // (its CJS twin is `.cjs` — see scripts/ensure-fresh.sh).
59
+ const marker = ['index.js', 'index.mjs', 'index.cjs']
60
+ .map(f => path.join(pkg, 'dist', f))
61
+ .filter(f => fs.existsSync(f))
62
+ // Newest wins — a stale `index.js` left beside a fresh `index.mjs` by a
63
+ // pre-`.cjs`-rename build must not become the marker.
64
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
58
65
  if (!fs.existsSync(srcDir)) continue;
59
- if (!fs.existsSync(marker) || hasNewerTs(srcDir, fs.statSync(marker).mtimeMs)) {
66
+ if (!marker || hasNewerTs(srcDir, fs.statSync(marker).mtimeMs)) {
60
67
  stale.push(name);
61
68
  }
62
69
  }
@@ -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');
package/src/vocab/cli.ts CHANGED
@@ -43,8 +43,15 @@ function findStaleDists(): string[] {
43
43
  const stale: string[] = [];
44
44
  for (const name of DIST_GUARD_PACKAGES) {
45
45
  const srcDir = path.join(packagesRoot, name, 'src');
46
- const marker = path.join(packagesRoot, name, 'dist', 'index.js');
47
- if (!fs.existsSync(marker) || hasNewerTs(srcDir, fs.statSync(marker).mtimeMs)) {
46
+ // Whichever entry the build emits (`.js` for most, `.mjs` for core — its CJS
47
+ // twin is `.cjs`; see scripts/ensure-fresh.sh).
48
+ const marker = ['index.js', 'index.mjs', 'index.cjs']
49
+ .map(f => path.join(packagesRoot, name, 'dist', f))
50
+ .filter(f => fs.existsSync(f))
51
+ // Newest wins — a stale `index.js` left beside a fresh `index.mjs` by a
52
+ // pre-`.cjs`-rename build must not become the marker.
53
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
54
+ if (!marker || hasNewerTs(srcDir, fs.statSync(marker).mtimeMs)) {
48
55
  stale.push(name);
49
56
  }
50
57
  }