@hyperfixi/testing-framework 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -1
- package/package.json +8 -8
- package/src/multilingual/README.md +32 -0
- package/src/multilingual/command-shapes-gate.ts +32 -0
- package/src/multilingual/command-shapes.ts +21 -14
- package/src/multilingual/english-leaks.corpus.test.ts +74 -0
- package/src/multilingual/english-leaks.test.ts +351 -0
- package/src/multilingual/english-leaks.ts +835 -0
|
@@ -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
|
+
}
|