diff --git a/.husky/pre-commit b/.husky/pre-commit index 1a227336a..ba726b54f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -30,6 +30,25 @@ 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=ACMRD -- \ + "src/locales/*.json" "src/assets/_locales/*.json" > "$i18n_files" + +if [ -s "$i18n_files" ]; then + rm -f "$i18n_files" + 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 + # 在 main 或 release/* 分支上提交时额外跑测试 branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = "main" ] || echo "$branch" | grep -q "^release/"; then diff --git a/docs/translation.md b/docs/translation.md index 7a2ac5683..7ecd388e5 100644 --- a/docs/translation.md +++ b/docs/translation.md @@ -66,4 +66,20 @@ 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/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` 是否(以真实的 `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/`(或历史上未拆分时的单文件 `langs.ts`)中 `editorLangs`(编辑器悬浮提示、脚本头字段提示等)的 key 是否与 `en-US` 一致;**`src/locales/` 下的每一个 locale 都必须有对应的 `editorLangs` 条目**,缺失或 key 集合不一致都会导致检查失败。 + +这个脚本无法判断翻译措辞是否准确、是否符合术语规范——那部分仍需人工审阅并遵循本文件与对应的 `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/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..71bd2ee51 --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,635 @@ +#!/usr/bin/env node + +// 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 — 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 (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. +// +// 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, realpathSync } 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)); + +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 }; +} + +// 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) || ts.isSatisfiesExpression(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); +} + +// 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 { 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); +} + +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}`); + } + } + + 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; + } + } + + 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. + // `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, 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; + } + 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.` + ); + } + + // 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 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)}`); + } + + 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 = isDir(LOCALES_DIR) + ? readdirSync(LOCALES_DIR).filter((name) => isDir(path.join(LOCALES_DIR, name))) + : []; + + // --- 0: src/locales/locales.ts — every locale directory must be registered --- + + guard("src/locales/locales.ts", () => { + if (!existsSync(LOCALES_TS_PATH)) { + reportError(`src/locales/locales.ts is missing.`); + return; + } + + 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); + } + } + + // `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 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(", ") + ); + } + } + } + + // `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); + + if (!resourcesNode) { + reportError(`src/locales/locales.ts: could not find a \`resources: {...}\` object passed to i18next's init().`); + return; + } + + 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/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; + } + const spreadIdent = registeredLocales.get(dir); + if (spreadIdent === undefined) { + reportError( + `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.` + ); + } + } + }); + + // --- 1 & 2: src/locales namespace files + index.ts exports --- + + 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 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 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 ${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 --- + + 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) --- + + 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.` + ); + 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; + } + + const referenceKeys = editorLangsByLocale.get(REFERENCE_LOCALE); + + for (const locale of localeDirs) { + if (locale === REFERENCE_LOCALE) continue; + + if (!editorLangsByLocale.has(locale)) { + 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; + } + + 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") + ); + } + } + }); + + return { problems, hasError }; +} + +function main() { + const rootArg = process.argv.find((a) => a.startsWith("--root=")); + const root = rootArg ? path.resolve(rootArg.slice("--root=".length)) : path.resolve(__dirname, ".."); + + 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); + } + + console.log("\n✅ i18n check passed (with warnings above)."); +} + +// 判断是否被直接执行时,必须两边都归一化成真实文件路径再比: +// - `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(); +} + +export { runCheck }; diff --git a/scripts/check-i18n.test.mjs b/scripts/check-i18n.test.mjs new file mode 100644 index 000000000..c02d25828 --- /dev/null +++ b/scripts/check-i18n.test.mjs @@ -0,0 +1,324 @@ +import { describe, it, expect, afterEach } from "vitest"; +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 +// 路径,锁定回归。 + +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); + }); + }); +}); + +// 上面的用例直接调 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 new file mode 100644 index 000000000..37de94ce2 --- /dev/null +++ b/scripts/git-staged-snapshot.mjs @@ -0,0 +1,37 @@ +#!/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, 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 }); + const prefix = destDir.endsWith(path.sep) ? destDir : destDir + path.sep; + execFileSync("git", ["checkout-index", "-a", "-f", `--prefix=${prefix}`], { + cwd: repoRoot, + stdio: "inherit", + }); +} + +// 同 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 "); + 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..8e9c941eb --- /dev/null +++ b/scripts/git-staged-snapshot.test.mjs @@ -0,0 +1,107 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +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 的内容),而不是工作区当前内容。 + +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 }); + }); + + // 与 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 + ); + } + }); +});