@hyperfixi/testing-framework 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,835 @@
1
+ /**
2
+ * English left in a translation — the M2 naturalness gate
3
+ * --------------------------------------------------------
4
+ * Every other multilingual gate asks whether a translation means what the
5
+ * source means. None asks whether it reads as the language: a Spanish render
6
+ * that keeps `repeat`, `I match` or `debounced at` parses back perfectly and
7
+ * passes all of them. Measured 2026-10-08, 18% of corpus renders and 24% of
8
+ * the command-shape scripts' renders hold at least one English word.
9
+ *
10
+ * This gate counts three kinds of finding in a render of English `source`
11
+ * into a language:
12
+ * - `word` an English word the engine reads as grammar in the source (or a
13
+ * reference: `me`, `my`, `I`, `it`, …), written as English in the
14
+ * render;
15
+ * - `event` an event the source handles or sends, written in English where
16
+ * the language's lexicon has a word for it and the pair is not on
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).
20
+ *
21
+ * ## What counts as English
22
+ * Derived, never a hand list. A source's grammar words are the tokens the
23
+ * engine's own parser matched as literal words while reading it
24
+ * (`Parser.match`, which also consumes every command and feature keyword),
25
+ * plus the references in its parse. A render word counts when it is one of
26
+ * them (up to as many times as the source uses it as grammar, so a variable
27
+ * named like a keyword is never counted), and the language has no word spelled
28
+ * the same (es `a`, de `in`, it `in` are their own words). A property name
29
+ * (`#a's children`) counts when the language's lexicon has a word for it.
30
+ * Strings, `js` bodies and brace interiors (CSS, object literals) are code and
31
+ * are not read.
32
+ *
33
+ * Each finding carries a context, for the report: `clause` (inside a clause
34
+ * semantic keeps as written, M1's `verbatimClause` / `headClause`), `bracket`,
35
+ * `call` (a call's arguments), `property`, or `plain`; and whether the
36
+ * language has its own word for it (`hasWord`: then a renderer or reader can
37
+ * write it; otherwise it is a vocabulary decision, roadmap policy 5).
38
+ *
39
+ * ## Inputs and baseline
40
+ * Two halves, one rule. The corpus half renders every non-markup corpus row
41
+ * (`render(parse(en), L)`, what `populate` writes) and needs a freshly
42
+ * populated patterns.db, like the other corpus gates (`test:canonical`). The
43
+ * command-shape half reads the renders the command-shape gate already makes
44
+ * (`translate(src, 'en', L)`), in its shards. Each half's baseline lists, per
45
+ * source, the findings of each language; the gate fails on a finding a pair
46
+ * did not have, and on a listed finding that is gone (prune it with
47
+ * tools/regen-english-leaks-baseline.ts), so the lists only shrink.
48
+ */
49
+
50
+ import { readFileSync } from 'node:fs';
51
+ import path from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { readerOf, type CaseResult, type CommandShapeCase } from './command-shapes';
54
+ import { FOREIGN_LANGUAGES } from './value-matrix';
55
+
56
+ const here = path.dirname(fileURLToPath(import.meta.url));
57
+
58
+ export const LEAK_BASELINE_PATHS = {
59
+ corpus: path.resolve(here, '../../baselines/english-leaks.corpus.json'),
60
+ shapes: path.resolve(here, '../../baselines/english-leaks.shapes.json'),
61
+ } as const;
62
+
63
+ export type LeakHalf = keyof typeof LEAK_BASELINE_PATHS;
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Findings
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export type LeakKind = 'word' | 'event' | 'case';
70
+ export type LeakContext = 'plain' | 'clause' | 'bracket' | 'call' | 'property';
71
+
72
+ export interface LeakFinding {
73
+ kind: LeakKind;
74
+ /** The English word; for `event` the event name; for `case` the pronoun (`me`). */
75
+ word: string;
76
+ context: LeakContext;
77
+ /** Does the language have its own word for it? */
78
+ hasWord: boolean;
79
+ /** English a recorded decision keeps ({@link KEPT_ENGLISH}): not counted toward M2's targets. */
80
+ kept?: true;
81
+ }
82
+
83
+ /**
84
+ * English a recorded decision keeps, so M2's words target does not count it
85
+ * (the gate still does: it only shrinks). Contexts policy 6 keeps (roadmap §5),
86
+ * and the vocabulary sheet's keep rows (`docs-internal/multilingual/
87
+ * VOCABULARY_SHEET.md`, C1–C3, decided 2026-10-08).
88
+ */
89
+ export const KEPT_ENGLISH: {
90
+ readonly contexts: ReadonlySet<LeakContext>;
91
+ readonly phrases: ReadonlyArray<{ readonly phrase: string; readonly why: string }>;
92
+ } = {
93
+ // Policy 6: bracket and brace interiors, a call's arguments, a property name
94
+ // its reader cannot bring back, and a clause no pattern models (M1).
95
+ contexts: new Set(['bracket', 'call', 'property', 'clause']),
96
+ phrases: [
97
+ { phrase: 'debounced at', why: 'C1: a term of art with no settled native word' },
98
+ { phrase: 'throttled at', why: 'C1: a term of art with no settled native word' },
99
+ { phrase: 'url', why: 'C2: an international loanword (policy 6 keeps push/replace url)' },
100
+ { phrase: 'dom', why: 'C3: an acronym' },
101
+ {
102
+ phrase: 'view transition',
103
+ why: "C3: the web API's name (policy 6 keeps using view transition)",
104
+ },
105
+ { phrase: 'using', why: 'policy 6: `using view transition` (the phrase above holds the rest)' },
106
+ ],
107
+ };
108
+
109
+ /** A finding as the baseline writes it: the word, or `event:<name>` / `case:<pronoun>`. */
110
+ export const findingKey = (f: Pick<LeakFinding, 'kind' | 'word'>): string =>
111
+ f.kind === 'word' ? f.word : `${f.kind}:${f.word}`;
112
+
113
+ /**
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.
121
+ */
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',
136
+ ]);
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // What a source holds
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export type LeakReader = 'program' | 'statements';
143
+
144
+ export interface SourceInfo {
145
+ /** Words the engine read as grammar, with how often. */
146
+ grammar: Map<string, number>;
147
+ /** Property names in the source (`my value`, `#a's children`), with how often. */
148
+ props: Map<string, number>;
149
+ /** Events the source handles, sends or triggers, with how often. */
150
+ events: Map<string, number>;
151
+ /** `js` blocks: never translated, never read. */
152
+ jsBodies: JsBlock[];
153
+ /** Words of the clauses semantic keeps as written. */
154
+ clauseWords: Set<string>;
155
+ }
156
+
157
+ /** References the engine reads as names; semantic translates them (`me` → es `yo`). */
158
+ const REFERENCE_NAMES: ReadonlySet<string> = new Set([
159
+ 'I',
160
+ 'me',
161
+ 'my',
162
+ 'it',
163
+ 'its',
164
+ 'you',
165
+ 'your',
166
+ 'result',
167
+ 'event',
168
+ 'target',
169
+ 'detail',
170
+ 'body',
171
+ 'document',
172
+ 'window',
173
+ ]);
174
+
175
+ const bump = (map: Map<string, number>, key: string, by = 1): void => {
176
+ map.set(key, (map.get(key) ?? 0) + by);
177
+ };
178
+
179
+ /** A `js` block: its parameter names (the script's, passed in) and its body (JavaScript). */
180
+ export interface JsBlock {
181
+ params: string;
182
+ body: string;
183
+ }
184
+
185
+ /** The `js` blocks of a source: `js`, an optional parameter list, a body, `end`. */
186
+ function jsBlocksOf(source: string): JsBlock[] {
187
+ return [...source.matchAll(/\bjs\b\s*(?:\(([^)]*)\))?([\s\S]*?)\bend\b/g)]
188
+ .map(m => ({ params: (m[1] ?? '').trim(), body: (m[2] ?? '').trim() }))
189
+ .filter(b => b.body || b.params);
190
+ }
191
+
192
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // What a language holds
196
+ // ---------------------------------------------------------------------------
197
+
198
+ interface KeywordTranslationLike {
199
+ primary?: string;
200
+ alternatives?: readonly string[];
201
+ render?: boolean;
202
+ }
203
+ interface ProfileLike {
204
+ keywords?: Record<string, KeywordTranslationLike>;
205
+ roleMarkers?: Record<string, KeywordTranslationLike & { position?: string }>;
206
+ references?: Record<string, string>;
207
+ possessive?: {
208
+ marker?: string;
209
+ specialForms?: Record<string, string>;
210
+ keywords?: Record<string, string>;
211
+ };
212
+ eventHandler?: Record<string, unknown>;
213
+ }
214
+ type LexiconLike = Record<string, Record<string, KeywordTranslationLike> | undefined>;
215
+
216
+ const LEXICON_CATEGORIES = ['values', 'expressions', 'logical', 'temporal', 'attributes'] as const;
217
+
218
+ const wordsOf = (text: string | undefined): string[] => (text ?? '').split(/\s+/).filter(Boolean);
219
+ const spellings = (t: KeywordTranslationLike | undefined): string[] =>
220
+ t ? [t.primary ?? '', ...(t.alternatives ?? [])].flatMap(wordsOf) : [];
221
+
222
+ interface LanguageWords {
223
+ /** Every word the language writes: a render word spelled like one is its own. */
224
+ native: Set<string>;
225
+ /** English words the language has a word for (lexicon, keywords, references). */
226
+ translated: Set<string>;
227
+ /** Events its lexicon names, minus the renderer's denylist for it. */
228
+ events: Set<string>;
229
+ /** Its `me` word, and its role markers by side. */
230
+ me: string | undefined;
231
+ markersBefore: Set<string>;
232
+ markersAfter: Set<string>;
233
+ }
234
+
235
+ function languageWords(
236
+ profile: ProfileLike | undefined,
237
+ lexicon: LexiconLike | undefined,
238
+ denied: ReadonlySet<string> | undefined
239
+ ): LanguageWords {
240
+ const native = new Set<string>();
241
+ const translated = new Set<string>();
242
+ const add = (english: string, t: KeywordTranslationLike | undefined): void => {
243
+ const forms = spellings(t);
244
+ // An alternative spelled like the English word, beside a primary that is
245
+ // the language's own, is English the reader still takes (code-switched
246
+ // input), not a word the language writes: a render that writes it leaks.
247
+ // Counted as native, it hid every such leak (`forever` in the eight
248
+ // languages whose primary is their own, V1).
249
+ const englishWords = new Set(wordsOf(english));
250
+ const primary = wordsOf(t?.primary);
251
+ const ownPrimary = primary.some(w => !englishWords.has(w));
252
+ for (const w of forms)
253
+ if (!(ownPrimary && englishWords.has(w) && !primary.includes(w))) native.add(w);
254
+ if (forms.length && !forms.includes(english))
255
+ for (const w of wordsOf(english)) translated.add(w);
256
+ };
257
+ for (const [english, t] of Object.entries(profile?.keywords ?? {})) add(english, t);
258
+ for (const t of Object.values(profile?.roleMarkers ?? {}))
259
+ for (const w of spellings(t)) native.add(w);
260
+ for (const [english, n] of Object.entries(profile?.references ?? {}))
261
+ add(english, { primary: n });
262
+ for (const [english, n] of Object.entries(profile?.possessive?.specialForms ?? {}))
263
+ add(english === 'me' ? 'my' : english === 'it' ? 'its' : english === 'you' ? 'your' : english, {
264
+ primary: n,
265
+ });
266
+ // Native form → the English reference it stands for (es `mi` → `me`).
267
+ for (const n of Object.keys(profile?.possessive?.keywords ?? {}))
268
+ for (const w of wordsOf(n)) native.add(w);
269
+ for (const w of wordsOf(profile?.possessive?.marker)) native.add(w);
270
+ const handler = JSON.stringify(profile?.eventHandler ?? {});
271
+ for (const m of handler.matchAll(
272
+ /"(?:primary|alternatives|temporalMarkers)":(\[[^\]]*\]|"[^"]*")/g
273
+ ))
274
+ for (const w of (JSON.parse(m[1] ?? '""') as string | string[]).toString().split(/[\s,]+/))
275
+ if (w) native.add(w);
276
+ for (const category of LEXICON_CATEGORIES)
277
+ for (const [english, t] of Object.entries(lexicon?.[category] ?? {})) add(english, t);
278
+ const events = new Set<string>();
279
+ for (const [english, t] of Object.entries(lexicon?.events ?? {})) {
280
+ for (const w of spellings(t)) native.add(w);
281
+ if (t.render !== false && t.primary && t.primary !== english && !denied?.has(english))
282
+ events.add(english);
283
+ }
284
+ const markersBefore = new Set<string>();
285
+ const markersAfter = new Set<string>();
286
+ for (const t of Object.values(profile?.roleMarkers ?? {}))
287
+ for (const w of spellings(t)) (t.position === 'after' ? markersAfter : markersBefore).add(w);
288
+ return {
289
+ native,
290
+ translated,
291
+ events,
292
+ me: profile?.references?.me,
293
+ markersBefore,
294
+ markersAfter,
295
+ };
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Reading a render
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /** Blank out `{…}` groups: CSS blocks and object literals are code. */
303
+ function blankBraces(text: string): string {
304
+ let out = '';
305
+ let depth = 0;
306
+ for (const c of text) {
307
+ if (c === '{') depth++;
308
+ out += depth > 0 ? ' ' : c;
309
+ if (c === '}' && depth > 0) depth--;
310
+ }
311
+ return out;
312
+ }
313
+
314
+ /** Strings, `js` bodies and brace interiors replaced by spaces (offsets kept). */
315
+ export function codeOnly(render: string, jsBlocks: readonly JsBlock[]): string {
316
+ const blank = (m: string): string => ' '.repeat(m.length);
317
+ let text = render;
318
+ for (const { params, body } of jsBlocks) {
319
+ const list = params
320
+ ? `\\(\\s*${params
321
+ .split(/\s*,\s*/)
322
+ .map(escapeRegExp)
323
+ .join('\\s*,\\s*')}\\s*\\)\\s*`
324
+ : '';
325
+ text = text.replace(new RegExp(`${list}${escapeRegExp(body)}`, 'g'), blank);
326
+ }
327
+ return blankBraces(text)
328
+ .replace(/`[^`]*`/g, blank)
329
+ .replace(/"(?:[^"\\]|\\.)*"/g, blank)
330
+ .replace(/(?<![\p{L}\p{N}])'(?:[^'\\\n]|\\.)*'/gu, blank);
331
+ }
332
+
333
+ /** Where `[…]` groups and a call's `(…)` arguments sit, innermost last. */
334
+ function groupSpans(text: string): Array<[number, number, 'bracket' | 'call']> {
335
+ const spans: Array<[number, number, 'bracket' | 'call']> = [];
336
+ const open: Array<[number, string, boolean]> = [];
337
+ for (let i = 0; i < text.length; i++) {
338
+ const c = text[i];
339
+ if (c === '[' || c === '(')
340
+ open.push([i, c, c === '(' && i > 0 && /[\w$]/.test(text[i - 1] ?? '')]);
341
+ else if ((c === ']' || c === ')') && open.length) {
342
+ const [start, kind, call] = open.pop()!;
343
+ if (kind === '[') spans.push([start, i, 'bracket']);
344
+ else if (call) spans.push([start, i, 'call']);
345
+ }
346
+ }
347
+ return spans.sort((a, b) => a[0] - b[0]);
348
+ }
349
+
350
+ interface RenderWord {
351
+ word: string;
352
+ offset: number;
353
+ }
354
+
355
+ /** The words of a render's code, each with its offset. */
356
+ function renderWords(text: string): RenderWord[] {
357
+ const out: RenderWord[] = [];
358
+ for (const m of text.matchAll(/\S+/g)) {
359
+ let token = m[0];
360
+ let offset = m.index ?? 0;
361
+ const lead = /^[([,]+/.exec(token)?.[0].length ?? 0;
362
+ token = token.slice(lead);
363
+ offset += lead;
364
+ out.push({ word: token.replace(/[)\],;]+$/, '').replace(/'s$/, ''), offset });
365
+ }
366
+ return out;
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // The scanner
371
+ // ---------------------------------------------------------------------------
372
+
373
+ export interface LeakScanner {
374
+ /** What the engine and semantic read in a source; `null` when the engine rejects it. */
375
+ sourceInfo(source: string, reader: LeakReader): SourceInfo | null;
376
+ /** The findings of one render of `info`'s source into `language`. */
377
+ scan(render: string, info: SourceInfo, language: string): LeakFinding[];
378
+ }
379
+
380
+ interface EngineModule {
381
+ register(...modules: unknown[]): void;
382
+ everything: unknown[];
383
+ parse(source: string): unknown;
384
+ parseProgram(source: string): unknown;
385
+ Parser: { prototype: { match(value: string, type?: string): unknown } };
386
+ }
387
+ interface SemanticModule {
388
+ parse(source: string, language: string): unknown;
389
+ tryGetProfile(language: string): unknown;
390
+ getLexicon(language: string): unknown;
391
+ getEventLocalizationDenylist(): Record<string, ReadonlySet<string>>;
392
+ }
393
+
394
+ /** Load the engine and semantic (ESM-only, so imported here) and return a scanner. */
395
+ export async function initLeakScanner(): Promise<LeakScanner> {
396
+ const engine = (await import('@hyperfixi/engine')) as unknown as EngineModule;
397
+ engine.register(...engine.everything);
398
+ const semantic = (await import('@lokascript/semantic')) as unknown as SemanticModule;
399
+ const denylist = semantic.getEventLocalizationDenylist();
400
+ const languages = new Map<string, LanguageWords>();
401
+ for (const language of FOREIGN_LANGUAGES) {
402
+ languages.set(
403
+ language,
404
+ languageWords(
405
+ semantic.tryGetProfile(language) as ProfileLike | undefined,
406
+ semantic.getLexicon(language) as LexiconLike | undefined,
407
+ denylist[language]
408
+ )
409
+ );
410
+ }
411
+
412
+ /** Parse with the engine, recording every token its grammar matched as a word. */
413
+ const readGrammar = (
414
+ source: string,
415
+ reader: LeakReader
416
+ ): { ast: unknown; matched: Set<object> } | null => {
417
+ const proto = engine.Parser.prototype;
418
+ const original = proto.match;
419
+ const matched = new Set<object>();
420
+ proto.match = function (this: unknown, value: string, type?: string) {
421
+ const token = original.call(this, value, type);
422
+ if (token && (type ?? 'IDENTIFIER') === 'IDENTIFIER') matched.add(token as object);
423
+ return token;
424
+ };
425
+ try {
426
+ return {
427
+ ast: reader === 'program' ? engine.parseProgram(source) : engine.parse(source),
428
+ matched,
429
+ };
430
+ } catch {
431
+ return null;
432
+ } finally {
433
+ proto.match = original;
434
+ }
435
+ };
436
+
437
+ return {
438
+ sourceInfo(source, reader) {
439
+ const read = readGrammar(source, reader);
440
+ if (!read) return null;
441
+ const grammar = new Map<string, number>();
442
+ for (const token of read.matched) bump(grammar, (token as { value: string }).value);
443
+ const references = new Map<string, number>();
444
+ const props = new Map<string, number>();
445
+ const events = new Map<string, number>();
446
+ const seen = new WeakSet<object>();
447
+ const walk = (value: unknown, key: string): void => {
448
+ if (typeof value === 'string') {
449
+ if (key === 'name' && REFERENCE_NAMES.has(value)) bump(references, value);
450
+ else if (key === 'prop') bump(props, value);
451
+ else if (key === 'on' || key === 'event') bump(events, value);
452
+ return;
453
+ }
454
+ if (!value || typeof value !== 'object' || seen.has(value)) return;
455
+ seen.add(value);
456
+ for (const [k, v] of Object.entries(value))
457
+ if (typeof v !== 'function') walk(v, Array.isArray(value) ? key : k);
458
+ };
459
+ walk(read.ast, '');
460
+ for (const [word, n] of references) grammar.set(word, Math.max(grammar.get(word) ?? 0, n));
461
+
462
+ const clauseWords = new Set<string>();
463
+ let node: unknown = null;
464
+ try {
465
+ node = semantic.parse(source, 'en');
466
+ } catch {
467
+ // No clause words: every finding reads as plain.
468
+ }
469
+ const clauses = (n: unknown): void => {
470
+ if (!n || typeof n !== 'object') return;
471
+ if (Array.isArray(n)) return n.forEach(clauses);
472
+ const record = n as Record<string, unknown>;
473
+ for (const k of ['verbatimClause', 'headClause'])
474
+ if (typeof record[k] === 'string')
475
+ for (const w of wordsOf(record[k] as string)) clauseWords.add(w);
476
+ for (const v of Object.values(record)) if (v && typeof v === 'object') clauses(v);
477
+ };
478
+ clauses(node);
479
+ return { grammar, props, events, jsBodies: jsBlocksOf(source), clauseWords };
480
+ },
481
+
482
+ scan(render, info, language) {
483
+ const lang = languages.get(language);
484
+ if (!lang) throw new Error(`no vocabulary for ${language}`);
485
+ const text = codeOnly(render, info.jsBodies);
486
+ const spans = groupSpans(text);
487
+ const contextAt = (offset: number): 'bracket' | 'call' | undefined => {
488
+ let found: 'bracket' | 'call' | undefined;
489
+ for (const [start, end, kind] of spans) if (start < offset && offset < end) found = kind;
490
+ return found;
491
+ };
492
+ const keptSpans: Array<[number, number]> = [];
493
+ for (const { phrase } of KEPT_ENGLISH.phrases)
494
+ for (const m of text.matchAll(
495
+ new RegExp(
496
+ `(?<![\\p{L}\\p{N}])${escapeRegExp(phrase).replace(/ /g, '\\s+')}(?![\\p{L}\\p{N}])`,
497
+ 'gu'
498
+ )
499
+ ))
500
+ keptSpans.push([m.index ?? 0, (m.index ?? 0) + m[0].length]);
501
+ const isKept = (offset: number, context: LeakContext): boolean =>
502
+ KEPT_ENGLISH.contexts.has(context) ||
503
+ keptSpans.some(([start, end]) => start <= offset && offset < end);
504
+ const findings: LeakFinding[] = [];
505
+ const grammarLeft = new Map(info.grammar);
506
+ const propsLeft = new Map(info.props);
507
+ const eventsLeft = new Map(info.events);
508
+ const words = renderWords(text);
509
+ for (const { word, offset } of words) {
510
+ if (!/^[A-Za-z]+$/.test(word) || lang.native.has(word)) continue;
511
+ if ((eventsLeft.get(word) ?? 0) > 0) {
512
+ eventsLeft.set(word, eventsLeft.get(word)! - 1);
513
+ if (lang.events.has(word))
514
+ findings.push({ kind: 'event', word, context: 'plain', hasWord: true });
515
+ continue;
516
+ }
517
+ const grammar = (grammarLeft.get(word) ?? 0) > 0;
518
+ const prop = !grammar && (propsLeft.get(word) ?? 0) > 0 && lang.translated.has(word);
519
+ if (!grammar && !prop) continue;
520
+ (grammar ? grammarLeft : propsLeft).set(
521
+ word,
522
+ (grammar ? grammarLeft : propsLeft).get(word)! - 1
523
+ );
524
+ const context: LeakContext = info.clauseWords.has(word)
525
+ ? 'clause'
526
+ : (contextAt(offset) ?? (prop ? 'property' : 'plain'));
527
+ findings.push({
528
+ kind: 'word',
529
+ word,
530
+ context,
531
+ hasWord: lang.translated.has(word),
532
+ ...(isKept(offset, context) ? { kept: true as const } : {}),
533
+ });
534
+ }
535
+ if (CASE_LANGUAGES.has(language) && lang.me) {
536
+ const tokens = words.map(w => w.word);
537
+ for (let i = 0; i < tokens.length; i++) {
538
+ if (tokens[i] !== lang.me) continue;
539
+ const before = tokens[i - 1];
540
+ 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 });
543
+ }
544
+ }
545
+ return findings;
546
+ },
547
+ };
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // Baseline
552
+ // ---------------------------------------------------------------------------
553
+
554
+ /** One half's committed findings: per source, a findings string → the languages that have it. */
555
+ export interface LeakBaseline {
556
+ description: string;
557
+ /** Renders read, renders with a finding, and findings, at the last regeneration. */
558
+ renders: number;
559
+ leaky: number;
560
+ findings: number;
561
+ /** The same, per kind: `word.renders` is the leak rate's numerator. */
562
+ kinds: Record<LeakKind, { renders: number; findings: number }>;
563
+ /** Per source id: `"<finding> <finding> …"` (sorted keys) → languages (`*` for all 23). */
564
+ entries: Record<string, Record<string, string>>;
565
+ /**
566
+ * Per source id, the languages with no render (the translation was refused).
567
+ * A lane that starts to render is progress, not a leak: the gate reports it
568
+ * as one to prune, and its English is counted from then on.
569
+ */
570
+ unrendered?: Record<string, string>;
571
+ }
572
+
573
+ /** The results of one half: per source id, per language, the findings (`null`: no render). */
574
+ export type LeakResults = Map<string, Map<string, LeakFinding[] | null>>;
575
+
576
+ const ALL = '*';
577
+ export function compressLanguages(languages: readonly string[]): string {
578
+ const set = new Set(languages);
579
+ return FOREIGN_LANGUAGES.every(l => set.has(l))
580
+ ? ALL
581
+ : FOREIGN_LANGUAGES.filter(l => set.has(l)).join(' ');
582
+ }
583
+ export function expandLanguages(text: string): string[] {
584
+ return text === ALL ? [...FOREIGN_LANGUAGES] : text.split(' ').filter(Boolean);
585
+ }
586
+
587
+ export const keysOf = (findings: readonly LeakFinding[] | null): string[] =>
588
+ (findings ?? []).map(findingKey).sort();
589
+
590
+ export function leakBaselineFrom(results: LeakResults, description: string): LeakBaseline {
591
+ const doc: LeakBaseline = {
592
+ description,
593
+ renders: 0,
594
+ leaky: 0,
595
+ findings: 0,
596
+ kinds: {
597
+ word: { renders: 0, findings: 0 },
598
+ event: { renders: 0, findings: 0 },
599
+ case: { renders: 0, findings: 0 },
600
+ },
601
+ entries: {},
602
+ unrendered: {},
603
+ };
604
+ for (const [id, byLanguage] of [...results].sort((a, b) => a[0].localeCompare(b[0]))) {
605
+ const groups = new Map<string, string[]>();
606
+ const unrendered: string[] = [];
607
+ for (const [language, findings] of byLanguage) {
608
+ if (!findings) {
609
+ unrendered.push(language);
610
+ continue;
611
+ }
612
+ doc.renders++;
613
+ if (!findings.length) continue;
614
+ doc.leaky++;
615
+ doc.findings += findings.length;
616
+ for (const kind of ['word', 'event', 'case'] as const) {
617
+ const n = findings.filter(f => f.kind === kind).length;
618
+ doc.kinds[kind].findings += n;
619
+ if (n) doc.kinds[kind].renders++;
620
+ }
621
+ const key = keysOf(findings).join(' ');
622
+ groups.set(key, [...(groups.get(key) ?? []), language]);
623
+ }
624
+ if (unrendered.length) doc.unrendered![id] = compressLanguages(unrendered);
625
+ if (!groups.size) continue;
626
+ doc.entries[id] = Object.fromEntries(
627
+ [...groups]
628
+ .sort((a, b) => a[0].localeCompare(b[0]))
629
+ .map(([key, ls]) => [key, compressLanguages(ls)])
630
+ );
631
+ }
632
+ return doc;
633
+ }
634
+
635
+ /** The findings a baseline lists for one (source, language). */
636
+ export function listedKeys(
637
+ baseline: Pick<LeakBaseline, 'entries'>,
638
+ id: string,
639
+ language: string
640
+ ): string[] {
641
+ for (const [key, languages] of Object.entries(baseline.entries[id] ?? {}))
642
+ if (expandLanguages(languages).includes(language)) return key.split(' ');
643
+ return [];
644
+ }
645
+
646
+ export interface LeakChange {
647
+ id: string;
648
+ language: string;
649
+ /** Findings the pair has now and the baseline does not list. */
650
+ added: string[];
651
+ /** Findings the baseline lists and the pair no longer has. */
652
+ gone: string[];
653
+ /** The baseline lists the pair as unrendered and it renders now: `added` is its English. */
654
+ newRender?: boolean;
655
+ }
656
+
657
+ /** Multiset difference `a − b`. */
658
+ function minus(a: readonly string[], b: readonly string[]): string[] {
659
+ const left = [...b];
660
+ return a.filter(x => {
661
+ const i = left.indexOf(x);
662
+ if (i < 0) return true;
663
+ left.splice(i, 1);
664
+ return false;
665
+ });
666
+ }
667
+
668
+ /** Compare results with a baseline, over the sources and languages that ran. */
669
+ export function diffLeakBaseline(
670
+ results: LeakResults,
671
+ baseline: Pick<LeakBaseline, 'entries' | 'unrendered'>
672
+ ): LeakChange[] {
673
+ const changes: LeakChange[] = [];
674
+ for (const [id, byLanguage] of results) {
675
+ const unrendered = expandLanguages(baseline.unrendered?.[id] ?? '');
676
+ for (const [language, findings] of byLanguage) {
677
+ const wasUnrendered = unrendered.includes(language);
678
+ if (findings && wasUnrendered) {
679
+ changes.push({ id, language, added: keysOf(findings), gone: [], newRender: true });
680
+ continue;
681
+ }
682
+ const got = keysOf(findings);
683
+ const listed = listedKeys(baseline, id, language);
684
+ const added = minus(got, listed);
685
+ const gone = minus(listed, got);
686
+ if (added.length || gone.length) changes.push({ id, language, added, gone });
687
+ }
688
+ }
689
+ return changes;
690
+ }
691
+
692
+ /** Findings a pair gained: a regression (a lane that starts to render is not one). */
693
+ export const gainedLeaks = (changes: readonly LeakChange[]): LeakChange[] =>
694
+ changes.filter(c => c.added.length > 0 && !c.newRender);
695
+
696
+ /** Changes the baseline must be pruned for: findings gone, or a lane that renders now. */
697
+ export const prunableLeaks = (changes: readonly LeakChange[]): LeakChange[] =>
698
+ changes.filter(c => c.gone.length > 0 || c.newRender);
699
+
700
+ export function loadLeakBaseline(half: LeakHalf): LeakBaseline {
701
+ return JSON.parse(readFileSync(LEAK_BASELINE_PATHS[half], 'utf8')) as LeakBaseline;
702
+ }
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // M2's exit targets
706
+ // ---------------------------------------------------------------------------
707
+
708
+ /**
709
+ * M2's exit targets (decided 2026-10-08): the most of a language's renders that
710
+ * may hold a finding of each kind, in EVERY language. Per language because a
711
+ * reader reads one, and an average hid he (35% of corpus renders with an English
712
+ * word where the others sit near 18%). Words count only English no recorded
713
+ * decision keeps ({@link KEPT_ENGLISH}). Events are already net of the denylist,
714
+ * whose entries carry reasons.
715
+ */
716
+ 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 },
719
+ };
720
+
721
+ const KINDS: readonly LeakKind[] = ['word', 'event', 'case'];
722
+
723
+ export interface LanguageLeakRates {
724
+ renders: number;
725
+ /** The share of the language's renders with a finding of each kind. */
726
+ rate: Record<LeakKind, number>;
727
+ /** Whether each rate is within {@link LEAK_TARGETS}. */
728
+ met: Record<LeakKind, boolean>;
729
+ }
730
+
731
+ /** Each language's leak rates against the half's targets; a lane with no render is not counted. */
732
+ export function leakRatesByLanguage(
733
+ results: LeakResults,
734
+ half: LeakHalf
735
+ ): Map<string, LanguageLeakRates> {
736
+ const counts = new Map<string, { renders: number } & Record<LeakKind, number>>();
737
+ for (const byLanguage of results.values()) {
738
+ for (const [language, findings] of byLanguage) {
739
+ if (!findings) continue;
740
+ const row = counts.get(language) ?? { renders: 0, word: 0, event: 0, case: 0 };
741
+ row.renders++;
742
+ for (const kind of KINDS) if (findings.some(f => f.kind === kind && !f.kept)) row[kind]++;
743
+ counts.set(language, row);
744
+ }
745
+ }
746
+ const out = new Map<string, LanguageLeakRates>();
747
+ for (const [language, row] of counts) {
748
+ const rate = { word: 0, event: 0, case: 0 };
749
+ const met = { word: true, event: true, case: true };
750
+ for (const kind of KINDS) {
751
+ rate[kind] = row[kind] / row.renders;
752
+ met[kind] = rate[kind] <= LEAK_TARGETS[half][kind];
753
+ }
754
+ out.set(language, { renders: row.renders, rate, met });
755
+ }
756
+ return out;
757
+ }
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // The two inputs
761
+ // ---------------------------------------------------------------------------
762
+
763
+ /** The command-shape half: the renders the command-shape gate made for `results`. */
764
+ export function shapeLeaks(
765
+ scanner: LeakScanner,
766
+ results: readonly CaseResult[],
767
+ cases: readonly CommandShapeCase[]
768
+ ): LeakResults {
769
+ const byId = new Map(cases.map(c => [c.id, c]));
770
+ const out: LeakResults = new Map();
771
+ for (const r of results) {
772
+ const shape = byId.get(r.id);
773
+ if (!shape) continue;
774
+ const info = scanner.sourceInfo(shape.source, readerOf(shape));
775
+ if (!info) continue;
776
+ const byLanguage = new Map<string, LeakFinding[] | null>();
777
+ for (const language of FOREIGN_LANGUAGES) {
778
+ const rendered = r.lanes[language]?.rendered;
779
+ byLanguage.set(
780
+ language,
781
+ rendered === undefined ? null : scanner.scan(rendered, info, language)
782
+ );
783
+ }
784
+ out.set(r.id, byLanguage);
785
+ }
786
+ return out;
787
+ }
788
+
789
+ interface CorpusRow {
790
+ id: string;
791
+ rawCode: string;
792
+ }
793
+
794
+ /**
795
+ * The corpus half: every non-markup row rendered as `populate` writes it
796
+ * (`render(parse(en), L)`). A row the engine rejects has no grammar to read,
797
+ * so it is skipped; the caller can count them from `skipped`.
798
+ */
799
+ export async function corpusLeaks(
800
+ scanner: LeakScanner,
801
+ rows: readonly CorpusRow[]
802
+ ): Promise<{ results: LeakResults; skipped: string[] }> {
803
+ const { parse, render } = (await import('@lokascript/semantic')) as unknown as {
804
+ parse(source: string, language: string): unknown;
805
+ render(node: unknown, language: string): string;
806
+ };
807
+ const results: LeakResults = new Map();
808
+ const skipped: string[] = [];
809
+ for (const row of rows) {
810
+ if (row.rawCode.trimStart().startsWith('<')) continue;
811
+ const info = scanner.sourceInfo(row.rawCode, 'program');
812
+ let node: unknown = null;
813
+ try {
814
+ node = parse(row.rawCode, 'en');
815
+ } catch {
816
+ node = null;
817
+ }
818
+ if (!info || !node) {
819
+ skipped.push(row.id);
820
+ continue;
821
+ }
822
+ const byLanguage = new Map<string, LeakFinding[] | null>();
823
+ for (const language of FOREIGN_LANGUAGES) {
824
+ let rendered: string | null = null;
825
+ try {
826
+ rendered = render(node, language);
827
+ } catch {
828
+ rendered = null;
829
+ }
830
+ byLanguage.set(language, rendered ? scanner.scan(rendered, info, language) : null);
831
+ }
832
+ results.set(row.id, byLanguage);
833
+ }
834
+ return { results, skipped };
835
+ }