@hyperfixi/testing-framework 4.3.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 +130 -1
- package/package.json +8 -8
- package/src/multilingual/README.md +35 -0
- package/src/multilingual/command-shapes-gate.ts +32 -0
- package/src/multilingual/command-shapes.ts +22 -23
- package/src/multilingual/direct-path-shapes.ts +60 -26
- package/src/multilingual/english-leaks.corpus.test.ts +74 -0
- package/src/multilingual/english-leaks.test.ts +463 -0
- package/src/multilingual/english-leaks.ts +1000 -0
- package/src/multilingual/hand-written-input.tasks.json +162 -0
- package/src/multilingual/hand-written-input.test.ts +62 -0
- package/src/multilingual/hand-written-input.ts +201 -0
- package/src/vocab/batch3-roundtrip.test.ts +2 -1
|
@@ -0,0 +1,1000 @@
|
|
|
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 four 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` 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).
|
|
24
|
+
*
|
|
25
|
+
* ## What counts as English
|
|
26
|
+
* Derived, never a hand list. A source's grammar words are the tokens the
|
|
27
|
+
* engine's own parser matched as literal words while reading it
|
|
28
|
+
* (`Parser.match`, which also consumes every command and feature keyword),
|
|
29
|
+
* plus the references in its parse. A render word counts when it is one of
|
|
30
|
+
* them (up to as many times as the source uses it as grammar, so a variable
|
|
31
|
+
* named like a keyword is never counted), and the language has no word spelled
|
|
32
|
+
* the same (es `a`, de `in`, it `in` are their own words). A property name
|
|
33
|
+
* (`#a's children`) counts when the language's lexicon has a word for it.
|
|
34
|
+
* Strings, `js` bodies and brace interiors (CSS, object literals) are code and
|
|
35
|
+
* are not read.
|
|
36
|
+
*
|
|
37
|
+
* Each finding carries a context, for the report: `clause` (inside a clause
|
|
38
|
+
* semantic keeps as written, M1's `verbatimClause` / `headClause`), `bracket`,
|
|
39
|
+
* `call` (a call's arguments), `property`, or `plain`; and whether the
|
|
40
|
+
* language has its own word for it (`hasWord`: then a renderer or reader can
|
|
41
|
+
* write it; otherwise it is a vocabulary decision, roadmap policy 5).
|
|
42
|
+
*
|
|
43
|
+
* ## Inputs and baseline
|
|
44
|
+
* Two halves, one rule. The corpus half renders every non-markup corpus row
|
|
45
|
+
* (`render(parse(en), L)`, what `populate` writes) and needs a freshly
|
|
46
|
+
* populated patterns.db, like the other corpus gates (`test:canonical`). The
|
|
47
|
+
* command-shape half reads the renders the command-shape gate already makes
|
|
48
|
+
* (`translate(src, 'en', L)`), in its shards. Each half's baseline lists, per
|
|
49
|
+
* source, the findings of each language; the gate fails on a finding a pair
|
|
50
|
+
* did not have, and on a listed finding that is gone (prune it with
|
|
51
|
+
* tools/regen-english-leaks-baseline.ts), so the lists only shrink.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { readFileSync } from 'node:fs';
|
|
55
|
+
import path from 'node:path';
|
|
56
|
+
import { fileURLToPath } from 'node:url';
|
|
57
|
+
import { readerOf, type CaseResult, type CommandShapeCase } from './command-shapes';
|
|
58
|
+
import { FOREIGN_LANGUAGES } from './value-matrix';
|
|
59
|
+
|
|
60
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
61
|
+
|
|
62
|
+
export const LEAK_BASELINE_PATHS = {
|
|
63
|
+
corpus: path.resolve(here, '../../baselines/english-leaks.corpus.json'),
|
|
64
|
+
shapes: path.resolve(here, '../../baselines/english-leaks.shapes.json'),
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
export type LeakHalf = keyof typeof LEAK_BASELINE_PATHS;
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Findings
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export type LeakKind = 'word' | 'event' | 'case' | 'spacing';
|
|
74
|
+
export type LeakContext = 'plain' | 'clause' | 'bracket' | 'call' | 'property';
|
|
75
|
+
export const KINDS: readonly LeakKind[] = ['word', 'event', 'case', 'spacing'];
|
|
76
|
+
|
|
77
|
+
export interface LeakFinding {
|
|
78
|
+
kind: LeakKind;
|
|
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
|
+
*/
|
|
84
|
+
word: string;
|
|
85
|
+
context: LeakContext;
|
|
86
|
+
/** Does the language have its own word for it? */
|
|
87
|
+
hasWord: boolean;
|
|
88
|
+
/** English a recorded decision keeps ({@link KEPT_ENGLISH}): not counted toward M2's targets. */
|
|
89
|
+
kept?: true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* English a recorded decision keeps, so M2's words target does not count it
|
|
94
|
+
* (the gate still does: it only shrinks). Contexts policy 6 keeps (roadmap §5),
|
|
95
|
+
* and the vocabulary sheet's keep rows (`docs-internal/multilingual/
|
|
96
|
+
* VOCABULARY_SHEET.md`, C1–C3, decided 2026-10-08).
|
|
97
|
+
*/
|
|
98
|
+
export const KEPT_ENGLISH: {
|
|
99
|
+
readonly contexts: ReadonlySet<LeakContext>;
|
|
100
|
+
readonly phrases: ReadonlyArray<{ readonly phrase: string; readonly why: string }>;
|
|
101
|
+
} = {
|
|
102
|
+
// Policy 6: bracket and brace interiors, a call's arguments, a property name
|
|
103
|
+
// its reader cannot bring back, and a clause no pattern models (M1).
|
|
104
|
+
contexts: new Set(['bracket', 'call', 'property', 'clause']),
|
|
105
|
+
phrases: [
|
|
106
|
+
{ phrase: 'debounced at', why: 'C1: a term of art with no settled native word' },
|
|
107
|
+
{ phrase: 'throttled at', why: 'C1: a term of art with no settled native word' },
|
|
108
|
+
{ phrase: 'url', why: 'C2: an international loanword (policy 6 keeps push/replace url)' },
|
|
109
|
+
{ phrase: 'dom', why: 'C3: an acronym' },
|
|
110
|
+
{
|
|
111
|
+
phrase: 'view transition',
|
|
112
|
+
why: "C3: the web API's name (policy 6 keeps using view transition)",
|
|
113
|
+
},
|
|
114
|
+
{ phrase: 'using', why: 'policy 6: `using view transition` (the phrase above holds the rest)' },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** A finding as the baseline writes it: the word, or `event:<name>` / `case:<pronoun>` / `spacing:<marker>`. */
|
|
119
|
+
export const findingKey = (f: Pick<LeakFinding, 'kind' | 'word'>): string =>
|
|
120
|
+
f.kind === 'word' ? f.word : `${f.kind}:${f.word}`;
|
|
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
|
+
|
|
137
|
+
/**
|
|
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.
|
|
169
|
+
*/
|
|
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',
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// What a source holds
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
export type LeakReader = 'program' | 'statements';
|
|
244
|
+
|
|
245
|
+
export interface SourceInfo {
|
|
246
|
+
/** Words the engine read as grammar, with how often. */
|
|
247
|
+
grammar: Map<string, number>;
|
|
248
|
+
/** Property names in the source (`my value`, `#a's children`), with how often. */
|
|
249
|
+
props: Map<string, number>;
|
|
250
|
+
/** Events the source handles, sends or triggers, with how often. */
|
|
251
|
+
events: Map<string, number>;
|
|
252
|
+
/** `js` blocks: never translated, never read. */
|
|
253
|
+
jsBodies: JsBlock[];
|
|
254
|
+
/** Words of the clauses semantic keeps as written. */
|
|
255
|
+
clauseWords: Set<string>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** References the engine reads as names; semantic translates them (`me` → es `yo`). */
|
|
259
|
+
const REFERENCE_NAMES: ReadonlySet<string> = new Set([
|
|
260
|
+
'I',
|
|
261
|
+
'me',
|
|
262
|
+
'my',
|
|
263
|
+
'it',
|
|
264
|
+
'its',
|
|
265
|
+
'you',
|
|
266
|
+
'your',
|
|
267
|
+
'result',
|
|
268
|
+
'event',
|
|
269
|
+
'target',
|
|
270
|
+
'detail',
|
|
271
|
+
'body',
|
|
272
|
+
'document',
|
|
273
|
+
'window',
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const bump = (map: Map<string, number>, key: string, by = 1): void => {
|
|
277
|
+
map.set(key, (map.get(key) ?? 0) + by);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** A `js` block: its parameter names (the script's, passed in) and its body (JavaScript). */
|
|
281
|
+
export interface JsBlock {
|
|
282
|
+
params: string;
|
|
283
|
+
body: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The `js` blocks of a source: `js`, an optional parameter list, a body, `end`. */
|
|
287
|
+
function jsBlocksOf(source: string): JsBlock[] {
|
|
288
|
+
return [...source.matchAll(/\bjs\b\s*(?:\(([^)]*)\))?([\s\S]*?)\bend\b/g)]
|
|
289
|
+
.map(m => ({ params: (m[1] ?? '').trim(), body: (m[2] ?? '').trim() }))
|
|
290
|
+
.filter(b => b.body || b.params);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// What a language holds
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
interface KeywordTranslationLike {
|
|
300
|
+
primary?: string;
|
|
301
|
+
alternatives?: readonly string[];
|
|
302
|
+
render?: boolean;
|
|
303
|
+
}
|
|
304
|
+
interface ProfileLike {
|
|
305
|
+
keywords?: Record<string, KeywordTranslationLike>;
|
|
306
|
+
roleMarkers?: Record<string, KeywordTranslationLike & { position?: string }>;
|
|
307
|
+
references?: Record<string, string>;
|
|
308
|
+
possessive?: {
|
|
309
|
+
marker?: string;
|
|
310
|
+
specialForms?: Record<string, string>;
|
|
311
|
+
keywords?: Record<string, string>;
|
|
312
|
+
};
|
|
313
|
+
eventHandler?: Record<string, unknown>;
|
|
314
|
+
objectClitics?: { forms?: Record<string, string>; attach?: string };
|
|
315
|
+
}
|
|
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
|
+
}
|
|
321
|
+
|
|
322
|
+
const LEXICON_CATEGORIES = ['values', 'expressions', 'logical', 'temporal', 'attributes'] as const;
|
|
323
|
+
|
|
324
|
+
const wordsOf = (text: string | undefined): string[] => (text ?? '').split(/\s+/).filter(Boolean);
|
|
325
|
+
const spellings = (t: KeywordTranslationLike | undefined): string[] =>
|
|
326
|
+
t ? [t.primary ?? '', ...(t.alternatives ?? [])].flatMap(wordsOf) : [];
|
|
327
|
+
|
|
328
|
+
interface LanguageWords {
|
|
329
|
+
/** Every word the language writes: a render word spelled like one is its own. */
|
|
330
|
+
native: Set<string>;
|
|
331
|
+
/** English words the language has a word for (lexicon, keywords, references). */
|
|
332
|
+
translated: Set<string>;
|
|
333
|
+
/** Events its lexicon names, minus the renderer's denylist for it. */
|
|
334
|
+
events: Set<string>;
|
|
335
|
+
/** Its `me` and `it` words, its role markers by side, and its command verbs (as word lists). */
|
|
336
|
+
me: string | undefined;
|
|
337
|
+
it: string | undefined;
|
|
338
|
+
markersBefore: Set<string>;
|
|
339
|
+
markersAfter: Set<string>;
|
|
340
|
+
verbs: string[][];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function languageWords(
|
|
344
|
+
profile: ProfileLike | undefined,
|
|
345
|
+
lexicon: LexiconLike | undefined,
|
|
346
|
+
denied: ReadonlySet<string> | undefined,
|
|
347
|
+
commands: readonly string[],
|
|
348
|
+
patterns: readonly PatternLike[]
|
|
349
|
+
): LanguageWords {
|
|
350
|
+
const native = new Set<string>();
|
|
351
|
+
const translated = new Set<string>();
|
|
352
|
+
const add = (english: string, t: KeywordTranslationLike | undefined): void => {
|
|
353
|
+
const forms = spellings(t);
|
|
354
|
+
// An alternative spelled like the English word, beside a primary that is
|
|
355
|
+
// the language's own, is English the reader still takes (code-switched
|
|
356
|
+
// input), not a word the language writes: a render that writes it leaks.
|
|
357
|
+
// Counted as native, it hid every such leak (`forever` in the eight
|
|
358
|
+
// languages whose primary is their own, V1).
|
|
359
|
+
const englishWords = new Set(wordsOf(english));
|
|
360
|
+
const primary = wordsOf(t?.primary);
|
|
361
|
+
const ownPrimary = primary.some(w => !englishWords.has(w));
|
|
362
|
+
for (const w of forms)
|
|
363
|
+
if (!(ownPrimary && englishWords.has(w) && !primary.includes(w))) native.add(w);
|
|
364
|
+
if (forms.length && !forms.includes(english))
|
|
365
|
+
for (const w of wordsOf(english)) translated.add(w);
|
|
366
|
+
};
|
|
367
|
+
for (const [english, t] of Object.entries(profile?.keywords ?? {})) add(english, t);
|
|
368
|
+
for (const t of Object.values(profile?.roleMarkers ?? {}))
|
|
369
|
+
for (const w of spellings(t)) native.add(w);
|
|
370
|
+
for (const [english, n] of Object.entries(profile?.references ?? {}))
|
|
371
|
+
add(english, { primary: n });
|
|
372
|
+
for (const [english, n] of Object.entries(profile?.possessive?.specialForms ?? {}))
|
|
373
|
+
add(english === 'me' ? 'my' : english === 'it' ? 'its' : english === 'you' ? 'your' : english, {
|
|
374
|
+
primary: n,
|
|
375
|
+
});
|
|
376
|
+
// Native form → the English reference it stands for (es `mi` → `me`).
|
|
377
|
+
for (const n of Object.keys(profile?.possessive?.keywords ?? {}))
|
|
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);
|
|
383
|
+
for (const w of wordsOf(profile?.possessive?.marker)) native.add(w);
|
|
384
|
+
const handler = JSON.stringify(profile?.eventHandler ?? {});
|
|
385
|
+
for (const m of handler.matchAll(
|
|
386
|
+
/"(?:primary|alternatives|temporalMarkers)":(\[[^\]]*\]|"[^"]*")/g
|
|
387
|
+
))
|
|
388
|
+
for (const w of (JSON.parse(m[1] ?? '""') as string | string[]).toString().split(/[\s,]+/))
|
|
389
|
+
if (w) native.add(w);
|
|
390
|
+
for (const category of LEXICON_CATEGORIES)
|
|
391
|
+
for (const [english, t] of Object.entries(lexicon?.[category] ?? {})) add(english, t);
|
|
392
|
+
const events = new Set<string>();
|
|
393
|
+
for (const [english, t] of Object.entries(lexicon?.events ?? {})) {
|
|
394
|
+
for (const w of spellings(t)) native.add(w);
|
|
395
|
+
if (t.render !== false && t.primary && t.primary !== english && !denied?.has(english))
|
|
396
|
+
events.add(english);
|
|
397
|
+
}
|
|
398
|
+
const markersBefore = new Set<string>();
|
|
399
|
+
const markersAfter = new Set<string>();
|
|
400
|
+
for (const t of Object.values(profile?.roleMarkers ?? {}))
|
|
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);
|
|
416
|
+
return {
|
|
417
|
+
native,
|
|
418
|
+
translated,
|
|
419
|
+
events,
|
|
420
|
+
me: profile?.references?.me,
|
|
421
|
+
it: profile?.references?.it,
|
|
422
|
+
markersBefore,
|
|
423
|
+
markersAfter,
|
|
424
|
+
verbs,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Reading a render
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
/** Blank out `{…}` groups: CSS blocks and object literals are code. */
|
|
433
|
+
function blankBraces(text: string): string {
|
|
434
|
+
let out = '';
|
|
435
|
+
let depth = 0;
|
|
436
|
+
for (const c of text) {
|
|
437
|
+
if (c === '{') depth++;
|
|
438
|
+
out += depth > 0 ? ' ' : c;
|
|
439
|
+
if (c === '}' && depth > 0) depth--;
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Strings, `js` bodies and brace interiors replaced by spaces (offsets kept). */
|
|
445
|
+
export function codeOnly(render: string, jsBlocks: readonly JsBlock[]): string {
|
|
446
|
+
const blank = (m: string): string => ' '.repeat(m.length);
|
|
447
|
+
let text = render;
|
|
448
|
+
for (const { params, body } of jsBlocks) {
|
|
449
|
+
const list = params
|
|
450
|
+
? `\\(\\s*${params
|
|
451
|
+
.split(/\s*,\s*/)
|
|
452
|
+
.map(escapeRegExp)
|
|
453
|
+
.join('\\s*,\\s*')}\\s*\\)\\s*`
|
|
454
|
+
: '';
|
|
455
|
+
text = text.replace(new RegExp(`${list}${escapeRegExp(body)}`, 'g'), blank);
|
|
456
|
+
}
|
|
457
|
+
return blankBraces(text)
|
|
458
|
+
.replace(/`[^`]*`/g, blank)
|
|
459
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, blank)
|
|
460
|
+
.replace(/(?<![\p{L}\p{N}])'(?:[^'\\\n]|\\.)*'/gu, blank);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Where `[…]` groups and a call's `(…)` arguments sit, innermost last. */
|
|
464
|
+
function groupSpans(text: string): Array<[number, number, 'bracket' | 'call']> {
|
|
465
|
+
const spans: Array<[number, number, 'bracket' | 'call']> = [];
|
|
466
|
+
const open: Array<[number, string, boolean]> = [];
|
|
467
|
+
for (let i = 0; i < text.length; i++) {
|
|
468
|
+
const c = text[i];
|
|
469
|
+
if (c === '[' || c === '(')
|
|
470
|
+
open.push([i, c, c === '(' && i > 0 && /[\w$]/.test(text[i - 1] ?? '')]);
|
|
471
|
+
else if ((c === ']' || c === ')') && open.length) {
|
|
472
|
+
const [start, kind, call] = open.pop()!;
|
|
473
|
+
if (kind === '[') spans.push([start, i, 'bracket']);
|
|
474
|
+
else if (call) spans.push([start, i, 'call']);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return spans.sort((a, b) => a[0] - b[0]);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
interface RenderWord {
|
|
481
|
+
word: string;
|
|
482
|
+
offset: number;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The words of a render's code, each with its offset. */
|
|
486
|
+
function renderWords(text: string): RenderWord[] {
|
|
487
|
+
const out: RenderWord[] = [];
|
|
488
|
+
for (const m of text.matchAll(/\S+/g)) {
|
|
489
|
+
let token = m[0];
|
|
490
|
+
let offset = m.index ?? 0;
|
|
491
|
+
const lead = /^[([,]+/.exec(token)?.[0].length ?? 0;
|
|
492
|
+
token = token.slice(lead);
|
|
493
|
+
offset += lead;
|
|
494
|
+
out.push({ word: token.replace(/[)\],;]+$/, '').replace(/'s$/, ''), offset });
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// The scanner
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
export interface LeakScanner {
|
|
504
|
+
/** What the engine and semantic read in a source; `null` when the engine rejects it. */
|
|
505
|
+
sourceInfo(source: string, reader: LeakReader): SourceInfo | null;
|
|
506
|
+
/** The findings of one render of `info`'s source into `language`. */
|
|
507
|
+
scan(render: string, info: SourceInfo, language: string): LeakFinding[];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
interface EngineModule {
|
|
511
|
+
register(...modules: unknown[]): void;
|
|
512
|
+
everything: unknown[];
|
|
513
|
+
parse(source: string): unknown;
|
|
514
|
+
parseProgram(source: string): unknown;
|
|
515
|
+
Parser: { prototype: { match(value: string, type?: string): unknown } };
|
|
516
|
+
}
|
|
517
|
+
interface SemanticModule {
|
|
518
|
+
parse(source: string, language: string): unknown;
|
|
519
|
+
tryGetProfile(language: string): unknown;
|
|
520
|
+
getLexicon(language: string): unknown;
|
|
521
|
+
getEventLocalizationDenylist(): Record<string, ReadonlySet<string>>;
|
|
522
|
+
commandSchemas: Record<string, unknown>;
|
|
523
|
+
getPatternsForLanguage(language: string): PatternLike[];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Load the engine and semantic (ESM-only, so imported here) and return a scanner. */
|
|
527
|
+
export async function initLeakScanner(): Promise<LeakScanner> {
|
|
528
|
+
const engine = (await import('@hyperfixi/engine')) as unknown as EngineModule;
|
|
529
|
+
engine.register(...engine.everything);
|
|
530
|
+
const semantic = (await import('@lokascript/semantic')) as unknown as SemanticModule;
|
|
531
|
+
const denylist = semantic.getEventLocalizationDenylist();
|
|
532
|
+
const commands = Object.keys(semantic.commandSchemas);
|
|
533
|
+
const languages = new Map<string, LanguageWords>();
|
|
534
|
+
for (const language of FOREIGN_LANGUAGES) {
|
|
535
|
+
languages.set(
|
|
536
|
+
language,
|
|
537
|
+
languageWords(
|
|
538
|
+
semantic.tryGetProfile(language) as ProfileLike | undefined,
|
|
539
|
+
semantic.getLexicon(language) as LexiconLike | undefined,
|
|
540
|
+
denylist[language],
|
|
541
|
+
commands,
|
|
542
|
+
semantic.getPatternsForLanguage(language)
|
|
543
|
+
)
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Parse with the engine, recording every token its grammar matched as a word. */
|
|
548
|
+
const readGrammar = (
|
|
549
|
+
source: string,
|
|
550
|
+
reader: LeakReader
|
|
551
|
+
): { ast: unknown; matched: Set<object> } | null => {
|
|
552
|
+
const proto = engine.Parser.prototype;
|
|
553
|
+
const original = proto.match;
|
|
554
|
+
const matched = new Set<object>();
|
|
555
|
+
proto.match = function (this: unknown, value: string, type?: string) {
|
|
556
|
+
const token = original.call(this, value, type);
|
|
557
|
+
if (token && (type ?? 'IDENTIFIER') === 'IDENTIFIER') matched.add(token as object);
|
|
558
|
+
return token;
|
|
559
|
+
};
|
|
560
|
+
try {
|
|
561
|
+
return {
|
|
562
|
+
ast: reader === 'program' ? engine.parseProgram(source) : engine.parse(source),
|
|
563
|
+
matched,
|
|
564
|
+
};
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
} finally {
|
|
568
|
+
proto.match = original;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
sourceInfo(source, reader) {
|
|
574
|
+
const read = readGrammar(source, reader);
|
|
575
|
+
if (!read) return null;
|
|
576
|
+
const grammar = new Map<string, number>();
|
|
577
|
+
for (const token of read.matched) bump(grammar, (token as { value: string }).value);
|
|
578
|
+
const references = new Map<string, number>();
|
|
579
|
+
const props = new Map<string, number>();
|
|
580
|
+
const events = new Map<string, number>();
|
|
581
|
+
const seen = new WeakSet<object>();
|
|
582
|
+
const walk = (value: unknown, key: string): void => {
|
|
583
|
+
if (typeof value === 'string') {
|
|
584
|
+
if (key === 'name' && REFERENCE_NAMES.has(value)) bump(references, value);
|
|
585
|
+
else if (key === 'prop') bump(props, value);
|
|
586
|
+
else if (key === 'on' || key === 'event') bump(events, value);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return;
|
|
590
|
+
seen.add(value);
|
|
591
|
+
for (const [k, v] of Object.entries(value))
|
|
592
|
+
if (typeof v !== 'function') walk(v, Array.isArray(value) ? key : k);
|
|
593
|
+
};
|
|
594
|
+
walk(read.ast, '');
|
|
595
|
+
for (const [word, n] of references) grammar.set(word, Math.max(grammar.get(word) ?? 0, n));
|
|
596
|
+
|
|
597
|
+
const clauseWords = new Set<string>();
|
|
598
|
+
let node: unknown = null;
|
|
599
|
+
try {
|
|
600
|
+
node = semantic.parse(source, 'en');
|
|
601
|
+
} catch {
|
|
602
|
+
// No clause words: every finding reads as plain.
|
|
603
|
+
}
|
|
604
|
+
const clauses = (n: unknown): void => {
|
|
605
|
+
if (!n || typeof n !== 'object') return;
|
|
606
|
+
if (Array.isArray(n)) return n.forEach(clauses);
|
|
607
|
+
const record = n as Record<string, unknown>;
|
|
608
|
+
for (const k of ['verbatimClause', 'headClause'])
|
|
609
|
+
if (typeof record[k] === 'string')
|
|
610
|
+
for (const w of wordsOf(record[k] as string)) clauseWords.add(w);
|
|
611
|
+
for (const v of Object.values(record)) if (v && typeof v === 'object') clauses(v);
|
|
612
|
+
};
|
|
613
|
+
clauses(node);
|
|
614
|
+
return { grammar, props, events, jsBodies: jsBlocksOf(source), clauseWords };
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
scan(render, info, language) {
|
|
618
|
+
const lang = languages.get(language);
|
|
619
|
+
if (!lang) throw new Error(`no vocabulary for ${language}`);
|
|
620
|
+
const text = codeOnly(render, info.jsBodies);
|
|
621
|
+
const spans = groupSpans(text);
|
|
622
|
+
const contextAt = (offset: number): 'bracket' | 'call' | undefined => {
|
|
623
|
+
let found: 'bracket' | 'call' | undefined;
|
|
624
|
+
for (const [start, end, kind] of spans) if (start < offset && offset < end) found = kind;
|
|
625
|
+
return found;
|
|
626
|
+
};
|
|
627
|
+
const keptSpans: Array<[number, number]> = [];
|
|
628
|
+
for (const { phrase } of KEPT_ENGLISH.phrases)
|
|
629
|
+
for (const m of text.matchAll(
|
|
630
|
+
new RegExp(
|
|
631
|
+
`(?<![\\p{L}\\p{N}])${escapeRegExp(phrase).replace(/ /g, '\\s+')}(?![\\p{L}\\p{N}])`,
|
|
632
|
+
'gu'
|
|
633
|
+
)
|
|
634
|
+
))
|
|
635
|
+
keptSpans.push([m.index ?? 0, (m.index ?? 0) + m[0].length]);
|
|
636
|
+
const isKept = (offset: number, context: LeakContext): boolean =>
|
|
637
|
+
KEPT_ENGLISH.contexts.has(context) ||
|
|
638
|
+
keptSpans.some(([start, end]) => start <= offset && offset < end);
|
|
639
|
+
const findings: LeakFinding[] = [];
|
|
640
|
+
const grammarLeft = new Map(info.grammar);
|
|
641
|
+
const propsLeft = new Map(info.props);
|
|
642
|
+
const eventsLeft = new Map(info.events);
|
|
643
|
+
const words = renderWords(text);
|
|
644
|
+
for (const { word, offset } of words) {
|
|
645
|
+
if (!/^[A-Za-z]+$/.test(word) || lang.native.has(word)) continue;
|
|
646
|
+
if ((eventsLeft.get(word) ?? 0) > 0) {
|
|
647
|
+
eventsLeft.set(word, eventsLeft.get(word)! - 1);
|
|
648
|
+
if (lang.events.has(word))
|
|
649
|
+
findings.push({ kind: 'event', word, context: 'plain', hasWord: true });
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const grammar = (grammarLeft.get(word) ?? 0) > 0;
|
|
653
|
+
const prop = !grammar && (propsLeft.get(word) ?? 0) > 0 && lang.translated.has(word);
|
|
654
|
+
if (!grammar && !prop) continue;
|
|
655
|
+
(grammar ? grammarLeft : propsLeft).set(
|
|
656
|
+
word,
|
|
657
|
+
(grammar ? grammarLeft : propsLeft).get(word)! - 1
|
|
658
|
+
);
|
|
659
|
+
const context: LeakContext = info.clauseWords.has(word)
|
|
660
|
+
? 'clause'
|
|
661
|
+
: (contextAt(offset) ?? (prop ? 'property' : 'plain'));
|
|
662
|
+
findings.push({
|
|
663
|
+
kind: 'word',
|
|
664
|
+
word,
|
|
665
|
+
context,
|
|
666
|
+
hasWord: lang.translated.has(word),
|
|
667
|
+
...(isKept(offset, context) ? { kept: true as const } : {}),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const rule = CASE_RULES[language];
|
|
671
|
+
if (rule) {
|
|
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
|
+
);
|
|
677
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
678
|
+
const pronoun = tokens[i] === lang.me ? 'me' : tokens[i] === lang.it ? 'it' : undefined;
|
|
679
|
+
if (!pronoun || /[([]/.test(text[words[i]!.offset - 1] ?? '')) continue;
|
|
680
|
+
const before = tokens[i - 1];
|
|
681
|
+
const after = tokens[i + 1];
|
|
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 });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return findings;
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// Baseline
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
/** One half's committed findings: per source, a findings string → the languages that have it. */
|
|
720
|
+
export interface LeakBaseline {
|
|
721
|
+
description: string;
|
|
722
|
+
/** Renders read, renders with a finding, and findings, at the last regeneration. */
|
|
723
|
+
renders: number;
|
|
724
|
+
leaky: number;
|
|
725
|
+
findings: number;
|
|
726
|
+
/** The same, per kind: `word.renders` is the leak rate's numerator. */
|
|
727
|
+
kinds: Record<LeakKind, { renders: number; findings: number }>;
|
|
728
|
+
/** Per source id: `"<finding> <finding> …"` (sorted keys) → languages (`*` for all 23). */
|
|
729
|
+
entries: Record<string, Record<string, string>>;
|
|
730
|
+
/**
|
|
731
|
+
* Per source id, the languages with no render (the translation was refused).
|
|
732
|
+
* A lane that starts to render is progress, not a leak: the gate reports it
|
|
733
|
+
* as one to prune, and its English is counted from then on.
|
|
734
|
+
*/
|
|
735
|
+
unrendered?: Record<string, string>;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** The results of one half: per source id, per language, the findings (`null`: no render). */
|
|
739
|
+
export type LeakResults = Map<string, Map<string, LeakFinding[] | null>>;
|
|
740
|
+
|
|
741
|
+
const ALL = '*';
|
|
742
|
+
export function compressLanguages(languages: readonly string[]): string {
|
|
743
|
+
const set = new Set(languages);
|
|
744
|
+
return FOREIGN_LANGUAGES.every(l => set.has(l))
|
|
745
|
+
? ALL
|
|
746
|
+
: FOREIGN_LANGUAGES.filter(l => set.has(l)).join(' ');
|
|
747
|
+
}
|
|
748
|
+
export function expandLanguages(text: string): string[] {
|
|
749
|
+
return text === ALL ? [...FOREIGN_LANGUAGES] : text.split(' ').filter(Boolean);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export const keysOf = (findings: readonly LeakFinding[] | null): string[] =>
|
|
753
|
+
(findings ?? []).map(findingKey).sort();
|
|
754
|
+
|
|
755
|
+
export function leakBaselineFrom(results: LeakResults, description: string): LeakBaseline {
|
|
756
|
+
const doc: LeakBaseline = {
|
|
757
|
+
description,
|
|
758
|
+
renders: 0,
|
|
759
|
+
leaky: 0,
|
|
760
|
+
findings: 0,
|
|
761
|
+
kinds: {
|
|
762
|
+
word: { renders: 0, findings: 0 },
|
|
763
|
+
event: { renders: 0, findings: 0 },
|
|
764
|
+
case: { renders: 0, findings: 0 },
|
|
765
|
+
spacing: { renders: 0, findings: 0 },
|
|
766
|
+
},
|
|
767
|
+
entries: {},
|
|
768
|
+
unrendered: {},
|
|
769
|
+
};
|
|
770
|
+
for (const [id, byLanguage] of [...results].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
771
|
+
const groups = new Map<string, string[]>();
|
|
772
|
+
const unrendered: string[] = [];
|
|
773
|
+
for (const [language, findings] of byLanguage) {
|
|
774
|
+
if (!findings) {
|
|
775
|
+
unrendered.push(language);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
doc.renders++;
|
|
779
|
+
if (!findings.length) continue;
|
|
780
|
+
doc.leaky++;
|
|
781
|
+
doc.findings += findings.length;
|
|
782
|
+
for (const kind of KINDS) {
|
|
783
|
+
const n = findings.filter(f => f.kind === kind).length;
|
|
784
|
+
doc.kinds[kind].findings += n;
|
|
785
|
+
if (n) doc.kinds[kind].renders++;
|
|
786
|
+
}
|
|
787
|
+
const key = keysOf(findings).join(' ');
|
|
788
|
+
groups.set(key, [...(groups.get(key) ?? []), language]);
|
|
789
|
+
}
|
|
790
|
+
if (unrendered.length) doc.unrendered![id] = compressLanguages(unrendered);
|
|
791
|
+
if (!groups.size) continue;
|
|
792
|
+
doc.entries[id] = Object.fromEntries(
|
|
793
|
+
[...groups]
|
|
794
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
795
|
+
.map(([key, ls]) => [key, compressLanguages(ls)])
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
return doc;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** The findings a baseline lists for one (source, language). */
|
|
802
|
+
export function listedKeys(
|
|
803
|
+
baseline: Pick<LeakBaseline, 'entries'>,
|
|
804
|
+
id: string,
|
|
805
|
+
language: string
|
|
806
|
+
): string[] {
|
|
807
|
+
for (const [key, languages] of Object.entries(baseline.entries[id] ?? {}))
|
|
808
|
+
if (expandLanguages(languages).includes(language)) return key.split(' ');
|
|
809
|
+
return [];
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export interface LeakChange {
|
|
813
|
+
id: string;
|
|
814
|
+
language: string;
|
|
815
|
+
/** Findings the pair has now and the baseline does not list. */
|
|
816
|
+
added: string[];
|
|
817
|
+
/** Findings the baseline lists and the pair no longer has. */
|
|
818
|
+
gone: string[];
|
|
819
|
+
/** The baseline lists the pair as unrendered and it renders now: `added` is its English. */
|
|
820
|
+
newRender?: boolean;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Multiset difference `a − b`. */
|
|
824
|
+
function minus(a: readonly string[], b: readonly string[]): string[] {
|
|
825
|
+
const left = [...b];
|
|
826
|
+
return a.filter(x => {
|
|
827
|
+
const i = left.indexOf(x);
|
|
828
|
+
if (i < 0) return true;
|
|
829
|
+
left.splice(i, 1);
|
|
830
|
+
return false;
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Compare results with a baseline, over the sources and languages that ran. */
|
|
835
|
+
export function diffLeakBaseline(
|
|
836
|
+
results: LeakResults,
|
|
837
|
+
baseline: Pick<LeakBaseline, 'entries' | 'unrendered'>
|
|
838
|
+
): LeakChange[] {
|
|
839
|
+
const changes: LeakChange[] = [];
|
|
840
|
+
for (const [id, byLanguage] of results) {
|
|
841
|
+
const unrendered = expandLanguages(baseline.unrendered?.[id] ?? '');
|
|
842
|
+
for (const [language, findings] of byLanguage) {
|
|
843
|
+
const wasUnrendered = unrendered.includes(language);
|
|
844
|
+
if (findings && wasUnrendered) {
|
|
845
|
+
changes.push({ id, language, added: keysOf(findings), gone: [], newRender: true });
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
const got = keysOf(findings);
|
|
849
|
+
const listed = listedKeys(baseline, id, language);
|
|
850
|
+
const added = minus(got, listed);
|
|
851
|
+
const gone = minus(listed, got);
|
|
852
|
+
if (added.length || gone.length) changes.push({ id, language, added, gone });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return changes;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Findings a pair gained: a regression (a lane that starts to render is not one). */
|
|
859
|
+
export const gainedLeaks = (changes: readonly LeakChange[]): LeakChange[] =>
|
|
860
|
+
changes.filter(c => c.added.length > 0 && !c.newRender);
|
|
861
|
+
|
|
862
|
+
/** Changes the baseline must be pruned for: findings gone, or a lane that renders now. */
|
|
863
|
+
export const prunableLeaks = (changes: readonly LeakChange[]): LeakChange[] =>
|
|
864
|
+
changes.filter(c => c.gone.length > 0 || c.newRender);
|
|
865
|
+
|
|
866
|
+
export function loadLeakBaseline(half: LeakHalf): LeakBaseline {
|
|
867
|
+
return JSON.parse(readFileSync(LEAK_BASELINE_PATHS[half], 'utf8')) as LeakBaseline;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ---------------------------------------------------------------------------
|
|
871
|
+
// M2's exit targets
|
|
872
|
+
// ---------------------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* M2's exit targets (decided 2026-10-08): the most of a language's renders that
|
|
876
|
+
* may hold a finding of each kind, in EVERY language. Per language because a
|
|
877
|
+
* reader reads one, and an average hid he (35% of corpus renders with an English
|
|
878
|
+
* word where the others sit near 18%). Words count only English no recorded
|
|
879
|
+
* decision keeps ({@link KEPT_ENGLISH}). Events are already net of the denylist,
|
|
880
|
+
* whose entries carry reasons. Spacing (N5) joined on 2026-10-11, with none
|
|
881
|
+
* allowed, as case did (FUSED_FORMS.md Q3).
|
|
882
|
+
*/
|
|
883
|
+
export const LEAK_TARGETS: Readonly<Record<LeakHalf, Readonly<Record<LeakKind, number>>>> = {
|
|
884
|
+
corpus: { word: 0.05, event: 0, case: 0, spacing: 0 },
|
|
885
|
+
shapes: { word: 0.1, event: 0, case: 0, spacing: 0 },
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
export interface LanguageLeakRates {
|
|
889
|
+
renders: number;
|
|
890
|
+
/** The share of the language's renders with a finding of each kind. */
|
|
891
|
+
rate: Record<LeakKind, number>;
|
|
892
|
+
/** Whether each rate is within {@link LEAK_TARGETS}. */
|
|
893
|
+
met: Record<LeakKind, boolean>;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Each language's leak rates against the half's targets; a lane with no render is not counted. */
|
|
897
|
+
export function leakRatesByLanguage(
|
|
898
|
+
results: LeakResults,
|
|
899
|
+
half: LeakHalf
|
|
900
|
+
): Map<string, LanguageLeakRates> {
|
|
901
|
+
const counts = new Map<string, { renders: number } & Record<LeakKind, number>>();
|
|
902
|
+
for (const byLanguage of results.values()) {
|
|
903
|
+
for (const [language, findings] of byLanguage) {
|
|
904
|
+
if (!findings) continue;
|
|
905
|
+
const row = counts.get(language) ?? { renders: 0, word: 0, event: 0, case: 0, spacing: 0 };
|
|
906
|
+
row.renders++;
|
|
907
|
+
for (const kind of KINDS) if (findings.some(f => f.kind === kind && !f.kept)) row[kind]++;
|
|
908
|
+
counts.set(language, row);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const out = new Map<string, LanguageLeakRates>();
|
|
912
|
+
for (const [language, row] of counts) {
|
|
913
|
+
const rate = { word: 0, event: 0, case: 0, spacing: 0 };
|
|
914
|
+
const met = { word: true, event: true, case: true, spacing: true };
|
|
915
|
+
for (const kind of KINDS) {
|
|
916
|
+
rate[kind] = row[kind] / row.renders;
|
|
917
|
+
met[kind] = rate[kind] <= LEAK_TARGETS[half][kind];
|
|
918
|
+
}
|
|
919
|
+
out.set(language, { renders: row.renders, rate, met });
|
|
920
|
+
}
|
|
921
|
+
return out;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// The two inputs
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
/** The command-shape half: the renders the command-shape gate made for `results`. */
|
|
929
|
+
export function shapeLeaks(
|
|
930
|
+
scanner: LeakScanner,
|
|
931
|
+
results: readonly CaseResult[],
|
|
932
|
+
cases: readonly CommandShapeCase[]
|
|
933
|
+
): LeakResults {
|
|
934
|
+
const byId = new Map(cases.map(c => [c.id, c]));
|
|
935
|
+
const out: LeakResults = new Map();
|
|
936
|
+
for (const r of results) {
|
|
937
|
+
const shape = byId.get(r.id);
|
|
938
|
+
if (!shape) continue;
|
|
939
|
+
const info = scanner.sourceInfo(shape.source, readerOf(shape));
|
|
940
|
+
if (!info) continue;
|
|
941
|
+
const byLanguage = new Map<string, LeakFinding[] | null>();
|
|
942
|
+
for (const language of FOREIGN_LANGUAGES) {
|
|
943
|
+
const rendered = r.lanes[language]?.rendered;
|
|
944
|
+
byLanguage.set(
|
|
945
|
+
language,
|
|
946
|
+
rendered === undefined ? null : scanner.scan(rendered, info, language)
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
out.set(r.id, byLanguage);
|
|
950
|
+
}
|
|
951
|
+
return out;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
interface CorpusRow {
|
|
955
|
+
id: string;
|
|
956
|
+
rawCode: string;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* The corpus half: every non-markup row rendered as `populate` writes it
|
|
961
|
+
* (`render(parse(en), L)`). A row the engine rejects has no grammar to read,
|
|
962
|
+
* so it is skipped; the caller can count them from `skipped`.
|
|
963
|
+
*/
|
|
964
|
+
export async function corpusLeaks(
|
|
965
|
+
scanner: LeakScanner,
|
|
966
|
+
rows: readonly CorpusRow[]
|
|
967
|
+
): Promise<{ results: LeakResults; skipped: string[] }> {
|
|
968
|
+
const { parse, render } = (await import('@lokascript/semantic')) as unknown as {
|
|
969
|
+
parse(source: string, language: string): unknown;
|
|
970
|
+
render(node: unknown, language: string): string;
|
|
971
|
+
};
|
|
972
|
+
const results: LeakResults = new Map();
|
|
973
|
+
const skipped: string[] = [];
|
|
974
|
+
for (const row of rows) {
|
|
975
|
+
if (row.rawCode.trimStart().startsWith('<')) continue;
|
|
976
|
+
const info = scanner.sourceInfo(row.rawCode, 'program');
|
|
977
|
+
let node: unknown = null;
|
|
978
|
+
try {
|
|
979
|
+
node = parse(row.rawCode, 'en');
|
|
980
|
+
} catch {
|
|
981
|
+
node = null;
|
|
982
|
+
}
|
|
983
|
+
if (!info || !node) {
|
|
984
|
+
skipped.push(row.id);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const byLanguage = new Map<string, LeakFinding[] | null>();
|
|
988
|
+
for (const language of FOREIGN_LANGUAGES) {
|
|
989
|
+
let rendered: string | null = null;
|
|
990
|
+
try {
|
|
991
|
+
rendered = render(node, language);
|
|
992
|
+
} catch {
|
|
993
|
+
rendered = null;
|
|
994
|
+
}
|
|
995
|
+
byLanguage.set(language, rendered ? scanner.scan(rendered, info, language) : null);
|
|
996
|
+
}
|
|
997
|
+
results.set(row.id, byLanguage);
|
|
998
|
+
}
|
|
999
|
+
return { results, skipped };
|
|
1000
|
+
}
|