#!/usr/bin/env node // AZ-465 — i18n coverage + key-parity static checks. // // FT-P-22 (row 45) — keys(en.json) === keys(ua.json) (deep set equality) // FT-P-23 (row 46) — every JSX text node + string-literal aria-*/title/ // placeholder either lives in an i18n key, is in the // allow-list (brand names, fixed-meaning labels), or // carries a `// i18n-ok: ` marker on the same // or preceding line. // // Inputs: // - src/i18n/en.json, src/i18n/ua.json — translation bundles // - tests/i18n-allowlist.json — { "*": [...global], "": [...] } // // Output: // - exit 0 + stdout summary on PASS // - exit 1 + per-finding lines on FAIL // // Modes: // --check (default) PASS/FAIL gate // --report list every detected raw string, grouped by file (used to seed // the allow-list when FT-P-23 first lands) // // Black-box discipline: the checker walks production source ONLY. Test files // are excluded. The allow-list mechanism IS the deliverable — its content // reflects the codebase's current i18n posture and grows only with // code-review approval per the AZ-465 spec constraint. import fs from 'node:fs' import path from 'node:path' import process from 'node:process' const PROJECT_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..') const SRC_ROOT = path.join(PROJECT_ROOT, 'src') const EN_PATH = path.join(SRC_ROOT, 'i18n/en.json') const UA_PATH = path.join(SRC_ROOT, 'i18n/ua.json') const ALLOWLIST_PATH = path.join(PROJECT_ROOT, 'tests/i18n-allowlist.json') const ARG_REPORT = process.argv.includes('--report') const ARG_CHECK_PARITY_ONLY = process.argv.includes('--parity-only') const ARG_CHECK_COVERAGE_ONLY = process.argv.includes('--coverage-only') function readJson(p) { return JSON.parse(fs.readFileSync(p, 'utf8')) } // Deep-keys: collect dot-paths over a translation tree. function deepKeys(obj, prefix = '') { const out = [] if (obj == null || typeof obj !== 'object') return out for (const [k, v] of Object.entries(obj)) { if (k.startsWith('$')) continue // skip $schema_note etc. const key = prefix ? `${prefix}.${k}` : k if (v != null && typeof v === 'object') { out.push(...deepKeys(v, key)) } else { out.push(key) } } return out } // Deep-values: collect every string value (used to detect "the JSX text // already lives in an i18n key — its content matches a value in en.json"). function deepValues(obj) { const out = [] if (obj == null) return out if (typeof obj === 'string') return [obj] if (Array.isArray(obj)) { for (const v of obj) out.push(...deepValues(v)) return out } if (typeof obj === 'object') { for (const [k, v] of Object.entries(obj)) { if (k.startsWith('$')) continue out.push(...deepValues(v)) } } return out } function walkTsx(dir) { const out = [] const entries = fs.readdirSync(dir, { withFileTypes: true }) for (const e of entries) { const p = path.join(dir, e.name) if (e.isDirectory()) { out.push(...walkTsx(p)) } else if (e.isFile() && /\.tsx$/.test(e.name) && !/\.(test|spec)\.tsx$/.test(e.name)) { out.push(p) } } return out } // Strip TS/TSX block comments + line comments so regex sweeps don't pick up // strings that exist only in commented-out code. function stripComments(src) { let out = '' let i = 0 let inString = null // null | '"' | "'" | '`' while (i < src.length) { const c = src[i] const n = src[i + 1] if (inString) { out += c if (c === '\\') { out += src[i + 1] ?? '' i += 2 continue } if (c === inString) inString = null i += 1 continue } if (c === '/' && n === '/') { // Skip to end of line but preserve newline. while (i < src.length && src[i] !== '\n') i += 1 continue } if (c === '/' && n === '*') { // Skip to */; preserve newlines for line numbering. i += 2 while (i < src.length - 1 && !(src[i] === '*' && src[i + 1] === '/')) { if (src[i] === '\n') out += '\n' i += 1 } i += 2 continue } if (c === '"' || c === "'" || c === '`') { inString = c out += c i += 1 continue } out += c i += 1 } return out } function lineNumberAt(src, idx) { let line = 1 for (let i = 0; i < idx; i += 1) if (src[i] === '\n') line += 1 return line } // JSX text-node + attribute scanner — regex-based, defensive enough to skip // TS generic type expressions (`Promise`) and JSX comparison expressions // (`{num >= 1 && num <= 9}`). The spec calls for "ripgrep + AST walker"; the // regex sweep ships today and is sufficient to land the allow-list mechanism // the constraint requires. An AST-walker upgrade is a future Phase B task. // // The opening `>` must NOT be the second character of `=>` (TS arrow). The // closing `<` must be followed by a tag character (`/` or letter); a `<` // followed by digit/space/operator is part of a TypeScript expression and // is excluded here. const JSX_TEXT_RE = /(?([^<>{}\n]*?[A-Za-z][^<>{}\n]*?)<(?=[A-Za-z/])/g const JSX_ATTR_RE = /\b(aria-label|aria-description|placeholder|title)\s*=\s*"([^"\n]+)"/g // Heuristic blocklist for false-positive JSX-text candidates that survived // the boundary tightening — operators / fragments that are clearly code, not // user-visible English. const TEXT_FALSE_POSITIVE = /(^|\s)(&&|\|\||==|!=|=>|<=|>=|return\s|throw\s)/ function findRawStrings(filePath) { const raw = fs.readFileSync(filePath, 'utf8') const src = stripComments(raw) const findings = [] let m JSX_TEXT_RE.lastIndex = 0 while ((m = JSX_TEXT_RE.exec(src)) != null) { const text = m[1].trim() if (!text) continue if (!/[A-Za-z]/.test(text)) continue // pure punctuation/numbers if (text.length < 2) continue if (/^\d+([.,]\d+)?(\s*[A-Za-z]{1,3})?$/.test(text)) continue // "12.5", "12 m" if (TEXT_FALSE_POSITIVE.test(text)) continue findings.push({ kind: 'text', text, line: lineNumberAt(src, m.index + 1) }) } JSX_ATTR_RE.lastIndex = 0 while ((m = JSX_ATTR_RE.exec(src)) != null) { const text = m[2].trim() if (!text) continue if (!/[A-Za-z]/.test(text)) continue findings.push({ kind: m[1], text, line: lineNumberAt(src, m.index + 1) }) } return findings } function isIokMarker(filePath, line) { const raw = fs.readFileSync(filePath, 'utf8') const lines = raw.split('\n') // Same-line or preceding-line marker. for (const candidate of [lines[line - 1] ?? '', lines[line - 2] ?? '']) { if (/\/\/\s*i18n-ok\b/.test(candidate)) return true } return false } function checkParity() { const en = readJson(EN_PATH) const ua = readJson(UA_PATH) const enKeys = new Set(deepKeys(en)) const uaKeys = new Set(deepKeys(ua)) const missingInUa = [...enKeys].filter((k) => !uaKeys.has(k)) const missingInEn = [...uaKeys].filter((k) => !enKeys.has(k)) return { ok: missingInUa.length === 0 && missingInEn.length === 0, missingInUa, missingInEn } } function checkCoverage() { const en = readJson(EN_PATH) const enValues = new Set(deepValues(en)) const allow = readJson(ALLOWLIST_PATH) const globalAllow = new Set(allow['*'] ?? []) const files = walkTsx(SRC_ROOT) const failures = [] const reportEntries = [] for (const f of files) { const rel = path.relative(PROJECT_ROOT, f) const fileAllow = new Set(allow[rel] ?? []) const findings = findRawStrings(f) for (const fnd of findings) { reportEntries.push({ rel, ...fnd }) if (enValues.has(fnd.text)) continue // already a translated value if (globalAllow.has(fnd.text)) continue // global brand / acronym if (fileAllow.has(fnd.text)) continue // file-scoped allow-list if (isIokMarker(f, fnd.line)) continue // // i18n-ok marker failures.push({ file: rel, line: fnd.line, kind: fnd.kind, text: fnd.text }) } } return { ok: failures.length === 0, failures, reportEntries } } function main() { if (ARG_REPORT) { const cov = checkCoverage() const grouped = {} for (const e of cov.reportEntries) (grouped[e.rel] ??= []).push(e) for (const rel of Object.keys(grouped).sort()) { console.log(`# ${rel}`) for (const e of grouped[rel]) { console.log(` ${e.line.toString().padStart(4)} [${e.kind}] ${JSON.stringify(e.text)}`) } } process.exit(0) } let ok = true const lines = [] if (!ARG_CHECK_COVERAGE_ONLY) { const parity = checkParity() if (parity.ok) { lines.push('FT-P-22 (key parity): PASS') } else { ok = false lines.push('FT-P-22 (key parity): FAIL') for (const k of parity.missingInUa) lines.push(` missing in ua.json: ${k}`) for (const k of parity.missingInEn) lines.push(` missing in en.json: ${k}`) } } if (!ARG_CHECK_PARITY_ONLY) { const cov = checkCoverage() if (cov.ok) { lines.push(`FT-P-23 (t() coverage): PASS (allow-list size: ${Object.keys(readJson(ALLOWLIST_PATH)).length} groups)`) } else { ok = false lines.push(`FT-P-23 (t() coverage): FAIL (${cov.failures.length} unallowed raw strings)`) for (const f of cov.failures.slice(0, 50)) { lines.push(` ${f.file}:${f.line} [${f.kind}] ${JSON.stringify(f.text)}`) } if (cov.failures.length > 50) lines.push(` ... and ${cov.failures.length - 50} more`) } } console.log(lines.join('\n')) process.exit(ok ? 0 : 1) } main()