@hyperfixi/testing-framework 4.2.0 → 4.4.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 +126 -1
- package/package.json +8 -8
- package/src/multilingual/README.md +32 -0
- package/src/multilingual/command-shapes-gate.ts +32 -0
- package/src/multilingual/command-shapes.ts +130 -43
- package/src/multilingual/english-leaks.corpus.test.ts +74 -0
- package/src/multilingual/english-leaks.test.ts +351 -0
- package/src/multilingual/english-leaks.ts +835 -0
- package/src/multilingual/value-matrix.branch-phrases.test.ts +13 -0
- package/src/multilingual/value-matrix.branch.test.ts +13 -0
- package/src/multilingual/value-matrix.ts +56 -10
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.4.0] - 2026-10-10
|
|
11
|
+
|
|
12
|
+
Translations now read as their language. In 4.3.0, 19% of the corpus's translations and 24% of the
|
|
13
|
+
command-shape scripts' translations kept at least one English word, and a third of the corpus kept an
|
|
14
|
+
English event name. Counting only the English a recorded decision does not keep (type names, `url`,
|
|
15
|
+
`debounced at`, …), every one of the 23 languages is now at or under 5% of corpus renders (0.7–4.6%)
|
|
16
|
+
and 10% of command-shape renders (3.9–6.2%), and no translation writes an English event name its
|
|
17
|
+
language has a word for. The words are the owner's vocabulary sheet
|
|
18
|
+
(`docs-internal/multilingual/VOCABULARY_SHEET.md`), decided 2026-10-08.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`@hyperfixi/testing-framework`: the English left in a translation is gated.** `english-leaks.ts`
|
|
23
|
+
counts, per render, the English grammar words (the tokens the engine's own parser matched in the
|
|
24
|
+
source), English event names the language's lexicon has a word for, and a nominative `me` beside a
|
|
25
|
+
marker in the 13 languages where that case is wrong. Two shrink-only baselines (the corpus, in
|
|
26
|
+
`test:canonical`, and the command-shape cases, in their shards); `tools/regen-english-leaks-baseline.ts
|
|
27
|
+
--report` prints each language against M2's targets. An English alternative a profile keeps for
|
|
28
|
+
code-switched input, beside a primary that is the language's own, counts as English.
|
|
29
|
+
- **`@lokascript/semantic`: `transition … from … to`** reads and writes in every language (a `source`
|
|
30
|
+
role), and the bare command reads in English. The schema's `destination` role is gone: upstream has
|
|
31
|
+
none, and SOV destination markers read an owned property's owner as an element (tr and qu swapped
|
|
32
|
+
`#foo's *width` silently). English writes `using` for an easing.
|
|
33
|
+
- **`@lokascript/semantic`: `add/remove/hide/show … when <condition>`** in every language, through a
|
|
34
|
+
`condition` role marked with each profile's `when`. It was English in every translation, and refused
|
|
35
|
+
as a bare command.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **`@lokascript/semantic` writes each language's own words** for: loop heads (`repeat for x in`,
|
|
40
|
+
`while`, `until`; es `repetir para x en .items`), `I match` / `I am` (written as the third person, and
|
|
41
|
+
back in English as upstream's first person), `does not match`, `fetch … do not throw`, `on first`,
|
|
42
|
+
`go back`, `from elsewhere`, a loop's `index`, `start view transition`, a query's `in`, a conversion's
|
|
43
|
+
`as` (the type name stays English), `the X of Y` inside an expression (es `valor de #price`, ja
|
|
44
|
+
`#priceの値`), `catch` and `finally`, `element x`, and `def` (as the noun "function", es `función
|
|
45
|
+
greet(name)`). bn, th and vi have their own `document` and `window`; he has its own words for all of
|
|
46
|
+
these and for `matches`, `exists`, `or`, `not`, `first`, `next`, `previous`, `closest`, `empty`, `is`,
|
|
47
|
+
`document` and `window`; and the 15 languages that wrote English `forever` have their own (es
|
|
48
|
+
`repetir siempre`, de `endlos`, ja `ずっと`). Every reader still takes the English words. Where a word
|
|
49
|
+
would read back as another (es `en` is also a marker), the translation keeps English's.
|
|
50
|
+
- **`@lokascript/semantic` writes native event names** in he, hi, it, pl, qu, ru, th, uk and vi where
|
|
51
|
+
the lexicon's word reads back; the rest are denylisted with their reasons.
|
|
52
|
+
- **`@lokascript/semantic`: a foreign `put X at start of Y` is written with the language's `prepend`**,
|
|
53
|
+
which English writes back as `put … at start of`.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **`@lokascript/semantic` reads `me` after a marker in the case the marker takes**, as authors write it:
|
|
58
|
+
de `zu mir`, pt `a mim`, pl `do mnie`, ru `ко мне`, uk `до мене`, hi `मुझ में`. Each was read as a
|
|
59
|
+
variable (`zu mir` ran as `to mir`) or refused. Translations still write the nominative.
|
|
60
|
+
|
|
61
|
+
- **`@lokascript/semantic`: `put X at start of Y` translates.** It was refused in all 23 languages: no
|
|
62
|
+
language but English had a pattern for it, and the render wrote `at end of` in its place.
|
|
63
|
+
- **`@lokascript/semantic`: a one-line handler's `put … at end of`** translates in es, id, it, pt, tl and
|
|
64
|
+
uk. The fused handler pattern took the end noun for the destination wherever the language's `at` word
|
|
65
|
+
is also a destination marker.
|
|
66
|
+
- **`@lokascript/semantic`: `^x is …` keeps its copula** in ar, hi and th, which read the `is` word after a
|
|
67
|
+
`^` variable as the pronoun `it`.
|
|
68
|
+
- **`@lokascript/semantic`: an SOV loop body may open with a variable named `index`** (ja `index を …`),
|
|
69
|
+
and `repeat` is never read as an event name.
|
|
70
|
+
|
|
71
|
+
## [4.3.0] - 2026-10-07
|
|
72
|
+
|
|
73
|
+
More of what upstream reads now translates, and what does not is refused instead of lost. On the
|
|
74
|
+
command-shape gate, round trips that came back silently different fell from 174 in 4.2.0 to 31,
|
|
75
|
+
none of them in English; 24,386 of 27,240 now read as their source does. English-to-English refuses
|
|
76
|
+
61 of the 1,135 scripts, down from 82, and each of the 61 now belongs to a named family with the
|
|
77
|
+
reason it is not carried yet.
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
|
|
81
|
+
- **`@lokascript/semantic` reads `on every <event>` and `init immediately`.** Both lost their head
|
|
82
|
+
word, so the handler or the block was refused. `EventHandlerSemanticNode.eventModifiers.every` and
|
|
83
|
+
`FeatureSemanticNode.immediately` carry them. English writes them as upstream does; every other
|
|
84
|
+
language writes the English word first (`every al clic …`), as it already did for `on first`.
|
|
85
|
+
- **`@hyperfixi/testing-framework`: two new value-matrix axes.** A `branch` position is an `if` with
|
|
86
|
+
no `then` before a command that names no target (`if 2 is in arr add .a end`). The matrix's `if`
|
|
87
|
+
cells always wrote `then` and a `put` with its target, so an SOV reader always saw where the
|
|
88
|
+
condition ended, and none of them could fail the way Turkish did (see Fixed). The 610 pairs that
|
|
89
|
+
`branch` refuses are listed in the baseline. A twelfth operand kind is a CSS length (`100px`,
|
|
90
|
+
`50%`). The matrix now has 5,396 cells.
|
|
91
|
+
|
|
92
|
+
### Changed
|
|
93
|
+
|
|
94
|
+
- **`@lokascript/semantic` refuses core's swap strategies that upstream has no spelling for.**
|
|
95
|
+
`translate()` gains a loss kind, `'core-only'` (`TranslationLossKind`). Upstream and the engine
|
|
96
|
+
read `swap morph of #t with it` as an exchange with a property named `morph`, so core's morph
|
|
97
|
+
strategies, `none`, and a strategy with no content are now refused in every language. Before,
|
|
98
|
+
English wrote them as they were read. Core's `swap into #t with x` and `swap over #t with x` are its
|
|
99
|
+
innerHTML and outerHTML, and they are now written as `put x into #t` and `put x into #t's
|
|
100
|
+
outerHTML`. Before, English wrote `swap into of #t with x`, which runs as an exchange.
|
|
101
|
+
- **`@lokascript/semantic` writes a word in the target language only where that language's reader
|
|
102
|
+
brings it back.** A property name is localized only through the reader's property table or as a
|
|
103
|
+
keyword. So `add .foo to my children` keeps `children` in Spanish (`mi children`), where it wrote
|
|
104
|
+
`mi hijos` and read back a property named `hijos`. A call's arguments are written as written
|
|
105
|
+
(`call sprayInto(me)` wrote `sprayInto(yo)`). So is every bracket group inside an expression:
|
|
106
|
+
`2 is in [true, n]` wrote `[verdadero, n]` in Spanish, a variable no reader de-localizes. And so is a
|
|
107
|
+
type name after `am a`. These spots now show English words in foreign renders where they used to
|
|
108
|
+
show words that did not come back.
|
|
109
|
+
- **`@lokascript/semantic`: `innerHTML` and `outerHTML` are no longer English keywords.** A swap's
|
|
110
|
+
strategy slot still reads them, as words.
|
|
111
|
+
|
|
112
|
+
### Fixed
|
|
113
|
+
|
|
114
|
+
- **`@lokascript/semantic`: Turkish keeps an `if` condition whole before a command with no target.**
|
|
115
|
+
`if 2 is in arr add .a end` read back as `if 2 add .a to is in arr end`, with any operand, and so
|
|
116
|
+
did `is a Number` and `<` after a possessive. Turkish case markers may be dropped, and the marker
|
|
117
|
+
test counted a marker word anywhere in the value: `in` (also the operator), `a` (also the article),
|
|
118
|
+
`nin` (also the possessive). The marker must now be the last token of its group.
|
|
119
|
+
- **`@lokascript/semantic`: a number with a CSS unit is one value.** Upstream reads `100px`,
|
|
120
|
+
`1.5rem` and `50%` as one string. Every tokenizer split them, so `set my *width to 100px` and
|
|
121
|
+
`transition height to 100px` were refused in all 24 languages. A `%` that an operand touches
|
|
122
|
+
(`5%2`) or that is spaced still reads as the operator.
|
|
123
|
+
- **`@lokascript/semantic`: more English forms that were refused in every language now read.**
|
|
124
|
+
`set innerHTML of #d1 to …` and `increment innerHTML of #d1`; a single-quoted string that holds a
|
|
125
|
+
`"` (`set #t to '<div _="…">'`), which was written back in double quotes and so closed early;
|
|
126
|
+
`fetch … don't throw`; `put … at the start of` / `at the end of`, where the `end` closed the
|
|
127
|
+
handler; `remove 3 from :arr` and `remove {color} from me`; `otherwise` as `else`. In a translation
|
|
128
|
+
of an empty then-branch's `otherwise`, 13 languages wrote their own word for it inside the
|
|
129
|
+
condition and read it back there, silently.
|
|
130
|
+
- **`@lokascript/semantic`: a handler's `catch` and `finally` bodies are written in upstream's
|
|
131
|
+
spelling.** `catch e prepend …` kept core's `prepend`, which the engine rejects.
|
|
132
|
+
|
|
10
133
|
## [4.2.0] - 2026-10-07
|
|
11
134
|
|
|
12
135
|
A translation that would lose part of a script is now refused instead of returned short.
|
|
@@ -1423,7 +1546,9 @@ _Synchronized version release. See git history for details._
|
|
|
1423
1546
|
- npm access token stored in GitHub Secrets
|
|
1424
1547
|
- 2FA recommended for npm organization
|
|
1425
1548
|
|
|
1426
|
-
[Unreleased]: https://github.com/codetalcott/hyperfixi/compare/v4.
|
|
1549
|
+
[Unreleased]: https://github.com/codetalcott/hyperfixi/compare/v4.4.0...HEAD
|
|
1550
|
+
[4.4.0]: https://github.com/codetalcott/hyperfixi/compare/v4.3.0...v4.4.0
|
|
1551
|
+
[4.3.0]: https://github.com/codetalcott/hyperfixi/compare/v4.2.0...v4.3.0
|
|
1427
1552
|
[4.2.0]: https://github.com/codetalcott/hyperfixi/compare/v4.1.0...v4.2.0
|
|
1428
1553
|
[4.1.0]: https://github.com/codetalcott/hyperfixi/compare/v4.0.1...v4.1.0
|
|
1429
1554
|
[4.0.1]: https://github.com/codetalcott/hyperfixi/compare/v4.0.0...v4.0.1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperfixi/testing-framework",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.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_TIMEOUT=240 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 src/multilingual/render-fidelity.test.ts src/multilingual/bare-render-fidelity.test.ts src/multilingual/en-reference-preservation.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 src/multilingual/en-reference-preservation.test.ts src/multilingual/english-leaks.corpus.test.ts",
|
|
44
44
|
"test:shipped-sources": "VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/shipped-sources-engine.test.ts src/multilingual/shipped-sources-localized.test.ts src/multilingual/shipped-examples-execution.test.ts"
|
|
45
45
|
},
|
|
46
46
|
"keywords": [
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
"author": "LokaScript Contributors",
|
|
58
58
|
"license": "MIT",
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@hyperfixi/patterns-reference": "^4.
|
|
61
|
-
"@lokascript/compilation-service": "^4.
|
|
62
|
-
"@lokascript/i18n": "^4.
|
|
63
|
-
"@lokascript/semantic": "^4.
|
|
60
|
+
"@hyperfixi/patterns-reference": "^4.4.0",
|
|
61
|
+
"@lokascript/compilation-service": "^4.4.0",
|
|
62
|
+
"@lokascript/i18n": "^4.4.0",
|
|
63
|
+
"@lokascript/semantic": "^4.4.0",
|
|
64
64
|
"diff": "^8.0.3",
|
|
65
65
|
"esbuild": "^0.25.12",
|
|
66
66
|
"happy-dom": "^20.14.5",
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
"vite": "^8.3.1"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@hyperfixi/engine": "^4.
|
|
76
|
-
"@lokascript/hyperscript-adapter": "^4.
|
|
75
|
+
"@hyperfixi/engine": "^4.4.0",
|
|
76
|
+
"@lokascript/hyperscript-adapter": "^4.4.0",
|
|
77
77
|
"@types/diff": "^5.0.0",
|
|
78
78
|
"@types/node": "^26.6.3",
|
|
79
79
|
"@vitest/coverage-v8": "^5.0.0",
|
|
@@ -294,6 +294,38 @@ npx tsx tools/regen-value-matrix-baseline.ts --dry-run --results /tmp/matrix.jso
|
|
|
294
294
|
A lane that fails in `en-rt` fails in nearly every translation: semantic's English
|
|
295
295
|
parse lost the value, and every translation is rendered from it. Fix that first.
|
|
296
296
|
|
|
297
|
+
## English left in a translation
|
|
298
|
+
|
|
299
|
+
Every gate above asks whether a translation means what the source means; none asks
|
|
300
|
+
whether it reads as the language. `english-leaks.ts` counts, in each render, three kinds
|
|
301
|
+
of finding:
|
|
302
|
+
|
|
303
|
+
| Finding | What it is |
|
|
304
|
+
| -------------- | ----------------------------------------------------------------------------------------- |
|
|
305
|
+
| `<word>` | an English word the engine reads as grammar in the source, written in English |
|
|
306
|
+
| `event:<name>` | an English event name where the language's lexicon has a word and the renderer may use it |
|
|
307
|
+
| `case:me` | the nominative `me` beside a role marker, in the 13 languages where that form is wrong |
|
|
308
|
+
|
|
309
|
+
"Grammar" is derived per source: the tokens the engine's own parser matched as words, plus
|
|
310
|
+
the references in its parse. A word the language spells the same (es `a`, de `in`) and a
|
|
311
|
+
name spelled like a keyword are never counted; strings, `js` blocks and brace interiors
|
|
312
|
+
are not read. The report splits each word by context (`clause`: in a clause semantic keeps
|
|
313
|
+
as written; `bracket`, `call`, `property`, `plain`) and by whether the language has its own
|
|
314
|
+
word for it.
|
|
315
|
+
|
|
316
|
+
Two halves, each with a shrink-only baseline: `baselines/english-leaks.corpus.json` (every
|
|
317
|
+
non-markup corpus row, `render(parse(en), L)`; in `test:canonical`, after `populate`) and
|
|
318
|
+
`baselines/english-leaks.shapes.json` (the renders the command-shape shards already make).
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
# The burn-down: per language, per word (with contexts), per event
|
|
322
|
+
npx tsx tools/regen-english-leaks-baseline.ts --report
|
|
323
|
+
|
|
324
|
+
# After a fix: prune the findings that are gone (refuses to add new ones)
|
|
325
|
+
npx tsx tools/regen-english-leaks-baseline.ts # both halves (corpus needs a fresh populate)
|
|
326
|
+
npx tsx tools/regen-english-leaks-baseline.ts --shapes # one half
|
|
327
|
+
```
|
|
328
|
+
|
|
297
329
|
## Direct-path shapes on the text path
|
|
298
330
|
|
|
299
331
|
Core's `src/multilingual/*-direct-path.test.ts` files pin shapes core's direct path ran
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The cases file, the baseline's own consistency and the oracle are checked
|
|
15
15
|
* in command-shapes.cases.test.ts and command-shapes.oracle.test.ts.
|
|
16
|
+
*
|
|
17
|
+
* Each shard also judges the English left in its renders (english-leaks.ts),
|
|
18
|
+
* against baselines/english-leaks.shapes.json: the same renders, a second
|
|
19
|
+
* question, so the gate costs no second translation pass.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import { readFileSync } from 'node:fs';
|
|
@@ -27,6 +31,16 @@ import {
|
|
|
27
31
|
type CommandShapesBaseline,
|
|
28
32
|
type PairChange,
|
|
29
33
|
} from './command-shapes';
|
|
34
|
+
import {
|
|
35
|
+
diffLeakBaseline,
|
|
36
|
+
gainedLeaks,
|
|
37
|
+
prunableLeaks,
|
|
38
|
+
initLeakScanner,
|
|
39
|
+
loadLeakBaseline,
|
|
40
|
+
shapeLeaks,
|
|
41
|
+
type LeakChange,
|
|
42
|
+
type LeakResults,
|
|
43
|
+
} from './english-leaks';
|
|
30
44
|
|
|
31
45
|
export const COMMAND_SHAPE_SHARDS = 3;
|
|
32
46
|
|
|
@@ -47,11 +61,16 @@ export function describeCommandShapesShard(shard: number): void {
|
|
|
47
61
|
`${c.id} [${c.lane}] ${c.listed} → ${c.got}: ${sources.get(c.id)?.slice(0, 80)}` +
|
|
48
62
|
(c.detail ? ` ⇒ ${JSON.stringify(collapse(c.detail).slice(0, 80))}` : '');
|
|
49
63
|
|
|
64
|
+
const leakLine = (c: LeakChange, words: string[]): string =>
|
|
65
|
+
`${c.id} [${c.language}] ${words.join(' ')}: ${sources.get(c.id)?.slice(0, 80)}`;
|
|
66
|
+
|
|
50
67
|
describe(`command shapes [${shard + 1}/${COMMAND_SHAPE_SHARDS}]`, () => {
|
|
51
68
|
let results: CaseResult[] = [];
|
|
69
|
+
let leaks: LeakResults = new Map();
|
|
52
70
|
|
|
53
71
|
beforeAll(async () => {
|
|
54
72
|
results = await runCommandShapes(own);
|
|
73
|
+
leaks = shapeLeaks(await initLeakScanner(), results, own);
|
|
55
74
|
const pairs = results.reduce((n, r) => n + Object.keys(r.lanes).length, 0);
|
|
56
75
|
const failing = results.reduce(
|
|
57
76
|
(n, r) => n + Object.values(r.lanes).filter(l => l.outcome !== 'pass').length,
|
|
@@ -72,5 +91,18 @@ export function describeCommandShapesShard(shard: number): void {
|
|
|
72
91
|
const { better } = diffBaseline(results, loadCommandShapesBaseline());
|
|
73
92
|
expect(capped(better.map(line))).toEqual([]);
|
|
74
93
|
});
|
|
94
|
+
|
|
95
|
+
it('no render holds English the leak baseline does not list (english-leaks.ts)', () => {
|
|
96
|
+
expect(leaks.size).toBeGreaterThan(300);
|
|
97
|
+
const gained = gainedLeaks(diffLeakBaseline(leaks, loadLeakBaseline('shapes')));
|
|
98
|
+
expect(capped(gained.map(c => leakLine(c, c.added)))).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('no listed English is gone, nor a listed refusal rendering now (prune with tools/regen-english-leaks-baseline.ts --shapes)', () => {
|
|
102
|
+
const prune = prunableLeaks(diffLeakBaseline(leaks, loadLeakBaseline('shapes')));
|
|
103
|
+
expect(capped(prune.map(c => leakLine(c, c.newRender ? ['now renders'] : c.gone)))).toEqual(
|
|
104
|
+
[]
|
|
105
|
+
);
|
|
106
|
+
});
|
|
75
107
|
});
|
|
76
108
|
}
|
|
@@ -111,6 +111,8 @@ export interface LaneResult {
|
|
|
111
111
|
/** The lane's English (a silent lane), or the error's first line (a refused one). */
|
|
112
112
|
detail?: string;
|
|
113
113
|
kind?: SilentKind;
|
|
114
|
+
/** The language's render (`translate(src, 'en', L)`), when there was one: what english-leaks.ts reads. */
|
|
115
|
+
rendered?: string;
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
export interface CaseResult {
|
|
@@ -497,11 +499,6 @@ export async function initCommandShapes(): Promise<CommandShapeRunner> {
|
|
|
497
499
|
const readers = await loadEngineReaders();
|
|
498
500
|
const { translate } = (await import('@lokascript/semantic')) as { translate: Translate };
|
|
499
501
|
|
|
500
|
-
const lane = (source: string, language: string): string =>
|
|
501
|
-
language === 'en'
|
|
502
|
-
? translate(source, 'en', 'en')
|
|
503
|
-
: translate(translate(source, 'en', language), language, 'en');
|
|
504
|
-
|
|
505
502
|
return {
|
|
506
503
|
runCase(shape, lanes = LANES) {
|
|
507
504
|
const engine = readers[readerOf(shape)];
|
|
@@ -516,14 +513,26 @@ export async function initCommandShapes(): Promise<CommandShapeRunner> {
|
|
|
516
513
|
lanes: {},
|
|
517
514
|
};
|
|
518
515
|
for (const language of lanes) {
|
|
516
|
+
let rendered: string | undefined;
|
|
519
517
|
let english: string;
|
|
520
518
|
try {
|
|
521
|
-
english =
|
|
519
|
+
if (language === 'en') english = translate(shape.source, 'en', 'en');
|
|
520
|
+
else {
|
|
521
|
+
rendered = translate(shape.source, 'en', language);
|
|
522
|
+
english = translate(rendered, language, 'en');
|
|
523
|
+
}
|
|
522
524
|
} catch (e) {
|
|
523
|
-
result.lanes[language] = {
|
|
525
|
+
result.lanes[language] = {
|
|
526
|
+
outcome: 'refused',
|
|
527
|
+
detail: firstLine(e),
|
|
528
|
+
...(rendered !== undefined ? { rendered } : {}),
|
|
529
|
+
};
|
|
524
530
|
continue;
|
|
525
531
|
}
|
|
526
|
-
result.lanes[language] =
|
|
532
|
+
result.lanes[language] = {
|
|
533
|
+
...judge(engine, want, wantPlain, english),
|
|
534
|
+
...(rendered !== undefined ? { rendered } : {}),
|
|
535
|
+
};
|
|
527
536
|
}
|
|
528
537
|
return result;
|
|
529
538
|
},
|
|
@@ -589,6 +598,115 @@ export const LOUD: readonly LoudFamily[] = [
|
|
|
589
598
|
"a possessive with nothing after it (`halt the event's then …`), which the engine " +
|
|
590
599
|
'reads as the bare reference; one upstream test source',
|
|
591
600
|
matches: source => /'s(?=\s+(then|end)\b|\s*$)/.test(source),
|
|
601
|
+
}, // M1 step 3 (2026-10-07): the English refusals left after the value and
|
|
602
|
+
// command-form fixes, each family with why it is not carried.
|
|
603
|
+
{
|
|
604
|
+
name: 'scroll-by',
|
|
605
|
+
reason:
|
|
606
|
+
"scroll's relative form (`scroll #box down by 200px`) names a direction, which no " +
|
|
607
|
+
"language's dictionary has: adding them is an owner vocabulary decision (the M2 sheet)",
|
|
608
|
+
matches: (_source, _types, plain) =>
|
|
609
|
+
someNode(plain, node => node.type === 'scrollCommand' && 'by' in node),
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: 'beep',
|
|
613
|
+
reason:
|
|
614
|
+
"`beep!`, upstream's debugging command, has no schema: its form in each language is an " +
|
|
615
|
+
'owner vocabulary decision (the M2 sheet)',
|
|
616
|
+
matches: (_source, types) => types.has('beepCommand'),
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
name: 'halt-modes',
|
|
620
|
+
reason:
|
|
621
|
+
"halt's `default` (also a command word) and `the event's bubbling|default`: the slot " +
|
|
622
|
+
'that keeps `call` out of `halt call f()` cannot tell `default` from a command without ' +
|
|
623
|
+
'a halt-only reading (OPEN_ITEMS P56)',
|
|
624
|
+
matches: source => /\bhalt\s+(default\b|the event's\s+(bubbling|default)\b)/.test(source),
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: 'collection-expressions',
|
|
628
|
+
reason:
|
|
629
|
+
"upstream 0.9.93's collection expressions (`… where it matches .a`, `mapped to`) are an " +
|
|
630
|
+
"expression form semantic's reader does not have: a parser-track item (OPEN_ITEMS P57)",
|
|
631
|
+
// Not `if no <q/> in … where …`, which English reads (its foreign lanes are
|
|
632
|
+
// the value-words family).
|
|
633
|
+
matches: (source, types) => types.has('collectionExpression') && !/\bif\s+no\s+</.test(source),
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
name: 'pseudo-class-query',
|
|
637
|
+
reason:
|
|
638
|
+
'a query of a pseudo-class (`<:checked/>`): the tokenizers read `:checked` as a local ' +
|
|
639
|
+
'variable; a tokenizer item, rare in pages',
|
|
640
|
+
matches: source => /<:[\w-]+\/>/.test(source),
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: 'closest-forms',
|
|
644
|
+
reason:
|
|
645
|
+
"`closest @attr` (an ancestor's attribute) and `closest parent <q/>`: the forms of " +
|
|
646
|
+
'`closest` besides a query, rare in pages',
|
|
647
|
+
matches: (_source, _types, plain) =>
|
|
648
|
+
someNode(
|
|
649
|
+
plain,
|
|
650
|
+
node =>
|
|
651
|
+
node.type === 'closestExpr' &&
|
|
652
|
+
// Not `closest @foo to #d1`, which English reads (value-words).
|
|
653
|
+
(node.parentSearch === true ||
|
|
654
|
+
(String(node.css ?? '').startsWith('[') &&
|
|
655
|
+
(node.to as { type?: unknown } | undefined)?.type === 'implicitMeTarget'))
|
|
656
|
+
),
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
name: 'possessive-after-query',
|
|
660
|
+
reason:
|
|
661
|
+
"a possessive after a positional query (`the next <div/>'s textContent`), rare in pages; " +
|
|
662
|
+
'the `of` form (`the textContent of the next <div/>`) is carried',
|
|
663
|
+
matches: (source, types) => types.has('relativePositionalExpression') && /\/>'s\b/.test(source),
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
name: 'colon-event-arguments',
|
|
667
|
+
reason: 'a colon-qualified event name with arguments (`send foo:bar(x:42)`), rare in pages',
|
|
668
|
+
matches: (source, types) => types.has('namedArgumentList') && /\w:\w+\(/.test(source),
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
name: 'call-chain-command',
|
|
672
|
+
reason: 'a call chain as a command (`bar().foo()`), rare in pages',
|
|
673
|
+
matches: (source, types) => types.has('pseudoCommand') && /\)\.\w+\(/.test(source),
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
name: 'rare-heads',
|
|
677
|
+
reason:
|
|
678
|
+
'handler heads rare in pages: a quoted event name (`on "a-b"`), a filter after ' +
|
|
679
|
+
'parameters (`on foo(bar)[bar]`), `and on`, a source on each `or` leg, a ' +
|
|
680
|
+
'parenthesized feature (`(on click …)`)',
|
|
681
|
+
matches: source =>
|
|
682
|
+
/^\s*on\s+"|^\s*on\s+[\w:.-]+\([^)]*\)\[|\bon\s+\w+\s+\d+\s+and\s+on\b|\bfrom\s+\S+\s+or\s+\w+\s+from\b|^\s*\(on\s/.test(
|
|
683
|
+
source
|
|
684
|
+
),
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
name: 'rare-values',
|
|
688
|
+
reason:
|
|
689
|
+
'value spellings rare in pages: an escaped class name (`.group-\\[…\\]`), a comma ' +
|
|
690
|
+
'list in a query (`<#d1, #d2/>`), an attribute after a bare or bracketed reference ' +
|
|
691
|
+
'(`it @a`, `my [@a]`), a dotted path before `of`, a unit after parentheses ' +
|
|
692
|
+
'(`(w)px`), `set {…} on <obj>`',
|
|
693
|
+
matches: source =>
|
|
694
|
+
/\\\[|<[^<>]*,[^<>]*\/>|\b(my|its|your)\s+\[@|\bset\s+(it|me|you)\s+@|\b[a-z]\w*\.[a-z]\w*\s+of\b|\)(px|em|rem|%)|\bset\s+\{[^}]*\}\s+on\b/i.test(
|
|
695
|
+
source
|
|
696
|
+
),
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
name: 'rare-commands',
|
|
700
|
+
reason:
|
|
701
|
+
'commands rare in pages: `measure` of a property list, `pick characters 0 to 3 from …`, ' +
|
|
702
|
+
'an `if` whose body is empty (`if false end`), a behavior holding a bare command, two ' +
|
|
703
|
+
'behaviors with no `end` between',
|
|
704
|
+
matches: (source, types, plain) =>
|
|
705
|
+
(types.has('measureCommand') && /\bmeasure\s+\w+\s*,/.test(source)) ||
|
|
706
|
+
someNode(plain, node => node.type === 'pickCommand' && node.variant === 'range') ||
|
|
707
|
+
/\bif\s+\S+\s+end\b/.test(source) ||
|
|
708
|
+
(types.has('behaviorFeature') && types.has('setFeature')) ||
|
|
709
|
+
(source.match(/\bbehavior\b/g)?.length ?? 0) > 1,
|
|
592
710
|
},
|
|
593
711
|
];
|
|
594
712
|
|
|
@@ -606,34 +724,11 @@ export interface SilentFamily {
|
|
|
606
724
|
}
|
|
607
725
|
|
|
608
726
|
export const SILENT_FAMILIES: readonly SilentFamily[] = [
|
|
609
|
-
{
|
|
610
|
-
name: 'localized-call-arguments',
|
|
611
|
-
reason:
|
|
612
|
-
"a call's arguments are written in the target language (`sprayInto(أنا)`, " +
|
|
613
|
-
'`writeText(قيمة لـ #input)`) and read back raw, so English holds the foreign words',
|
|
614
|
-
ids: ['2d6fa6cf27', '2218bab0ca'],
|
|
615
|
-
},
|
|
616
|
-
{
|
|
617
|
-
name: 'localized-dom-names',
|
|
618
|
-
reason:
|
|
619
|
-
'a DOM property or type name is translated as a word (`my children` → de `my kinder`, ' +
|
|
620
|
-
'`my style[…]` → `my stil […]`, `a Element` → ar `a عنصر`) and not read back',
|
|
621
|
-
ids: [
|
|
622
|
-
'76bf595d21',
|
|
623
|
-
'a1ca7a74a0',
|
|
624
|
-
'e172bd73af',
|
|
625
|
-
'ddb498424a',
|
|
626
|
-
'dc9ce23e31',
|
|
627
|
-
'bf3ceac332',
|
|
628
|
-
'8ecf869842',
|
|
629
|
-
],
|
|
630
|
-
},
|
|
631
727
|
{
|
|
632
728
|
name: 'value-words',
|
|
633
729
|
reason:
|
|
634
|
-
"a language's word inside a value reads back as another English word:
|
|
635
|
-
'`it
|
|
636
|
-
'ms `it` → `its`',
|
|
730
|
+
"a language's word inside a value reads back as another English word: pl `to` → " +
|
|
731
|
+
'`it`, qu `where` → `target`, ms/pl `undefined`',
|
|
637
732
|
ids: [
|
|
638
733
|
'a30e7d6174',
|
|
639
734
|
'69537d01ae',
|
|
@@ -641,17 +736,9 @@ export const SILENT_FAMILIES: readonly SilentFamily[] = [
|
|
|
641
736
|
'da24c9d768',
|
|
642
737
|
'b948ed872b',
|
|
643
738
|
'a4c56acf1e',
|
|
644
|
-
'87250fbc5e',
|
|
645
739
|
'54a65f4602',
|
|
646
740
|
],
|
|
647
741
|
},
|
|
648
|
-
{
|
|
649
|
-
name: 'else-word',
|
|
650
|
-
reason:
|
|
651
|
-
"an empty then-branch's `otherwise` (de `andernfalls`, …) is not read as `else` and " +
|
|
652
|
-
'stays a word in the condition',
|
|
653
|
-
ids: ['e60afa928a'],
|
|
654
|
-
},
|
|
655
742
|
{
|
|
656
743
|
name: 'it-locative',
|
|
657
744
|
reason: 'it `in me` (a query scope) reads back as `on me`',
|
|
@@ -661,8 +748,8 @@ export const SILENT_FAMILIES: readonly SilentFamily[] = [
|
|
|
661
748
|
name: 'possessive-split',
|
|
662
749
|
reason:
|
|
663
750
|
"a possessive reads back split or reworded: qu `$data 's inner' s val`, id `my` → " +
|
|
664
|
-
|
|
665
|
-
ids: ['76130fbf7d', '36e2294330', 'b839394853'
|
|
751
|
+
'`me punya`',
|
|
752
|
+
ids: ['76130fbf7d', '36e2294330', 'b839394853'],
|
|
666
753
|
},
|
|
667
754
|
];
|
|
668
755
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The English-leak gate, corpus half (see english-leaks.ts): every non-markup
|
|
3
|
+
* corpus row rendered into each language as `populate` writes it, judged
|
|
4
|
+
* against baselines/english-leaks.corpus.json. Fails on a finding a pair did
|
|
5
|
+
* not have, and on a listed one that is gone (prune with
|
|
6
|
+
* tools/regen-english-leaks-baseline.ts --corpus), so the list only shrinks.
|
|
7
|
+
*
|
|
8
|
+
* Needs FOREIGN_CANONICAL_VALIDITY=1 and a freshly populated patterns.db: the
|
|
9
|
+
* SET of rows depends on `populate`, the same contract the other corpus gates
|
|
10
|
+
* carry (`npm run test:canonical`).
|
|
11
|
+
*
|
|
12
|
+
* @vitest-environment node
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
15
|
+
import { getAllPatterns } from '@hyperfixi/patterns-reference';
|
|
16
|
+
import {
|
|
17
|
+
corpusLeaks,
|
|
18
|
+
diffLeakBaseline,
|
|
19
|
+
gainedLeaks,
|
|
20
|
+
prunableLeaks,
|
|
21
|
+
initLeakScanner,
|
|
22
|
+
loadLeakBaseline,
|
|
23
|
+
type LeakResults,
|
|
24
|
+
} from './english-leaks';
|
|
25
|
+
|
|
26
|
+
const DB_FRESHLY_POPULATED = process.env.FOREIGN_CANONICAL_VALIDITY === '1';
|
|
27
|
+
|
|
28
|
+
/** At most `max` lines, then a count: a broad regression must not print thousands. */
|
|
29
|
+
function capped(lines: string[], max = 40): string[] {
|
|
30
|
+
return lines.length <= max ? lines : [...lines.slice(0, max), `… and ${lines.length - max} more`];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe.skipIf(!DB_FRESHLY_POPULATED)('english-leaks: corpus renders', () => {
|
|
34
|
+
let results: LeakResults = new Map();
|
|
35
|
+
let skipped: string[] = [];
|
|
36
|
+
const sources = new Map<string, string>();
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
const rows = await getAllPatterns({ limit: 5000 });
|
|
40
|
+
for (const row of rows) sources.set(row.id, row.rawCode.replace(/\s+/g, ' ').trim());
|
|
41
|
+
({ results, skipped } = await corpusLeaks(await initLeakScanner(), rows));
|
|
42
|
+
}, 300_000);
|
|
43
|
+
|
|
44
|
+
it('scores the corpus (sanity: guards a false green)', () => {
|
|
45
|
+
expect(results.size).toBeGreaterThan(140);
|
|
46
|
+
// Rows the engine rejects have no grammar to read (worker, socket, eventsource, …).
|
|
47
|
+
expect(skipped.length).toBeLessThan(10);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('no render holds English the baseline does not list', () => {
|
|
51
|
+
const gained = gainedLeaks(diffLeakBaseline(results, loadLeakBaseline('corpus')));
|
|
52
|
+
expect(
|
|
53
|
+
capped(
|
|
54
|
+
gained.map(
|
|
55
|
+
c => `${c.id} [${c.language}] ${c.added.join(' ')}: ${sources.get(c.id)?.slice(0, 80)}`
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('no listed English is gone (prune it with tools/regen-english-leaks-baseline.ts --corpus)', () => {
|
|
62
|
+
const prune = prunableLeaks(diffLeakBaseline(results, loadLeakBaseline('corpus')));
|
|
63
|
+
expect(
|
|
64
|
+
capped(
|
|
65
|
+
prune.map(c => `${c.id} [${c.language}] ${c.newRender ? 'now renders' : c.gone.join(' ')}`)
|
|
66
|
+
)
|
|
67
|
+
).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps no entry for a row the corpus no longer holds', () => {
|
|
71
|
+
const stale = Object.keys(loadLeakBaseline('corpus').entries).filter(id => !results.has(id));
|
|
72
|
+
expect(stale).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|