@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 +301 -1
- package/package.json +8 -8
- package/src/multilingual/bare-render-fidelity.test.ts +100 -0
- package/src/multilingual/bare-render-fidelity.ts +105 -0
- package/src/multilingual/cli.ts +9 -2
- package/src/multilingual/fidelity.ts +1 -0
- package/src/multilingual/render-fidelity.test.ts +126 -0
- package/src/multilingual/render-fidelity.ts +204 -0
- package/src/multilingual/shipped-sources-validity.ts +43 -4
- package/src/vocab/batch3-roundtrip.test.ts +30 -48
- package/src/vocab/cli.ts +9 -2
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/
|
|
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": "
|
|
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": "^
|
|
61
|
-
"@hyperfixi/patterns-reference": "^
|
|
62
|
-
"@lokascript/compilation-service": "^
|
|
63
|
-
"@lokascript/i18n": "^
|
|
64
|
-
"@lokascript/semantic": "^
|
|
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": "^
|
|
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
|
+
}
|
package/src/multilingual/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
66
|
+
if (!marker || hasNewerTs(srcDir, fs.statSync(marker).mtimeMs)) {
|
|
60
67
|
stale.push(name);
|
|
61
68
|
}
|
|
62
69
|
}
|
|
@@ -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
|
-
|
|
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))
|
|
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 =
|
|
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
|
-
|
|
47
|
-
|
|
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
|
}
|