From 484eec7b3c91e1830afea172e729eaf61dae3e88 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:30:48 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=8C=90=20=E6=B7=BB=E5=8A=A0=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=AE=8C=E6=95=B4=E6=80=A7=E6=9C=BA=E6=A2=B0=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=20(check:i18n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1587 (pt-BR) 合并后发现 messages.json 与 monaco 编辑器语言遗漏,且当时无机制拦截; 本次新增 scripts/check-i18n.mjs 并接入 pnpm lint / lint:ci,机械校验: - src/locales//*.json 各命名空间 key 是否与 en-US 一一对应(缺失/多余均报错) - src/locales//index.ts 是否导出全部命名空间 - src/assets/_locales//messages.json 与 en/messages.json 的 key 是否一致 (尚未创建该目录时仅提示,不阻塞) - docs/references/terminology-.md 是否存在(每个 locale 必须有,缺失即报错) - src/pkg/utils/monaco-editor/langs.ts 中 editorLangs 各 locale 的 key 是否与 en-US 一致 (尚未创建该 locale 条目时仅提示;已创建则 key 必须对齐) 已用 scriptscat/scriptcat 官方仓库的 PR #1568(韩语)与 #1587(pt-BR)实际验证: 正确通过完整、正确的翻译提交,也能在人为剔除 key / 文件时正确报错。 Co-Authored-By: Claude Sonnet 5 --- docs/translation.md | 15 +- package.json | 7 +- scripts/check-i18n.mjs | 335 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 scripts/check-i18n.mjs diff --git a/docs/translation.md b/docs/translation.md index 7a2ac5683..4b9c149e5 100644 --- a/docs/translation.md +++ b/docs/translation.md @@ -66,4 +66,17 @@ 2. 使用目标语言的自然表达;对同一 ScriptCat 概念使用规范中的固定术语,不要基于相近措辞合并不同的脚本类型。 3. 对需结合语境的术语,先核对实际功能、控件类型与上下文文案再决定用词。 4. 保留 i18next 插值、程序标识符、HTML/React 标记、URL 与元数据标识符(`@match`、`@require` 等)。 -5. 完成后复查本次修改,确认符合对应术语规范,并检查命名一致性、名词/动词混用等问题。 \ No newline at end of file +5. 完成后复查本次修改,确认符合对应术语规范,并检查命名一致性、名词/动词混用等问题。 +6. 运行 `pnpm run check:i18n`(或 `pnpm lint`,已内含此检查),确认没有遗漏或多余的翻译 key。 + +## 机械检查:遗漏翻译 / Mechanical check: missing translations + +`scripts/check-i18n.mjs`(`pnpm run check:i18n`)在每次 `pnpm lint` / `pnpm lint:ci` 时自动运行,用于捕获人工审阅容易漏掉的问题: + +- `src/locales//*.json` 中每个 key 是否与 `en-US`(模板 / fallback 语言)的 key 集合一一对应,缺失或多余的 key 都会报错。 +- `src/locales//index.ts` 是否导出了 `en-US` 拥有的全部命名空间。 +- `src/assets/_locales//messages.json`(`chrome.i18n` 语言文件,见上文"翻译工作流"一节)是否与 `en/messages.json` 的 key 一致;如果某个 locale 尚未创建该目录,只会给出提示(warning),不会导致失败。 +- `docs/references/terminology-.md` 是否存在:**`src/locales/` 下的每一个 locale 都必须有对应的术语规范文件**,缺失会导致检查失败——不允许新增或修改某个 locale 却不提交其 `terminology-.md`。 +- `src/pkg/utils/monaco-editor/langs.ts` 中 `editorLangs`(编辑器悬浮提示、脚本头字段提示等)的 key 是否与 `en-US` 一致;如果某个 locale 尚未在 `editorLangs` 中创建条目,只会给出提示(warning);但只要该 locale 已有条目,其 key 缺失或多余就会报错。 + +这个脚本无法判断翻译措辞是否准确、是否符合术语规范——那部分仍需人工审阅并遵循本文件与对应的 `terminology-.md`;它只保证不会有 key 或术语规范文件被整段遗漏。 \ No newline at end of file diff --git a/package.json b/package.json index 67d9c7b04..16f331519 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build": "cross-env NODE_ENV=production rspack build", "pack": "node ./scripts/pack.js", "typecheck": "tsc --noEmit", - "lint": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" && eslint . --cache --cache-location .eslintcache", - "lint:ci": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" && eslint . --cache --cache-location .eslintcache", + "lint": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" \"pnpm run check:i18n\" && eslint . --cache --cache-location .eslintcache", + "lint:ci": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" \"pnpm run check:i18n\" && eslint . --cache --cache-location .eslintcache", "lint-fix": "concurrently -g \"prettier --write --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" && eslint --fix . --cache --cache-location .eslintcache", "test": "vitest --no-coverage --reporter=verbose", "test:ci": "vitest run --no-coverage --reporter=default --reporter.summary=false", @@ -25,7 +25,8 @@ "test:e2e": "pnpm exec playwright test", "test:e2e:ui": "pnpm exec playwright test --ui", "validate:yaml": "node ./scripts/validate-yaml.mjs", - "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all" + "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", + "check:i18n": "node ./scripts/check-i18n.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 000000000..ccf5eea97 --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node + +// Mechanical check for missing/extra translations in a locale PR. +// +// Checks: +// 1. src/locales//*.json namespace files — every key path present in the +// en-US reference must exist in every other locale (and vice versa for extras). +// 2. src/locales//index.ts — must export every namespace that en-US exports. +// 3. src/assets/_locales//messages.json — key parity against the en/ reference, +// for locales that already have a directory (chrome.i18n store-listing strings). +// 4. docs/references/terminology-.md — every locale under src/locales MUST have one. +// A translation PR that adds/changes a locale without its terminology file is rejected. +// 5. src/pkg/utils/monaco-editor/langs.ts — for locales that already have an `editorLangs` entry, +// its key set must match en-US (hover prompts, script-header field prompts). +// +// Locales that only exist under src/locales but have no src/assets/_locales directory, or no +// editorLangs entry yet, are reported as a warning, not a failure — those two surfaces are +// optional/best-effort (see docs/translation.md); once a locale HAS an entry, its keys must +// stay in sync with en-US or the check fails. + +import process from "node:process"; +import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); + +const LOCALES_DIR = path.join(ROOT, "src/locales"); +const REFERENCE_LOCALE = "en-US"; +const CHROME_LOCALES_DIR = path.join(ROOT, "src/assets/_locales"); +const CHROME_REFERENCE_LOCALE = "en"; +const TERMINOLOGY_DIR = path.join(ROOT, "docs/references"); +const MONACO_LANGS_PATH = path.join(ROOT, "src/pkg/utils/monaco-editor/langs.ts"); + +// chrome.i18n `_locales` directories don't follow one fixed rule (some are the bare language +// subtag like "ko", others are the full region-qualified code like "zh_CN" or "pt_BR"), and a +// hardcoded i18next-locale -> chrome-dir map silently rots the moment a PR adds a directory this +// script doesn't know about yet. So resolve it by scanning what's actually on disk instead. +function findChromeDirName(locale, actualChromeDirs) { + const [lang, region] = locale.split("-"); + const candidates = region ? [`${lang}_${region}`, lang] : [lang]; + return candidates.find((candidate) => actualChromeDirs.includes(candidate)); +} + +function isDir(p) { + return existsSync(p) && statSync(p).isDirectory(); +} + +function readJson(p) { + return JSON.parse(readFileSync(p, "utf8")); +} + +function flattenKeys(obj, prefix = "", out = new Set()) { + for (const [key, value] of Object.entries(obj)) { + const keyPath = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + flattenKeys(value, keyPath, out); + } else { + out.add(keyPath); + } + } + return out; +} + +function diffKeys(referenceKeys, targetKeys) { + const missing = [...referenceKeys].filter((k) => !targetKeys.has(k)).sort(); + const extra = [...targetKeys].filter((k) => !referenceKeys.has(k)).sort(); + return { missing, extra }; +} + +// Parse `export const editorLangs = { "locale": { ... } } as const` in a TS source file +// (not JSON, so it needs real AST parsing rather than JSON.parse) and return a Map from +// locale code to its flattened key set, resolving identifier references (e.g. a +// `grantValuePrompts: grantValuePromptsEnUS` property) against the file's top-level consts. +function parseEditorLangs(filePath) { + const source = readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + function unwrap(node) { + while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression?.(node)) { + node = node.expression; + } + return node; + } + + const topLevelConsts = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const decl of statement.declarationList.declarations) { + if (!decl.initializer) continue; + const initializer = unwrap(decl.initializer); + if (ts.isObjectLiteralExpression(initializer)) { + topLevelConsts.set(decl.name.getText(sourceFile), initializer); + } + } + } + + function propName(nameNode) { + if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) { + return nameNode.text; + } + return nameNode.getText(sourceFile); + } + + function flattenNode(node, prefix, out) { + node = unwrap(node); + if (ts.isObjectLiteralExpression(node)) { + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const key = propName(prop.name); + flattenNode(prop.initializer, prefix ? `${prefix}.${key}` : key, out); + } + return; + } + if (ts.isIdentifier(node) && topLevelConsts.has(node.text)) { + flattenNode(topLevelConsts.get(node.text), prefix, out); + return; + } + // Leaf: string/template literal, `.replace(...)` call, or an unresolved identifier. + out.add(prefix); + } + + const editorLangsNode = topLevelConsts.get("editorLangs"); + if (!editorLangsNode) { + throw new Error(`Could not find "export const editorLangs = ..." in ${filePath}`); + } + + const localeMap = new Map(); + for (const prop of editorLangsNode.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const locale = propName(prop.name); + const keys = new Set(); + flattenNode(prop.initializer, "", keys); + localeMap.set(locale, keys); + } + return localeMap; +} + +let hasError = false; +const problems = []; + +function reportError(message) { + hasError = true; + problems.push({ level: "error", message }); +} + +function reportWarning(message) { + problems.push({ level: "warning", message }); +} + +// --- 1 & 2: src/locales namespace files + index.ts exports --- + +const localeDirs = readdirSync(LOCALES_DIR).filter((name) => isDir(path.join(LOCALES_DIR, name))); + +if (!localeDirs.includes(REFERENCE_LOCALE)) { + reportError(`Reference locale directory "${REFERENCE_LOCALE}" not found under src/locales.`); +} else { + const referenceDir = path.join(LOCALES_DIR, REFERENCE_LOCALE); + const namespaceFiles = readdirSync(referenceDir) + .filter((name) => name.endsWith(".json")) + .sort(); + + const otherLocales = localeDirs.filter((name) => name !== REFERENCE_LOCALE).sort(); + + for (const locale of otherLocales) { + const localeDir = path.join(LOCALES_DIR, locale); + + // index.ts must export every namespace en-US exports. + const indexPath = path.join(localeDir, "index.ts"); + if (!existsSync(indexPath)) { + reportError(`src/locales/${locale}/index.ts is missing.`); + } else { + const indexSource = readFileSync(indexPath, "utf8"); + for (const file of namespaceFiles) { + const ns = path.basename(file, ".json"); + if (!indexSource.includes(`"./${ns}.json"`)) { + reportError(`src/locales/${locale}/index.ts does not export namespace "${ns}" (missing "./${ns}.json").`); + } + } + } + + for (const file of namespaceFiles) { + const referenceJson = readJson(path.join(referenceDir, file)); + const referenceKeys = flattenKeys(referenceJson); + + const targetPath = path.join(localeDir, file); + if (!existsSync(targetPath)) { + reportError(`src/locales/${locale}/${file} is missing entirely (${referenceKeys.size} keys untranslated).`); + continue; + } + + const targetJson = readJson(targetPath); + const targetKeys = flattenKeys(targetJson); + const { missing, extra } = diffKeys(referenceKeys, targetKeys); + + if (missing.length > 0) { + reportError( + `src/locales/${locale}/${file} is missing ${missing.length} key(s) present in ${REFERENCE_LOCALE}:\n` + + missing.map((k) => ` - ${k}`).join("\n") + ); + } + if (extra.length > 0) { + reportError( + `src/locales/${locale}/${file} has ${extra.length} key(s) not present in ${REFERENCE_LOCALE} (stale or misspelled key?):\n` + + extra.map((k) => ` - ${k}`).join("\n") + ); + } + } + } +} + +// --- 3: src/assets/_locales//messages.json --- + +if (isDir(CHROME_LOCALES_DIR)) { + const chromeReferencePath = path.join(CHROME_LOCALES_DIR, CHROME_REFERENCE_LOCALE, "messages.json"); + if (!existsSync(chromeReferencePath)) { + reportError(`Reference file ${path.relative(ROOT, chromeReferencePath)} not found.`); + } else { + const chromeReferenceKeys = flattenKeys(readJson(chromeReferencePath)); + const actualChromeDirs = readdirSync(CHROME_LOCALES_DIR).filter((name) => + isDir(path.join(CHROME_LOCALES_DIR, name)) + ); + + for (const locale of localeDirs) { + if (locale === REFERENCE_LOCALE) continue; + const chromeDirName = findChromeDirName(locale, actualChromeDirs); + + if (!chromeDirName) { + reportWarning( + `src/assets/_locales has no directory for locale "${locale}" yet (optional, but consider adding it — see docs/translation.md).` + ); + continue; + } + + const messagesPath = path.join(CHROME_LOCALES_DIR, chromeDirName, "messages.json"); + if (!existsSync(messagesPath)) { + reportError(`src/assets/_locales/${chromeDirName}/ exists but is missing messages.json.`); + continue; + } + + const targetKeys = flattenKeys(readJson(messagesPath)); + const { missing, extra } = diffKeys(chromeReferenceKeys, targetKeys); + + if (missing.length > 0) { + reportError( + `src/assets/_locales/${chromeDirName}/messages.json is missing ${missing.length} key(s) present in ` + + `${CHROME_REFERENCE_LOCALE}/messages.json:\n` + + missing.map((k) => ` - ${k}`).join("\n") + ); + } + if (extra.length > 0) { + reportError( + `src/assets/_locales/${chromeDirName}/messages.json has ${extra.length} key(s) not present in ` + + `${CHROME_REFERENCE_LOCALE}/messages.json:\n` + + extra.map((k) => ` - ${k}`).join("\n") + ); + } + } + } +} + +// --- 4: docs/references/terminology-.md --- + +for (const locale of localeDirs) { + const terminologyPath = path.join(TERMINOLOGY_DIR, `terminology-${locale}.md`); + if (!existsSync(terminologyPath)) { + reportError( + `docs/references/terminology-${locale}.md is missing. Every locale under src/locales/ must have a ` + + `terminology guide — see docs/translation.md § 各语言术语规范 / Per-locale terminology.` + ); + } +} + +// --- 5: src/pkg/utils/monaco-editor/langs.ts --- + +if (!existsSync(MONACO_LANGS_PATH)) { + reportError(`${path.relative(ROOT, MONACO_LANGS_PATH)} is missing.`); +} else { + const editorLangsByLocale = parseEditorLangs(MONACO_LANGS_PATH); + const relPath = path.relative(ROOT, MONACO_LANGS_PATH); + + if (!editorLangsByLocale.has(REFERENCE_LOCALE)) { + reportError(`${relPath}: editorLangs has no "${REFERENCE_LOCALE}" entry to use as a reference.`); + } else { + const referenceKeys = editorLangsByLocale.get(REFERENCE_LOCALE); + + for (const locale of localeDirs) { + if (locale === REFERENCE_LOCALE) continue; + + if (!editorLangsByLocale.has(locale)) { + reportWarning( + `${relPath}: editorLangs has no "${locale}" entry yet (${referenceKeys.size} keys untranslated for the ` + + `Monaco editor's hover prompts; optional, but consider adding it).` + ); + continue; + } + + const targetKeys = editorLangsByLocale.get(locale); + const { missing, extra } = diffKeys(referenceKeys, targetKeys); + + if (missing.length > 0) { + reportError( + `${relPath}: editorLangs["${locale}"] is missing ${missing.length} key(s) present in "${REFERENCE_LOCALE}":\n` + + missing.map((k) => ` - ${k}`).join("\n") + ); + } + if (extra.length > 0) { + reportError( + `${relPath}: editorLangs["${locale}"] has ${extra.length} key(s) not present in "${REFERENCE_LOCALE}" (stale or misspelled key?):\n` + + extra.map((k) => ` - ${k}`).join("\n") + ); + } + } + } +} + +// --- report --- + +if (problems.length === 0) { + console.log("✅ i18n check passed: all locales match the en-US / en reference key sets."); + process.exit(0); +} + +for (const { level, message } of problems) { + console.error(level === "error" ? `\n❌ ${message}` : `\n⚠️ ${message}`); +} + +if (hasError) { + console.error("\ni18n check failed. Fix the missing/extra keys above before submitting the translation PR."); + process.exit(1); +} + +console.log("\n✅ i18n check passed (with warnings above)."); From 5b7a5ca841a4eea3f426e4c473370d93c8384e17 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:37:46 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=94=A7=20=E6=94=AF=E6=8C=81=E6=8B=86?= =?UTF-8?q?=E5=88=86=E5=90=8E=E7=9A=84=20monaco-editor=20langs=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 PR #1605 实测时发现:check-i18n.mjs 硬编码了单文件 src/pkg/utils/monaco-editor/langs.ts 路径,一旦该文件被拆分为 langs/.ts + langs/index.ts(如 #1605 所做),检查会误报 "文件缺失"。改为先探测 langs/index.ts 是否存在,再退回单文件路径;并将 key 展开逻辑改为 按模块解析(parseModule/flattenNode),支持跨文件解析 import 绑定(如 index.ts 里 "pt-BR": ptBR 指向 ./pt-BR.ts 的 default export)与同文件 shorthand 属性 (如 pt-BR.ts 内的 grantValuePrompts,)。 已用 #1605 的实际分支验证:新结构下 0 误报,人为在拆分后的 pt-BR.ts 中删除顶层 key 与 grantValuePrompts 子 key 均能正确报出具体路径。 Co-Authored-By: Claude Sonnet 5 --- scripts/check-i18n.mjs | 160 +++++++++++++++++++++++++++++------------ 1 file changed, 114 insertions(+), 46 deletions(-) diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index ccf5eea97..abb4fd530 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -32,7 +32,6 @@ const REFERENCE_LOCALE = "en-US"; const CHROME_LOCALES_DIR = path.join(ROOT, "src/assets/_locales"); const CHROME_REFERENCE_LOCALE = "en"; const TERMINOLOGY_DIR = path.join(ROOT, "docs/references"); -const MONACO_LANGS_PATH = path.join(ROOT, "src/pkg/utils/monaco-editor/langs.ts"); // chrome.i18n `_locales` directories don't follow one fixed rule (some are the bare language // subtag like "ko", others are the full region-qualified code like "zh_CN" or "pt_BR"), and a @@ -70,69 +69,133 @@ function diffKeys(referenceKeys, targetKeys) { return { missing, extra }; } -// Parse `export const editorLangs = { "locale": { ... } } as const` in a TS source file -// (not JSON, so it needs real AST parsing rather than JSON.parse) and return a Map from -// locale code to its flattened key set, resolving identifier references (e.g. a -// `grantValuePrompts: grantValuePromptsEnUS` property) against the file's top-level consts. -function parseEditorLangs(filePath) { - const source = readFileSync(filePath, "utf8"); - const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +// Find the entry point for the Monaco editor's language data, whether it's still the original +// single file or has been split into src/pkg/utils/monaco-editor/langs/.ts + index.ts +// (each locale imported into an `editorLangs` map). Support both so a refactor of this file +// doesn't make the check report a false "file missing". +function findEditorLangsEntry() { + const splitIndex = path.join(ROOT, "src/pkg/utils/monaco-editor/langs/index.ts"); + if (existsSync(splitIndex)) return splitIndex; + const singleFile = path.join(ROOT, "src/pkg/utils/monaco-editor/langs.ts"); + if (existsSync(singleFile)) return singleFile; + return null; +} - function unwrap(node) { - while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression?.(node)) { - node = node.expression; - } - return node; +function unwrapExpression(node) { + while (node && (ts.isAsExpression(node) || ts.isParenthesizedExpression(node))) { + node = node.expression; + } + return node; +} + +function propName(nameNode, sourceFile) { + if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) { + return nameNode.text; } + return nameNode.getText(sourceFile); +} + +const moduleCache = new Map(); + +// Parse one TS module: its top-level `const x = {...}` object literals (for resolving +// same-file identifier references like `grantValuePrompts: grantValuePromptsEnUS`), its +// default-import bindings resolved to absolute file paths (for the split-file layout, where +// `editorLangs` maps a locale to an imported identifier), and its `export default {...}`. +function parseModule(filePath) { + if (moduleCache.has(filePath)) return moduleCache.get(filePath); + + const source = readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const topLevelConsts = new Map(); + const imports = new Map(); + let defaultExport; + for (const statement of sourceFile.statements) { - if (!ts.isVariableStatement(statement)) continue; - for (const decl of statement.declarationList.declarations) { - if (!decl.initializer) continue; - const initializer = unwrap(decl.initializer); - if (ts.isObjectLiteralExpression(initializer)) { - topLevelConsts.set(decl.name.getText(sourceFile), initializer); + if (ts.isVariableStatement(statement)) { + for (const decl of statement.declarationList.declarations) { + if (!decl.initializer) continue; + const initializer = unwrapExpression(decl.initializer); + if (ts.isObjectLiteralExpression(initializer)) { + topLevelConsts.set(decl.name.getText(sourceFile), initializer); + } + } + } else if ( + ts.isImportDeclaration(statement) && + statement.importClause?.name && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith(".") + ) { + const baseDir = path.dirname(filePath); + const specifier = statement.moduleSpecifier.text; + const resolved = [ + path.join(baseDir, `${specifier}.ts`), + path.join(baseDir, `${specifier}.tsx`), + path.join(baseDir, specifier, "index.ts"), + ].find((candidate) => existsSync(candidate)); + if (resolved) { + imports.set(statement.importClause.name.getText(sourceFile), resolved); } + } else if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + const expr = unwrapExpression(statement.expression); + if (ts.isObjectLiteralExpression(expr)) defaultExport = expr; } } - function propName(nameNode) { - if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) { - return nameNode.text; - } - return nameNode.getText(sourceFile); - } + const moduleInfo = { sourceFile, topLevelConsts, imports, defaultExport }; + moduleCache.set(filePath, moduleInfo); + return moduleInfo; +} - function flattenNode(node, prefix, out) { - node = unwrap(node); - if (ts.isObjectLiteralExpression(node)) { - for (const prop of node.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const key = propName(prop.name); - flattenNode(prop.initializer, prefix ? `${prefix}.${key}` : key, out); +// Flatten an object-literal AST node into dot-path keys, resolving identifiers against the +// given module's own top-level consts first, then (for the split-file layout) against its +// imports — recursing into the imported module's default export with ITS OWN scope. +function flattenNode(node, prefix, out, moduleInfo) { + node = unwrapExpression(node); + if (ts.isObjectLiteralExpression(node)) { + for (const prop of node.properties) { + if (ts.isPropertyAssignment(prop)) { + const key = propName(prop.name, moduleInfo.sourceFile); + flattenNode(prop.initializer, prefix ? `${prefix}.${key}` : key, out, moduleInfo); + } else if (ts.isShorthandPropertyAssignment(prop)) { + const key = propName(prop.name, moduleInfo.sourceFile); + flattenNode(prop.name, prefix ? `${prefix}.${key}` : key, out, moduleInfo); } - return; } - if (ts.isIdentifier(node) && topLevelConsts.has(node.text)) { - flattenNode(topLevelConsts.get(node.text), prefix, out); + return; + } + if (ts.isIdentifier(node)) { + if (moduleInfo.topLevelConsts.has(node.text)) { + flattenNode(moduleInfo.topLevelConsts.get(node.text), prefix, out, moduleInfo); return; } - // Leaf: string/template literal, `.replace(...)` call, or an unresolved identifier. - out.add(prefix); + if (moduleInfo.imports.has(node.text)) { + const importedModule = parseModule(moduleInfo.imports.get(node.text)); + if (importedModule.defaultExport) { + flattenNode(importedModule.defaultExport, prefix, out, importedModule); + return; + } + } } + // Leaf: string/template literal, `.replace(...)` call, or an unresolved identifier. + out.add(prefix); +} - const editorLangsNode = topLevelConsts.get("editorLangs"); +// Return a Map from locale code to its flattened key set, for either layout of the Monaco +// editor's language data. +function parseEditorLangs(entryPath) { + const moduleInfo = parseModule(entryPath); + const editorLangsNode = moduleInfo.topLevelConsts.get("editorLangs"); if (!editorLangsNode) { - throw new Error(`Could not find "export const editorLangs = ..." in ${filePath}`); + throw new Error(`Could not find "export const editorLangs = ..." in ${entryPath}`); } const localeMap = new Map(); for (const prop of editorLangsNode.properties) { if (!ts.isPropertyAssignment(prop)) continue; - const locale = propName(prop.name); + const locale = propName(prop.name, moduleInfo.sourceFile); const keys = new Set(); - flattenNode(prop.initializer, "", keys); + flattenNode(prop.initializer, "", keys, moduleInfo); localeMap.set(locale, keys); } return localeMap; @@ -273,13 +336,18 @@ for (const locale of localeDirs) { } } -// --- 5: src/pkg/utils/monaco-editor/langs.ts --- +// --- 5: src/pkg/utils/monaco-editor/langs.ts (or the split langs/.ts + index.ts) --- + +const editorLangsEntry = findEditorLangsEntry(); -if (!existsSync(MONACO_LANGS_PATH)) { - reportError(`${path.relative(ROOT, MONACO_LANGS_PATH)} is missing.`); +if (!editorLangsEntry) { + reportError( + `Could not find the Monaco editor's language data — neither src/pkg/utils/monaco-editor/langs.ts nor ` + + `src/pkg/utils/monaco-editor/langs/index.ts exists.` + ); } else { - const editorLangsByLocale = parseEditorLangs(MONACO_LANGS_PATH); - const relPath = path.relative(ROOT, MONACO_LANGS_PATH); + const editorLangsByLocale = parseEditorLangs(editorLangsEntry); + const relPath = path.relative(ROOT, editorLangsEntry); if (!editorLangsByLocale.has(REFERENCE_LOCALE)) { reportError(`${relPath}: editorLangs has no "${REFERENCE_LOCALE}" entry to use as a reference.`); From b3cbe7d28c34273e5cd8bfe4c8800c434a82b732 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:50:26 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=94=92=20pre-commit=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E5=BC=BA=E5=88=B6=E7=BF=BB=E8=AF=91=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=80=A7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 lint:ci 已经跑 check:i18n,但那只在 push/PR 之后才会看到红叉。 本次让本地 pre-commit 钩子在暂存区涉及翻译相关路径 (src/locales/、src/assets/_locales/、docs/references/terminology-*.md、 src/pkg/utils/monaco-editor/langs.ts 或 langs/ 拆分文件)时, 提前跑一遍 pnpm run check:i18n,不通过则直接拒绝提交, 不需要等到推送后在 CI 里才发现。 真正能拦住"不通过就不许合并"的还有一层:仓库 main 分支的 branch protection 需要把 Lint 设为 required status check, 这一步需要 scriptscat/scriptcat 的仓库管理员在 GitHub 设置里配置, 不是贡献者这边能做的。 Co-Authored-By: Claude Sonnet 5 --- .husky/pre-commit | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index 1a227336a..2f14c282d 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -30,6 +30,20 @@ fi rm -f "$lint_files" +# 涉及翻译相关文件时,强制跑机械完整性检查(缺失/多余 key、缺失 messages.json、 +# 缺失 terminology 规范文件等),未通过则拒绝提交 +i18n_files=$(mktemp) +git diff --cached --name-only -z --diff-filter=ACMR -- \ + "src/locales/*" "src/assets/_locales/*" "docs/references/terminology-*.md" \ + "src/pkg/utils/monaco-editor/langs.ts" "src/pkg/utils/monaco-editor/langs/*" > "$i18n_files" + +if [ -s "$i18n_files" ]; then + rm -f "$i18n_files" + pnpm run check:i18n || exit 1 +else + rm -f "$i18n_files" +fi + # 在 main 或 release/* 分支上提交时额外跑测试 branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = "main" ] || echo "$branch" | grep -q "^release/"; then From 450cc0b685d6f1c86ba54bdd52897ece780639f4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:53:09 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=94=A7=20pre-commit=20=E6=9C=BA?= =?UTF-8?q?=E6=A2=B0=E6=A3=80=E6=9F=A5=E8=8C=83=E5=9B=B4=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E4=B8=BA=E4=BB=85=20json=20=E6=96=87=E4=BB=B6=E6=94=B9?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前的触发范围包含 terminology-*.md、langs.ts/langs/* 等非 json 文件, 导致本 PR 自身改动 .husky/pre-commit、scripts/check-i18n.mjs 这类非 翻译内容的提交也可能被牵连(虽然本次未命中,但范围过宽)。收窄为只在 暂存区包含 src/locales/**/*.json 或 src/assets/_locales/**/*.json 时才 触发 pnpm run check:i18n,其余改动(含本 PR 自身)不受影响,仍可正常 提交推送。CI 侧的 lint:ci 不受影响,仍覆盖全部 5 类检查。 Co-Authored-By: Claude Sonnet 5 --- .husky/pre-commit | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 2f14c282d..95edecfa2 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -30,12 +30,11 @@ fi rm -f "$lint_files" -# 涉及翻译相关文件时,强制跑机械完整性检查(缺失/多余 key、缺失 messages.json、 -# 缺失 terminology 规范文件等),未通过则拒绝提交 +# 涉及翻译 json 文件时,强制跑机械完整性检查(缺失/多余 key、缺失 messages.json 等), +# 未通过则拒绝提交 i18n_files=$(mktemp) git diff --cached --name-only -z --diff-filter=ACMR -- \ - "src/locales/*" "src/assets/_locales/*" "docs/references/terminology-*.md" \ - "src/pkg/utils/monaco-editor/langs.ts" "src/pkg/utils/monaco-editor/langs/*" > "$i18n_files" + "src/locales/*.json" "src/assets/_locales/*.json" > "$i18n_files" if [ -s "$i18n_files" ]; then rm -f "$i18n_files" From ffae9f16659ee139046fe1e2ac257b5e84d2d353 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:06:17 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20check:i?= =?UTF-8?q?18n=20=E7=9A=84=E5=A4=9A=E5=A4=84=20fail-open=20=E6=BC=8F?= =?UTF-8?q?=E6=B4=9E=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodFrm 在 PR #1606 review 中指出: pre-commit 的 check:i18n 校验的是工作区而非 Git 暂存快照(暂存坏版本、工作区改回好版本可绕过检查,删除操作也不触发); check-i18n.mjs 从未与 src/locales/locales.ts 的实际注册(NS/resources/import) 交叉核对,index.ts 导出检查只是子串匹配可被注释字符串骗过;Monaco 自定义 AST evaluator 对 spread、计算属性、循环别名等无法解析的结构会静默折叠为空/叶子键集 而非报错;Chrome _locales 与 Monaco 覆盖面缺失只是 warning。 本次改动: - scripts/git-staged-snapshot.mjs: 用 `git checkout-index` 直接从索引物化暂存 快照,.husky/pre-commit 改为对该快照跑 check:i18n(--diff-filter 补上 D)。 - scripts/check-i18n.mjs: 新增 locales.ts 双向一致性检查(NS 数组 vs 命名空间 文件、import/resources vs 磁盘目录);index.ts 导出改用真实 AST 解析;Monaco AST evaluator 对 spread/计算属性/不支持的表达式/循环引用/语法错误一律报错 (fail-closed);Chrome _locales 与 Monaco 覆盖面缺失由 warning 升级为 error。 - 新增 scripts/check-i18n.test.mjs、scripts/git-staged-snapshot.test.mjs 覆盖 上述所有可复现场景。 - docs/translation.md 同步更新为当前 fail-closed 行为。 Co-Authored-By: Claude Sonnet 5 --- .husky/pre-commit | 12 +- docs/translation.md | 11 +- scripts/check-i18n.mjs | 729 ++++++++++++++++++--------- scripts/check-i18n.test.mjs | 264 ++++++++++ scripts/git-staged-snapshot.mjs | 33 ++ scripts/git-staged-snapshot.test.mjs | 71 +++ 6 files changed, 862 insertions(+), 258 deletions(-) create mode 100644 scripts/check-i18n.test.mjs create mode 100644 scripts/git-staged-snapshot.mjs create mode 100644 scripts/git-staged-snapshot.test.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index 95edecfa2..ba726b54f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -31,14 +31,20 @@ fi rm -f "$lint_files" # 涉及翻译 json 文件时,强制跑机械完整性检查(缺失/多余 key、缺失 messages.json 等), -# 未通过则拒绝提交 +# 未通过则拒绝提交。--diff-filter 包含 D:删除一个翻译文件同样要触发检查。 +# 检查对象是 Git 索引里的暂存快照(git checkout-index 直接读索引,不碰工作区/索引本身), +# 而不是工作区当前文件——避免"暂存坏版本、工作区又改回好版本"绕过检查。 i18n_files=$(mktemp) -git diff --cached --name-only -z --diff-filter=ACMR -- \ +git diff --cached --name-only -z --diff-filter=ACMRD -- \ "src/locales/*.json" "src/assets/_locales/*.json" > "$i18n_files" if [ -s "$i18n_files" ]; then rm -f "$i18n_files" - pnpm run check:i18n || exit 1 + snapshot_dir=$(mktemp -d) + node scripts/git-staged-snapshot.mjs "$(pwd)" "$snapshot_dir" && pnpm run check:i18n -- "--root=$snapshot_dir" + status=$? + rm -rf "$snapshot_dir" + [ $status -eq 0 ] || exit 1 else rm -f "$i18n_files" fi diff --git a/docs/translation.md b/docs/translation.md index 4b9c149e5..7ecd388e5 100644 --- a/docs/translation.md +++ b/docs/translation.md @@ -73,10 +73,13 @@ `scripts/check-i18n.mjs`(`pnpm run check:i18n`)在每次 `pnpm lint` / `pnpm lint:ci` 时自动运行,用于捕获人工审阅容易漏掉的问题: +- `src/locales/locales.ts`:`src/locales/` 下的每一个 locale 目录都必须被 `import * as X from "./"` 并在 `resources` 中以自己的 locale code 展开注册;一个目录只存在于磁盘、没有接入 `locales.ts` 会导致检查失败。顶层 `NS` 数组也必须与 `en-US/` 下的命名空间文件集合完全一致,多一个或少一个都会报错。 - `src/locales//*.json` 中每个 key 是否与 `en-US`(模板 / fallback 语言)的 key 集合一一对应,缺失或多余的 key 都会报错。 -- `src/locales//index.ts` 是否导出了 `en-US` 拥有的全部命名空间。 -- `src/assets/_locales//messages.json`(`chrome.i18n` 语言文件,见上文"翻译工作流"一节)是否与 `en/messages.json` 的 key 一致;如果某个 locale 尚未创建该目录,只会给出提示(warning),不会导致失败。 +- `src/locales//index.ts` 是否(以真实的 `export ... from "./.json"` 语句,而非文本匹配)导出了 `en-US` 拥有的全部命名空间。 +- `src/assets/_locales//messages.json`(`chrome.i18n` 语言文件,见上文"翻译工作流"一节)是否与 `en/messages.json` 的 key 一致;**`src/locales/` 下的每一个 locale 都必须有对应的 `_locales` 目录**,缺失会导致检查失败。 - `docs/references/terminology-.md` 是否存在:**`src/locales/` 下的每一个 locale 都必须有对应的术语规范文件**,缺失会导致检查失败——不允许新增或修改某个 locale 却不提交其 `terminology-.md`。 -- `src/pkg/utils/monaco-editor/langs.ts` 中 `editorLangs`(编辑器悬浮提示、脚本头字段提示等)的 key 是否与 `en-US` 一致;如果某个 locale 尚未在 `editorLangs` 中创建条目,只会给出提示(warning);但只要该 locale 已有条目,其 key 缺失或多余就会报错。 +- `src/pkg/utils/monaco-editor/langs/`(或历史上未拆分时的单文件 `langs.ts`)中 `editorLangs`(编辑器悬浮提示、脚本头字段提示等)的 key 是否与 `en-US` 一致;**`src/locales/` 下的每一个 locale 都必须有对应的 `editorLangs` 条目**,缺失或 key 集合不一致都会导致检查失败。 -这个脚本无法判断翻译措辞是否准确、是否符合术语规范——那部分仍需人工审阅并遵循本文件与对应的 `terminology-.md`;它只保证不会有 key 或术语规范文件被整段遗漏。 \ No newline at end of file +这个脚本无法判断翻译措辞是否准确、是否符合术语规范——那部分仍需人工审阅并遵循本文件与对应的 `terminology-.md`;它只保证不会有 key、注册项或术语规范文件被整段遗漏。检查本身是 fail-closed 的:任何它无法静态解析的结构(`spread`、计算属性、`satisfies`、语法错误、循环引用等)都会报错,而不会被静默放行。 + +提交时机上,`git commit` 触发的 `.husky/pre-commit` 校验的是 **Git 暂存区(`git add` 之后的内容)**,不是工作区当前文件——`pnpm lint` / `pnpm run check:i18n` 手动运行时校验的才是工作区文件。 \ No newline at end of file diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index abb4fd530..e6061bb04 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -2,21 +2,32 @@ // Mechanical check for missing/extra translations in a locale PR. // +// Fail-closed by design: every surface below either verifies a concrete, statically-resolvable +// match, or hard-fails with a specific reason. None of the checks silently pass on structures +// they can't parse (spreads, computed keys, `satisfies`, syntax errors, unregistered locales, +// namespace/property shapes the evaluator doesn't recognize) — anything it can't statically +// verify is reported as an error asking a human to extend the check, never passed through. +// // Checks: +// 0. src/locales/locales.ts — every directory under src/locales/ must be `import * as X`'d +// and spread into `resources` under its own locale code, and the top-level `NS` array +// must match the namespace files under src/locales/en-US/ exactly (no unregistered +// directory, no stale/missing namespace). // 1. src/locales//*.json namespace files — every key path present in the // en-US reference must exist in every other locale (and vice versa for extras). -// 2. src/locales//index.ts — must export every namespace that en-US exports. -// 3. src/assets/_locales//messages.json — key parity against the en/ reference, -// for locales that already have a directory (chrome.i18n store-listing strings). +// 2. src/locales//index.ts — its real `export ... from "./.json"` declarations +// (parsed via the TS compiler, not string-matched) must cover every namespace en-US exports. +// 3. src/assets/_locales//messages.json — key parity against the en/ reference. +// Every locale under src/locales/ must have a chrome.i18n directory (store-listing +// strings); a missing directory is a failure, not a warning. // 4. docs/references/terminology-.md — every locale under src/locales MUST have one. // A translation PR that adds/changes a locale without its terminology file is rejected. -// 5. src/pkg/utils/monaco-editor/langs.ts — for locales that already have an `editorLangs` entry, -// its key set must match en-US (hover prompts, script-header field prompts). +// 5. src/pkg/utils/monaco-editor/langs.ts (or the split langs/.ts + index.ts) — every +// locale under src/locales/ must have an `editorLangs` entry, and its key set must match +// en-US (hover prompts, script-header field prompts). A missing entry is a failure. // -// Locales that only exist under src/locales but have no src/assets/_locales directory, or no -// editorLangs entry yet, are reported as a warning, not a failure — those two surfaces are -// optional/best-effort (see docs/translation.md); once a locale HAS an entry, its keys must -// stay in sync with en-US or the check fails. +// Run with `--root=` to check a different tree than this file's repo checkout — used by +// .husky/pre-commit to validate the Git *staged* snapshot rather than the working tree. import process from "node:process"; import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; @@ -25,23 +36,6 @@ import { fileURLToPath } from "node:url"; import ts from "typescript"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(__dirname, ".."); - -const LOCALES_DIR = path.join(ROOT, "src/locales"); -const REFERENCE_LOCALE = "en-US"; -const CHROME_LOCALES_DIR = path.join(ROOT, "src/assets/_locales"); -const CHROME_REFERENCE_LOCALE = "en"; -const TERMINOLOGY_DIR = path.join(ROOT, "docs/references"); - -// chrome.i18n `_locales` directories don't follow one fixed rule (some are the bare language -// subtag like "ko", others are the full region-qualified code like "zh_CN" or "pt_BR"), and a -// hardcoded i18next-locale -> chrome-dir map silently rots the moment a PR adds a directory this -// script doesn't know about yet. So resolve it by scanning what's actually on disk instead. -function findChromeDirName(locale, actualChromeDirs) { - const [lang, region] = locale.split("-"); - const candidates = region ? [`${lang}_${region}`, lang] : [lang]; - return candidates.find((candidate) => actualChromeDirs.includes(candidate)); -} function isDir(p) { return existsSync(p) && statSync(p).isDirectory(); @@ -69,20 +63,18 @@ function diffKeys(referenceKeys, targetKeys) { return { missing, extra }; } -// Find the entry point for the Monaco editor's language data, whether it's still the original -// single file or has been split into src/pkg/utils/monaco-editor/langs/.ts + index.ts -// (each locale imported into an `editorLangs` map). Support both so a refactor of this file -// doesn't make the check report a false "file missing". -function findEditorLangsEntry() { - const splitIndex = path.join(ROOT, "src/pkg/utils/monaco-editor/langs/index.ts"); - if (existsSync(splitIndex)) return splitIndex; - const singleFile = path.join(ROOT, "src/pkg/utils/monaco-editor/langs.ts"); - if (existsSync(singleFile)) return singleFile; - return null; +// chrome.i18n `_locales` directories don't follow one fixed rule (some are the bare language +// subtag like "ko", others are the full region-qualified code like "zh_CN" or "pt_BR"), and a +// hardcoded i18next-locale -> chrome-dir map silently rots the moment a PR adds a directory this +// script doesn't know about yet. So resolve it by scanning what's actually on disk instead. +function findChromeDirName(locale, actualChromeDirs) { + const [lang, region] = locale.split("-"); + const candidates = region ? [`${lang}_${region}`, lang] : [lang]; + return candidates.find((candidate) => actualChromeDirs.includes(candidate)); } function unwrapExpression(node) { - while (node && (ts.isAsExpression(node) || ts.isParenthesizedExpression(node))) { + while (node && (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node))) { node = node.expression; } return node; @@ -95,272 +87,494 @@ function propName(nameNode, sourceFile) { return nameNode.getText(sourceFile); } -const moduleCache = new Map(); - -// Parse one TS module: its top-level `const x = {...}` object literals (for resolving -// same-file identifier references like `grantValuePrompts: grantValuePromptsEnUS`), its -// default-import bindings resolved to absolute file paths (for the split-file layout, where -// `editorLangs` maps a locale to an imported identifier), and its `export default {...}`. -function parseModule(filePath) { - if (moduleCache.has(filePath)) return moduleCache.get(filePath); - +// Syntax errors don't make `ts.createSourceFile` throw — it silently recovers by emitting error +// nodes, which the AST walkers below would then read as an ordinary (wrong) shape. Parse with +// diagnostics first so a malformed file fails loudly instead of quietly mis-evaluating. +function parseTsFile(filePath, relLabel) { const source = readFileSync(filePath, "utf8"); - const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const { diagnostics } = ts.transpileModule(source, { + reportDiagnostics: true, + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.Latest }, + }); + const syntaxErrors = (diagnostics || []).filter((d) => d.category === ts.DiagnosticCategory.Error); + if (syntaxErrors.length > 0) { + const messages = syntaxErrors.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n")); + throw new Error(`${relLabel} failed to parse:\n` + messages.map((m) => ` - ${m}`).join("\n")); + } + return ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} - const topLevelConsts = new Map(); - const imports = new Map(); - let defaultExport; +function runCheck(root) { + const LOCALES_DIR = path.join(root, "src/locales"); + const REFERENCE_LOCALE = "en-US"; + const CHROME_LOCALES_DIR = path.join(root, "src/assets/_locales"); + const CHROME_REFERENCE_LOCALE = "en"; + const TERMINOLOGY_DIR = path.join(root, "docs/references"); + const LOCALES_TS_PATH = path.join(LOCALES_DIR, "locales.ts"); + + const rel = (p) => path.relative(root, p); + + let hasError = false; + const problems = []; + function reportError(message) { + hasError = true; + problems.push({ level: "error", message }); + } + // For sections whose own parsing can throw (malformed TS): report it as a normal error + // instead of crashing the whole run, so one broken file doesn't hide problems elsewhere. + function guard(sectionLabel, fn) { + try { + fn(); + } catch (err) { + reportError(`${sectionLabel}: ${err.message}`); + } + } - for (const statement of sourceFile.statements) { - if (ts.isVariableStatement(statement)) { - for (const decl of statement.declarationList.declarations) { - if (!decl.initializer) continue; - const initializer = unwrapExpression(decl.initializer); - if (ts.isObjectLiteralExpression(initializer)) { - topLevelConsts.set(decl.name.getText(sourceFile), initializer); + const moduleCache = new Map(); + + // Parse one TS module: its top-level `const x = {...}` object literals (for resolving + // same-file identifier references like `grantValuePrompts: grantValuePromptsEnUS`), its + // default-import bindings resolved to absolute file paths (for the split-file layout, where + // `editorLangs` maps a locale to an imported identifier), and its `export default {...}`. + function parseModule(filePath) { + if (moduleCache.has(filePath)) return moduleCache.get(filePath); + + const sourceFile = parseTsFile(filePath, rel(filePath)); + + const topLevelConsts = new Map(); + const imports = new Map(); + let defaultExport; + + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement)) { + for (const decl of statement.declarationList.declarations) { + if (!decl.initializer) continue; + const initializer = unwrapExpression(decl.initializer); + if (ts.isObjectLiteralExpression(initializer)) { + topLevelConsts.set(decl.name.getText(sourceFile), initializer); + } } + } else if ( + ts.isImportDeclaration(statement) && + statement.importClause?.name && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith(".") + ) { + const baseDir = path.dirname(filePath); + const specifier = statement.moduleSpecifier.text; + const resolved = [ + path.join(baseDir, `${specifier}.ts`), + path.join(baseDir, `${specifier}.tsx`), + path.join(baseDir, specifier, "index.ts"), + ].find((candidate) => existsSync(candidate)); + if (resolved) { + imports.set(statement.importClause.name.getText(sourceFile), resolved); + } + } else if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + const expr = unwrapExpression(statement.expression); + if (ts.isObjectLiteralExpression(expr)) defaultExport = expr; } - } else if ( - ts.isImportDeclaration(statement) && - statement.importClause?.name && - ts.isStringLiteral(statement.moduleSpecifier) && - statement.moduleSpecifier.text.startsWith(".") - ) { - const baseDir = path.dirname(filePath); - const specifier = statement.moduleSpecifier.text; - const resolved = [ - path.join(baseDir, `${specifier}.ts`), - path.join(baseDir, `${specifier}.tsx`), - path.join(baseDir, specifier, "index.ts"), - ].find((candidate) => existsSync(candidate)); - if (resolved) { - imports.set(statement.importClause.name.getText(sourceFile), resolved); - } - } else if (ts.isExportAssignment(statement) && !statement.isExportEquals) { - const expr = unwrapExpression(statement.expression); - if (ts.isObjectLiteralExpression(expr)) defaultExport = expr; } - } - const moduleInfo = { sourceFile, topLevelConsts, imports, defaultExport }; - moduleCache.set(filePath, moduleInfo); - return moduleInfo; -} + const moduleInfo = { filePath, sourceFile, topLevelConsts, imports, defaultExport }; + moduleCache.set(filePath, moduleInfo); + return moduleInfo; + } -// Flatten an object-literal AST node into dot-path keys, resolving identifiers against the -// given module's own top-level consts first, then (for the split-file layout) against its -// imports — recursing into the imported module's default export with ITS OWN scope. -function flattenNode(node, prefix, out, moduleInfo) { - node = unwrapExpression(node); - if (ts.isObjectLiteralExpression(node)) { - for (const prop of node.properties) { - if (ts.isPropertyAssignment(prop)) { - const key = propName(prop.name, moduleInfo.sourceFile); - flattenNode(prop.initializer, prefix ? `${prefix}.${key}` : key, out, moduleInfo); - } else if (ts.isShorthandPropertyAssignment(prop)) { - const key = propName(prop.name, moduleInfo.sourceFile); - flattenNode(prop.name, prefix ? `${prefix}.${key}` : key, out, moduleInfo); + // Flatten an object-literal AST node into dot-path keys, resolving identifiers against the + // given module's own top-level consts first, then (for the split-file layout) against its + // imports — recursing into the imported module's default export with ITS OWN scope. + // `visiting` guards against a circular alias chain recursing forever. + function flattenNode(node, prefix, out, moduleInfo, visiting = new Set()) { + node = unwrapExpression(node); + if (ts.isObjectLiteralExpression(node)) { + for (const prop of node.properties) { + if (ts.isPropertyAssignment(prop)) { + const key = propName(prop.name, moduleInfo.sourceFile); + if (prop.name && ts.isComputedPropertyName(prop.name) && !ts.isStringLiteralLike(prop.name.expression)) { + throw new Error( + `${rel(moduleInfo.filePath)} has a computed property key ("${key}") the checker can't resolve statically.` + ); + } + flattenNode(prop.initializer, prefix ? `${prefix}.${key}` : key, out, moduleInfo, visiting); + } else if (ts.isShorthandPropertyAssignment(prop)) { + const key = propName(prop.name, moduleInfo.sourceFile); + flattenNode(prop.name, prefix ? `${prefix}.${key}` : key, out, moduleInfo, visiting); + } else { + throw new Error( + `${rel(moduleInfo.filePath)} has an object property at "${prefix || ""}" the checker can't resolve ` + + `statically (spread, method, or accessor) — rewrite it as a plain key or extend the checker.` + ); + } } - } - return; - } - if (ts.isIdentifier(node)) { - if (moduleInfo.topLevelConsts.has(node.text)) { - flattenNode(moduleInfo.topLevelConsts.get(node.text), prefix, out, moduleInfo); return; } - if (moduleInfo.imports.has(node.text)) { - const importedModule = parseModule(moduleInfo.imports.get(node.text)); - if (importedModule.defaultExport) { - flattenNode(importedModule.defaultExport, prefix, out, importedModule); + if (ts.isIdentifier(node)) { + if (moduleInfo.topLevelConsts.has(node.text)) { + flattenNode(moduleInfo.topLevelConsts.get(node.text), prefix, out, moduleInfo, visiting); return; } + if (moduleInfo.imports.has(node.text)) { + const importedPath = moduleInfo.imports.get(node.text); + if (visiting.has(importedPath)) { + throw new Error(`Circular import detected while resolving "${node.text}" back into ${rel(importedPath)}.`); + } + const importedModule = parseModule(importedPath); + if (importedModule.defaultExport) { + const nextVisiting = new Set(visiting); + nextVisiting.add(moduleInfo.filePath); + flattenNode(importedModule.defaultExport, prefix, out, importedModule, nextVisiting); + return; + } + throw new Error( + `${rel(importedPath)}: imported by ${rel(moduleInfo.filePath)} but has no "export default {...}".` + ); + } + // A genuinely unresolved identifier (e.g. an imported helper function, not a locale + // module) is treated as an opaque leaf value, same as a string/template literal. + out.add(prefix); + return; } - } - // Leaf: string/template literal, `.replace(...)` call, or an unresolved identifier. - out.add(prefix); -} - -// Return a Map from locale code to its flattened key set, for either layout of the Monaco -// editor's language data. -function parseEditorLangs(entryPath) { - const moduleInfo = parseModule(entryPath); - const editorLangsNode = moduleInfo.topLevelConsts.get("editorLangs"); - if (!editorLangsNode) { - throw new Error(`Could not find "export const editorLangs = ..." in ${entryPath}`); + if (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node) || ts.isCallExpression(node)) { + out.add(prefix); + return; + } + throw new Error( + `${rel(moduleInfo.filePath)} has an unsupported expression at "${prefix || ""}" (kind ${ts.SyntaxKind[node.kind]}) ` + + `the checker can't resolve statically — extend the checker or rewrite it as a plain literal.` + ); } - const localeMap = new Map(); - for (const prop of editorLangsNode.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const locale = propName(prop.name, moduleInfo.sourceFile); - const keys = new Set(); - flattenNode(prop.initializer, "", keys, moduleInfo); - localeMap.set(locale, keys); + // Find the entry point for the Monaco editor's language data, whether it's still the original + // single file or has been split into src/pkg/utils/monaco-editor/langs/.ts + index.ts + // (each locale imported into an `editorLangs` map). Support both so a refactor of this file + // doesn't make the check report a false "file missing". + function findEditorLangsEntry() { + const splitIndex = path.join(root, "src/pkg/utils/monaco-editor/langs/index.ts"); + if (existsSync(splitIndex)) return splitIndex; + const singleFile = path.join(root, "src/pkg/utils/monaco-editor/langs.ts"); + if (existsSync(singleFile)) return singleFile; + return null; } - return localeMap; -} - -let hasError = false; -const problems = []; -function reportError(message) { - hasError = true; - problems.push({ level: "error", message }); -} - -function reportWarning(message) { - problems.push({ level: "warning", message }); -} + // Return a Map from locale code to its flattened key set, for either layout of the Monaco + // editor's language data. + function parseEditorLangs(entryPath) { + const moduleInfo = parseModule(entryPath); + const editorLangsNode = moduleInfo.topLevelConsts.get("editorLangs"); + if (!editorLangsNode) { + throw new Error(`Could not find "export const editorLangs = ..." in ${rel(entryPath)}`); + } -// --- 1 & 2: src/locales namespace files + index.ts exports --- + const localeMap = new Map(); + for (const prop of editorLangsNode.properties) { + if (!ts.isPropertyAssignment(prop)) { + throw new Error(`${rel(entryPath)}: editorLangs has an entry the checker can't resolve statically.`); + } + const locale = propName(prop.name, moduleInfo.sourceFile); + const keys = new Set(); + flattenNode(prop.initializer, "", keys, moduleInfo); + localeMap.set(locale, keys); + } + return localeMap; + } -const localeDirs = readdirSync(LOCALES_DIR).filter((name) => isDir(path.join(LOCALES_DIR, name))); + const localeDirs = isDir(LOCALES_DIR) + ? readdirSync(LOCALES_DIR).filter((name) => isDir(path.join(LOCALES_DIR, name))) + : []; -if (!localeDirs.includes(REFERENCE_LOCALE)) { - reportError(`Reference locale directory "${REFERENCE_LOCALE}" not found under src/locales.`); -} else { - const referenceDir = path.join(LOCALES_DIR, REFERENCE_LOCALE); - const namespaceFiles = readdirSync(referenceDir) - .filter((name) => name.endsWith(".json")) - .sort(); + // --- 0: src/locales/locales.ts — every locale directory must be registered --- - const otherLocales = localeDirs.filter((name) => name !== REFERENCE_LOCALE).sort(); + guard("src/locales/locales.ts", () => { + if (!existsSync(LOCALES_TS_PATH)) { + reportError(`src/locales/locales.ts is missing.`); + return; + } - for (const locale of otherLocales) { - const localeDir = path.join(LOCALES_DIR, locale); + const sourceFile = parseTsFile(LOCALES_TS_PATH, rel(LOCALES_TS_PATH)); + + // `import * as enUS from "./en-US";` — namespace import of a locale directory. + const namespaceImportsByDir = new Map(); + for (const statement of sourceFile.statements) { + if ( + ts.isImportDeclaration(statement) && + statement.importClause?.namedBindings && + ts.isNamespaceImport(statement.importClause.namedBindings) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith("./") + ) { + const dirName = statement.moduleSpecifier.text.slice(2); + namespaceImportsByDir.set(dirName, statement.importClause.namedBindings.name.text); + } + } - // index.ts must export every namespace en-US exports. - const indexPath = path.join(localeDir, "index.ts"); - if (!existsSync(indexPath)) { - reportError(`src/locales/${locale}/index.ts is missing.`); + // `const NS = [...] as const;` + let nsNode; + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const decl of statement.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.name.text === "NS" && decl.initializer) { + const initializer = unwrapExpression(decl.initializer); + if (ts.isArrayLiteralExpression(initializer)) nsNode = initializer; + } + } + } + if (!nsNode) { + reportError(`src/locales/locales.ts does not declare a top-level \`const NS = [...] as const\` array.`); } else { - const indexSource = readFileSync(indexPath, "utf8"); - for (const file of namespaceFiles) { - const ns = path.basename(file, ".json"); - if (!indexSource.includes(`"./${ns}.json"`)) { - reportError(`src/locales/${locale}/index.ts does not export namespace "${ns}" (missing "./${ns}.json").`); + const nsEntries = new Set(); + for (const el of nsNode.elements) { + if (!ts.isStringLiteralLike(el)) { + reportError(`src/locales/locales.ts: NS array has a non-string-literal entry the checker can't resolve.`); + continue; + } + nsEntries.add(el.text); + } + if (isDir(path.join(LOCALES_DIR, REFERENCE_LOCALE))) { + const namespaceFiles = readdirSync(path.join(LOCALES_DIR, REFERENCE_LOCALE)) + .filter((name) => name.endsWith(".json")) + .map((name) => path.basename(name, ".json")); + const { missing, extra } = diffKeys(new Set(namespaceFiles), nsEntries); + if (missing.length > 0) { + reportError( + `src/locales/locales.ts: NS array is missing namespace(s) present under src/locales/${REFERENCE_LOCALE}/: ` + + missing.join(", ") + ); + } + if (extra.length > 0) { + reportError( + `src/locales/locales.ts: NS array has stale namespace(s) with no matching src/locales/${REFERENCE_LOCALE}/*.json file: ` + + extra.join(", ") + ); } } } - for (const file of namespaceFiles) { - const referenceJson = readJson(path.join(referenceDir, file)); - const referenceKeys = flattenKeys(referenceJson); - - const targetPath = path.join(localeDir, file); - if (!existsSync(targetPath)) { - reportError(`src/locales/${locale}/${file} is missing entirely (${referenceKeys.size} keys untranslated).`); - continue; + // `resources: { "en-US": { title: "...", ...enUS }, ... }` inside `i18n.use(...).init({...})`. + let resourcesNode; + (function findResourcesNode(node) { + if (resourcesNode) return; + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text === "resources" && + ts.isObjectLiteralExpression(node.initializer) + ) { + resourcesNode = node.initializer; + return; } + ts.forEachChild(node, findResourcesNode); + })(sourceFile); - const targetJson = readJson(targetPath); - const targetKeys = flattenKeys(targetJson); - const { missing, extra } = diffKeys(referenceKeys, targetKeys); + if (!resourcesNode) { + reportError(`src/locales/locales.ts: could not find a \`resources: {...}\` object passed to i18next's init().`); + return; + } - if (missing.length > 0) { + const registeredLocales = new Map(); // locale code -> spread identifier used, if any + for (const prop of resourcesNode.properties) { + if (!ts.isPropertyAssignment(prop) || !ts.isObjectLiteralExpression(prop.initializer)) continue; + const localeCode = propName(prop.name, sourceFile); + const spreadIdent = prop.initializer.properties.find( + (p) => ts.isSpreadAssignment(p) && ts.isIdentifier(p.expression) + ); + registeredLocales.set(localeCode, spreadIdent ? spreadIdent.expression.text : null); + } + + for (const dir of localeDirs) { + const importIdent = namespaceImportsByDir.get(dir); + if (!importIdent) { reportError( - `src/locales/${locale}/${file} is missing ${missing.length} key(s) present in ${REFERENCE_LOCALE}:\n` + - missing.map((k) => ` - ${k}`).join("\n") + `src/locales/locales.ts does not \`import * as X from "./${dir}"\` — the "${dir}" locale directory ` + + `exists on disk but is never imported, so it can't be registered with i18next.` ); + continue; } - if (extra.length > 0) { + const spreadIdent = registeredLocales.get(dir); + if (spreadIdent === undefined) { reportError( - `src/locales/${locale}/${file} has ${extra.length} key(s) not present in ${REFERENCE_LOCALE} (stale or misspelled key?):\n` + - extra.map((k) => ` - ${k}`).join("\n") + `src/locales/locales.ts imports "./${dir}" as "${importIdent}" but \`resources\` has no "${dir}" entry ` + + `spreading it in — the locale is parsed but never registered with i18next.` + ); + } else if (spreadIdent !== importIdent) { + reportError( + `src/locales/locales.ts: resources["${dir}"] does not spread "${importIdent}" (the import bound to ` + + `"./${dir}") — it looks unregistered or registered under the wrong locale code.` ); } } - } -} + }); -// --- 3: src/assets/_locales//messages.json --- + // --- 1 & 2: src/locales namespace files + index.ts exports --- -if (isDir(CHROME_LOCALES_DIR)) { - const chromeReferencePath = path.join(CHROME_LOCALES_DIR, CHROME_REFERENCE_LOCALE, "messages.json"); - if (!existsSync(chromeReferencePath)) { - reportError(`Reference file ${path.relative(ROOT, chromeReferencePath)} not found.`); + if (!localeDirs.includes(REFERENCE_LOCALE)) { + reportError(`Reference locale directory "${REFERENCE_LOCALE}" not found under src/locales.`); } else { - const chromeReferenceKeys = flattenKeys(readJson(chromeReferencePath)); - const actualChromeDirs = readdirSync(CHROME_LOCALES_DIR).filter((name) => - isDir(path.join(CHROME_LOCALES_DIR, name)) - ); + const referenceDir = path.join(LOCALES_DIR, REFERENCE_LOCALE); + const namespaceFiles = readdirSync(referenceDir) + .filter((name) => name.endsWith(".json")) + .sort(); + + const otherLocales = localeDirs.filter((name) => name !== REFERENCE_LOCALE).sort(); + + for (const locale of otherLocales) { + const localeDir = path.join(LOCALES_DIR, locale); + + // index.ts must really export every namespace en-US exports (parsed, not string-matched). + const indexPath = path.join(localeDir, "index.ts"); + if (!existsSync(indexPath)) { + reportError(`src/locales/${locale}/index.ts is missing.`); + } else { + guard(`src/locales/${locale}/index.ts`, () => { + const indexSourceFile = parseTsFile(indexPath, rel(indexPath)); + const exportedNamespaces = new Set(); + for (const statement of indexSourceFile.statements) { + if ( + ts.isExportDeclaration(statement) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith("./") && + statement.moduleSpecifier.text.endsWith(".json") + ) { + exportedNamespaces.add(path.basename(statement.moduleSpecifier.text, ".json")); + } + } + for (const file of namespaceFiles) { + const ns = path.basename(file, ".json"); + if (!exportedNamespaces.has(ns)) { + reportError( + `src/locales/${locale}/index.ts does not export namespace "${ns}" (no \`export ... from "./${ns}.json"\`).` + ); + } + } + }); + } - for (const locale of localeDirs) { - if (locale === REFERENCE_LOCALE) continue; - const chromeDirName = findChromeDirName(locale, actualChromeDirs); + for (const file of namespaceFiles) { + const referenceJson = readJson(path.join(referenceDir, file)); + const referenceKeys = flattenKeys(referenceJson); - if (!chromeDirName) { - reportWarning( - `src/assets/_locales has no directory for locale "${locale}" yet (optional, but consider adding it — see docs/translation.md).` - ); - continue; - } + const targetPath = path.join(localeDir, file); + if (!existsSync(targetPath)) { + reportError(`src/locales/${locale}/${file} is missing entirely (${referenceKeys.size} keys untranslated).`); + continue; + } - const messagesPath = path.join(CHROME_LOCALES_DIR, chromeDirName, "messages.json"); - if (!existsSync(messagesPath)) { - reportError(`src/assets/_locales/${chromeDirName}/ exists but is missing messages.json.`); - continue; + const targetJson = readJson(targetPath); + const targetKeys = flattenKeys(targetJson); + const { missing, extra } = diffKeys(referenceKeys, targetKeys); + + if (missing.length > 0) { + reportError( + `src/locales/${locale}/${file} is missing ${missing.length} key(s) present in ${REFERENCE_LOCALE}:\n` + + missing.map((k) => ` - ${k}`).join("\n") + ); + } + if (extra.length > 0) { + reportError( + `src/locales/${locale}/${file} has ${extra.length} key(s) not present in ${REFERENCE_LOCALE} (stale or misspelled key?):\n` + + extra.map((k) => ` - ${k}`).join("\n") + ); + } } + } + } - const targetKeys = flattenKeys(readJson(messagesPath)); - const { missing, extra } = diffKeys(chromeReferenceKeys, targetKeys); + // --- 3: src/assets/_locales//messages.json --- - if (missing.length > 0) { - reportError( - `src/assets/_locales/${chromeDirName}/messages.json is missing ${missing.length} key(s) present in ` + - `${CHROME_REFERENCE_LOCALE}/messages.json:\n` + - missing.map((k) => ` - ${k}`).join("\n") - ); - } - if (extra.length > 0) { - reportError( - `src/assets/_locales/${chromeDirName}/messages.json has ${extra.length} key(s) not present in ` + - `${CHROME_REFERENCE_LOCALE}/messages.json:\n` + - extra.map((k) => ` - ${k}`).join("\n") - ); + if (isDir(CHROME_LOCALES_DIR)) { + const chromeReferencePath = path.join(CHROME_LOCALES_DIR, CHROME_REFERENCE_LOCALE, "messages.json"); + if (!existsSync(chromeReferencePath)) { + reportError(`Reference file ${rel(chromeReferencePath)} not found.`); + } else { + const chromeReferenceKeys = flattenKeys(readJson(chromeReferencePath)); + const actualChromeDirs = readdirSync(CHROME_LOCALES_DIR).filter((name) => + isDir(path.join(CHROME_LOCALES_DIR, name)) + ); + + for (const locale of localeDirs) { + if (locale === REFERENCE_LOCALE) continue; + const chromeDirName = findChromeDirName(locale, actualChromeDirs); + + if (!chromeDirName) { + reportError( + `src/assets/_locales has no directory for locale "${locale}" — every locale under src/locales/ must ` + + `have a chrome.i18n directory (see docs/translation.md).` + ); + continue; + } + + const messagesPath = path.join(CHROME_LOCALES_DIR, chromeDirName, "messages.json"); + if (!existsSync(messagesPath)) { + reportError(`src/assets/_locales/${chromeDirName}/ exists but is missing messages.json.`); + continue; + } + + const targetKeys = flattenKeys(readJson(messagesPath)); + const { missing, extra } = diffKeys(chromeReferenceKeys, targetKeys); + + if (missing.length > 0) { + reportError( + `src/assets/_locales/${chromeDirName}/messages.json is missing ${missing.length} key(s) present in ` + + `${CHROME_REFERENCE_LOCALE}/messages.json:\n` + + missing.map((k) => ` - ${k}`).join("\n") + ); + } + if (extra.length > 0) { + reportError( + `src/assets/_locales/${chromeDirName}/messages.json has ${extra.length} key(s) not present in ` + + `${CHROME_REFERENCE_LOCALE}/messages.json:\n` + + extra.map((k) => ` - ${k}`).join("\n") + ); + } } } + } else { + reportError(`src/assets/_locales directory not found.`); } -} -// --- 4: docs/references/terminology-.md --- + // --- 4: docs/references/terminology-.md --- -for (const locale of localeDirs) { - const terminologyPath = path.join(TERMINOLOGY_DIR, `terminology-${locale}.md`); - if (!existsSync(terminologyPath)) { - reportError( - `docs/references/terminology-${locale}.md is missing. Every locale under src/locales/ must have a ` + - `terminology guide — see docs/translation.md § 各语言术语规范 / Per-locale terminology.` - ); + for (const locale of localeDirs) { + const terminologyPath = path.join(TERMINOLOGY_DIR, `terminology-${locale}.md`); + if (!existsSync(terminologyPath)) { + reportError( + `docs/references/terminology-${locale}.md is missing. Every locale under src/locales/ must have a ` + + `terminology guide — see docs/translation.md § 各语言术语规范 / Per-locale terminology.` + ); + } } -} -// --- 5: src/pkg/utils/monaco-editor/langs.ts (or the split langs/.ts + index.ts) --- + // --- 5: src/pkg/utils/monaco-editor/langs.ts (or the split langs/.ts + index.ts) --- -const editorLangsEntry = findEditorLangsEntry(); + guard("src/pkg/utils/monaco-editor/langs", () => { + const editorLangsEntry = findEditorLangsEntry(); -if (!editorLangsEntry) { - reportError( - `Could not find the Monaco editor's language data — neither src/pkg/utils/monaco-editor/langs.ts nor ` + - `src/pkg/utils/monaco-editor/langs/index.ts exists.` - ); -} else { - const editorLangsByLocale = parseEditorLangs(editorLangsEntry); - const relPath = path.relative(ROOT, editorLangsEntry); + if (!editorLangsEntry) { + reportError( + `Could not find the Monaco editor's language data — neither src/pkg/utils/monaco-editor/langs.ts nor ` + + `src/pkg/utils/monaco-editor/langs/index.ts exists.` + ); + return; + } + + const editorLangsByLocale = parseEditorLangs(editorLangsEntry); + const relPath = rel(editorLangsEntry); + + if (!editorLangsByLocale.has(REFERENCE_LOCALE)) { + reportError(`${relPath}: editorLangs has no "${REFERENCE_LOCALE}" entry to use as a reference.`); + return; + } - if (!editorLangsByLocale.has(REFERENCE_LOCALE)) { - reportError(`${relPath}: editorLangs has no "${REFERENCE_LOCALE}" entry to use as a reference.`); - } else { const referenceKeys = editorLangsByLocale.get(REFERENCE_LOCALE); for (const locale of localeDirs) { if (locale === REFERENCE_LOCALE) continue; if (!editorLangsByLocale.has(locale)) { - reportWarning( - `${relPath}: editorLangs has no "${locale}" entry yet (${referenceKeys.size} keys untranslated for the ` + - `Monaco editor's hover prompts; optional, but consider adding it).` + reportError( + `${relPath}: editorLangs has no "${locale}" entry — every locale under src/locales/ must have one ` + + `(${referenceKeys.size} keys untranslated for the Monaco editor's hover prompts).` ); continue; } @@ -381,23 +595,36 @@ if (!editorLangsEntry) { ); } } - } + }); + + return { problems, hasError }; } -// --- report --- +function main() { + const rootArg = process.argv.find((a) => a.startsWith("--root=")); + const root = rootArg ? path.resolve(rootArg.slice("--root=".length)) : path.resolve(__dirname, ".."); -if (problems.length === 0) { - console.log("✅ i18n check passed: all locales match the en-US / en reference key sets."); - process.exit(0); -} + const { problems, hasError } = runCheck(root); + + if (problems.length === 0) { + console.log("✅ i18n check passed: all locales match the en-US / en reference key sets."); + process.exit(0); + } + + for (const { level, message } of problems) { + console.error(level === "error" ? `\n❌ ${message}` : `\n⚠️ ${message}`); + } + + if (hasError) { + console.error("\ni18n check failed. Fix the missing/extra keys above before submitting the translation PR."); + process.exit(1); + } -for (const { level, message } of problems) { - console.error(level === "error" ? `\n❌ ${message}` : `\n⚠️ ${message}`); + console.log("\n✅ i18n check passed (with warnings above)."); } -if (hasError) { - console.error("\ni18n check failed. Fix the missing/extra keys above before submitting the translation PR."); - process.exit(1); +if (import.meta.url === `file://${process.argv[1]}`) { + main(); } -console.log("\n✅ i18n check passed (with warnings above)."); +export { runCheck }; diff --git a/scripts/check-i18n.test.mjs b/scripts/check-i18n.test.mjs new file mode 100644 index 000000000..cb3bce2c5 --- /dev/null +++ b/scripts/check-i18n.test.mjs @@ -0,0 +1,264 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { runCheck } from "./check-i18n.mjs"; + +// check-i18n.mjs 是 fail-closed 的机械检查:任何它无法静态解析的结构都必须报错,而不是放行。 +// 这里用最小 fixture 仓库树(而非真实仓库)逐一复现 PR #1606 review 中指出的可复现 fail-open +// 路径,锁定回归。 + +const tmpDirs = []; + +function makeFixtureRoot() { + const dir = path.join(os.tmpdir(), `check-i18n-test-${Math.random().toString(36).slice(2)}`); + tmpDirs.push(dir); + + mkdirSync(path.join(dir, "src/locales/en-US"), { recursive: true }); + mkdirSync(path.join(dir, "src/locales/zh-CN"), { recursive: true }); + mkdirSync(path.join(dir, "src/assets/_locales/en"), { recursive: true }); + mkdirSync(path.join(dir, "src/assets/_locales/zh_CN"), { recursive: true }); + mkdirSync(path.join(dir, "docs/references"), { recursive: true }); + mkdirSync(path.join(dir, "src/pkg/utils/monaco-editor/langs"), { recursive: true }); + + writeFileSync(path.join(dir, "src/locales/en-US/common.json"), JSON.stringify({ hello: "hi" })); + writeFileSync(path.join(dir, "src/locales/en-US/index.ts"), `export { default as common } from "./common.json";\n`); + writeFileSync(path.join(dir, "src/locales/zh-CN/common.json"), JSON.stringify({ hello: "你好" })); + writeFileSync(path.join(dir, "src/locales/zh-CN/index.ts"), `export { default as common } from "./common.json";\n`); + + writeFileSync( + path.join(dir, "src/locales/locales.ts"), + [ + `import * as enUS from "./en-US";`, + `import * as zhCN from "./zh-CN";`, + `const NS = ["common"] as const;`, + `i18n.use(x).init({`, + ` ns: [...NS],`, + ` resources: {`, + ` "en-US": { title: "English", ...enUS },`, + ` "zh-CN": { title: "中文", ...zhCN },`, + ` },`, + `});`, + ``, + ].join("\n") + ); + + writeFileSync(path.join(dir, "src/assets/_locales/en/messages.json"), JSON.stringify({ appName: { message: "x" } })); + writeFileSync( + path.join(dir, "src/assets/_locales/zh_CN/messages.json"), + JSON.stringify({ appName: { message: "y" } }) + ); + + writeFileSync(path.join(dir, "docs/references/terminology-en-US.md"), "# en-US\n"); + writeFileSync(path.join(dir, "docs/references/terminology-zh-CN.md"), "# zh-CN\n"); + + writeFileSync( + path.join(dir, "src/pkg/utils/monaco-editor/langs/en-US.ts"), + `export default {\n hover: "hover text",\n};\n` + ); + writeFileSync( + path.join(dir, "src/pkg/utils/monaco-editor/langs/zh-CN.ts"), + `export default {\n hover: "悬停文本",\n};\n` + ); + writeFileSync( + path.join(dir, "src/pkg/utils/monaco-editor/langs/index.ts"), + [ + `import enUS from "./en-US";`, + `import zhCN from "./zh-CN";`, + `export const editorLangs = {`, + ` "en-US": enUS,`, + ` "zh-CN": zhCN,`, + `};`, + ``, + ].join("\n") + ); + + return dir; +} + +function messages(problems, level = "error") { + return problems.filter((p) => p.level === level).map((p) => p.message); +} + +afterEach(() => { + while (tmpDirs.length) { + rmSync(tmpDirs.pop(), { recursive: true, force: true }); + } +}); + +describe("check-i18n 机械完整性检查", () => { + it("干净的 fixture 仓库应通过检查", () => { + const { hasError } = runCheck(makeFixtureRoot()); + expect(hasError).toBe(false); + }); + + describe("0. src/locales/locales.ts 与磁盘目录的双向一致性", () => { + it("存在完整 locale 目录但未在 locales.ts 中 import,应报错而非放行", () => { + const root = makeFixtureRoot(); + mkdirSync(path.join(root, "src/locales/ja-JP"), { recursive: true }); + writeFileSync(path.join(root, "src/locales/ja-JP/common.json"), JSON.stringify({ hello: "こんにちは" })); + writeFileSync( + path.join(root, "src/locales/ja-JP/index.ts"), + `export { default as common } from "./common.json";\n` + ); + writeFileSync(path.join(root, "docs/references/terminology-ja-JP.md"), "# ja\n"); + mkdirSync(path.join(root, "src/assets/_locales/ja"), { recursive: true }); + writeFileSync( + path.join(root, "src/assets/_locales/ja/messages.json"), + JSON.stringify({ appName: { message: "z" } }) + ); + writeFileSync( + path.join(root, "src/pkg/utils/monaco-editor/langs/ja-JP.ts"), + `export default {\n hover: "x",\n};\n` + ); + + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes('import * as X from "./ja-JP"'))).toBe(true); + }); + + it("resources 中缺失已 import 的 locale 条目,应报错", () => { + const root = makeFixtureRoot(); + writeFileSync( + path.join(root, "src/locales/locales.ts"), + [ + `import * as enUS from "./en-US";`, + `import * as zhCN from "./zh-CN";`, + `const NS = ["common"] as const;`, + `i18n.use(x).init({`, + ` ns: [...NS],`, + ` resources: {`, + ` "en-US": { title: "English", ...enUS },`, + ` },`, + `});`, + ``, + ].join("\n") + ); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes("never registered with i18next"))).toBe(true); + }); + + it("NS 数组含有真实文件不存在的 stale 命名空间,应报错", () => { + const root = makeFixtureRoot(); + writeFileSync( + path.join(root, "src/locales/locales.ts"), + [ + `import * as enUS from "./en-US";`, + `import * as zhCN from "./zh-CN";`, + `const NS = ["common", "ghost"] as const;`, + `i18n.use(x).init({`, + ` ns: [...NS],`, + ` resources: {`, + ` "en-US": { title: "English", ...enUS },`, + ` "zh-CN": { title: "中文", ...zhCN },`, + ` },`, + `});`, + ``, + ].join("\n") + ); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes("stale namespace(s)") && m.includes("ghost"))).toBe(true); + }); + + it("NS 数组缺少 en-US 下真实存在的命名空间,应报错", () => { + const root = makeFixtureRoot(); + writeFileSync(path.join(root, "src/locales/en-US/extra.json"), JSON.stringify({ a: "b" })); + writeFileSync(path.join(root, "src/locales/zh-CN/extra.json"), JSON.stringify({ a: "c" })); + writeFileSync( + path.join(root, "src/locales/en-US/index.ts"), + `export { default as common } from "./common.json";\nexport { default as extra } from "./extra.json";\n` + ); + writeFileSync( + path.join(root, "src/locales/zh-CN/index.ts"), + `export { default as common } from "./common.json";\nexport { default as extra } from "./extra.json";\n` + ); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes("missing namespace(s)") && m.includes("extra"))).toBe(true); + }); + }); + + describe("2. index.ts 命名空间导出改用真实 AST 解析", () => { + it("注释掉的 export 字符串不应再骗过检查", () => { + const root = makeFixtureRoot(); + writeFileSync( + path.join(root, "src/locales/zh-CN/index.ts"), + `// export { default as common } from "./common.json";\n` + ); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes('does not export namespace "common"'))).toBe(true); + }); + + it("index.ts 存在语法错误时应报错而非静默误判", () => { + const root = makeFixtureRoot(); + writeFileSync(path.join(root, "src/locales/zh-CN/index.ts"), `export { default as common from "./common.json"\n`); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes("failed to parse"))).toBe(true); + }); + }); + + describe("3. Chrome _locales 覆盖面", () => { + it("已注册 locale 缺少 _locales 目录应报错(原来只是 warning)", () => { + const root = makeFixtureRoot(); + rmSync(path.join(root, "src/assets/_locales/zh_CN"), { recursive: true, force: true }); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes("chrome.i18n directory"))).toBe(true); + }); + }); + + describe("5. Monaco editorLangs AST 解析", () => { + it("缺少某 locale 的 editorLangs 条目应报错(原来只是 warning)", () => { + const root = makeFixtureRoot(); + rmSync(path.join(root, "src/pkg/utils/monaco-editor/langs/zh-CN.ts"), { force: true }); + writeFileSync( + path.join(root, "src/pkg/utils/monaco-editor/langs/index.ts"), + `import enUS from "./en-US";\nexport const editorLangs = {\n "en-US": enUS,\n};\n` + ); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes('no "zh-CN" entry'))).toBe(true); + }); + + it("对象展开(spread)应报错,不应静默折叠成空/叶子键集", () => { + const root = makeFixtureRoot(); + writeFileSync( + path.join(root, "src/pkg/utils/monaco-editor/langs/zh-CN.ts"), + `const base = { hover: "悬停文本" };\nexport default {\n ...base,\n};\n` + ); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes("can't resolve statically"))).toBe(true); + }); + + it("计算属性键(非字符串字面量)应报错", () => { + const root = makeFixtureRoot(); + writeFileSync( + path.join(root, "src/pkg/utils/monaco-editor/langs/zh-CN.ts"), + `const key = "hover";\nexport default {\n [key]: "悬停文本",\n};\n` + ); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes("computed property key"))).toBe(true); + }); + + it("循环别名导入应报错而非栈溢出", () => { + const root = makeFixtureRoot(); + writeFileSync( + path.join(root, "src/pkg/utils/monaco-editor/langs/en-US.ts"), + `import zhCNRef from "./zh-CN";\nexport default {\n hover: zhCNRef,\n};\n` + ); + writeFileSync( + path.join(root, "src/pkg/utils/monaco-editor/langs/zh-CN.ts"), + `import enUSRef from "./en-US";\nexport default {\n hover: enUSRef,\n};\n` + ); + const { hasError, problems } = runCheck(root); + expect(hasError).toBe(true); + expect(messages(problems).some((m) => m.includes("Circular import"))).toBe(true); + }); + }); +}); diff --git a/scripts/git-staged-snapshot.mjs b/scripts/git-staged-snapshot.mjs new file mode 100644 index 000000000..1fd0674e4 --- /dev/null +++ b/scripts/git-staged-snapshot.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +// Materialize the Git index's staged content into `destDir`, bypassing any unstaged +// working-tree edits (and any working-tree files not yet staged at all). Used so a +// content check can validate what's about to be committed, not whatever happens to be on +// disk — staging a bad file and then reverting the working copy to a good one (or the +// reverse) must not fool the check. +// +// `git checkout-index` reads straight from the index, so this touches neither the working +// tree nor the index itself. + +import { execFileSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +export function materializeStagedSnapshot(repoRoot, destDir) { + mkdirSync(destDir, { recursive: true }); + const prefix = destDir.endsWith(path.sep) ? destDir : destDir + path.sep; + execFileSync("git", ["checkout-index", "-a", "-f", `--prefix=${prefix}`], { + cwd: repoRoot, + stdio: "inherit", + }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const [repoRoot, destDir] = process.argv.slice(2); + if (!repoRoot || !destDir) { + console.error("Usage: git-staged-snapshot.mjs "); + process.exit(1); + } + materializeStagedSnapshot(path.resolve(repoRoot), path.resolve(destDir)); +} diff --git a/scripts/git-staged-snapshot.test.mjs b/scripts/git-staged-snapshot.test.mjs new file mode 100644 index 000000000..a00a30936 --- /dev/null +++ b/scripts/git-staged-snapshot.test.mjs @@ -0,0 +1,71 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { materializeStagedSnapshot } from "./git-staged-snapshot.mjs"; + +// pre-commit 原先用 `git diff --cached` 只判断"是否要跑检查",随后检查脚本却读普通工作区文件, +// 所以"暂存坏版本、工作区恢复为好版本"会放行坏提交。这里验证 materializeStagedSnapshot 真的 +// 读的是 Git 索引(已 add 的内容),而不是工作区当前内容。 + +const tmpDirs = []; + +function git(cwd, args) { + execFileSync("git", args, { cwd, stdio: "pipe" }); +} + +function makeTempRepo() { + const dir = path.join(os.tmpdir(), `staged-snapshot-test-${Math.random().toString(36).slice(2)}`); + tmpDirs.push(dir); + mkdirSync(dir, { recursive: true }); + git(dir, ["init", "-q"]); + git(dir, ["config", "user.email", "test@example.com"]); + git(dir, ["config", "user.name", "Test"]); + return dir; +} + +afterEach(() => { + while (tmpDirs.length) { + rmSync(tmpDirs.pop(), { recursive: true, force: true }); + } +}); + +describe("materializeStagedSnapshot", () => { + it("暂存坏内容、工作区改回好内容时,快照应反映暂存区(坏)内容", () => { + const repo = makeTempRepo(); + writeFileSync(path.join(repo, "a.json"), JSON.stringify({ good: true })); + git(repo, ["add", "a.json"]); + git(repo, ["commit", "-q", "-m", "init"]); + + // 暂存"坏"内容 + writeFileSync(path.join(repo, "a.json"), JSON.stringify({ bad: true })); + git(repo, ["add", "a.json"]); + // 工作区恢复为"好"内容(不影响索引) + writeFileSync(path.join(repo, "a.json"), JSON.stringify({ good: true })); + + const dest = path.join(repo, "..", `snapshot-${Math.random().toString(36).slice(2)}`); + tmpDirs.push(dest); + materializeStagedSnapshot(repo, dest); + + const snapshotContent = JSON.parse(readFileSync(path.join(dest, "a.json"), "utf8")); + expect(snapshotContent).toEqual({ bad: true }); + }); + + it("未暂存的工作区改动不应出现在快照中", () => { + const repo = makeTempRepo(); + writeFileSync(path.join(repo, "a.json"), JSON.stringify({ v: 1 })); + git(repo, ["add", "a.json"]); + git(repo, ["commit", "-q", "-m", "init"]); + + // 只改工作区,不 git add + writeFileSync(path.join(repo, "a.json"), JSON.stringify({ v: 2 })); + + const dest = path.join(repo, "..", `snapshot-${Math.random().toString(36).slice(2)}`); + tmpDirs.push(dest); + materializeStagedSnapshot(repo, dest); + + const snapshotContent = JSON.parse(readFileSync(path.join(dest, "a.json"), "utf8")); + expect(snapshotContent).toEqual({ v: 1 }); + }); +}); From e34fd967923621d0d15778763ee9aca24c197b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 20 Jul 2026 17:05:52 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20check:i?= =?UTF-8?q?18n=20=E5=85=A5=E5=8F=A3=E5=AE=88=E5=8D=AB=E5=9C=A8=E7=89=B9?= =?UTF-8?q?=E6=AE=8A=E8=B7=AF=E5=BE=84=E4=B8=8B=E9=9D=99=E9=BB=98=E5=A4=B1?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-i18n.mjs 与 git-staged-snapshot.mjs 都用 `import.meta.url === \`file://${process.argv[1]}\`` 判断是否被直接执行。 这个比较在两种情况下永不成立,main() / CLI 分支不执行,进程零输出 exit 0: - `import.meta.url` 是 percent-encoded 的,而 argv[1] 是原始路径:仓库路径含 空格或非 ASCII 字符(如 ~/我的项目/scriptcat)时两者不等; - `import.meta.url` 会解析软链而 argv[1] 不会:macOS 的 /tmp、/var 即是软链。 后果是本 PR 建立的机制整体失效且毫无迹象——`pnpm lint` / `lint:ci` 里这一格 永远绿,pre-commit 中两个脚本以 `&&` 串联、一起放行坏提交。实测在 ~/…/中文目录/ 下删掉 zh-CN/common.json 的一个 key 后 `git commit` 直接落库, 而同样的破坏在 ASCII 路径下会被正确拦截。这恰好是脚本自身 "fail-closed by design" 承诺要杜绝的失败模式。 改为两边都归一化成真实文件路径再比对,同时覆盖编码与软链两种情形。 补充 CLI 入口回归测试:原有用例都直接调 runCheck(),绕过了 CLI 入口, 覆盖不到"脚本到底有没有被执行"这一层。新用例把脚本复制到含空格 / 非 ASCII 字符的目录下真实 spawn,断言干净树输出通过信息、缺 key 时 exit 1。因需拉起 node 子进程(实测 300ms+),显式放宽这些用例的超时, 不套用 vitest.config.ts 给单元测试定的 340ms 预算。 --- scripts/check-i18n.mjs | 9 +++- scripts/check-i18n.test.mjs | 62 +++++++++++++++++++++++++++- scripts/git-staged-snapshot.mjs | 8 +++- scripts/git-staged-snapshot.test.mjs | 38 ++++++++++++++++- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index e6061bb04..71bd2ee51 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -30,7 +30,7 @@ // .husky/pre-commit to validate the Git *staged* snapshot rather than the working tree. import process from "node:process"; -import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import { readdirSync, readFileSync, existsSync, statSync, realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; @@ -623,7 +623,12 @@ function main() { console.log("\n✅ i18n check passed (with warnings above)."); } -if (import.meta.url === `file://${process.argv[1]}`) { +// 判断是否被直接执行时,必须两边都归一化成真实文件路径再比: +// - `import.meta.url` 是 percent-encoded 的,拼字符串 `file://${argv[1]}` 在仓库路径含空格或 +// 非 ASCII 字符(如 ~/我的项目/)时永不相等; +// - `import.meta.url` 还会解析软链,而 argv[1] 不会(macOS 的 /tmp、/var 即是软链)。 +// 任一情况下 main() 都不执行,进程零输出 exit 0——CI 的 lint 与 pre-commit 双双静默放行坏翻译。 +if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) { main(); } diff --git a/scripts/check-i18n.test.mjs b/scripts/check-i18n.test.mjs index cb3bce2c5..c02d25828 100644 --- a/scripts/check-i18n.test.mjs +++ b/scripts/check-i18n.test.mjs @@ -1,9 +1,14 @@ import { describe, it, expect, afterEach } from "vitest"; -import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdirSync, writeFileSync, rmSync, copyFileSync, symlinkSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import path from "node:path"; import os from "node:os"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; import { runCheck } from "./check-i18n.mjs"; +const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url)); + // check-i18n.mjs 是 fail-closed 的机械检查:任何它无法静态解析的结构都必须报错,而不是放行。 // 这里用最小 fixture 仓库树(而非真实仓库)逐一复现 PR #1606 review 中指出的可复现 fail-open // 路径,锁定回归。 @@ -262,3 +267,58 @@ describe("check-i18n 机械完整性检查", () => { }); }); }); + +// 上面的用例直接调 runCheck(),绕过了 CLI 入口,因此覆盖不到"脚本到底有没有被执行"这一层。 +// `import.meta.url` 是 percent-encoded 的,而 `process.argv[1]` 是原始路径:仓库路径一旦含空格或 +// 非 ASCII 字符(如 ~/我的项目/),两者永不相等,main() 不执行,进程零输出 exit 0 —— CI 的 +// lint 与 pre-commit 会双双静默放行坏翻译,恰好是本脚本 fail-closed 承诺要杜绝的失败模式。 +describe("CLI 入口守卫(脚本路径含空格 / 非 ASCII 字符)", () => { + // 这些用例要真实 spawn node 子进程(实测 300ms+),不适用 vitest.config.ts 给单元测试定的 + // 340ms 预算,否则必然在 CI 满载下偶发超时。 + const CLI_TIMEOUT = 15_000; + + // 把脚本复制到含特殊字符的目录下真实 spawn;node_modules 放在其父级,让 typescript 仍可解析。 + function runScriptAt(dirName, scriptName, args) { + const host = path.join(os.tmpdir(), `check-i18n-cli-${Math.random().toString(36).slice(2)}`); + tmpDirs.push(host); + const dir = path.join(host, dirName); + mkdirSync(dir, { recursive: true }); + symlinkSync(path.join(SCRIPTS_DIR, "../node_modules"), path.join(host, "node_modules"), "dir"); + const scriptPath = path.join(dir, scriptName); + copyFileSync(path.join(SCRIPTS_DIR, scriptName), scriptPath); + + try { + const stdout = execFileSync(process.execPath, [scriptPath, ...args], { encoding: "utf8", stdio: "pipe" }); + return { status: 0, output: stdout }; + } catch (err) { + return { status: err.status, output: `${err.stdout || ""}${err.stderr || ""}` }; + } + } + + for (const dirName of ["with space", "中文目录"]) { + describe(`目录名 "${dirName}"`, () => { + it( + "干净仓库树应真正执行检查并输出通过信息,而不是静默 no-op", + () => { + const root = makeFixtureRoot(); + const { status, output } = runScriptAt(dirName, "check-i18n.mjs", [`--root=${root}`]); + expect(status).toBe(0); + expect(output).toContain("i18n check passed"); + }, + CLI_TIMEOUT + ); + + it( + "缺失翻译 key 时必须以 exit 1 失败,不得静默放行", + () => { + const root = makeFixtureRoot(); + writeFileSync(path.join(root, "src/locales/zh-CN/common.json"), JSON.stringify({})); + const { status, output } = runScriptAt(dirName, "check-i18n.mjs", [`--root=${root}`]); + expect(status).toBe(1); + expect(output).toContain("hello"); + }, + CLI_TIMEOUT + ); + }); + } +}); diff --git a/scripts/git-staged-snapshot.mjs b/scripts/git-staged-snapshot.mjs index 1fd0674e4..37de94ce2 100644 --- a/scripts/git-staged-snapshot.mjs +++ b/scripts/git-staged-snapshot.mjs @@ -10,9 +10,10 @@ // tree nor the index itself. import { execFileSync } from "node:child_process"; -import { mkdirSync } from "node:fs"; +import { mkdirSync, realpathSync } from "node:fs"; import path from "node:path"; import process from "node:process"; +import { fileURLToPath } from "node:url"; export function materializeStagedSnapshot(repoRoot, destDir) { mkdirSync(destDir, { recursive: true }); @@ -23,7 +24,10 @@ export function materializeStagedSnapshot(repoRoot, destDir) { }); } -if (import.meta.url === `file://${process.argv[1]}`) { +// 同 check-i18n.mjs:两边都要归一化成真实文件路径(percent-encoding + 软链)再比对,否则含空格 / +// 非 ASCII 字符或位于软链下的仓库路径里这段不会执行,快照目录留空,pre-commit 的 `&&` 串联随之 +// 整体静默放行。 +if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) { const [repoRoot, destDir] = process.argv.slice(2); if (!repoRoot || !destDir) { console.error("Usage: git-staged-snapshot.mjs "); diff --git a/scripts/git-staged-snapshot.test.mjs b/scripts/git-staged-snapshot.test.mjs index a00a30936..8e9c941eb 100644 --- a/scripts/git-staged-snapshot.test.mjs +++ b/scripts/git-staged-snapshot.test.mjs @@ -1,10 +1,14 @@ import { describe, it, expect, afterEach } from "vitest"; import { execFileSync } from "node:child_process"; -import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, writeFileSync, readFileSync, rmSync, copyFileSync, existsSync } from "node:fs"; import path from "node:path"; import os from "node:os"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; import { materializeStagedSnapshot } from "./git-staged-snapshot.mjs"; +const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url)); + // pre-commit 原先用 `git diff --cached` 只判断"是否要跑检查",随后检查脚本却读普通工作区文件, // 所以"暂存坏版本、工作区恢复为好版本"会放行坏提交。这里验证 materializeStagedSnapshot 真的 // 读的是 Git 索引(已 add 的内容),而不是工作区当前内容。 @@ -68,4 +72,36 @@ describe("materializeStagedSnapshot", () => { const snapshotContent = JSON.parse(readFileSync(path.join(dest, "a.json"), "utf8")); expect(snapshotContent).toEqual({ v: 1 }); }); + + // 与 check-i18n.mjs 同源的入口守卫问题:脚本路径含空格 / 非 ASCII 字符时 CLI 分支不执行, + // 快照目录留空。pre-commit 里它与 check-i18n 用 `&&` 串联,两者一起静默放行坏提交。 + describe("CLI 入口守卫(脚本路径含空格 / 非 ASCII 字符)", () => { + // 真实 spawn node 子进程(实测 200ms+),不适用 vitest.config.ts 给单元测试定的 340ms 预算。 + const CLI_TIMEOUT = 15_000; + + for (const dirName of ["with space", "中文目录"]) { + it( + `目录名 "${dirName}" 下仍应真正物化暂存快照`, + () => { + const repo = makeTempRepo(); + writeFileSync(path.join(repo, "a.json"), JSON.stringify({ v: 1 })); + git(repo, ["add", "a.json"]); + git(repo, ["commit", "-q", "-m", "init"]); + + const host = path.join(os.tmpdir(), `snapshot-cli-${Math.random().toString(36).slice(2)}`); + tmpDirs.push(host); + const scriptDir = path.join(host, dirName); + mkdirSync(scriptDir, { recursive: true }); + const scriptPath = path.join(scriptDir, "git-staged-snapshot.mjs"); + copyFileSync(path.join(SCRIPTS_DIR, "git-staged-snapshot.mjs"), scriptPath); + + const dest = path.join(host, "dest"); + execFileSync(process.execPath, [scriptPath, repo, dest], { stdio: "pipe" }); + + expect(existsSync(path.join(dest, "a.json"))).toBe(true); + }, + CLI_TIMEOUT + ); + } + }); });