diff --git a/test/helpers/i18n-consumers.mjs b/test/helpers/i18n-consumers.mjs new file mode 100644 index 00000000..b5382432 --- /dev/null +++ b/test/helpers/i18n-consumers.mjs @@ -0,0 +1,173 @@ +// Чистые функции гейта мёртвых i18n-ключей (#502). +// +// Гейт (`test/i18n-dead-keys.test.mjs`) считает ключ словаря живым, если в +// `src/**` есть его потребитель: строковый литерал, «динамическая семья» +// (конкатенация или шаблонная строка, из которой ключ собирается по данным) +// или производный `.aria` от вызова `_help('x.help')`. +// +// До #502 динамической семьёй считалось любое строковое выражение с буквой: +// `'r' + Date.now().toString(36)` (генерация id черновика) превращалось в +// `^r.+$` и «читало» весь `radar.*`, `room.*`, `resize.*`, `run.*`. Так +// `radar.bad_references` (#485) прошёл гейт мёртвым. Теперь потребитель обязан +// быть похож на ключ (см. `isKeyShapedPattern`). +// +// Здесь нет чтения `src/**` и словарей — только AST → паттерны → множество +// потребителей → список мёртвых ключей, чтобы каждый шаг проверялся на +// синтетических входах. + +import ts from 'typescript'; + +export const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Разобрать одно выражение из строки — для юнитов на синтетическом AST. + * `(${code});` даёт ParenthesizedExpression, который expressionPattern снимает. + */ +export const parseExpression = (code) => { + const file = ts.createSourceFile('probe.ts', `(${code});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const [statement] = file.statements; + if (!statement || !ts.isExpressionStatement(statement)) throw new Error(`not an expression: ${code}`); + return statement.expression; +}; + +/** + * Паттерн ключа из строкового выражения: `source` — тело регулярки без + * якорей, `dynamic` — есть ли вычисляемая часть, `staticText` — склейка + * литеральных частей (по ней решается, похож ли паттерн на ключ). + * Не строковое выражение — null. + */ +export const expressionPattern = (node) => { + if (ts.isStringLiteralLike(node)) { + return { source: escapeRegExp(node.text), dynamic: false, staticText: node.text }; + } + if (ts.isParenthesizedExpression(node)) return expressionPattern(node.expression); + if (ts.isTemplateExpression(node)) { + let source = escapeRegExp(node.head.text); + let staticText = node.head.text; + for (const span of node.templateSpans) { + source += '.+' + escapeRegExp(span.literal.text); + staticText += span.literal.text; + } + return { source, dynamic: true, staticText }; + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = expressionPattern(node.left); + const right = expressionPattern(node.right); + if (!left && !right) return null; + return { + source: (left?.source || '.+') + (right?.source || '.+'), + dynamic: (left?.dynamic ?? true) || (right?.dynamic ?? true), + staticText: (left?.staticText ?? '') + (right?.staticText ?? ''), + }; + } + return null; +}; + +/** + * Динамический потребитель обязан быть похож на i18n-ключ (#502, контракт + * п.1–2): каждый ключ словаря имеет вид `namespace.key`, поэтому в статической + * части выражения должны быть разделитель `.` и хотя бы одна буква. + * + * `radar.${code}`, prefix + '.title', `${ns}.aria` → потребители; + * 'r' + Date.now().toString(36), `${a}b`, 'x' + id → нет: точки нет; + * `${a}.${b}`, a + '.' + b → нет: одна точка без + * буквы совпала бы с каждым ключом словаря — это не семья, а всё. + * + * Буква до точки не требуется: `.+\.title` покрывает ровно ключи с этим + * суффиксом. Требование буквы (в любом месте статической части) принято + * предположительно — оно было и в прежней версии гейта. + */ +export const isKeyShapedPattern = (pattern) => Boolean(pattern?.dynamic) + && pattern.staticText.includes('.') + && /[A-Za-z]/.test(pattern.staticText); + +export const isStringJoin = (node) => ts.isTemplateExpression(node) + || (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken); + +/** + * Собрать потребителей из исходников `{ path, text }[]`. + * + * `literals` — все строковые литералы; `dynamic` — регулярки семей, похожих на + * ключ; `derivedHelpAria` — `x.help.aria` для каждого `_help('x.help')`; + * `discarded` — динамические паттерны, отброшенные как непохожие на ключ + * (для отчёта о сужении, AC5; уникальны по source). + */ +export const collectConsumers = (sources) => { + const literals = new Set(); + const dynamic = []; + const derivedHelpAria = new Set(); + const discardedBySource = new Map(); + + for (const { path, text } of sources) { + const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const visit = (node) => { + if (ts.isStringLiteralLike(node)) literals.add(node.text); + + if (isStringJoin(node)) { + const pattern = expressionPattern(node); + if (isKeyShapedPattern(pattern)) { + dynamic.push(new RegExp(`^${pattern.source}$`)); + } else if (pattern?.dynamic && /[A-Za-z]/.test(pattern.source) && !discardedBySource.has(pattern.source)) { + // ровно то, что прежний гейт принял бы за семью + discardedBySource.set(pattern.source, { regExp: new RegExp(`^${pattern.source}$`), path }); + } + } + + if (ts.isCallExpression(node) && node.arguments.length) { + const callee = ts.isPropertyAccessExpression(node.expression) + ? node.expression.name.text + : ts.isIdentifier(node.expression) ? node.expression.text : ''; + const key = node.arguments[0]; + if (callee === '_help' && ts.isStringLiteralLike(key) && key.text.endsWith('.help')) { + derivedHelpAria.add(`${key.text}.aria`); + } + } + ts.forEachChild(node, visit); + }; + visit(file); + } + + return { literals, dynamic, derivedHelpAria, discarded: [...discardedBySource.values()] }; +}; + +const hasConsumer = (key, consumers, families) => consumers.literals.has(key) + || consumers.derivedHelpAria.has(key) + || consumers.dynamic.some((pattern) => pattern.test(key)) + || families.some(({ pattern }) => pattern.test(key)); + +/** Ключи словаря без потребителя. `families` — явные динамические семьи (контракт п.3). */ +export const unusedKeys = (keys, consumers, families = []) => keys + .filter((key) => !hasConsumer(key, consumers, families)); + +/** + * Дефекты списка явных семей (контракт п.3): пустая причина, не RegExp, + * запись без единого ключа словаря. Пустой список — норма. + */ +export const familyProblems = (families, keys) => { + const problems = []; + families.forEach((family, index) => { + const label = `DYNAMIC_KEY_FAMILIES[${index}]`; + if (!(family?.pattern instanceof RegExp)) { + problems.push(`${label}: pattern must be a RegExp`); + return; + } + if (typeof family.because !== 'string' || !family.because.trim()) { + problems.push(`${label} (${family.pattern}): because must name the consumer and the reason`); + } + if (!keys.some((key) => family.pattern.test(key))) { + problems.push(`${label} (${family.pattern}): covers no dictionary key — drop the entry`); + } + }); + return problems; +}; + +/** + * Отчёт о сужении (AC5): сколько паттернов отброшено и какие ключи держались + * только на них. Непустой `onlyDiscarded` на реальном дереве — либо мёртвый + * ключ (отдельный issue, класс A), либо кандидат в явную семью. + */ +export const narrowingReport = (keys, consumers, families = []) => ({ + discarded: consumers.discarded.length, + onlyDiscarded: keys.filter((key) => !hasConsumer(key, consumers, families) + && consumers.discarded.some(({ regExp }) => regExp.test(key))), +}); diff --git a/test/i18n-dead-keys.test.mjs b/test/i18n-dead-keys.test.mjs index 60e9c5e6..62575e82 100644 --- a/test/i18n-dead-keys.test.mjs +++ b/test/i18n-dead-keys.test.mjs @@ -3,7 +3,15 @@ import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; -import ts from 'typescript'; +import { + collectConsumers, + expressionPattern, + familyProblems, + isKeyShapedPattern, + narrowingReport, + parseExpression, + unusedKeys, +} from './helpers/i18n-consumers.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const dictionary = { @@ -12,6 +20,19 @@ const dictionary = { }; const dictionaryKeys = Object.keys(dictionary); +/** + * Явные динамические семьи ключей (#502, контракт п.3): ключи, которые + * читаются по данным, а не по литералу или похожему на ключ выражению в + * `src/**`. Одна запись — одна семья, `because` называет потребителя (файл) + * и причину. Запись без покрытых ключей или с пустой причиной — красный тест. + * Мёртвый ключ сюда не добавляется ради зелёного гейта — он уходит отдельным + * issue (класс A: словари). + * + * После сужения на реальном дереве семей не осталось: каждый ключ имеет + * литерального, похожего на ключ динамического или производного потребителя. + */ +const DYNAMIC_KEY_FAMILIES = []; + const sourceFiles = []; const visitDirectory = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -22,80 +43,97 @@ const visitDirectory = (directory) => { }; visitDirectory(join(repoRoot, 'src')); -const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const consumers = collectConsumers(sourceFiles.map((path) => ({ path, text: readFileSync(path, 'utf8') }))); -/** Convert a string-producing expression into a dictionary-key matcher. */ -const expressionPattern = (node) => { - if (ts.isStringLiteralLike(node)) return { source: escapeRegExp(node.text), dynamic: false }; - if (ts.isParenthesizedExpression(node)) return expressionPattern(node.expression); - if (ts.isTemplateExpression(node)) { - let source = escapeRegExp(node.head.text); - for (const span of node.templateSpans) { - source += '.+' + escapeRegExp(span.literal.text); - } - return { source, dynamic: true }; - } - if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = expressionPattern(node.left); - const right = expressionPattern(node.right); - if (!left && !right) return null; - return { - source: (left?.source || '.+') + (right?.source || '.+'), - dynamic: (left?.dynamic ?? true) || (right?.dynamic ?? true), - }; - } - return null; -}; - -const literalConsumers = new Set(); -const dynamicConsumers = []; -const derivedHelpAria = new Set(); - -for (const path of sourceFiles) { - const source = ts.createSourceFile( - path, - readFileSync(path, 'utf8'), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const visit = (node) => { - if (ts.isStringLiteralLike(node)) literalConsumers.add(node.text); - - if (ts.isTemplateExpression(node) - || (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)) { - const pattern = expressionPattern(node); - if (pattern?.dynamic && /[A-Za-z]/.test(pattern.source)) { - dynamicConsumers.push(new RegExp(`^${pattern.source}$`)); - } - } - - if (ts.isCallExpression(node) && node.arguments.length) { - const callee = ts.isPropertyAccessExpression(node.expression) - ? node.expression.name.text - : ts.isIdentifier(node.expression) ? node.expression.text : ''; - const key = node.arguments[0]; - if (callee === '_help' && ts.isStringLiteralLike(key) && key.text.endsWith('.help')) { - derivedHelpAria.add(`${key.text}.aria`); - } - } - ts.forEachChild(node, visit); - }; - visit(source); -} - -test('every i18n key has a literal, dynamic-family or derived help consumer', () => { - const unused = dictionaryKeys.filter((key) => !literalConsumers.has(key) - && !derivedHelpAria.has(key) - && !dynamicConsumers.some((pattern) => pattern.test(key))); +test('every i18n key has a literal, dynamic-family or derived help consumer', (t) => { + const report = narrowingReport(dictionaryKeys, consumers, DYNAMIC_KEY_FAMILIES); + t.diagnostic(`i18n-dead-keys narrowing (#502): discarded ${report.discarded} dynamic patterns; ` + + `keys covered only by discarded patterns: ${report.onlyDiscarded.length ? report.onlyDiscarded.join(', ') : 'none'}`); + const unused = unusedKeys(dictionaryKeys, consumers, DYNAMIC_KEY_FAMILIES); assert.deepEqual(unused, [], unused.length ? `Unused i18n keys: ${unused.join(', ')}. Use each key from src/ or delete it from every locale.` : undefined); }); +test('every declared dynamic key family covers a dictionary key and names its reason (#502 AC4)', () => { + assert.deepEqual(familyProblems(DYNAMIC_KEY_FAMILIES, dictionaryKeys), []); +}); + test('help accessibility copy is derived from every literal help consumer', () => { - assert.equal(derivedHelpAria.size, 20, 'the current settings surface has 20 statically derived help descriptions'); - for (const key of derivedHelpAria) { + assert.equal(consumers.derivedHelpAria.size, 20, 'the current settings surface has 20 statically derived help descriptions'); + for (const key of consumers.derivedHelpAria) { assert.equal(typeof dictionary[key], 'string', `${key} must accompany its .help consumer`); } }); + +// --- #502: сужение динамических потребителей, юниты на синтетическом AST --- + +const patternOf = (code) => expressionPattern(parseExpression(code)); +const regExpOf = (code) => new RegExp(`^${patternOf(code).source}$`); + +test('a literal without a namespace dot joined to an expression is not a key consumer (#502 AC1)', () => { + for (const code of [ + "'r' + Date.now().toString(36)", + '`${a}b`', + "'x' + id", + "'' + id", + 'prefix + suffix', + ]) { + const pattern = patternOf(code); + assert.equal(pattern?.dynamic ?? true, true, `${code} must still be recognised as dynamic`); + assert.equal(isKeyShapedPattern(pattern), false, `${code} must not consume a key family`); + } +}); + +test('a single dot without letters would match every key and is not a family (#502)', () => { + for (const code of ['`${a}.${b}`', "a + '.' + b"]) { + assert.equal(isKeyShapedPattern(patternOf(code)), false, code); + } +}); + +test('key-shaped joins keep consuming their families (#502 AC2)', () => { + const cases = [ + ['`radar.${code}`', 'radar.bad_fit'], + ["prefix + '.title'", 'x.title'], + ['`${ns}.aria`', 'help.aria'], + ["('radar.' + code)", 'radar.ambiguous_sources'], + ]; + for (const [code, key] of cases) { + assert.equal(isKeyShapedPattern(patternOf(code)), true, `${code} must consume a key family`); + assert.match(key, regExpOf(code), `${code} must match ${key}`); + } + assert.doesNotMatch('room.name', regExpOf('`radar.${code}`')); + assert.doesNotMatch('x.titles', regExpOf("prefix + '.title'")); +}); + +test('static joins and non-string expressions produce no dynamic pattern', () => { + assert.equal(patternOf("'a.b' + 'c'").dynamic, false); + assert.equal(patternOf('a + b'), null); + assert.equal(patternOf('42'), null); +}); + +test('a dead key is reported even when an id generator shares its first letter (#502 AC3)', () => { + const synthetic = collectConsumers([{ + path: 'synthetic/probe.ts', + text: [ + "const draftId = 'p' + Date.now().toString(36);", + 'const label = t(`radar.${code}`);', + "const aria = this._help('space.cell_cm.help');", + ].join('\n'), + }]); + const keys = ['probe.dead', 'radar.bad_fit', 'space.cell_cm.help.aria']; + assert.deepEqual(unusedKeys(keys, synthetic), ['probe.dead']); + assert.equal(synthetic.discarded.length, 1, 'the id generator is the one discarded pattern'); + assert.deepEqual(narrowingReport(keys, synthetic), { discarded: 1, onlyDiscarded: ['probe.dead'] }); +}); + +test('an explicit family rescues a data-driven key, but only with a reason and coverage (#502 AC4)', () => { + const synthetic = collectConsumers([{ path: 'synthetic/empty.ts', text: 'export const x = 1;' }]); + const keys = ['feed.alpha', 'feed.beta']; + const family = { pattern: /^feed\./, because: 'synthetic/feed.ts reads feed.* by record type' }; + assert.deepEqual(unusedKeys(keys, synthetic, [family]), []); + assert.deepEqual(familyProblems([family], keys), []); + assert.deepEqual(familyProblems([{ pattern: /^feed\./, because: ' ' }], keys).length, 1); + assert.deepEqual(familyProblems([{ pattern: /^nothing\./, because: 'orphan' }], keys).length, 1); + assert.deepEqual(familyProblems([{ pattern: '^feed\\.', because: 'string, not RegExp' }], keys).length, 1); +});