@hyperfixi/testing-framework 4.4.0 → 4.5.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 +66 -0
- package/package.json +7 -7
- package/src/multilingual/README.md +9 -6
- package/src/multilingual/command-shapes.ts +2 -10
- package/src/multilingual/direct-path-shapes.ts +60 -26
- package/src/multilingual/english-leaks.test.ts +118 -6
- package/src/multilingual/english-leaks.ts +208 -43
- package/src/multilingual/hand-written-input.tasks.json +162 -0
- package/src/multilingual/hand-written-input.test.ts +62 -0
- package/src/multilingual/hand-written-input.ts +201 -0
- package/src/vocab/batch3-roundtrip.test.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.5.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
Pronouns, suffixes and handler heads read as their language. In 4.4.0 every translation wrote `me` and
|
|
13
|
+
`it` in the nominative beside a marker (es `a yo`, de `zu ich`, tr `ben e`, ar `إلى أنا`), wrote every
|
|
14
|
+
case particle as a word of its own (ko `#out 에`, ja `"x" を`), and wrote a Japanese, Bengali or Turkish
|
|
15
|
+
handler head with two markers (`クリック を で`, `ক্লিক তে`, `tıklama i üzerinde`). Each of these is now
|
|
16
|
+
the language's own form, and every reader still takes the old one. The forms are listed for native
|
|
17
|
+
review in `packages/semantic/NATIVE_REVIEW_NEEDED.md`; none has been reviewed yet.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`@lokascript/testing-framework`: the English-leak gate counts pronoun case and marker spacing.**
|
|
22
|
+
`case:it` and an unmarked object pronoun (`case:me-object`, `case:it-object`) beside `case:me`; and
|
|
23
|
+
`spacing:<marker>`, a particle ko, ja, bn or tr attaches to its noun written as a word of its own.
|
|
24
|
+
Both shrink-only, both halves (corpus and command shapes), with per-language exit targets in
|
|
25
|
+
`LEAK_TARGETS`.
|
|
26
|
+
- **`@lokascript/semantic`: `toggle … between`** in every language's own words (ja `の間で`, hi `के बीच`,
|
|
27
|
+
he `בין`), with the style form's element written in the language.
|
|
28
|
+
- **`docs-internal/multilingual/FUSED_FORMS.md`**: the design for pronouns fused with their markers and
|
|
29
|
+
suffixes attached to words, with the owner's four questions and their answers.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`@lokascript/semantic` writes a pronoun in the case its marker takes.** Free forms: es `en mí`,
|
|
34
|
+
pt `a mim`, it `a me`, tl `sa akin`, pl `do mnie`, ru `ко мне` / `во мне`, uk `до мене`, de `zu mir`
|
|
35
|
+
/ `in mich` (direction) / `auf mir` (location), hi `मुझ में`; `it` after a marker (ru `к этому`, pl `do
|
|
36
|
+
tego`, de `dazu` / `darin` / `darauf`, fr `à lui`, hi `इस में`); `me` as a verb's object (de `zeige
|
|
37
|
+
mich`, ru `покажи меня`). Fused forms: tr `bana` / `beni` / `benden`, `ona` / `onu`; ar `إليّ` / `فيه`;
|
|
38
|
+
he `אליי` / `ממני`; bn `আমাকে` / `আমাতে`; hi `मुझे`; tl `dito`; pt `nele`. Object clitics on the verb:
|
|
39
|
+
es `mostrarme`, it `metterlo`, pt `colocá-lo`, ar `ضعه`; fr writes the object pronoun before the
|
|
40
|
+
infinitive (`le mettre`, `l'afficher`, `me cacher`). Every reader takes the nominative and the English
|
|
41
|
+
forms as before.
|
|
42
|
+
- **`@lokascript/semantic` writes a case particle onto its word** in ko (`"x"을 #out에 넣다`, `나를`,
|
|
43
|
+
each particle in the form the syllable takes), ja (`#outに`, `.aを`) and bn (`#outএ`, `.xকে`), and tr
|
|
44
|
+
fuses a suffix with its own reference words (`sonucu`, `hedefe`, `belgeye`) while code keeps its
|
|
45
|
+
suffix apart (`#out e`) until a reviewer picks a code word's harmony. tr also reads a suffix on code
|
|
46
|
+
after an apostrophe (`#out'a`, `.active'i`).
|
|
47
|
+
- **`@lokascript/semantic`: a custom event in a wait takes the language's `event` word** (`esperar evento foo`,
|
|
48
|
+
ja `イベント foo 待つ`), as `repeat until event X` already does; a known DOM event keeps `esperar transitionend`.
|
|
49
|
+
`wait for <custom event>` was refused in every language. A possessive whose property is a reference word
|
|
50
|
+
(`its.detail`) now reads back as that property, not the language's word for it.
|
|
51
|
+
- **`@lokascript/semantic`: ms and pl read their lexicon's `undefined` word** (`tidak_ditakrifkan`, `niezdefiniowane`),
|
|
52
|
+
which read back as a variable name.
|
|
53
|
+
- **`@lokascript/testing-framework`: the hand-written-input gate** (`hand-written-input.test.ts`): twenty tasks a
|
|
54
|
+
native speaker writes from English, run as written on the engine against upstream; committed with no inputs.
|
|
55
|
+
- **`@lokascript/semantic` writes a handler head as one word**: ja `クリックで` (was `クリック を で`),
|
|
56
|
+
bn `ক্লিকে` (the locative in the form the word takes: `জমাতে` after a vowel; was `ক্লিক তে`),
|
|
57
|
+
tr `tıklamada` (the locative in the form harmony gives: `göndermede`, `değişiklikte`; code keeps it apart, `keyup da`; was `tıklama i üzerinde`). The readers keep taking the two-marker heads.
|
|
58
|
+
- **`@lokascript/semantic` writes a query's scope before the query** in bn, hi, tr, ja and ko (tr
|
|
59
|
+
`bendeki <p/>`, hi `मुझ में स्थित <p/>`, ja `自分 の中の <p/>`), where it wrote English's order.
|
|
60
|
+
- **`@lokascript/semantic`: es and pt write the `focus` and `blur` commands as verbs** (`enfocarme`,
|
|
61
|
+
`focar-me`); the nouns were the events' and still read.
|
|
62
|
+
- **`@lokascript/semantic`: ja's head pattern outranks its fused handler patterns**, so a ja handler's
|
|
63
|
+
body always takes the clause walk: a `js … end` block after `then` and `fetch … as T` round-trip
|
|
64
|
+
where the fused path dropped them. The 2026-08 transformer's verb-first `繰り返し イベント X を まで`
|
|
65
|
+
body no longer reads in ja (the render since 2026-08-28 does).
|
|
66
|
+
|
|
67
|
+
- **`@lokascript/htmx-adapter`: the tr vocab gains `hx-da` as an alias of `hx-on`;** `hx-üzerinde` stays the name
|
|
68
|
+
(the profile's `on` word moved to the locative; a suffix is the wrong register for an attribute name).
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **`publish.yml`: a repair run skips a version npm is still processing** (`E409 previously staged`)
|
|
73
|
+
instead of failing the whole run.
|
|
74
|
+
|
|
10
75
|
## [4.4.0] - 2026-10-10
|
|
11
76
|
|
|
12
77
|
Translations now read as their language. In 4.3.0, 19% of the corpus's translations and 24% of the
|
|
@@ -1547,6 +1612,7 @@ _Synchronized version release. See git history for details._
|
|
|
1547
1612
|
- 2FA recommended for npm organization
|
|
1548
1613
|
|
|
1549
1614
|
[Unreleased]: https://github.com/codetalcott/hyperfixi/compare/v4.4.0...HEAD
|
|
1615
|
+
[4.5.0]: https://github.com/codetalcott/hyperfixi/compare/v4.4.0...v4.5.0
|
|
1550
1616
|
[4.4.0]: https://github.com/codetalcott/hyperfixi/compare/v4.3.0...v4.4.0
|
|
1551
1617
|
[4.3.0]: https://github.com/codetalcott/hyperfixi/compare/v4.2.0...v4.3.0
|
|
1552
1618
|
[4.2.0]: https://github.com/codetalcott/hyperfixi/compare/v4.1.0...v4.2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperfixi/testing-framework",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "Cross-platform behavior testing suite for LokaScript applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -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.5.0",
|
|
61
|
+
"@lokascript/compilation-service": "^4.5.0",
|
|
62
|
+
"@lokascript/i18n": "^4.5.0",
|
|
63
|
+
"@lokascript/semantic": "^4.5.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.5.0",
|
|
76
|
+
"@lokascript/hyperscript-adapter": "^4.5.0",
|
|
77
77
|
"@types/diff": "^5.0.0",
|
|
78
78
|
"@types/node": "^26.6.3",
|
|
79
79
|
"@vitest/coverage-v8": "^5.0.0",
|
|
@@ -297,14 +297,17 @@ parse lost the value, and every translation is rendered from it. Fix that first.
|
|
|
297
297
|
## English left in a translation
|
|
298
298
|
|
|
299
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,
|
|
300
|
+
whether it reads as the language. `english-leaks.ts` counts, in each render, four kinds
|
|
301
301
|
of finding:
|
|
302
302
|
|
|
303
|
-
| Finding
|
|
304
|
-
|
|
|
305
|
-
| `<word>`
|
|
306
|
-
| `event:<name>`
|
|
307
|
-
| `case:me`
|
|
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
|
+
| `case:it` | the nominative `it` beside a role marker that wants another form (`CASE_RULES`) |
|
|
309
|
+
| `case:me-object` / `case:it-object` | the nominative pronoun as a command's unmarked object, where that form is wrong |
|
|
310
|
+
| `spacing:<marker>` | a marker ko, ja, bn or tr attaches to its noun, written apart (`#out 에`; `ATTACHED_MARKERS`) |
|
|
308
311
|
|
|
309
312
|
"Grammar" is derived per source: the tokens the engine's own parser matched as words, plus
|
|
310
313
|
the references in its parse. A word the language spells the same (es `a`, de `in`) and a
|
|
@@ -728,16 +728,8 @@ export const SILENT_FAMILIES: readonly SilentFamily[] = [
|
|
|
728
728
|
name: 'value-words',
|
|
729
729
|
reason:
|
|
730
730
|
"a language's word inside a value reads back as another English word: pl `to` → " +
|
|
731
|
-
'`it
|
|
732
|
-
ids: [
|
|
733
|
-
'a30e7d6174',
|
|
734
|
-
'69537d01ae',
|
|
735
|
-
'd74a86f3a0',
|
|
736
|
-
'da24c9d768',
|
|
737
|
-
'b948ed872b',
|
|
738
|
-
'a4c56acf1e',
|
|
739
|
-
'54a65f4602',
|
|
740
|
-
],
|
|
731
|
+
'`it` (its `it` word, after `closest`), qu `where` → `target`, vi `where` split at `_`',
|
|
732
|
+
ids: ['d74a86f3a0', 'da24c9d768', 'b948ed872b'],
|
|
741
733
|
},
|
|
742
734
|
{
|
|
743
735
|
name: 'it-locative',
|
|
@@ -57,6 +57,11 @@ export interface DirectPathCase {
|
|
|
57
57
|
event: string | null;
|
|
58
58
|
/** The languages the core test ran; `[]` = English only. */
|
|
59
59
|
languages: 'all23' | string[];
|
|
60
|
+
/**
|
|
61
|
+
* The source defines a behavior of this name (a hand-written task): it is
|
|
62
|
+
* defined under a fresh name and installed with no arguments.
|
|
63
|
+
*/
|
|
64
|
+
behaviorName?: string;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
const CASES_PATH = path.resolve(
|
|
@@ -234,6 +239,12 @@ export function caseFails(r: DirectPathResult): boolean {
|
|
|
234
239
|
|
|
235
240
|
export interface DirectPathHosts {
|
|
236
241
|
runCase(c: DirectPathCase): Promise<DirectPathResult>;
|
|
242
|
+
/**
|
|
243
|
+
* One translation someone WROTE (the hand-written-input gate), run as
|
|
244
|
+
* runCase runs a render: `written` as written on the engine with the
|
|
245
|
+
* adapter, against upstream on the English source.
|
|
246
|
+
*/
|
|
247
|
+
runWritten(c: DirectPathCase, lang: string, written: string): Promise<PairResult>;
|
|
237
248
|
/** Restore the console, the rejection listeners and EventTarget. */
|
|
238
249
|
close(): Promise<void>;
|
|
239
250
|
}
|
|
@@ -321,14 +332,17 @@ export async function initDirectPathHosts(): Promise<DirectPathHosts> {
|
|
|
321
332
|
document.documentElement.removeAttribute('lang');
|
|
322
333
|
try {
|
|
323
334
|
let install = code;
|
|
324
|
-
|
|
335
|
+
const coreBehavior = c.id.startsWith('behavior-handlers');
|
|
336
|
+
if (coreBehavior || c.behaviorName) {
|
|
325
337
|
// The source defines a behavior: define it (under a fresh name), then
|
|
326
|
-
// install it in English, as the core test does.
|
|
338
|
+
// install it in English, as the core test does. A hand-written task
|
|
339
|
+
// names its behavior (`Hider`) and installs it with no arguments.
|
|
327
340
|
const name = `Demo${letter(behaviorSerial)}${letter(Math.floor(behaviorSerial / 26))}`;
|
|
328
341
|
behaviorSerial++;
|
|
329
|
-
const
|
|
342
|
+
const pattern = coreBehavior ? /Demo[A-Z]+/g : new RegExp(`\\b${c.behaviorName}\\b`, 'g');
|
|
343
|
+
const definition = code.replace(pattern, name);
|
|
330
344
|
host.evaluate(lang === 'en' ? definition : preprocess(definition, lang));
|
|
331
|
-
install = `install ${name}(h: 1)`;
|
|
345
|
+
install = coreBehavior ? `install ${name}(h: 1)` : `install ${name}()`;
|
|
332
346
|
lang = 'en';
|
|
333
347
|
}
|
|
334
348
|
document.body.innerHTML = c.fixture;
|
|
@@ -386,15 +400,44 @@ export async function initDirectPathHosts(): Promise<DirectPathHosts> {
|
|
|
386
400
|
}
|
|
387
401
|
};
|
|
388
402
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
403
|
+
/** Upstream on the English source: the oracle, or why there is none. */
|
|
404
|
+
type Oracle = { rejected: string | undefined; want: string };
|
|
405
|
+
const oracle = async (c: DirectPathCase): Promise<Oracle> => {
|
|
406
|
+
// A behavior definition is not a handler: it is checked by running it.
|
|
407
|
+
const rejected =
|
|
408
|
+
c.id.startsWith('behavior-handlers') || c.behaviorName
|
|
393
409
|
? undefined
|
|
394
410
|
: rejects(upstream, c.source);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
411
|
+
const want = rejected
|
|
412
|
+
? `✗upstream rejects: ${rejected}`
|
|
413
|
+
: await runAll(upstream, c, c.source, 'en');
|
|
414
|
+
return { rejected, want };
|
|
415
|
+
};
|
|
416
|
+
/** One translation, written or rendered, on the engine against the oracle. */
|
|
417
|
+
const pairFor = async (
|
|
418
|
+
c: DirectPathCase,
|
|
419
|
+
{ rejected, want }: Oracle,
|
|
420
|
+
lang: string,
|
|
421
|
+
written: string
|
|
422
|
+
): Promise<PairResult> => {
|
|
423
|
+
let english: string;
|
|
424
|
+
try {
|
|
425
|
+
english = preprocess(written, lang);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
english = `✗${(e as Error).message}`;
|
|
428
|
+
}
|
|
429
|
+
const got = await runAll(engine, c, written, lang);
|
|
430
|
+
let ref = want;
|
|
431
|
+
if (rejected && !english.startsWith('✗') && !rejects(upstream, english)) {
|
|
432
|
+
ref = await runAll(upstream, c, english, 'en');
|
|
433
|
+
}
|
|
434
|
+
const ok = got === ref && !EMPTY.test(ref) && !ref.startsWith('✗');
|
|
435
|
+
return { lang, written, english, got, ref, ok };
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
async runCase(c) {
|
|
440
|
+
const o = await oracle(c);
|
|
398
441
|
const eng = await runAll(engine, c, c.source, 'en');
|
|
399
442
|
const languages = c.languages === 'all23' ? [...FOREIGN_LANGUAGES] : c.languages;
|
|
400
443
|
const node = parseSemantic(c.source, 'en').node;
|
|
@@ -407,24 +450,15 @@ export async function initDirectPathHosts(): Promise<DirectPathHosts> {
|
|
|
407
450
|
written = null;
|
|
408
451
|
}
|
|
409
452
|
if (written === null) {
|
|
410
|
-
pairs.push({ lang, written, english: '', got: '✗untranslatable', ref: want, ok: false });
|
|
453
|
+
pairs.push({ lang, written, english: '', got: '✗untranslatable', ref: o.want, ok: false });
|
|
411
454
|
continue;
|
|
412
455
|
}
|
|
413
|
-
|
|
414
|
-
try {
|
|
415
|
-
english = preprocess(written, lang);
|
|
416
|
-
} catch (e) {
|
|
417
|
-
english = `✗${(e as Error).message}`;
|
|
418
|
-
}
|
|
419
|
-
const got = await runAll(engine, c, written, lang);
|
|
420
|
-
let ref = want;
|
|
421
|
-
if (rejected && !english.startsWith('✗') && !rejects(upstream, english)) {
|
|
422
|
-
ref = await runAll(upstream, c, english, 'en');
|
|
423
|
-
}
|
|
424
|
-
const ok = got === ref && !EMPTY.test(ref) && !ref.startsWith('✗');
|
|
425
|
-
pairs.push({ lang, written, english, got, ref, ok });
|
|
456
|
+
pairs.push(await pairFor(c, o, lang, written));
|
|
426
457
|
}
|
|
427
|
-
return { id: c.id, want, eng, pairs };
|
|
458
|
+
return { id: c.id, want: o.want, eng, pairs };
|
|
459
|
+
},
|
|
460
|
+
async runWritten(c, lang, written) {
|
|
461
|
+
return pairFor(c, await oracle(c), lang, written);
|
|
428
462
|
},
|
|
429
463
|
async close() {
|
|
430
464
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
@@ -34,6 +34,11 @@ function keys(source: string, render: string, language: string): string[] {
|
|
|
34
34
|
return scanner.scan(render, info, language).map(findingKey).sort();
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** The same, of one kind (the spaced markers of tr and ja stand beside their cases). */
|
|
38
|
+
function keysOf(kind: string, source: string, render: string, language: string): string[] {
|
|
39
|
+
return keys(source, render, language).filter(k => k.startsWith(`${kind}:`));
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
function findings(source: string, render: string, language: string): LeakFinding[] {
|
|
38
43
|
const info = scanner.sourceInfo(source, 'program');
|
|
39
44
|
if (!info) throw new Error(`the engine rejects ${source}`);
|
|
@@ -102,7 +107,7 @@ describe('english-leaks scanner', () => {
|
|
|
102
107
|
expect(
|
|
103
108
|
keys(
|
|
104
109
|
'on click js(me) return new Date() end then put it into #a',
|
|
105
|
-
'al clic js (me) return new Date() fin entonces
|
|
110
|
+
'al clic js (me) return new Date() fin entonces ponerlo en #a',
|
|
106
111
|
'es'
|
|
107
112
|
)
|
|
108
113
|
).toEqual([]);
|
|
@@ -128,7 +133,7 @@ describe('english-leaks scanner', () => {
|
|
|
128
133
|
expect(
|
|
129
134
|
keys(
|
|
130
135
|
'on click put me into #a then js(me) return 1 end',
|
|
131
|
-
'al clic
|
|
136
|
+
'al clic ponerme en #a entonces js (me) return 1 fin',
|
|
132
137
|
'es'
|
|
133
138
|
)
|
|
134
139
|
).toEqual([]);
|
|
@@ -195,10 +200,117 @@ describe('english-leaks scanner', () => {
|
|
|
195
200
|
|
|
196
201
|
it('counts a nominative `me` beside a marker, in the languages where that is wrong', () => {
|
|
197
202
|
expect(keys('on click add .b to me', 'al clic agregar .b a yo', 'es')).toEqual(['case:me']);
|
|
198
|
-
expect(
|
|
203
|
+
expect(keysOf('case', 'on click add .b to me', 'tıklama da .b i ben e ekle', 'tr')).toEqual([
|
|
204
|
+
'case:me',
|
|
205
|
+
]);
|
|
199
206
|
// fr writes the stressed form; ja's particle attaches to any noun.
|
|
200
207
|
expect(keys('on click add .b to me', 'sur clic ajouter .b à moi', 'fr')).toEqual([]);
|
|
201
|
-
expect(
|
|
208
|
+
expect(keysOf('case', 'on click add .b to me', 'クリック で 自分 に .b を 追加', 'ja')).toEqual(
|
|
209
|
+
[]
|
|
210
|
+
);
|
|
211
|
+
// The oblique forms are right.
|
|
212
|
+
expect(keys('on click add .b to me', 'al clic agregar .b a mí', 'es')).toEqual([]);
|
|
213
|
+
expect(keys('on click add .b to me', 'при клик добавить .b ко мне', 'ru')).toEqual([]);
|
|
214
|
+
// it writes `me` after a marker: the profile's own word, not English's.
|
|
215
|
+
expect(keys('on click put "x" into me', 'su clic mettere "x" in me', 'it')).toEqual([]);
|
|
216
|
+
expect(keys('on click put "x" into me', 'su clic mettere "x" in io', 'it')).toEqual([
|
|
217
|
+
'case:me',
|
|
218
|
+
]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('counts a nominative `it` beside a marker, where the language has another form', () => {
|
|
222
|
+
const source = 'on click add .b to it';
|
|
223
|
+
expect(keys(source, 'при клик добавить .b к это', 'ru')).toEqual(['case:it']);
|
|
224
|
+
expect(keys(source, 'wenn klick hinzufügen .b zu es', 'de')).toEqual(['case:it']);
|
|
225
|
+
expect(keysOf('case', source, 'tıklama i üzerinde o e .b i ekle', 'tr')).toEqual(['case:it']);
|
|
226
|
+
expect(keys(source, 'при клик добавить .b к этому', 'ru')).toEqual([]);
|
|
227
|
+
// es `ello` and it `esso` are right after a marker.
|
|
228
|
+
expect(keys(source, 'al clic agregar .b a ello', 'es')).toEqual([]);
|
|
229
|
+
expect(keys(source, 'su clic aggiungere .b a esso', 'it')).toEqual([]);
|
|
230
|
+
// pt: only `em` and `de` contract with `ele` (nele, dele); `a ele` is right.
|
|
231
|
+
expect(keys('on click put "x" into it', 'ao clique colocar "x" em ele', 'pt')).toEqual([
|
|
232
|
+
'case:it',
|
|
233
|
+
]);
|
|
234
|
+
expect(keys(source, 'ao clique adicionar .b a ele', 'pt')).toEqual([]);
|
|
235
|
+
// The ru/uk/pl demonstrative's accusative is its nominative: right after a
|
|
236
|
+
// marker that takes the accusative (a direction), wrong after one that never does.
|
|
237
|
+
const into = 'on click put "x" into it';
|
|
238
|
+
expect(keys(into, 'при клик положить "x" в это', 'ru')).toEqual([]);
|
|
239
|
+
expect(keys(into, 'gdy kliknięcie umieść "x" w to', 'pl')).toEqual([]);
|
|
240
|
+
expect(keys(source, 'gdy kliknięcie dodaj .b do to', 'pl')).toEqual(['case:it']);
|
|
241
|
+
expect(keys(source, 'при клік додати .b до це', 'uk')).toEqual(['case:it']);
|
|
242
|
+
// tl `bago ito` (before this) is right; `sa ito` is `dito`.
|
|
243
|
+
expect(keys(source, 'kapag click idagdag .b sa ito', 'tl')).toEqual(['case:it']);
|
|
244
|
+
expect(keys('on click wait 1s', 'kapag click maghintay bago ito', 'tl')).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('does not count a pronoun that opens a parenthesized expression', () => {
|
|
248
|
+
const source = 'on click set $b to (it * 2)';
|
|
249
|
+
expect(keys(source, 'wenn klick setze $b auf (es * 2)', 'de')).toEqual([]);
|
|
250
|
+
expect(keys('on click set $b to it', 'wenn klick setze $b auf es', 'de')).toEqual(['case:it']);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("counts a nominative pronoun as a verb's unmarked object, where that is wrong", () => {
|
|
254
|
+
expect(keys('on click measure me', 'wenn klick messen ich', 'de')).toEqual(['case:me-object']);
|
|
255
|
+
expect(keys('on click show me', 'al clic mostrar yo', 'es')).toEqual(['case:me-object']);
|
|
256
|
+
expect(keys('on click hide it', 'al clic ocultar ello', 'es')).toEqual(['case:it-object']);
|
|
257
|
+
expect(keys('on click hide it', 'quand clic cacher il', 'fr')).toEqual(['case:it-object']);
|
|
258
|
+
expect(keys('on click put me into #o', 'при клик положить я в #o', 'ru')).toEqual([
|
|
259
|
+
'case:me-object',
|
|
260
|
+
]);
|
|
261
|
+
// A verb its patterns write (de `verstecke`; the keyword is `verbergen`).
|
|
262
|
+
expect(keys('on click hide me', 'wenn klick verstecke ich', 'de')).toEqual(['case:me-object']);
|
|
263
|
+
// The right object forms, and the objects that are right in the nominative.
|
|
264
|
+
expect(keys('on click measure me', 'wenn klick messen mich', 'de')).toEqual([]);
|
|
265
|
+
expect(keys('on click hide it', 'wenn klick verstecke es', 'de')).toEqual([]);
|
|
266
|
+
expect(keys('on click hide it', 'при клик скрыть это', 'ru')).toEqual([]);
|
|
267
|
+
// fr writes the clitic before the infinitive (`m'afficher`, `me cacher`), and
|
|
268
|
+
// `me` there is its own word.
|
|
269
|
+
expect(keys('on click show me', 'quand clic afficher moi', 'fr')).toEqual(['case:me-object']);
|
|
270
|
+
expect(keys('on click show me', "quand clic m'afficher", 'fr')).toEqual([]);
|
|
271
|
+
expect(keys('on click hide me', 'quand clic me cacher', 'fr')).toEqual([]);
|
|
272
|
+
expect(keys('on click hide it', 'quand clic le cacher', 'fr')).toEqual([]);
|
|
273
|
+
// A condition is not an object: `if` is no verb.
|
|
274
|
+
expect(
|
|
275
|
+
keys('on click if it is empty hide me end', 'al clic si ello es vacío ocultar yo fin', 'es')
|
|
276
|
+
).toEqual(['case:me-object']);
|
|
277
|
+
// A word before the pronoun that is no verb at all.
|
|
278
|
+
expect(keys('on click put it into #o', 'al clic ello en #o', 'es')).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('counts a marker its language attaches, written apart (N5)', () => {
|
|
282
|
+
const source = 'on click put "x" into #out';
|
|
283
|
+
expect(keys(source, '클릭 할 때 "x" 을 #out 에 넣다', 'ko')).toEqual([
|
|
284
|
+
'spacing:에',
|
|
285
|
+
'spacing:을',
|
|
286
|
+
]);
|
|
287
|
+
expect(keys(source, 'クリック で "x" を #out に 置く', 'ja')).toEqual([
|
|
288
|
+
'spacing:で',
|
|
289
|
+
'spacing:に',
|
|
290
|
+
'spacing:を',
|
|
291
|
+
]);
|
|
292
|
+
expect(keys(source, 'ক্লিক তে "x" কে #out এ রাখুন', 'bn')).toEqual([
|
|
293
|
+
'spacing:এ',
|
|
294
|
+
'spacing:কে',
|
|
295
|
+
'spacing:তে',
|
|
296
|
+
]);
|
|
297
|
+
expect(keys(source, 'tıklama i üzerinde "x" i #out e koy', 'tr')).toEqual([
|
|
298
|
+
'spacing:e',
|
|
299
|
+
'spacing:i',
|
|
300
|
+
'spacing:i',
|
|
301
|
+
]);
|
|
302
|
+
// Attached, nothing; a postposition that is its own word is spaced.
|
|
303
|
+
expect(keys(source, '클릭 할 때 "x"를 #out에 넣다', 'ko')).toEqual([]);
|
|
304
|
+
expect(keys(source, 'クリックで "x"を #outに 置く', 'ja')).toEqual([]);
|
|
305
|
+
expect(keys(source, 'tıklamada "x"\'i #out\'a koy', 'tr')).toEqual([]);
|
|
306
|
+
expect(
|
|
307
|
+
keys('on click hide <p/> in #list', "tıklamada #list içindeki <p/>'yi gizle", 'tr')
|
|
308
|
+
).toEqual([]);
|
|
309
|
+
// A marker that opens a line has nothing to attach to.
|
|
310
|
+
expect(keys(source, 'クリックで "x"を\nに 置く', 'ja')).toEqual([]);
|
|
311
|
+
// Not in a language whose markers are words (es `a`), nor in qu's yet.
|
|
312
|
+
expect(keys(source, 'al clic poner "x" en #out', 'es')).toEqual([]);
|
|
313
|
+
expect(keys(source, 'maykama ñitiy "x" ta #out man churay', 'qu')).toEqual([]);
|
|
202
314
|
});
|
|
203
315
|
});
|
|
204
316
|
|
|
@@ -328,8 +440,8 @@ describe('M2 exit targets', () => {
|
|
|
328
440
|
|
|
329
441
|
it('allows no event and no pronoun case, and skips a lane with no render', () => {
|
|
330
442
|
const rates = leakRatesByLanguage(rows({ ru: ['event'], pl: ['case'], he: 'none' }), 'shapes');
|
|
331
|
-
expect(rates.get('ru')!.met).toEqual({ word: true, event: false, case: true });
|
|
332
|
-
expect(rates.get('pl')!.met).toEqual({ word: true, event: true, case: false });
|
|
443
|
+
expect(rates.get('ru')!.met).toEqual({ word: true, event: false, case: true, spacing: true });
|
|
444
|
+
expect(rates.get('pl')!.met).toEqual({ word: true, event: true, case: false, spacing: true });
|
|
333
445
|
expect(rates.has('he')).toBe(false);
|
|
334
446
|
});
|
|
335
447
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* passes all of them. Measured 2026-10-08, 18% of corpus renders and 24% of
|
|
8
8
|
* the command-shape scripts' renders hold at least one English word.
|
|
9
9
|
*
|
|
10
|
-
* This gate counts
|
|
10
|
+
* This gate counts four kinds of finding in a render of English `source`
|
|
11
11
|
* into a language:
|
|
12
12
|
* - `word` an English word the engine reads as grammar in the source (or a
|
|
13
13
|
* reference: `me`, `my`, `I`, `it`, …), written as English in the
|
|
@@ -15,8 +15,12 @@
|
|
|
15
15
|
* - `event` an event the source handles or sends, written in English where
|
|
16
16
|
* the language's lexicon has a word for it and the pair is not on
|
|
17
17
|
* the renderer's denylist (OPEN_ITEMS N2);
|
|
18
|
-
* - `case` the language's nominative `me` next to a role marker,
|
|
19
|
-
*
|
|
18
|
+
* - `case` the language's nominative `me` or `it` next to a role marker,
|
|
19
|
+
* or as a command's unmarked object, in the languages where that
|
|
20
|
+
* form is wrong (N3, see CASE_RULES);
|
|
21
|
+
* - `spacing` a marker the language attaches to its noun, written as its
|
|
22
|
+
* own word after one (ko `#out 에`, tr `#out e`; N5, see
|
|
23
|
+
* ATTACHED_MARKERS).
|
|
20
24
|
*
|
|
21
25
|
* ## What counts as English
|
|
22
26
|
* Derived, never a hand list. A source's grammar words are the tokens the
|
|
@@ -66,12 +70,17 @@ export type LeakHalf = keyof typeof LEAK_BASELINE_PATHS;
|
|
|
66
70
|
// Findings
|
|
67
71
|
// ---------------------------------------------------------------------------
|
|
68
72
|
|
|
69
|
-
export type LeakKind = 'word' | 'event' | 'case';
|
|
73
|
+
export type LeakKind = 'word' | 'event' | 'case' | 'spacing';
|
|
70
74
|
export type LeakContext = 'plain' | 'clause' | 'bracket' | 'call' | 'property';
|
|
75
|
+
export const KINDS: readonly LeakKind[] = ['word', 'event', 'case', 'spacing'];
|
|
71
76
|
|
|
72
77
|
export interface LeakFinding {
|
|
73
78
|
kind: LeakKind;
|
|
74
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* The English word; for `event` the event name; for `case` the pronoun (`me`,
|
|
81
|
+
* `it`), with `-object` when it is a verb's unmarked object (`me-object`); for
|
|
82
|
+
* `spacing` the marker.
|
|
83
|
+
*/
|
|
75
84
|
word: string;
|
|
76
85
|
context: LeakContext;
|
|
77
86
|
/** Does the language have its own word for it? */
|
|
@@ -106,33 +115,125 @@ export const KEPT_ENGLISH: {
|
|
|
106
115
|
],
|
|
107
116
|
};
|
|
108
117
|
|
|
109
|
-
/** A finding as the baseline writes it: the word, or `event:<name>` / `case:<pronoun>`. */
|
|
118
|
+
/** A finding as the baseline writes it: the word, or `event:<name>` / `case:<pronoun>` / `spacing:<marker>`. */
|
|
110
119
|
export const findingKey = (f: Pick<LeakFinding, 'kind' | 'word'>): string =>
|
|
111
120
|
f.kind === 'word' ? f.word : `${f.kind}:${f.word}`;
|
|
112
121
|
|
|
122
|
+
/** Where a language's nominative `me` / `it` is the wrong form ({@link CASE_RULES}). */
|
|
123
|
+
export interface CaseRule {
|
|
124
|
+
/** `me` beside a role marker. */
|
|
125
|
+
me?: true;
|
|
126
|
+
/**
|
|
127
|
+
* `it` beside a role marker: any marker, or only these (pt contracts only
|
|
128
|
+
* `em`, `de`; the ru/uk/pl demonstrative's accusative is its nominative, so
|
|
129
|
+
* it is wrong only after a marker that never takes the accusative).
|
|
130
|
+
*/
|
|
131
|
+
it?: true | readonly string[];
|
|
132
|
+
/** `me` / `it` as a command's unmarked object, right after its verb. */
|
|
133
|
+
meObject?: true;
|
|
134
|
+
itObject?: true;
|
|
135
|
+
}
|
|
136
|
+
|
|
113
137
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
138
|
+
* The languages whose nominative `me` or `it` is the wrong form where a render
|
|
139
|
+
* writes it (OPEN_ITEMS N3). A finding is `case:me` / `case:it` beside a role
|
|
140
|
+
* marker, `case:me-object` / `case:it-object` right after a command's verb with
|
|
141
|
+
* no marker. Wrong → right:
|
|
142
|
+
*
|
|
143
|
+
* ar إلى أنا → إليّ; إلى هو → إليه; ضع أنا → ضعني; ضع هو → ضعه
|
|
144
|
+
* bn আমি তে → আমাতে
|
|
145
|
+
* de zu ich → zu mir; zu es → dazu, zu ihm; messen ich → messen mich
|
|
146
|
+
* es a yo → a mí; mostrar yo → mostrarme; poner ello → ponerlo
|
|
147
|
+
* fr dans il → dans lui, y; mettre il → le mettre; cacher moi → me cacher
|
|
148
|
+
* he אל אני → אליי
|
|
149
|
+
* hi मैं में → मुझमें; यह में → इसमें
|
|
150
|
+
* it a io → a me; mostrare io → mostrarmi; mettere esso → metterlo
|
|
151
|
+
* pl do ja → do mnie; do to → do tego, z to → z tego; pokaż ja → pokaż mnie
|
|
152
|
+
* pt a eu → a mim; em ele → nele, de ele → dele; mostrar eu → mostrar-me;
|
|
153
|
+
* colocar ele → colocá-lo
|
|
154
|
+
* ru к я → ко мне; к это → к этому, из это → из этого; показать я → показать меня
|
|
155
|
+
* tl sa ako → sa akin; sa ito → dito, mula sa ito → mula rito
|
|
156
|
+
* tr ben e → bana; o e → ona
|
|
157
|
+
* uk до я → до мене; до це → до цього, з це → з цього; показати я → показати мене
|
|
158
|
+
*
|
|
159
|
+
* Right as written: fr `à moi` (after a preposition); es `ello`, it `esso` and pt `ele` after any
|
|
160
|
+
* other marker (`a ello`, `a esso`, `a ele`); ru `в это`, uk `в це`, pl `w to`
|
|
161
|
+
* and `на`/`na` with them, the accusative of a direction (a location wants
|
|
162
|
+
* `в этом`, which the text cannot tell apart: not counted); tl `bago ito`,
|
|
163
|
+
* `matapos ito`; the objects de `es`, ru `это`, uk `це`, pl `to`. A pronoun
|
|
164
|
+
* that opens a parenthesized expression (`auf (es * 2)`) is not the marker's.
|
|
165
|
+
* Not counted here: he `ב זה` and bn `এটি কে`, whose fault is
|
|
166
|
+
* the spacing (N5); `you`, which waits on an owner decision (de `dir` is a
|
|
167
|
+
* plausible variable name). ja, zh, id, ms, vi, th, ko, sw and qu do not
|
|
168
|
+
* inflect these pronouns for case.
|
|
121
169
|
*/
|
|
122
|
-
export const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
'
|
|
132
|
-
'
|
|
133
|
-
'
|
|
134
|
-
'
|
|
135
|
-
|
|
170
|
+
export const CASE_RULES: Readonly<Record<string, CaseRule>> = {
|
|
171
|
+
ar: { me: true, it: true, meObject: true, itObject: true },
|
|
172
|
+
bn: { me: true },
|
|
173
|
+
de: { me: true, it: true, meObject: true },
|
|
174
|
+
es: { me: true, meObject: true, itObject: true },
|
|
175
|
+
fr: { it: true, meObject: true, itObject: true },
|
|
176
|
+
he: { me: true },
|
|
177
|
+
hi: { me: true, it: true },
|
|
178
|
+
it: { me: true, meObject: true, itObject: true },
|
|
179
|
+
pl: { me: true, it: ['do', 'z', 'ze', 'od'], meObject: true },
|
|
180
|
+
pt: { me: true, it: ['em', 'de'], meObject: true, itObject: true },
|
|
181
|
+
ru: { me: true, it: ['к', 'ко', 'из', 'от', 'с', 'со'], meObject: true },
|
|
182
|
+
tl: { me: true, it: ['sa', 'mula_sa'] },
|
|
183
|
+
tr: { me: true, it: true },
|
|
184
|
+
uk: { me: true, it: ['до', 'з', 'із', 'від'], meObject: true },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The markers each language attaches to the word before them, where a render
|
|
189
|
+
* that writes them apart reads as broken (OPEN_ITEMS N5, FUSED_FORMS.md §3.3):
|
|
190
|
+
* a `spacing:<marker>` finding is one of these written as its own word after
|
|
191
|
+
* another word (`#out 에` → `#out에`, `.active i` → `.active'i`). The case
|
|
192
|
+
* particles and suffixes only, which attach in the language's own writing; a
|
|
193
|
+
* postposition that is a word of its own (tr `içinde`, `arasında`, ko `사이`,
|
|
194
|
+
* `동안`, bn `থেকে`, `মধ্যে`) is spaced there and is not listed. tr's suffixes
|
|
195
|
+
* are every harmony form its reader takes. qu attaches its suffixes too, but
|
|
196
|
+
* its reader takes an attached one into the word before (`#outman` is one
|
|
197
|
+
* selector), so it waits on the reader (FUSED_FORMS.md Q2, Q3).
|
|
198
|
+
*/
|
|
199
|
+
export const ATTACHED_MARKERS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
200
|
+
bn: new Set(['কে', 'তে', 'এ']),
|
|
201
|
+
ja: new Set(['を', 'に', 'へ', 'で', 'の', 'から', 'として', 'の間で']),
|
|
202
|
+
ko: new Set(['을', '를', '에', '으로', '로', '의', '에서', '부터', '에게']),
|
|
203
|
+
tr: new Set(
|
|
204
|
+
[
|
|
205
|
+
'i ı u ü yi yı yu yü ni nı nu nü',
|
|
206
|
+
'e a ye ya ne na',
|
|
207
|
+
'de da te ta',
|
|
208
|
+
'den dan ten tan',
|
|
209
|
+
'in ın un ün nin nın nun nün',
|
|
210
|
+
'le la yle yla',
|
|
211
|
+
].flatMap(forms => forms.split(' '))
|
|
212
|
+
),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Commands whose next word is not their object: a handler or block head, or a
|
|
217
|
+
* condition (`if it is empty`, `while it …`).
|
|
218
|
+
*/
|
|
219
|
+
const NOT_OBJECT_HEADS: ReadonlySet<string> = new Set([
|
|
220
|
+
'on',
|
|
221
|
+
'if',
|
|
222
|
+
'unless',
|
|
223
|
+
'else',
|
|
224
|
+
'repeat',
|
|
225
|
+
'for',
|
|
226
|
+
'while',
|
|
227
|
+
'when',
|
|
228
|
+
'init',
|
|
229
|
+
'behavior',
|
|
230
|
+
'install',
|
|
231
|
+
'live',
|
|
232
|
+
'js',
|
|
233
|
+
'compound',
|
|
234
|
+
'eventsource',
|
|
235
|
+
'socket',
|
|
236
|
+
'worker',
|
|
136
237
|
]);
|
|
137
238
|
|
|
138
239
|
// ---------------------------------------------------------------------------
|
|
@@ -210,8 +311,13 @@ interface ProfileLike {
|
|
|
210
311
|
keywords?: Record<string, string>;
|
|
211
312
|
};
|
|
212
313
|
eventHandler?: Record<string, unknown>;
|
|
314
|
+
objectClitics?: { forms?: Record<string, string>; attach?: string };
|
|
213
315
|
}
|
|
214
316
|
type LexiconLike = Record<string, Record<string, KeywordTranslationLike> | undefined>;
|
|
317
|
+
interface PatternLike {
|
|
318
|
+
command: string;
|
|
319
|
+
template?: { tokens?: ReadonlyArray<{ type: string; value?: string; alternatives?: string[] }> };
|
|
320
|
+
}
|
|
215
321
|
|
|
216
322
|
const LEXICON_CATEGORIES = ['values', 'expressions', 'logical', 'temporal', 'attributes'] as const;
|
|
217
323
|
|
|
@@ -226,16 +332,20 @@ interface LanguageWords {
|
|
|
226
332
|
translated: Set<string>;
|
|
227
333
|
/** Events its lexicon names, minus the renderer's denylist for it. */
|
|
228
334
|
events: Set<string>;
|
|
229
|
-
/** Its `me`
|
|
335
|
+
/** Its `me` and `it` words, its role markers by side, and its command verbs (as word lists). */
|
|
230
336
|
me: string | undefined;
|
|
337
|
+
it: string | undefined;
|
|
231
338
|
markersBefore: Set<string>;
|
|
232
339
|
markersAfter: Set<string>;
|
|
340
|
+
verbs: string[][];
|
|
233
341
|
}
|
|
234
342
|
|
|
235
343
|
function languageWords(
|
|
236
344
|
profile: ProfileLike | undefined,
|
|
237
345
|
lexicon: LexiconLike | undefined,
|
|
238
|
-
denied: ReadonlySet<string> | undefined
|
|
346
|
+
denied: ReadonlySet<string> | undefined,
|
|
347
|
+
commands: readonly string[],
|
|
348
|
+
patterns: readonly PatternLike[]
|
|
239
349
|
): LanguageWords {
|
|
240
350
|
const native = new Set<string>();
|
|
241
351
|
const translated = new Set<string>();
|
|
@@ -266,6 +376,10 @@ function languageWords(
|
|
|
266
376
|
// Native form → the English reference it stands for (es `mi` → `me`).
|
|
267
377
|
for (const n of Object.keys(profile?.possessive?.keywords ?? {}))
|
|
268
378
|
for (const w of wordsOf(n)) native.add(w);
|
|
379
|
+
// A clitic written as its own word (fr `me cacher`) is the language's; one
|
|
380
|
+
// attached to its verb (es `mostrarme`) never stands alone.
|
|
381
|
+
if (profile?.objectClitics?.attach === 'before')
|
|
382
|
+
for (const clitic of Object.values(profile.objectClitics.forms ?? {})) native.add(clitic);
|
|
269
383
|
for (const w of wordsOf(profile?.possessive?.marker)) native.add(w);
|
|
270
384
|
const handler = JSON.stringify(profile?.eventHandler ?? {});
|
|
271
385
|
for (const m of handler.matchAll(
|
|
@@ -285,13 +399,29 @@ function languageWords(
|
|
|
285
399
|
const markersAfter = new Set<string>();
|
|
286
400
|
for (const t of Object.values(profile?.roleMarkers ?? {}))
|
|
287
401
|
for (const w of spellings(t)) (t.position === 'after' ? markersAfter : markersBefore).add(w);
|
|
402
|
+
// A command's verb: its keyword, and the word its own patterns open with
|
|
403
|
+
// (de's `hide` pattern writes `verstecke`, its keyword is `verbergen`).
|
|
404
|
+
const verbForms: string[] = [];
|
|
405
|
+
for (const command of commands) {
|
|
406
|
+
const t = profile?.keywords?.[command];
|
|
407
|
+
if (t && !NOT_OBJECT_HEADS.has(command))
|
|
408
|
+
verbForms.push(t.primary ?? '', ...(t.alternatives ?? []));
|
|
409
|
+
}
|
|
410
|
+
for (const pattern of patterns) {
|
|
411
|
+
const first = pattern.template?.tokens?.[0];
|
|
412
|
+
if (!NOT_OBJECT_HEADS.has(pattern.command) && first?.type === 'literal')
|
|
413
|
+
verbForms.push(first.value ?? '', ...(first.alternatives ?? []));
|
|
414
|
+
}
|
|
415
|
+
const verbs = [...new Set(verbForms.filter(Boolean))].map(wordsOf);
|
|
288
416
|
return {
|
|
289
417
|
native,
|
|
290
418
|
translated,
|
|
291
419
|
events,
|
|
292
420
|
me: profile?.references?.me,
|
|
421
|
+
it: profile?.references?.it,
|
|
293
422
|
markersBefore,
|
|
294
423
|
markersAfter,
|
|
424
|
+
verbs,
|
|
295
425
|
};
|
|
296
426
|
}
|
|
297
427
|
|
|
@@ -389,6 +519,8 @@ interface SemanticModule {
|
|
|
389
519
|
tryGetProfile(language: string): unknown;
|
|
390
520
|
getLexicon(language: string): unknown;
|
|
391
521
|
getEventLocalizationDenylist(): Record<string, ReadonlySet<string>>;
|
|
522
|
+
commandSchemas: Record<string, unknown>;
|
|
523
|
+
getPatternsForLanguage(language: string): PatternLike[];
|
|
392
524
|
}
|
|
393
525
|
|
|
394
526
|
/** Load the engine and semantic (ESM-only, so imported here) and return a scanner. */
|
|
@@ -397,6 +529,7 @@ export async function initLeakScanner(): Promise<LeakScanner> {
|
|
|
397
529
|
engine.register(...engine.everything);
|
|
398
530
|
const semantic = (await import('@lokascript/semantic')) as unknown as SemanticModule;
|
|
399
531
|
const denylist = semantic.getEventLocalizationDenylist();
|
|
532
|
+
const commands = Object.keys(semantic.commandSchemas);
|
|
400
533
|
const languages = new Map<string, LanguageWords>();
|
|
401
534
|
for (const language of FOREIGN_LANGUAGES) {
|
|
402
535
|
languages.set(
|
|
@@ -404,7 +537,9 @@ export async function initLeakScanner(): Promise<LeakScanner> {
|
|
|
404
537
|
languageWords(
|
|
405
538
|
semantic.tryGetProfile(language) as ProfileLike | undefined,
|
|
406
539
|
semantic.getLexicon(language) as LexiconLike | undefined,
|
|
407
|
-
denylist[language]
|
|
540
|
+
denylist[language],
|
|
541
|
+
commands,
|
|
542
|
+
semantic.getPatternsForLanguage(language)
|
|
408
543
|
)
|
|
409
544
|
);
|
|
410
545
|
}
|
|
@@ -532,14 +667,44 @@ export async function initLeakScanner(): Promise<LeakScanner> {
|
|
|
532
667
|
...(isKept(offset, context) ? { kept: true as const } : {}),
|
|
533
668
|
});
|
|
534
669
|
}
|
|
535
|
-
|
|
670
|
+
const rule = CASE_RULES[language];
|
|
671
|
+
if (rule) {
|
|
536
672
|
const tokens = words.map(w => w.word);
|
|
673
|
+
const afterVerb = (i: number): boolean =>
|
|
674
|
+
lang.verbs.some(
|
|
675
|
+
verb => i >= verb.length && verb.every((w, k) => tokens[i - verb.length + k] === w)
|
|
676
|
+
);
|
|
537
677
|
for (let i = 0; i < tokens.length; i++) {
|
|
538
|
-
|
|
678
|
+
const pronoun = tokens[i] === lang.me ? 'me' : tokens[i] === lang.it ? 'it' : undefined;
|
|
679
|
+
if (!pronoun || /[([]/.test(text[words[i]!.offset - 1] ?? '')) continue;
|
|
539
680
|
const before = tokens[i - 1];
|
|
540
681
|
const after = tokens[i + 1];
|
|
541
|
-
|
|
542
|
-
|
|
682
|
+
const marker =
|
|
683
|
+
before && lang.markersBefore.has(before)
|
|
684
|
+
? before
|
|
685
|
+
: after && lang.markersAfter.has(after)
|
|
686
|
+
? after
|
|
687
|
+
: undefined;
|
|
688
|
+
const wrong = rule[pronoun];
|
|
689
|
+
const wrongBeside =
|
|
690
|
+
marker !== undefined && (wrong === true || (!!wrong && wrong.includes(marker)));
|
|
691
|
+
const wrongObject = !!rule[`${pronoun}Object`] && afterVerb(i);
|
|
692
|
+
if (wrongBeside || wrongObject)
|
|
693
|
+
findings.push({
|
|
694
|
+
kind: 'case',
|
|
695
|
+
word: wrongObject ? `${pronoun}-object` : pronoun,
|
|
696
|
+
context: 'plain',
|
|
697
|
+
hasWord: true,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const attached = ATTACHED_MARKERS[language];
|
|
702
|
+
if (attached) {
|
|
703
|
+
for (const { word, offset } of words) {
|
|
704
|
+
if (!attached.has(word)) continue;
|
|
705
|
+
// After a word on its line, strings included (`"x" 을`): the render, not the code.
|
|
706
|
+
const before = /(\S)[^\S\n]+$/.exec(render.slice(0, offset));
|
|
707
|
+
if (before) findings.push({ kind: 'spacing', word, context: 'plain', hasWord: true });
|
|
543
708
|
}
|
|
544
709
|
}
|
|
545
710
|
return findings;
|
|
@@ -597,6 +762,7 @@ export function leakBaselineFrom(results: LeakResults, description: string): Lea
|
|
|
597
762
|
word: { renders: 0, findings: 0 },
|
|
598
763
|
event: { renders: 0, findings: 0 },
|
|
599
764
|
case: { renders: 0, findings: 0 },
|
|
765
|
+
spacing: { renders: 0, findings: 0 },
|
|
600
766
|
},
|
|
601
767
|
entries: {},
|
|
602
768
|
unrendered: {},
|
|
@@ -613,7 +779,7 @@ export function leakBaselineFrom(results: LeakResults, description: string): Lea
|
|
|
613
779
|
if (!findings.length) continue;
|
|
614
780
|
doc.leaky++;
|
|
615
781
|
doc.findings += findings.length;
|
|
616
|
-
for (const kind of
|
|
782
|
+
for (const kind of KINDS) {
|
|
617
783
|
const n = findings.filter(f => f.kind === kind).length;
|
|
618
784
|
doc.kinds[kind].findings += n;
|
|
619
785
|
if (n) doc.kinds[kind].renders++;
|
|
@@ -711,15 +877,14 @@ export function loadLeakBaseline(half: LeakHalf): LeakBaseline {
|
|
|
711
877
|
* reader reads one, and an average hid he (35% of corpus renders with an English
|
|
712
878
|
* word where the others sit near 18%). Words count only English no recorded
|
|
713
879
|
* decision keeps ({@link KEPT_ENGLISH}). Events are already net of the denylist,
|
|
714
|
-
* whose entries carry reasons.
|
|
880
|
+
* whose entries carry reasons. Spacing (N5) joined on 2026-10-11, with none
|
|
881
|
+
* allowed, as case did (FUSED_FORMS.md Q3).
|
|
715
882
|
*/
|
|
716
883
|
export const LEAK_TARGETS: Readonly<Record<LeakHalf, Readonly<Record<LeakKind, number>>>> = {
|
|
717
|
-
corpus: { word: 0.05, event: 0, case: 0 },
|
|
718
|
-
shapes: { word: 0.1, event: 0, case: 0 },
|
|
884
|
+
corpus: { word: 0.05, event: 0, case: 0, spacing: 0 },
|
|
885
|
+
shapes: { word: 0.1, event: 0, case: 0, spacing: 0 },
|
|
719
886
|
};
|
|
720
887
|
|
|
721
|
-
const KINDS: readonly LeakKind[] = ['word', 'event', 'case'];
|
|
722
|
-
|
|
723
888
|
export interface LanguageLeakRates {
|
|
724
889
|
renders: number;
|
|
725
890
|
/** The share of the language's renders with a finding of each kind. */
|
|
@@ -737,7 +902,7 @@ export function leakRatesByLanguage(
|
|
|
737
902
|
for (const byLanguage of results.values()) {
|
|
738
903
|
for (const [language, findings] of byLanguage) {
|
|
739
904
|
if (!findings) continue;
|
|
740
|
-
const row = counts.get(language) ?? { renders: 0, word: 0, event: 0, case: 0 };
|
|
905
|
+
const row = counts.get(language) ?? { renders: 0, word: 0, event: 0, case: 0, spacing: 0 };
|
|
741
906
|
row.renders++;
|
|
742
907
|
for (const kind of KINDS) if (findings.some(f => f.kind === kind && !f.kept)) row[kind]++;
|
|
743
908
|
counts.set(language, row);
|
|
@@ -745,8 +910,8 @@ export function leakRatesByLanguage(
|
|
|
745
910
|
}
|
|
746
911
|
const out = new Map<string, LanguageLeakRates>();
|
|
747
912
|
for (const [language, row] of counts) {
|
|
748
|
-
const rate = { word: 0, event: 0, case: 0 };
|
|
749
|
-
const met = { word: true, event: true, case: true };
|
|
913
|
+
const rate = { word: 0, event: 0, case: 0, spacing: 0 };
|
|
914
|
+
const met = { word: true, event: true, case: true, spacing: true };
|
|
750
915
|
for (const kind of KINDS) {
|
|
751
916
|
rate[kind] = row[kind] / row.renders;
|
|
752
917
|
met[kind] = rate[kind] <= LEAK_TARGETS[half][kind];
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "t01",
|
|
4
|
+
"ask": "When the button is clicked, toggle the class \"active\" on it.",
|
|
5
|
+
"source": "on click toggle .active on me",
|
|
6
|
+
"fixture": "<button></button>",
|
|
7
|
+
"installOn": "button",
|
|
8
|
+
"event": "click"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "t02",
|
|
12
|
+
"ask": "When the button is clicked, put the text Saved into the element with id out.",
|
|
13
|
+
"source": "on click put \"Saved\" into #out",
|
|
14
|
+
"fixture": "<button></button><div id=\"out\"></div>",
|
|
15
|
+
"installOn": "button",
|
|
16
|
+
"event": "click"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "t03",
|
|
20
|
+
"ask": "When the button is clicked, hide the element with id panel.",
|
|
21
|
+
"source": "on click hide #panel",
|
|
22
|
+
"fixture": "<button></button><div id=\"panel\">x</div>",
|
|
23
|
+
"installOn": "button",
|
|
24
|
+
"event": "click"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "t04",
|
|
28
|
+
"ask": "When the button is clicked, add the class \"open\" to the element with id menu, then remove its class \"closed\".",
|
|
29
|
+
"source": "on click add .open to #menu then remove .closed from #menu",
|
|
30
|
+
"fixture": "<button></button><div id=\"menu\" class=\"closed\"></div>",
|
|
31
|
+
"installOn": "button",
|
|
32
|
+
"event": "click"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "t05",
|
|
36
|
+
"ask": "When the button is clicked, fetch /api/greeting and put the result into the element with id out.",
|
|
37
|
+
"source": "on click fetch /api/greeting then put it into #out",
|
|
38
|
+
"fixture": "<button></button><div id=\"out\"></div>",
|
|
39
|
+
"installOn": "button",
|
|
40
|
+
"event": "click"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "t06",
|
|
44
|
+
"ask": "When the button is clicked: if it has the class \"on\", remove that class; otherwise add it.",
|
|
45
|
+
"source": "on click if I match .on remove .on else add .on end",
|
|
46
|
+
"fixture": "<button class=\"on\"></button>",
|
|
47
|
+
"installOn": "button",
|
|
48
|
+
"event": "click"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "t07",
|
|
52
|
+
"ask": "When the button is clicked, append the text \"x\" to the element with id out three times.",
|
|
53
|
+
"source": "on click repeat 3 times append \"x\" to #out end",
|
|
54
|
+
"fixture": "<button></button><div id=\"out\"></div>",
|
|
55
|
+
"installOn": "button",
|
|
56
|
+
"event": "click"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "t08",
|
|
60
|
+
"ask": "When the button is clicked, send a custom event named refresh to the element with id list, then add the class \"sent\" to the button.",
|
|
61
|
+
"source": "on click send refresh to #list then add .sent to me",
|
|
62
|
+
"fixture": "<button></button><ul id=\"list\"></ul>",
|
|
63
|
+
"installOn": "button",
|
|
64
|
+
"event": "click"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "t09",
|
|
68
|
+
"ask": "When the button is clicked, set the variable $n to 5 and put it into the element with id out.",
|
|
69
|
+
"source": "on click set $n to 5 then put $n into #out",
|
|
70
|
+
"fixture": "<button></button><div id=\"out\"></div>",
|
|
71
|
+
"installOn": "button",
|
|
72
|
+
"event": "click"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "t10",
|
|
76
|
+
"ask": "When the button is clicked, increase the variable $count by one and put it into the element with id out.",
|
|
77
|
+
"source": "on click increment $count then put $count into #out",
|
|
78
|
+
"fixture": "<button></button><div id=\"out\"></div>",
|
|
79
|
+
"installOn": "button",
|
|
80
|
+
"event": "click"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "t11",
|
|
84
|
+
"ask": "Define a behavior named Hider that hides the element when it is clicked (the page installs it on the button with: install Hider).",
|
|
85
|
+
"source": "behavior Hider\n on click hide me end\nend",
|
|
86
|
+
"fixture": "<button></button>",
|
|
87
|
+
"installOn": "button",
|
|
88
|
+
"event": "click"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"id": "t12",
|
|
92
|
+
"ask": "When the input receives input, put its value into the element with id out.",
|
|
93
|
+
"source": "on input put my value into #out",
|
|
94
|
+
"fixture": "<input value=\"hi\"><div id=\"out\"></div>",
|
|
95
|
+
"installOn": "input",
|
|
96
|
+
"event": "input"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "t13",
|
|
100
|
+
"ask": "When the button is clicked, put the value of the input with id field into the element with id out.",
|
|
101
|
+
"source": "on click put the value of #field into #out",
|
|
102
|
+
"fixture": "<button></button><input id=\"field\" value=\"abc\"><div id=\"out\"></div>",
|
|
103
|
+
"installOn": "button",
|
|
104
|
+
"event": "click"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": "t14",
|
|
108
|
+
"ask": "When the button is clicked, add the class \"sel\" to the first list item inside the element with id list.",
|
|
109
|
+
"source": "on click add .sel to the first <li/> in #list",
|
|
110
|
+
"fixture": "<button></button><ul id=\"list\"><li>a</li><li>b</li></ul>",
|
|
111
|
+
"installOn": "button",
|
|
112
|
+
"event": "click"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"id": "t15",
|
|
116
|
+
"ask": "When the button is clicked, set its attribute aria-expanded to \"true\".",
|
|
117
|
+
"source": "on click set @aria-expanded to \"true\"",
|
|
118
|
+
"fixture": "<button aria-expanded=\"false\"></button>",
|
|
119
|
+
"installOn": "button",
|
|
120
|
+
"event": "click"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "t16",
|
|
124
|
+
"ask": "When the button is clicked, put the text \"x\" at the end of the element with id out.",
|
|
125
|
+
"source": "on click put \"x\" at end of #out",
|
|
126
|
+
"fixture": "<button></button><div id=\"out\">start</div>",
|
|
127
|
+
"installOn": "button",
|
|
128
|
+
"event": "click"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "t17",
|
|
132
|
+
"ask": "When the button is clicked, for every element with the class \"item\", add the class \"seen\" to it.",
|
|
133
|
+
"source": "on click for item in .item add .seen to item end",
|
|
134
|
+
"fixture": "<button></button><p class=\"item\"></p><p class=\"item\"></p>",
|
|
135
|
+
"installOn": "button",
|
|
136
|
+
"event": "click"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"id": "t18",
|
|
140
|
+
"ask": "When the button is clicked, if there is no element with class \"item\" inside the element with id list, put the text \"empty\" into the element with id out.",
|
|
141
|
+
"source": "on click if no .item in #list put \"empty\" into #out end",
|
|
142
|
+
"fixture": "<button></button><ul id=\"list\"></ul><div id=\"out\"></div>",
|
|
143
|
+
"installOn": "button",
|
|
144
|
+
"event": "click"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"id": "t19",
|
|
148
|
+
"ask": "When the button is clicked, take the class \"selected\" from whichever element with class \"tab\" has it, and give it to the button.",
|
|
149
|
+
"source": "on click take .selected from .tab for me",
|
|
150
|
+
"fixture": "<button class=\"tab\"></button><a class=\"tab selected\"></a>",
|
|
151
|
+
"installOn": "button",
|
|
152
|
+
"event": "click"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"id": "t20",
|
|
156
|
+
"ask": "When the button is clicked, remove the button.",
|
|
157
|
+
"source": "on click remove me",
|
|
158
|
+
"fixture": "<button></button>",
|
|
159
|
+
"installOn": "button",
|
|
160
|
+
"event": "click"
|
|
161
|
+
}
|
|
162
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hand-written-input gate: see hand-written-input.ts.
|
|
3
|
+
*
|
|
4
|
+
* @vitest-environment node
|
|
5
|
+
* Required, not a preference: the hosts bind the jsdom window's DOM
|
|
6
|
+
* constructors (see shipped-examples-execution.test.ts).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
formatReport,
|
|
11
|
+
loadInputs,
|
|
12
|
+
loadTasks,
|
|
13
|
+
runHandWritten,
|
|
14
|
+
type HandWrittenReport,
|
|
15
|
+
} from './hand-written-input';
|
|
16
|
+
|
|
17
|
+
describe('hand-written inputs', () => {
|
|
18
|
+
let report: HandWrittenReport;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
report = await runHandWritten();
|
|
22
|
+
console.log(formatReport(report));
|
|
23
|
+
}, 300_000);
|
|
24
|
+
|
|
25
|
+
it('has about twenty tasks, each with a reference, a fixture and an event', () => {
|
|
26
|
+
const tasks = loadTasks();
|
|
27
|
+
expect(tasks.length).toBeGreaterThanOrEqual(18);
|
|
28
|
+
expect(tasks.length).toBeLessThanOrEqual(25);
|
|
29
|
+
for (const t of tasks) {
|
|
30
|
+
expect(t.ask.length).toBeGreaterThan(10);
|
|
31
|
+
expect(t.fixture).toContain('<');
|
|
32
|
+
expect(t.event).toBeTruthy();
|
|
33
|
+
}
|
|
34
|
+
expect(new Set(tasks.map(t => t.id)).size).toBe(tasks.length);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('every reference runs the same on upstream and the engine, and does something', () => {
|
|
38
|
+
const bad = report.references.filter(r => !r.holds);
|
|
39
|
+
expect(bad.map(r => `${r.task}: upstream ${r.want} | engine ${r.eng}`)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('every input names a task', () => {
|
|
43
|
+
expect(report.unknownTasks).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('every input file is valid JSON of {task, source, writer} rows', () => {
|
|
47
|
+
for (const rows of Object.values(loadInputs())) {
|
|
48
|
+
for (const r of rows) {
|
|
49
|
+
expect(typeof r.task).toBe('string');
|
|
50
|
+
expect(typeof r.source).toBe('string');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// No input is seeded here (a render of ours would measure nothing). Once
|
|
56
|
+
// native speakers' files exist, this is where a verdict is held: a `silent`
|
|
57
|
+
// input is a reader bug (the writer was told nothing), to fix or to list.
|
|
58
|
+
it('no hand-written input is silent', () => {
|
|
59
|
+
const silent = report.results.filter(r => r.verdict === 'silent');
|
|
60
|
+
expect(silent.map(r => `${r.lang} ${r.task} ${r.source}`)).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hand-written-input gate (M2, reliability row 6).
|
|
3
|
+
*
|
|
4
|
+
* Every other multilingual gate reads what OUR renderer writes. This one reads
|
|
5
|
+
* what a native speaker writes: about twenty tasks (`hand-written-input.tasks.json`),
|
|
6
|
+
* each an English reference source with a fixture and an event, and one input
|
|
7
|
+
* file per language under `hand-written-inputs/` holding the sources a native
|
|
8
|
+
* speaker wrote from the task's English description and the language's word
|
|
9
|
+
* list, without seeing our renders. Each input runs AS WRITTEN on
|
|
10
|
+
* `@hyperfixi/engine` with the adapter (direct-path-shapes.ts's hosts), against
|
|
11
|
+
* upstream `_hyperscript` running the English reference.
|
|
12
|
+
*
|
|
13
|
+
* Per task and language the verdict is one of:
|
|
14
|
+
* - `accepted`: the input ran and left the DOM the reference leaves;
|
|
15
|
+
* - `refused`: the adapter refused the input (translate threw: the writer is told);
|
|
16
|
+
* - `silent`: the input ran, or the engine rejected it, with another result.
|
|
17
|
+
*
|
|
18
|
+
* NEVER seed the inputs with our renders: the gate would then measure nothing.
|
|
19
|
+
* With no inputs it reports "no inputs yet" and still checks the references
|
|
20
|
+
* (upstream and the engine run each English source identically).
|
|
21
|
+
*
|
|
22
|
+
* The writer's instructions: `docs-internal/multilingual/HAND_WRITTEN_INPUT.md`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import {
|
|
29
|
+
initDirectPathHosts,
|
|
30
|
+
type DirectPathCase,
|
|
31
|
+
type DirectPathHosts,
|
|
32
|
+
type PairResult,
|
|
33
|
+
} from './direct-path-shapes';
|
|
34
|
+
|
|
35
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
export const TASKS_FILE = path.join(here, 'hand-written-input.tasks.json');
|
|
37
|
+
export const INPUTS_DIR = path.resolve(here, '../../hand-written-inputs');
|
|
38
|
+
|
|
39
|
+
export interface HandWrittenTask extends DirectPathCase {
|
|
40
|
+
/** What the writer is asked to make the page do, in plain English. */
|
|
41
|
+
ask: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TaskRow {
|
|
45
|
+
id: string;
|
|
46
|
+
ask: string;
|
|
47
|
+
source: string;
|
|
48
|
+
fixture: string;
|
|
49
|
+
installOn: string;
|
|
50
|
+
event: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadTasks(): HandWrittenTask[] {
|
|
54
|
+
const rows = JSON.parse(readFileSync(TASKS_FILE, 'utf8')) as TaskRow[];
|
|
55
|
+
return rows.map(t => {
|
|
56
|
+
const behavior = /^behavior\s+(\w+)/.exec(t.source);
|
|
57
|
+
return {
|
|
58
|
+
id: `hand-written#${t.id}`,
|
|
59
|
+
ask: t.ask,
|
|
60
|
+
source: t.source,
|
|
61
|
+
fixture: t.fixture,
|
|
62
|
+
installOn: t.installOn,
|
|
63
|
+
event: t.event,
|
|
64
|
+
languages: [],
|
|
65
|
+
...(behavior ? { behaviorName: behavior[1] } : {}),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface HandWrittenInput {
|
|
71
|
+
/** The task's id as the tasks file spells it (`t01`). */
|
|
72
|
+
task: string;
|
|
73
|
+
/** The hyperscript the writer wrote, in their language. */
|
|
74
|
+
source: string;
|
|
75
|
+
/** Who wrote it (a name or initials; one file may hold several writers). */
|
|
76
|
+
writer?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `hand-written-inputs/<lang>.json`: `[{ "task": "t01", "source": "…", "writer": "…" }]`. */
|
|
80
|
+
export function loadInputs(dir = INPUTS_DIR): Record<string, HandWrittenInput[]> {
|
|
81
|
+
const inputs: Record<string, HandWrittenInput[]> = {};
|
|
82
|
+
if (!existsSync(dir)) return inputs;
|
|
83
|
+
for (const file of readdirSync(dir).sort()) {
|
|
84
|
+
const m = /^([a-z]{2})\.json$/.exec(file);
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
const rows = JSON.parse(readFileSync(path.join(dir, file), 'utf8')) as HandWrittenInput[];
|
|
87
|
+
inputs[m[1]!] = rows.filter(r => typeof r.source === 'string' && r.source.trim() !== '');
|
|
88
|
+
}
|
|
89
|
+
return inputs;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type Verdict = 'accepted' | 'refused' | 'silent';
|
|
93
|
+
|
|
94
|
+
export interface HandWrittenResult {
|
|
95
|
+
task: string;
|
|
96
|
+
lang: string;
|
|
97
|
+
writer: string;
|
|
98
|
+
source: string;
|
|
99
|
+
verdict: Verdict;
|
|
100
|
+
pair: PairResult;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function verdictOf(pair: PairResult): Verdict {
|
|
104
|
+
if (pair.ok) return 'accepted';
|
|
105
|
+
if (pair.english.startsWith('✗')) return 'refused';
|
|
106
|
+
return 'silent';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ReferenceCheck {
|
|
110
|
+
task: string;
|
|
111
|
+
want: string;
|
|
112
|
+
eng: string;
|
|
113
|
+
holds: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface HandWrittenReport {
|
|
117
|
+
references: ReferenceCheck[];
|
|
118
|
+
results: HandWrittenResult[];
|
|
119
|
+
/** Tasks the inputs name that the tasks file does not. */
|
|
120
|
+
unknownTasks: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run every input against its task. `hosts` may be shared with another gate;
|
|
125
|
+
* when omitted, one is opened and closed here.
|
|
126
|
+
*/
|
|
127
|
+
export async function runHandWritten(
|
|
128
|
+
inputs = loadInputs(),
|
|
129
|
+
hosts?: DirectPathHosts
|
|
130
|
+
): Promise<HandWrittenReport> {
|
|
131
|
+
const tasks = loadTasks();
|
|
132
|
+
const own = hosts ?? (await initDirectPathHosts());
|
|
133
|
+
try {
|
|
134
|
+
const references: ReferenceCheck[] = [];
|
|
135
|
+
for (const task of tasks) {
|
|
136
|
+
const r = await own.runCase(task);
|
|
137
|
+
references.push({
|
|
138
|
+
task: task.id,
|
|
139
|
+
want: r.want,
|
|
140
|
+
eng: r.eng,
|
|
141
|
+
holds: !r.want.startsWith('✗') && !/^∅( ‖ ∅)*$/.test(r.want) && r.eng === r.want,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const results: HandWrittenResult[] = [];
|
|
145
|
+
const unknownTasks: string[] = [];
|
|
146
|
+
for (const [lang, rows] of Object.entries(inputs)) {
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
const task = tasks.find(t => t.id === `hand-written#${row.task}`);
|
|
149
|
+
if (!task) {
|
|
150
|
+
unknownTasks.push(`${lang}:${row.task}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const pair = await own.runWritten(task, lang, row.source);
|
|
154
|
+
results.push({
|
|
155
|
+
task: task.id,
|
|
156
|
+
lang,
|
|
157
|
+
writer: row.writer ?? '',
|
|
158
|
+
source: row.source,
|
|
159
|
+
verdict: verdictOf(pair),
|
|
160
|
+
pair,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { references, results, unknownTasks };
|
|
165
|
+
} finally {
|
|
166
|
+
if (!hosts) await own.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The report, per task and language, as text. */
|
|
171
|
+
export function formatReport(report: HandWrittenReport): string {
|
|
172
|
+
const lines: string[] = [];
|
|
173
|
+
const bad = report.references.filter(r => !r.holds);
|
|
174
|
+
lines.push(
|
|
175
|
+
`references: ${report.references.length - bad.length}/${report.references.length} run the same on upstream and the engine` +
|
|
176
|
+
(bad.length ? ` (NOT: ${bad.map(r => r.task).join(', ')})` : '')
|
|
177
|
+
);
|
|
178
|
+
if (report.results.length === 0) {
|
|
179
|
+
lines.push('hand-written inputs: no inputs yet (hand-written-inputs/<lang>.json)');
|
|
180
|
+
return lines.join('\n');
|
|
181
|
+
}
|
|
182
|
+
const langs = [...new Set(report.results.map(r => r.lang))].sort();
|
|
183
|
+
const counts: Record<Verdict, number> = { accepted: 0, refused: 0, silent: 0 };
|
|
184
|
+
for (const r of report.results) counts[r.verdict]++;
|
|
185
|
+
lines.push(
|
|
186
|
+
`hand-written inputs: ${report.results.length} from ${langs.length} language(s): ` +
|
|
187
|
+
`${counts.accepted} accepted, ${counts.refused} refused, ${counts.silent} silent`
|
|
188
|
+
);
|
|
189
|
+
for (const lang of langs) {
|
|
190
|
+
const rows = report.results.filter(r => r.lang === lang);
|
|
191
|
+
const c: Record<Verdict, number> = { accepted: 0, refused: 0, silent: 0 };
|
|
192
|
+
for (const r of rows) c[r.verdict]++;
|
|
193
|
+
lines.push(` ${lang}: ${c.accepted} accepted, ${c.refused} refused, ${c.silent} silent`);
|
|
194
|
+
for (const r of rows.filter(x => x.verdict !== 'accepted')) {
|
|
195
|
+
lines.push(` ${r.verdict.padEnd(8)} ${r.task.replace('hand-written#', '')} ${JSON.stringify(r.source)}`);
|
|
196
|
+
if (r.verdict === 'silent') lines.push(` got ${r.pair.got} | want ${r.pair.ref}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (report.unknownTasks.length) lines.push(`unknown tasks: ${report.unknownTasks.join(', ')}`);
|
|
200
|
+
return lines.join('\n');
|
|
201
|
+
}
|
|
@@ -127,7 +127,8 @@ describe('Batch 3 — wrong-verb class (dict word was another command)', () => {
|
|
|
127
127
|
describe('Batch 3 — it blur (noun form dropped the command patient)', () => {
|
|
128
128
|
it('it: blur command render captures blur.patient', () => {
|
|
129
129
|
const { render, triples: t } = renderAndParse('on keydown[key=="Escape"] blur me', 'it');
|
|
130
|
-
|
|
130
|
+
// The verb, not the noun; with its object attached since M2 N3 (`sfuocarmi`).
|
|
131
|
+
expect(render).toMatch(/sfuocar(?:e|mi)\b/);
|
|
131
132
|
expect(t).toContain('blur.patient=me');
|
|
132
133
|
});
|
|
133
134
|
|