check-i18n-parity.mjs 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. // Verifies parity across locale files (en / zh-CN / zh-TW):
  2. // 1. Leaf-key sets are identical
  3. // 2. Each leaf's {{placeholder}} set is identical
  4. // 3. Plural suffixes: every en key ending in _plural / _one / _other must
  5. // exist in every other locale, and other locales must not introduce an
  6. // _one key that en does not have.
  7. // Malformed input (missing `export default`, parse errors, non-string leaves,
  8. // unsupported property kinds) fails loudly instead of silently passing the gate.
  9. // Exits 1 with a diagnostic report on any failure, else exits 0.
  10. import fs from 'node:fs';
  11. import path from 'node:path';
  12. import url from 'node:url';
  13. const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
  14. const frontendDir = path.resolve(scriptDir, '..');
  15. const localesDir = path.join(frontendDir, 'src/i18n/locales');
  16. const tsPath = path.join(frontendDir, 'node_modules/typescript/lib/typescript.js');
  17. const tsModule = await import(url.pathToFileURL(tsPath).href);
  18. const ts = tsModule.default ?? tsModule;
  19. function collectLeaves(node, prefix, leaves) {
  20. if (!ts.isObjectLiteralExpression(node)) return;
  21. for (const prop of node.properties) {
  22. if (!ts.isPropertyAssignment(prop)) {
  23. console.error(
  24. `Unsupported property kind ${ts.SyntaxKind[prop.kind]} at "${prefix}" ` +
  25. `(locale files must use plain \`key: value\` assignments — no spread, shorthand, methods, or accessors).`,
  26. );
  27. process.exit(1);
  28. }
  29. let name;
  30. if (ts.isIdentifier(prop.name)) name = prop.name.text;
  31. else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) name = prop.name.text;
  32. else if (ts.isComputedPropertyName(prop.name)) {
  33. console.error(`ComputedPropertyName not allowed in locale file at path "${prefix}"`);
  34. process.exit(1);
  35. } else {
  36. console.error(`Unsupported property-name kind ${ts.SyntaxKind[prop.name.kind]} at "${prefix}"`);
  37. process.exit(1);
  38. }
  39. const p = prefix ? `${prefix}.${name}` : name;
  40. if (ts.isObjectLiteralExpression(prop.initializer)) {
  41. collectLeaves(prop.initializer, p, leaves);
  42. } else {
  43. const value = extractStringValue(prop.initializer, p);
  44. leaves.set(p, value);
  45. }
  46. }
  47. }
  48. function extractStringValue(node, keyPath) {
  49. if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
  50. if (ts.isTemplateExpression(node)) {
  51. let out = node.head.text;
  52. for (const span of node.templateSpans) {
  53. out += '${' + span.expression.getText() + '}';
  54. out += span.literal.text;
  55. }
  56. return out;
  57. }
  58. console.error(
  59. `Non-string leaf at "${keyPath}" (kind=${ts.SyntaxKind[node.kind]}): ${node.getText()}\n` +
  60. `Locale files must only contain string or template literals as leaf values.`,
  61. );
  62. process.exit(1);
  63. }
  64. function loadLocale(filePath) {
  65. const src = fs.readFileSync(filePath, 'utf8');
  66. const sf = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true);
  67. if (sf.parseDiagnostics && sf.parseDiagnostics.length > 0) {
  68. console.error(`${filePath}: ${sf.parseDiagnostics.length} parse error(s):`);
  69. for (const d of sf.parseDiagnostics.slice(0, 10)) {
  70. const msg = typeof d.messageText === 'string' ? d.messageText : d.messageText.messageText;
  71. const { line, character } = sf.getLineAndCharacterOfPosition(d.start ?? 0);
  72. console.error(` ${line + 1}:${character + 1} ${msg}`);
  73. }
  74. process.exit(1);
  75. }
  76. const leaves = new Map();
  77. let foundExport = false;
  78. ts.forEachChild(sf, (n) => {
  79. if (ts.isExportAssignment(n)) {
  80. foundExport = true;
  81. collectLeaves(n.expression, '', leaves);
  82. }
  83. });
  84. if (!foundExport) {
  85. console.error(`${filePath}: no \`export default\` found — locale files must use \`export default { ... }\`.`);
  86. process.exit(1);
  87. }
  88. if (leaves.size === 0) {
  89. console.error(`${filePath}: \`export default\` resolved to zero leaves — file is empty or not a nested object.`);
  90. process.exit(1);
  91. }
  92. return leaves;
  93. }
  94. const placeholderRe = /\{\{[^{}]+\}\}/g;
  95. // Pure comparison logic, exported so tests can verify each failure mode
  96. // without going through file IO or the TypeScript parser.
  97. // Input: locales = { code: Map<leafKey, leafString> } (must contain 'en')
  98. // Output: { failed, reports: Array<{ label, items }> }
  99. export function compareLocales(locales) {
  100. if (!locales.en) throw new Error("compareLocales requires a locales.en entry");
  101. const reports = [];
  102. const add = (label, items) => {
  103. if (items.length) reports.push({ label, items });
  104. };
  105. const enKeys = new Set(locales.en.keys());
  106. // Check 1: key set equality
  107. for (const [code, map] of Object.entries(locales)) {
  108. if (code === 'en') continue;
  109. const keys = new Set(map.keys());
  110. const missing = [...enKeys].filter((k) => !keys.has(k)).sort();
  111. const extra = [...keys].filter((k) => !enKeys.has(k)).sort();
  112. add(`${code}: missing keys vs en`, missing);
  113. add(`${code}: extra keys vs en`, extra);
  114. }
  115. // Check 2: placeholder set equality per leaf
  116. for (const [code, map] of Object.entries(locales)) {
  117. if (code === 'en') continue;
  118. const mismatches = [];
  119. for (const [key, enValue] of locales.en) {
  120. const otherValue = map.get(key);
  121. if (otherValue === undefined) continue;
  122. const enPlaceholders = new Set((enValue.match(placeholderRe) ?? []));
  123. const otherPlaceholders = new Set((otherValue.match(placeholderRe) ?? []));
  124. const missingPh = [...enPlaceholders].filter((p) => !otherPlaceholders.has(p));
  125. const extraPh = [...otherPlaceholders].filter((p) => !enPlaceholders.has(p));
  126. if (missingPh.length || extraPh.length) {
  127. mismatches.push(`${key}: en=${[...enPlaceholders].join(',') || '∅'} vs ${code}=${[...otherPlaceholders].join(',') || '∅'}`);
  128. }
  129. }
  130. add(`${code}: placeholder mismatch vs en`, mismatches);
  131. }
  132. // Check 3: plural suffix presence + reverse _one guard
  133. for (const [code, map] of Object.entries(locales)) {
  134. if (code === 'en') continue;
  135. const pluralIssues = [];
  136. for (const key of enKeys) {
  137. if (key.endsWith('_plural') && !map.has(key)) pluralIssues.push(`missing _plural key: ${key}`);
  138. if (key.endsWith('_one') && !map.has(key)) pluralIssues.push(`missing _one key: ${key}`);
  139. if (key.endsWith('_other') && !map.has(key)) pluralIssues.push(`missing _other key: ${key}`);
  140. }
  141. for (const key of map.keys()) {
  142. if (key.endsWith('_one') && !enKeys.has(key)) {
  143. pluralIssues.push(`unexpected _one not present in en: ${key}`);
  144. }
  145. }
  146. add(`${code}: plural key mismatch`, pluralIssues);
  147. }
  148. return { failed: reports.length > 0, reports };
  149. }
  150. // Strict locales fail CI when they drift from en. Everything else discovered
  151. // in the locales directory is reported informationally — promote a locale to
  152. // STRICT once its drift is caught up. en is implicitly the reference.
  153. const STRICT = ['de', 'zh-CN', 'zh-TW'];
  154. // Skip file IO / process.exit when imported as a library (e.g. from tests).
  155. const isMainModule = import.meta.url === url.pathToFileURL(process.argv[1] ?? '').href;
  156. if (isMainModule) {
  157. const discovered = fs
  158. .readdirSync(localesDir)
  159. .filter((f) => f.endsWith('.ts'))
  160. .map((f) => f.slice(0, -3))
  161. .sort();
  162. if (!discovered.includes('en')) {
  163. console.error(`No en.ts found in ${localesDir} — cannot run parity check without a reference locale.`);
  164. process.exit(1);
  165. }
  166. const missingStrict = STRICT.filter((c) => !discovered.includes(c));
  167. if (missingStrict.length) {
  168. console.error(`STRICT locales declared but not found on disk: ${missingStrict.join(', ')}`);
  169. process.exit(1);
  170. }
  171. const codes = ['en', ...discovered.filter((c) => c !== 'en')];
  172. const locales = Object.fromEntries(
  173. codes.map((c) => [c, loadLocale(path.join(localesDir, `${c}.ts`))]),
  174. );
  175. const MAX_REPORT = 20;
  176. const strictSet = new Set(STRICT);
  177. const printReports = (reports, header) => {
  178. if (!reports.length) return;
  179. console.error(`\n${header}`);
  180. for (const { label, items } of reports) {
  181. console.error(`\n[${label}] (${items.length})`);
  182. items.slice(0, MAX_REPORT).forEach((i) => console.error(` ${i}`));
  183. if (items.length > MAX_REPORT) console.error(` ... and ${items.length - MAX_REPORT} more`);
  184. }
  185. };
  186. // Label prefix is "${code}:" — route reports to strict vs informational.
  187. const { reports } = compareLocales(locales);
  188. const codeOf = (label) => label.split(':', 1)[0];
  189. const strictReports = reports.filter((r) => strictSet.has(codeOf(r.label)));
  190. const infoReports = reports.filter((r) => !strictSet.has(codeOf(r.label)));
  191. printReports(strictReports, '=== STRICT locales (failures below fail CI) ===');
  192. // Informational locales: show per-category drift counts only, not the
  193. // full key lists — the leaf-count table below already gives the overall
  194. // picture. Flip VERBOSE_INFO=1 to dump the full missing-key/placeholder
  195. // reports when actually working on translations.
  196. if (infoReports.length) {
  197. if (process.env.VERBOSE_INFO === '1') {
  198. printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
  199. } else {
  200. console.error('\n=== INFORMATIONAL locales (drift summary; VERBOSE_INFO=1 for detail) ===');
  201. for (const { label, items } of infoReports) {
  202. console.error(` ${label}: ${items.length}`);
  203. }
  204. }
  205. }
  206. console.log('\nLocale leaf counts:');
  207. for (const [code, map] of Object.entries(locales)) {
  208. const tier = code === 'en' ? 'ref' : strictSet.has(code) ? 'strict' : 'info';
  209. console.log(` ${code.padEnd(6)} ${String(map.size).padEnd(6)} [${tier}]`);
  210. }
  211. if (strictReports.length > 0) {
  212. console.error(`\n❌ i18n parity check failed (strict: ${STRICT.join(', ')}).`);
  213. process.exit(1);
  214. }
  215. console.log(`\n✓ Strict locales in parity (en / ${STRICT.join(' / ')}).`);
  216. }