@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 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.4.0",
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.4.0",
61
- "@lokascript/compilation-service": "^4.4.0",
62
- "@lokascript/i18n": "^4.4.0",
63
- "@lokascript/semantic": "^4.4.0",
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.4.0",
76
- "@lokascript/hyperscript-adapter": "^4.4.0",
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, three kinds
300
+ whether it reads as the language. `english-leaks.ts` counts, in each render, four kinds
301
301
  of finding:
302
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 |
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`, qu `where` → `target`, ms/pl `undefined`',
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
- if (c.id.startsWith('behavior-handlers')) {
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 definition = code.replace(/Demo[A-Z]+/g, name);
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
- return {
390
- async runCase(c) {
391
- // A behavior definition is not a handler: it is checked by running it.
392
- const rejected = c.id.startsWith('behavior-handlers')
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
- const want = rejected
396
- ? `✗upstream rejects: ${rejected}`
397
- : await runAll(upstream, c, c.source, 'en');
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
- let english: string;
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 poner ello en #a',
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 poner yo en #a entonces js (me) return 1 fin',
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(keys('on click add .b to me', 'tıklama da .b i ben e ekle', 'tr')).toEqual(['case:me']);
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(keys('on click add .b to me', 'クリック で 自分 に .b を 追加', 'ja')).toEqual([]);
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 three kinds of finding in a render of English `source`
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, in the
19
- * languages where that form is wrong (N3, see CASE_LANGUAGES).
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
- /** The English word; for `event` the event name; for `case` the pronoun (`me`). */
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
- * Languages whose `me` word is nominative while a marker next to it wants
115
- * another case: es `a yo` (a mí), pt `a eu` (a mim), it `a io` (a me), de
116
- * `zu ich` (zu mir), pl `do ja` (do mnie), ru `к я` (ко мне), uk `до я`
117
- * (до мене), tr `ben e` (bana), ar `إلى أنا` (إليّ), he `אל אני` (אליי),
118
- * tl `sa ako` (sa akin), hi `मैं में` (मुझमें), bn `আমি তে` (আমাতে). The other
119
- * ten are right as written (fr `à moi`, ja `自分 に`, zh/id/ms/vi/th/ko/sw/qu
120
- * do not inflect the pronoun for case here). OPEN_ITEMS N3.
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 CASE_LANGUAGES: ReadonlySet<string> = new Set([
123
- 'ar',
124
- 'bn',
125
- 'de',
126
- 'es',
127
- 'he',
128
- 'hi',
129
- 'it',
130
- 'pl',
131
- 'pt',
132
- 'ru',
133
- 'tl',
134
- 'tr',
135
- 'uk',
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` word, and its role markers by side. */
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
- if (CASE_LANGUAGES.has(language) && lang.me) {
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
- if (tokens[i] !== lang.me) continue;
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
- if ((before && lang.markersBefore.has(before)) || (after && lang.markersAfter.has(after)))
542
- findings.push({ kind: 'case', word: 'me', context: 'plain', hasWord: true });
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 ['word', 'event', 'case'] as const) {
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
- expect(render).toContain('sfuocare');
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