diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index 76f003e6c..374c75464 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -3,7 +3,7 @@ name: 'Tests (Unit)' on: pull_request: branches: [main, master] - types: [opened, synchronize, ready_for_review] + types: [opened, synchronize, ready_for_review, labeled, unlabeled] paths: - '.github/workflows/tests-unit.yml' - 'package.json' @@ -35,6 +35,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - name: Setup Bun id: setup-bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -59,3 +60,9 @@ jobs: run: bun run typecheck - name: Unit tests run: bun run test:unit + - name: Preserve English bytes in explicitly Chinese-only changes + if: ${{ contains(github.event.pull_request.labels.*.name, 'chinese-copy-only') }} + env: + ZH_GUARD_BASE_SHA: ${{ github.event.pull_request.base.sha }} + ZH_GUARD_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bun run --cwd packages/app guard:zh-copy:chinese-only diff --git a/AGENTS.md b/AGENTS.md index 1f943f877..310523b7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ The site ships a hand-authored Simplified Chinese sibling for every indexable pa > **Claude reviews every changed user-visible Chinese string, but the Chinese maintainer makes the final decision.** Load `review-zh-copy` before opening or reviewing any PR that touches user-visible Chinese text, including refactors whose filenames do not contain `zh`. The skill evaluates semantic fidelity and natural Chinese as separate gates and follows [docs/chinese-copy.md](./docs/chinese-copy.md). Its findings never block another contributor's merge. Do not mention `@edwingao28` for a clean review, routine coverage, or an ordinary wording or naturalness suggestion. Mention `@edwingao28` only for a high-confidence semantic or factual error, changed attribution or speaker voice, or unresolved high-impact ambiguity, and ask for confirmation. The Chinese maintainer makes the final editorial decision. -> **Chinese copy has a narrow mechanical CI guard.** `packages/app/src/lib/zh-copy.test.ts` checks objective regressions: the standalone English common noun `Chip` in Chinese text (with explicit identifier and unit exemptions), prose-like English `` labels that bypass an available `zh:` dictionary, and redundant English-first double translations such as `warmup 预热`. It does not globally ban `预热`, `随机种子`, or `卸载`, and deliberately does **not** enforce pronouns, preferred contextual terminology, punctuation style, sentence structure, fluency, tone, or register; those require page context and human review. Every mechanical rule must use the same matcher for the source scan and a hand-checked case in `packages/app/src/lib/zh-copy-mechanical-regressions.jsonl`, including an exemption case whenever the rule has one. +> **Chinese copy has a narrow objective CI guard.** `packages/app/src/lib/zh-copy.test.ts`, `blog-content.test.ts`, `i18n.test.ts`, and `zh-objective-guard.test.ts` check route and Blog sibling parity, explicit `en`/`zh` dictionary shape, protected Blog code/math/Figure/link/JSON-LD structure, and high-confidence regressions such as the standalone English common noun `Chip`, prose-like English `` labels that bypass an available `zh:` dictionary, redundant English-first translations such as `warmup 预热`, and unambiguous malformed punctuation. A PR labeled `chinese-copy-only` additionally compares English Blog bytes and raw `en` subtrees against the merge base. Exact temporary exceptions, reasons, and removal conditions live in `packages/app/src/lib/zh-objective-guard-exceptions.json`. Every rule must use the same parser or matcher for the real source scan and its mutation fixtures, with a failing mutation, accepted fix, and an accepted exception mutation when applicable. The guard deliberately does **not** enforce fluency, clause order, sentence structure, register, contextual pronouns, marketing tone, quotation voice, English-token ratios, or contextual terminology; those require page context and human review. See `docs/chinese-copy.md` for the complete boundary and exception policy. ## Chart Interpolation — TS and Python Helpers MUST Stay in Sync diff --git a/bun.lock b/bun.lock index 17b4d7b02..d12ba4bd9 100644 --- a/bun.lock +++ b/bun.lock @@ -77,6 +77,8 @@ "@types/react-dom": "19.2.5", "@types/three": "^0.185.4", "@vitest/coverage-v8": "^4.1.11", + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", "axe-core": "^4.13.0", "cypress": "15.21.1", "cypress-axe": "^1.7.0", diff --git a/docs/chinese-copy.md b/docs/chinese-copy.md index 0e18c3331..c7124ed68 100644 --- a/docs/chinese-copy.md +++ b/docs/chinese-copy.md @@ -190,3 +190,123 @@ ground truth. 维护者。CI 不全局禁用 `预热`、`随机种子` 或 `卸载`,因为这些写法在解释与引用中可能 正确;`warmup 预热` 这类与上下文无关的重复表达则可由 CI 拦截。#819 中经过单独验证的 机械案例可以进入 #820 的确定性 CI fixtures,但编辑性改写不得自动视为标准答案。 + +## Objective CI contract / 客观 CI 检查范围 + +The objective guard covers only invariants that can be decided without reading a sentence for +style. Its source scan and mutation fixtures call the same parsers and matchers. Every rule must +include a failing mutation and an accepted correction; a rule with an exception must also include +an accepted exception mutation. + +客观检查只覆盖无需判断文风即可确定的约束。源码扫描与 mutation fixture 必须调用同一套 +解析器和 matcher。每条规则都要同时提供会失败的 mutation 和可通过的修正;存在例外时, +还必须提供可通过的例外 mutation。 + +- App Router parity discovers every real `page.tsx`, removes route groups, and compares English + and `/zh` route patterns in both directions. Dynamic, noindex, hidden, and feature-gated pages are + included. API handlers, `feed.xml`, `llms*.txt`, sitemap handlers, and per-post OG renderers are + not page routes and therefore are not sibling candidates. +- Blog pairing is bidirectional and includes scheduled posts. Chinese posts continue to reuse the + English OG image. Paired content preserves fenced code, math blocks, `Figure` sources, protected + inline code, flags, identifiers, normalized unit multisets, link destinations, and JSON-LD + syntax/shape. Backtick and tilde fences of any legal length are recognized, as are inline-code + delimiters longer than one backtick. Markdown links and static MDX/JSX `href`/`src` props share + the same matcher; bare destinations with balanced parentheses, angle-bracket destinations, + reference definitions, and static string/template-literal props are all covered. Chinese + internal links may add `/zh` (including `/blog` → `/zh/blog`), + same-page heading fragments may be localized, and link order may change when paragraphs are + reorganized. JSX attribute expressions use a JSX-aware JavaScript tokenizer, so division, + regular-expression literals, comments, nested template interpolation, nested JSX or fragments, + comparisons, and arrow operators before a static link prop do not truncate the tag. Protected-token + counts are bidirectional: deleting or adding a flag, identifier, or unit occurrence fails. Protected + forms include throughput, FLOP, + bit-bandwidth, power-per-GPU, slash or hyphen GPU/chip-hour forms, and numeric cost-rate units used + by the real corpus. JSON-LD wrappers may contain legal whitespace and newlines, including in fenced + examples without leaking into prose scans. Their prose values may be translated; keys, types, + array shape, and the exact object/array paths of URLs, numbers, booleans, dates, flags, and + identifiers remain protected. GPU/chip-hour occurrence counts remain protected even when they + appear inside otherwise translatable JSON-LD prose. +- Every containing object with an explicit `en` or `zh` object literal must carry the other locale, + and the two objects must have the same statically declared nested key shape. Identifier and + quoted locale keys are treated alike. Computed keys and spread contents cannot be proven locally + and remain outside this structural check; their explicit assignment and shorthand sibling keys + are still checked. Temporary dictionary exceptions are consumed once, so one digest cannot hide + two identical object violations. +- A PR labeled `chinese-copy-only` runs the merge-base-aware English-byte check. English Blog MDX + is protected as a whole, while mixed TypeScript/TSX/JSON files protect raw `en` subtrees. The + repository-root pathspec works from the workflow's `packages/app` working directory, and + rename-aware base/head mapping preserves unchanged moves while still checking an edited renamed + file. Additions, copies, modifications, renames, deletions, and Git type changes are included. + The objective-guard exception manifest is locale-neutral plumbing rather than UI copy. The check + intentionally permits that plumbing and tests; remove the label when English copy is intentionally + part of the change. +- High-confidence copy rules remain limited to the documented `Chip` common-noun case, + prose-like hardcoded English labels beside a Chinese dictionary, redundant English-first + loanword translations, and unambiguous malformed punctuation such as whitespace before Chinese + terminal punctuation. TS/TSX string literals and JSX visible text, and MDX attributes and visible + prose, are scanned independently so one clean segment cannot hide another bad one. Units, + identifiers, and acronym/unit labels keep their documented English forms. + +- App Router 路由检查会发现所有真实的 `page.tsx`,去掉 route group 后双向比对英文与 + `/zh` 路由。动态、noindex、隐藏和 feature-gated 页面都在范围内。API handler、 + `feed.xml`、`llms*.txt`、sitemap handler 和文章 OG renderer 不是页面路由,因此不要求 + `/zh` sibling。 +- Blog 文章双向配对,定时发布的文章也不例外;中文文章继续复用英文 OG 图。中英文配对 + 必须保留 fenced code、数学公式块、`Figure` 图片来源、受保护的 inline code、flag、 + identifier、规范化后的单位多重集、链接目标,以及 JSON-LD 的语法和结构。检查器同时识别 + 任意合法长度的反引号或波浪线 fence,以及由多个反引号界定的 inline code;Markdown 链接 + 与 MDX/JSX 中静态的 `href`/`src` 属性共用同一 matcher;带平衡括号的普通 destination、 + 尖括号 destination、reference definition,以及静态字符串或 template literal 属性都在 + 检查范围内。中文站内链接可以添加 `/zh` + (包括 `/blog` → `/zh/blog`);页内标题锚点可以随中文标题本地化;段落重组时链接顺序可以 + 变化。JSX 属性表达式会使用支持 JSX 的 JavaScript tokenizer,因此静态链接属性前的除法、 + 正则表达式 literal、注释、嵌套 template interpolation、嵌套 JSX 或 fragment、比较和箭头 + 运算符都不会截断标签。受保护 token 会双向核对出现次数,增删任何 flag、identifier 或单位 + 都会失败; + 实际语料中的吞吐量、FLOP、bit 带宽、每 GPU 功耗、斜杠或连字符形式的 GPU/chip-hour, + 以及数值成本率等单位都会覆盖。JSON-LD wrapper 可以包含合法空白与换行;即使它位于 fenced + 示例中,也不会泄漏到正文扫描。JSON-LD 中的说明性文案可以翻译,但 key、type、数组结构, + 以及 URL、数字、boolean、日期、flag 和 identifier 所在的对象/数组路径必须保持不变;即使 + GPU/chip-hour 出现在其他内容可翻译的 JSON-LD 文案中,其出现次数仍受保护。 +- 只要一个 containing object 中显式声明了 `en` 或 `zh` object literal,就必须同时声明另一 + 个 locale,且两者的静态嵌套 key 结构一致;identifier key 与带引号的 locale key 采用相同 + 规则。computed key 和 spread 的内容无法在本地可靠推断,不纳入该结构检查;同一对象中的 + 显式 assignment 与 shorthand sibling key 仍会检查。临时字典例外按次消费,单个 digest + 不能同时掩盖两个相同的对象错误。 +- PR 添加 `chinese-copy-only` label 后,会运行基于 merge base 的英文逐字节检查。英文 Blog + MDX 整体受保护;混合语言 TypeScript、TSX 和 JSON 文件则保护原始 `en` subtree。路径检查 + 固定从仓库根目录解析,因此工作流在 `packages/app` 下执行也不会漏扫;rename-aware 的 + base/head 映射允许内容不变的移动,同时仍会拦截重命名文件中的英文改动;新增、复制、修改、 + 重命名、删除和 Git type change 都会纳入。客观检查的例外 manifest 属于 locale-neutral + plumbing,而不是 UI 英文文案。测试和这类 plumbing 可以修改;如果 PR 本来就要改英文文案, + 应移除该 label。 +- 高置信度文案规则仅限于已记录的 `Chip` 普通名词、已有中文字典旁绕过本地化的英文说明 + 标签、English-first 重复翻译,以及中文句末标点前多余空格等无歧义错误。单位和 identifier + 继续保留文档规定的英文写法;缩写和单位标签也明确豁免。TS/TSX 的字符串 literal 与 JSX + 可见文本、MDX 属性与可见正文会分别扫描,避免一个正常片段掩盖另一个片段中的错误。 + +Temporary baseline exceptions live in +`packages/app/src/lib/zh-objective-guard-exceptions.json`. Every row must record a reason and an +explicit removal condition. Blog inline-code exceptions match exact English and Chinese spans; +each file's protected-token exception set is pinned to the SHA-256 digest of the complete paired +English/Chinese source, so a waiver cannot migrate to another occurrence after either post changes. +an empty side records one exact addition/removal rather than a wildcard, and duplicate occurrences +require duplicate rows. Dictionary exceptions fingerprint the exact mismatched key set. A stale, +broadened, or newly different exception fails its non-vacuity test. + +临时 baseline 例外统一记录在 +`packages/app/src/lib/zh-objective-guard-exceptions.json`。每条记录都必须说明原因和明确的 +删除条件。Blog inline-code 例外精确匹配中英文两侧文本;每个文件的 protected-token 例外集合 +还会绑定中英文完整源码对的 SHA-256 digest,因此任一文章变化后,豁免都不能迁移到其他位置。 +空字符串一侧表示一次明确的新增或删除,并非通配符;重复出现的差异必须逐条记录。字典例外 +对完整的 key 差异集合计算 fingerprint。 +失效、扩大范围或内容发生变化的例外都会在非空验证中失败。 + +The deterministic guard never scores fluency, clause order, sentence structure, register, +contextual pronouns, marketing tone, or quotation voice. It also does not use English-token ratios +or a closed list to decide whether an established ML infrastructure term should remain English. +Those decisions stay with `review-zh-copy` and the Chinese maintainer. + +确定性检查永远不评价流畅度、分句顺序、句法、语域、上下文相关的第二人称、营销语气或引用 +者口吻,也不会通过英文 token 占比或封闭术语表来判断 ML 基础设施术语是否应保留英文。 +这些判断继续由 `review-zh-copy` 和中文维护者完成。 diff --git a/packages/app/package.json b/packages/app/package.json index dcd8f25c1..ef139aef0 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -30,7 +30,8 @@ "clean:all": "rimraf .next out cypress/videos cypress/screenshots coverage", "cache:invalidate": "bun --env-file=../../.env scripts/invalidate-cache.ts", "cache:warmup": "bun --env-file=../../.env scripts/warmup-cache.ts", - "capture:fixtures": "bun scripts/capture-cypress-fixtures.ts" + "capture:fixtures": "bun scripts/capture-cypress-fixtures.ts", + "guard:zh-copy:chinese-only": "bun scripts/check-zh-chinese-only.ts --chinese-only" }, "dependencies": { "@chenglou/pretext": "^0.0.8", @@ -90,6 +91,8 @@ "@types/react-dom": "19.2.5", "@types/three": "^0.185.4", "@vitest/coverage-v8": "^4.1.11", + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", "axe-core": "^4.13.0", "cypress": "15.21.1", "cypress-axe": "^1.7.0", diff --git a/packages/app/scripts/check-zh-chinese-only.ts b/packages/app/scripts/check-zh-chinese-only.ts new file mode 100644 index 000000000..942bbf46c --- /dev/null +++ b/packages/app/scripts/check-zh-chinese-only.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env bun +import { execFileSync } from 'node:child_process'; + +import { compareEnglishSurfaces } from '../src/lib/zh-objective-guard'; + +interface Options { + readonly chineseOnly: boolean; + readonly base: string; + readonly head: string; +} + +function optionValue(args: readonly string[], name: string): string | undefined { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +function parseOptions(args: readonly string[]): Options { + const chineseOnly = args.includes('--chinese-only'); + const base = optionValue(args, '--base') ?? process.env.ZH_GUARD_BASE_SHA ?? ''; + const head = optionValue(args, '--head') ?? process.env.ZH_GUARD_HEAD_SHA ?? ''; + if (!chineseOnly || !base || !head) { + throw new Error( + 'Usage: check-zh-chinese-only.ts --chinese-only --base --head ', + ); + } + return { chineseOnly, base, head }; +} + +function git(args: readonly string[]): string { + return execFileSync('git', args, { encoding: 'utf8' }).trim(); +} + +function sourceAt(revision: string, file: string): string { + if (!file) return ''; + try { + return execFileSync('git', ['show', `${revision}:${file}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return ''; + } +} + +interface ChangedFile { + readonly baseFile: string; + readonly headFile: string; +} + +function changedFiles(base: string, head: string): ChangedFile[] { + const raw = execFileSync( + 'git', + [ + 'diff', + '--name-status', + '-z', + '--find-renames', + '--diff-filter=ACMRTD', + `${base}...${head}`, + '--', + ':(top)packages/app', + ], + { encoding: 'utf8' }, + ); + const fields = raw.split('\0'); + if (fields.at(-1) === '') fields.pop(); + const changed: ChangedFile[] = []; + for (let index = 0; index < fields.length;) { + const status = fields[index++]; + if (status.startsWith('R') || status.startsWith('C')) { + changed.push({ baseFile: fields[index++], headFile: fields[index++] }); + continue; + } + const file = fields[index++]; + changed.push({ + baseFile: status === 'A' ? '' : file, + headFile: status === 'D' ? '' : file, + }); + } + return changed; +} + +const options = parseOptions(process.argv.slice(2)); +const mergeBase = git(['merge-base', options.base, options.head]); +const files = changedFiles(mergeBase, options.head); +const violations = files.flatMap(({ baseFile, headFile }) => { + const baseSource = sourceAt(mergeBase, baseFile); + const headSource = sourceAt(options.head, headFile); + const preferredFile = headFile || baseFile; + const preferred = compareEnglishSurfaces(preferredFile, baseSource, headSource); + if (preferred.length > 0 || baseFile === headFile || !baseFile) return preferred; + return compareEnglishSurfaces(baseFile, baseSource, headSource); +}); + +if (violations.length > 0) { + console.error( + [ + 'Chinese-only mode found changed English bytes:', + ...violations.map((violation) => ` ${violation.file}: ${violation.detail}`), + '', + 'Remove the chinese-copy-only label when the PR intentionally changes English copy.', + ].join('\n'), + ); + process.exitCode = 1; +} else { + console.log( + `Chinese-only English-byte guard passed for ${files.length} changed app file(s) against merge base ${mergeBase}.`, + ); +} diff --git a/packages/app/src/lib/blog-content.test.ts b/packages/app/src/lib/blog-content.test.ts index 8b660d5c7..c9f6efed7 100644 --- a/packages/app/src/lib/blog-content.test.ts +++ b/packages/app/src/lib/blog-content.test.ts @@ -2,6 +2,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; +import { + compareBlogPair, + findBlogPairViolations, + type BlogGuardException, +} from './zh-objective-guard'; + /** * Integrity checks over the real MDX in `content/blog/`. These are cheap file reads, * not a rendering test — they catch the mistakes that only surface at build time or, @@ -21,6 +27,14 @@ const enFiles = fs .readdirSync(CONTENT_DIR) .filter((f) => f.endsWith('.mdx')) .toSorted(); +const zhFiles = fs + .readdirSync(ZH_DIR) + .filter((f) => f.endsWith('.mdx')) + .toSorted(); + +const guardExceptions = JSON.parse( + fs.readFileSync(path.join(APP_DIR, 'src/lib/zh-objective-guard-exceptions.json'), 'utf8'), +) as { blog: BlogGuardException[] }; const read = (file: string) => fs.readFileSync(file, 'utf8'); @@ -59,6 +73,24 @@ it('finds English posts to check', () => { expect(enFiles.length).toBeGreaterThan(0); }); +it('keeps Blog siblings complete in both directions', () => { + expect(findBlogPairViolations(enFiles, zhFiles)).toEqual([]); +}); + +it('keeps every temporary Blog exception exact and still necessary', () => { + for (const exception of guardExceptions.blog) { + expect(exception.reason.trim()).not.toBe(''); + expect(exception.removeWhen.trim()).not.toBe(''); + const en = read(path.join(CONTENT_DIR, exception.file)); + const zh = read(path.join(ZH_DIR, exception.file)); + const withoutCurrent = guardExceptions.blog.filter((candidate) => candidate !== exception); + expect(compareBlogPair(exception.file, en, zh, withoutCurrent)).toContainEqual( + expect.objectContaining({ rule: exception.rule }), + ); + expect(compareBlogPair(exception.file, en, zh, guardExceptions.blog)).toEqual([]); + } +}); + describe.each(enFiles)('%s', (file) => { const en = read(path.join(CONTENT_DIR, file)); const zhPath = path.join(ZH_DIR, file); @@ -81,6 +113,10 @@ describe.each(enFiles)('%s', (file) => { expect(countMathFences(zh)).toBe(countMathFences(en)); }); + it('preserves code, math, Figure sources, links, inline identifiers, and JSON-LD structure', () => { + expect(compareBlogPair(file, en, read(zhPath), guardExceptions.blog)).toEqual([]); + }); + it('references only images that exist under public/', () => { for (const [locale, raw] of [ ['en', en], diff --git a/packages/app/src/lib/i18n.test.ts b/packages/app/src/lib/i18n.test.ts index 3a01e8fd5..41f222c6f 100644 --- a/packages/app/src/lib/i18n.test.ts +++ b/packages/app/src/lib/i18n.test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import nodePath from 'node:path'; + import { describe, expect, it } from 'vitest'; import { SITE_URL } from '@semianalysisai/inferencex-constants'; @@ -13,6 +16,39 @@ import { zhAlternates, zhPath, } from './i18n'; +import { findRoutePairViolations } from './zh-objective-guard'; + +const APP_ROUTER_DIR = nodePath.resolve(import.meta.dirname, '..', 'app'); +const APP_DIR = nodePath.resolve(APP_ROUTER_DIR, '..', '..'); + +function pageFiles(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const full = nodePath.join(directory, entry.name); + if (entry.isDirectory()) return pageFiles(full); + return entry.name === 'page.tsx' ? [nodePath.relative(APP_DIR, full)] : []; + }); +} + +describe('actual App Router Chinese sibling coverage', () => { + const pages = pageFiles(APP_ROUTER_DIR); + + it('discovers the complete current page tree non-vacuously', () => { + expect(pages.length).toBeGreaterThan(80); + expect(pages.filter((file) => file.startsWith('src/app/zh/')).length).toBeGreaterThan(40); + expect(pages.filter((file) => !file.startsWith('src/app/zh/')).length).toBeGreaterThan(40); + }); + + it('pairs every English page with a Chinese page in both directions', () => { + // /model and /model/[slug] are intentionally English-only: their metadata + // declares a canonical URL without hreflang alternates (see the comment in + // src/app/model/page.tsx), and they are absent from ZH_MIRRORED_ROUTES. + const EN_ONLY_ROUTES = new Set(['/model', '/model/[slug]']); + for (const route of EN_ONLY_ROUTES) { + expect(hasZhSibling(route), `${route} gained a /zh sibling; drop its exemption`).toBe(false); + } + expect(findRoutePairViolations(pages, EN_ONLY_ROUTES)).toEqual([]); + }); +}); describe('zhPath', () => { it('maps the root to /zh without a trailing slash', () => { diff --git a/packages/app/src/lib/zh-copy.test.ts b/packages/app/src/lib/zh-copy.test.ts index 87abbf8a0..c2d0b9f96 100644 --- a/packages/app/src/lib/zh-copy.test.ts +++ b/packages/app/src/lib/zh-copy.test.ts @@ -14,6 +14,14 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; +import { + countLocaleDictionaryObjects, + dictionaryViolationFingerprint, + findDictionaryParityViolations, + findMechanicalCopyViolations, + type DictionaryGuardException, +} from './zh-objective-guard'; + const APP_DIR = path.resolve(import.meta.dirname, '..', '..'); const SCAN_ROOTS = [path.join(APP_DIR, 'src'), path.join(APP_DIR, 'content')]; const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.mdx', '.json']); @@ -21,8 +29,13 @@ const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.mdx', '.json']); const HAN = String.raw`\p{Script=Han}`; const hasHan = new RegExp(HAN, 'u'); -/** This file quotes every banned span it looks for, so it must not scan itself. */ +/** Guard sources quote banned spans and mutation fixtures, so they must not scan themselves. */ const SELF = 'src/lib/zh-copy.test.ts'; +const GUARD_SOURCES = new Set([ + SELF, + 'src/lib/zh-objective-guard.ts', + 'src/lib/zh-objective-guard.test.ts', +]); interface Line { file: string; @@ -138,6 +151,10 @@ function walk(directory: string): string[] { const sourceFiles: string[] = SCAN_ROOTS.filter((root) => fs.existsSync(root)).flatMap(walk); +const objectiveExceptions = JSON.parse( + fs.readFileSync(path.join(APP_DIR, 'src/lib/zh-objective-guard-exceptions.json'), 'utf8'), +) as { dictionaries: DictionaryGuardException[] }; + /** * Han-carrying prose, split finely enough that a rule never sees an English * sibling as context. JSON-LD is dropped: structured data is JSON, so its quotes @@ -161,7 +178,7 @@ function chineseLinesFromSource(relative: string, source: string): Line[] { const chineseLines: Line[] = sourceFiles.flatMap((file) => { const relative = path.relative(APP_DIR, file); - if (relative === SELF) return []; + if (GUARD_SOURCES.has(relative)) return []; return chineseLinesFromSource(relative, fs.readFileSync(file, 'utf8')); }); @@ -179,7 +196,7 @@ function expectClean(violations: Violation[]): void { describe('zh copy — the tree obeys every mechanical rule', () => { const bilingualLines: Line[] = sourceFiles.flatMap((file) => { const relative = path.relative(APP_DIR, file); - if (relative === SELF || relative.includes('.test.')) return []; + if (GUARD_SOURCES.has(relative) || relative.includes('.test.')) return []; const source = fs.readFileSync(file, 'utf8'); if (!/\bzh:\s*\{/u.test(source)) return []; return source.split('\n').map((text, index) => ({ file: relative, line: index + 1, text })); @@ -269,3 +286,60 @@ describe('zh copy — coverage', () => { expect(chineseLines.length).toBeGreaterThan(1000); }); }); + +describe('zh copy — generic bilingual dictionary parity', () => { + const dictionaryFiles = sourceFiles.filter((file) => { + const relative = path.relative(APP_DIR, file); + return /\.tsx?$/u.test(relative) && !relative.includes('.test.'); + }); + + it('discovers bilingual dictionaries non-vacuously', () => { + const count = dictionaryFiles.reduce( + (total, file) => + total + + countLocaleDictionaryObjects(path.relative(APP_DIR, file), fs.readFileSync(file, 'utf8')), + 0, + ); + expect(count).toBeGreaterThan(20); + }); + + it('keeps every explicit en/zh object key shape aligned', () => { + const violations = dictionaryFiles.flatMap((file) => + findDictionaryParityViolations( + path.relative(APP_DIR, file), + fs.readFileSync(file, 'utf8'), + objectiveExceptions.dictionaries, + ), + ); + expect(violations).toEqual([]); + }); + + it('keeps every temporary dictionary exception exact and still necessary', () => { + for (const exception of objectiveExceptions.dictionaries) { + expect(exception.reason.trim()).not.toBe(''); + expect(exception.removeWhen.trim()).not.toBe(''); + const file = path.join(APP_DIR, exception.file); + const raw = findDictionaryParityViolations(exception.file, fs.readFileSync(file, 'utf8')); + expect(raw.map(dictionaryViolationFingerprint)).toContain(exception.mismatchSha256); + expect( + findDictionaryParityViolations(exception.file, fs.readFileSync(file, 'utf8'), [exception]), + ).toEqual([]); + } + }); +}); + +describe('zh copy — shared conservative scanner', () => { + it('keeps source clear of objective terminology and malformed-punctuation regressions', () => { + const excluded = new Set([ + SELF, + 'src/lib/zh-objective-guard.ts', + 'src/lib/zh-objective-guard.test.ts', + ]); + const violations = sourceFiles.flatMap((file) => { + const relative = path.relative(APP_DIR, file); + if (excluded.has(relative) || relative.includes('.test.')) return []; + return findMechanicalCopyViolations(relative, fs.readFileSync(file, 'utf8')); + }); + expect(violations).toEqual([]); + }); +}); diff --git a/packages/app/src/lib/zh-objective-guard-exceptions.json b/packages/app/src/lib/zh-objective-guard-exceptions.json new file mode 100644 index 000000000..51c029d9c --- /dev/null +++ b/packages/app/src/lib/zh-objective-guard-exceptions.json @@ -0,0 +1,368 @@ +{ + "blog": [ + { + "rule": "inline-code", + "file": "agentx-inferencexv3-does-cuda-moat.mdx", + "en": "", + "zh": "--enable-prefill-delayer", + "reason": "The baseline Chinese article wraps the SGLang flag in inline code while the English article spells it in prose. The content branch owns that editorial correction; this exact pair prevents the guard branch from copying the rewrite.", + "removeWhen": "Delete this row when both articles format --enable-prefill-delayer identically." + }, + { + "rule": "inline-code", + "file": "agentx-inferencexv3-does-cuda-moat.mdx", + "en": "", + "zh": "hipMemcpyBatchAsync", + "reason": "The baseline Chinese article wraps the ROCm API name in inline code while the English article spells it in prose. The content branch owns that editorial correction.", + "removeWhen": "Delete this row when both articles format hipMemcpyBatchAsync identically." + }, + { + "rule": "inline-code", + "file": "agentx-inferencexv3-does-cuda-moat.mdx", + "en": "", + "zh": "/btw", + "reason": "The baseline Chinese article wraps the Claude Code /btw command in inline code while the English article quotes it in prose. The content branch owns that editorial correction.", + "removeWhen": "Delete this row when both articles format /btw identically." + }, + { + "rule": "protected-token", + "file": "agentx-inferencexv3-does-cuda-moat.mdx", + "en": "--enable-prefill-delayer", + "zh": "", + "pairSha256": "6b00b67dae7c43f2165727893f07dfd13faad25b566f4429178b92a8c3221880", + "reason": "The English article carries the SGLang flag in prose where the Chinese article uses inline code, so the prose-token multisets differ by exactly this occurrence; the content branch owns the correction.", + "removeWhen": "Delete this row when both articles format --enable-prefill-delayer identically." + }, + { + "rule": "inline-code", + "file": "deepseekv4-16t-day-0-to-day-43-performance.mdx", + "en": "“mhcFusedHcLaunch: hidden_size=7168 not supported (only 4096)”", + "zh": "\"mhcFusedHcLaunch: hidden_size=7168 not supported (only 4096)\"", + "reason": "The existing translation changed only the quote glyphs inside an inline diagnostic. The content branch owns the editorial correction; this exact pair prevents the independent guard branch from copying that rewrite.", + "removeWhen": "Delete this row when the paired article uses the exact English diagnostic bytes." + }, + { + "rule": "inline-code", + "file": "gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx", + "en": "conc=3072, 28 GPU prefill, 32 GPU decode EP=16, 6,812 tok/s/GPU at 25.9 tok/s/user", + "zh": "conc=3072, 28 GPU 预填充, 32 GPU 解码 EP=16, 6,812 tok/s/GPU at 25.9 tok/s/user", + "reason": "The existing translation localized words inside inline code. The content branch owns the correction; the exception is exact on both sides and cannot hide another token change.", + "removeWhen": "Delete this row when the paired article restores the English inline-code bytes." + }, + { + "rule": "protected-token", + "file": "b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx", + "en": "", + "zh": "tok/s/user", + "pairSha256": "fa4f6dc38bd2734b93d19cb00a3ad4e2d136d318cf183e5ed90f314dcaa17368", + "reason": "The baseline Chinese article repeats this protected unit twice beyond the English article; each exact multiset occurrence remains isolated until the Blog rewrite owns the correction.", + "removeWhen": "Delete this row when one extra Chinese tok/s/user occurrence is removed." + }, + { + "rule": "protected-token", + "file": "b200-minimax-m2-5-vllm-nvfp4-vs-h100-fp8-perf-per-dollar.mdx", + "en": "", + "zh": "tok/s/user", + "reason": "The baseline Chinese article repeats this protected unit twice beyond the English article; each exact multiset occurrence remains isolated until the Blog rewrite owns the correction.", + "removeWhen": "Delete this row when the second extra Chinese tok/s/user occurrence is removed." + }, + { + "rule": "protected-token", + "file": "b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx", + "en": "", + "zh": "gpu/hr", + "pairSha256": "ea2ce6bcd6a363ae254cc2a50210894bf16aa3056d1a80d094dfe1b17fa2edc7", + "reason": "The baseline Chinese JSON-LD uses compact GPU/hr where the English source spells out per GPU per hour; the Blog rewrite branch owns the structured-data correction.", + "removeWhen": "Delete this row when one extra Chinese GPU/hr occurrence is removed from JSON-LD." + }, + { + "rule": "protected-token", + "file": "b200-glm5-nvfp4-vs-h200-fp8-3-6x-perf-per-dollar.mdx", + "en": "", + "zh": "gpu/hr", + "reason": "The baseline Chinese JSON-LD has a second compact GPU/hr occurrence where the English source spells out the unit; the Blog rewrite branch owns the correction.", + "removeWhen": "Delete this row when the second extra Chinese GPU/hr occurrence is removed from JSON-LD." + }, + { + "rule": "protected-token", + "file": "b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx", + "en": "", + "zh": "tok/s/user", + "pairSha256": "b350e97a4d3c851dd8f514caccf6643ee74c0f3a0c08f318b840806a5e1a4434", + "reason": "The baseline Chinese article has one additional protected unit occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the extra Chinese tok/s/user occurrence is removed." + }, + { + "rule": "protected-token", + "file": "b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx", + "en": "", + "zh": "gpu/hr", + "reason": "The baseline Chinese JSON-LD uses compact GPU/hr where the English source spells out per GPU per hour; the Blog rewrite branch owns the structured-data correction.", + "removeWhen": "Delete this row when one extra Chinese GPU/hr occurrence is removed from JSON-LD." + }, + { + "rule": "protected-token", + "file": "b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx", + "en": "", + "zh": "gpu/hr", + "reason": "The baseline Chinese JSON-LD has a second compact GPU/hr occurrence where the English source spells out the unit; the Blog rewrite branch owns the correction.", + "removeWhen": "Delete this row when the second extra Chinese GPU/hr occurrence is removed from JSON-LD." + }, + { + "rule": "protected-token", + "file": "b200-nvfp4-vs-h200-int4-kimi-k2-vllm-perf-per-dollar.mdx", + "en": "", + "zh": "gpu/hr", + "reason": "The baseline Chinese JSON-LD has a third compact GPU/hr occurrence where the English source spells out the unit; the Blog rewrite branch owns the correction.", + "removeWhen": "Delete this row when the third extra Chinese GPU/hr occurrence is removed from JSON-LD." + }, + { + "rule": "protected-token", + "file": "deepseekv4-16t-day-0-to-day-43-performance.mdx", + "en": "MW", + "zh": "tok/s/user", + "pairSha256": "0599d4db42ca4b155a1639609112de555b36add0ea5df11155fe9e0ed62fa6d4", + "reason": "The paired baseline articles differ by exactly one MW and one tok/s/user occurrence; the Blog rewrite branch owns that content correction.", + "removeWhen": "Delete this row when the article pair has equal protected-token multisets." + }, + { + "rule": "protected-token", + "file": "gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx", + "en": "gpu/hr", + "zh": "", + "pairSha256": "2f48e280295501b888d7bfb1b6bbef741f0827855a18dc0a3dd225059ee6d4f0", + "reason": "The baseline Chinese JSON-LD renders one GPU-hour occurrence as natural prose; the Blog rewrite branch owns the structured-data correction.", + "removeWhen": "Delete this row when one missing Chinese GPU-hour occurrence is restored in JSON-LD." + }, + { + "rule": "protected-token", + "file": "gb200-nvl72-vs-b200-disagg-deepseek-r1-fp4-dynamo-trt.mdx", + "en": "gpu/hr", + "zh": "", + "reason": "The baseline Chinese JSON-LD renders a second GPU-hour occurrence as natural prose; the Blog rewrite branch owns the structured-data correction.", + "removeWhen": "Delete this row when the second missing Chinese GPU-hour occurrence is restored in JSON-LD." + }, + { + "rule": "protected-token", + "file": "gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx", + "en": "tok/s/gpu", + "zh": "GB", + "pairSha256": "40f24e77ac9b75578c3c29d4ff9d8e354452daeb6ceab753be5b31a7e01a18a7", + "reason": "The paired baseline articles differ by one tok/s/gpu and two GB occurrences; the Blog rewrite branch owns that content correction.", + "removeWhen": "Delete this row when the tok/s/gpu and one extra GB mismatch is removed." + }, + { + "rule": "protected-token", + "file": "gb300-nvl72-vs-gb200-nvl72-dsv4-pro-vllm-fp4.mdx", + "en": "", + "zh": "GB", + "reason": "The baseline Chinese article has a second additional GB occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the second extra Chinese GB occurrence is removed." + }, + { + "rule": "protected-token", + "file": "inferencex-v2-nvidia-blackwell-vs-amd-vs-hopper.mdx", + "en": "", + "zh": "GB/s", + "pairSha256": "5ae2bcaa538c8f667f583e34b2b86cfd869f338a419e011a751c0820f143b7a9", + "reason": "The baseline Chinese article repeats GB/s five times beyond the English article; each exact multiset occurrence is isolated until its Blog rewrite lands.", + "removeWhen": "Delete this row when one extra Chinese GB/s occurrence is removed." + }, + { + "rule": "protected-token", + "file": "inferencex-v2-nvidia-blackwell-vs-amd-vs-hopper.mdx", + "en": "", + "zh": "GB/s", + "reason": "The baseline Chinese article repeats GB/s five times beyond the English article; each exact multiset occurrence is isolated until its Blog rewrite lands.", + "removeWhen": "Delete this row when a second extra Chinese GB/s occurrence is removed." + }, + { + "rule": "protected-token", + "file": "inferencex-v2-nvidia-blackwell-vs-amd-vs-hopper.mdx", + "en": "", + "zh": "GB/s", + "reason": "The baseline Chinese article repeats GB/s five times beyond the English article; each exact multiset occurrence is isolated until its Blog rewrite lands.", + "removeWhen": "Delete this row when a third extra Chinese GB/s occurrence is removed." + }, + { + "rule": "protected-token", + "file": "inferencex-v2-nvidia-blackwell-vs-amd-vs-hopper.mdx", + "en": "", + "zh": "GB/s", + "reason": "The baseline Chinese article repeats GB/s five times beyond the English article; each exact multiset occurrence is isolated until its Blog rewrite lands.", + "removeWhen": "Delete this row when a fourth extra Chinese GB/s occurrence is removed." + }, + { + "rule": "protected-token", + "file": "inferencex-v2-nvidia-blackwell-vs-amd-vs-hopper.mdx", + "en": "", + "zh": "GB/s", + "reason": "The baseline Chinese article repeats GB/s five times beyond the English article; each exact multiset occurrence is isolated until its Blog rewrite lands.", + "removeWhen": "Delete this row when the fifth extra Chinese GB/s occurrence is removed." + }, + { + "rule": "protected-token", + "file": "inferencex-v2-nvidia-blackwell-vs-amd-vs-hopper.mdx", + "en": "", + "zh": "$0.226/M", + "reason": "The baseline Chinese article has one additional numeric cost-rate occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the extra Chinese $0.226/M occurrence is removed." + }, + { + "rule": "protected-token", + "file": "inferencex-v2-nvidia-blackwell-vs-amd-vs-hopper.mdx", + "en": "", + "zh": "Gbit/s", + "reason": "The baseline Chinese article has one additional bandwidth-unit occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the extra Chinese Gbit/s occurrence is removed." + }, + { + "rule": "protected-token", + "file": "inferencex-v2-nvidia-blackwell-vs-amd-vs-hopper.mdx", + "en": "", + "zh": "GB", + "reason": "The baseline Chinese article has one additional GB occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the extra Chinese GB occurrence is removed." + }, + { + "rule": "protected-token", + "file": "mi355x-kimi-k2-5-vllm-aiter-7x-speedup.mdx", + "en": "", + "zh": "tok/s/user", + "pairSha256": "e9481ff7bc9f80193f83e2913516319fb78fa37db1f6b759cc586c5ac1d981d5", + "reason": "The baseline Chinese article has one additional protected unit occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the extra Chinese tok/s/user occurrence is removed." + }, + { + "rule": "protected-token", + "file": "mi355x-qwen3-5-sglang-v0-5-12-up-to-17x.mdx", + "en": "", + "zh": "tok/s/gpu", + "pairSha256": "60bad4c8e11f24ae822334fb2c382abc12173e77a3a8a6f47806358875a0552b", + "reason": "The baseline Chinese article has one additional protected unit occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the extra Chinese tok/s/gpu occurrence is removed." + }, + { + "rule": "protected-token", + "file": "openai-jalapeno-better-than-nvidia.mdx", + "en": "MW", + "zh": "tok/s/user", + "pairSha256": "3bde581165647beaceb8c8cac6d156f8082c6cfe914f149303d988ba83287687", + "reason": "The paired baseline articles differ by eight MW occurrences and three tok/s/user occurrences; the Blog rewrite branch owns that content correction.", + "removeWhen": "Delete this row when the article pair has equal protected-token multisets." + }, + { + "rule": "protected-token", + "file": "openai-jalapeno-better-than-nvidia.mdx", + "en": "MW", + "zh": "tok/s/user", + "reason": "The paired baseline articles differ by a second MW and tok/s/user occurrence; the Blog rewrite branch owns that content correction.", + "removeWhen": "Delete this row when the second extra MW and tok/s/user mismatch is removed." + }, + { + "rule": "protected-token", + "file": "openai-jalapeno-better-than-nvidia.mdx", + "en": "MW", + "zh": "tok/s/user", + "reason": "The paired baseline articles differ by a third MW and tok/s/user occurrence; the Blog rewrite branch owns that content correction.", + "removeWhen": "Delete this row when the third extra MW and tok/s/user mismatch is removed." + }, + { + "rule": "protected-token", + "file": "openai-jalapeno-better-than-nvidia.mdx", + "en": "MW", + "zh": "", + "reason": "The baseline Chinese article is missing a fourth English MW occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the fourth missing Chinese MW occurrence is restored." + }, + { + "rule": "protected-token", + "file": "openai-jalapeno-better-than-nvidia.mdx", + "en": "MW", + "zh": "", + "reason": "The baseline Chinese article is missing a fifth English MW occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the fifth missing Chinese MW occurrence is restored." + }, + { + "rule": "protected-token", + "file": "openai-jalapeno-better-than-nvidia.mdx", + "en": "MW", + "zh": "", + "reason": "The baseline Chinese article is missing a sixth English MW occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the sixth missing Chinese MW occurrence is restored." + }, + { + "rule": "protected-token", + "file": "openai-jalapeno-better-than-nvidia.mdx", + "en": "MW", + "zh": "", + "reason": "The baseline Chinese article is missing a seventh English MW occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the seventh missing Chinese MW occurrence is restored." + }, + { + "rule": "protected-token", + "file": "openai-jalapeno-better-than-nvidia.mdx", + "en": "MW", + "zh": "", + "reason": "The baseline Chinese article is missing an eighth English MW occurrence owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the eighth missing Chinese MW occurrence is restored." + }, + { + "rule": "protected-token", + "file": "vera-rubin-nvl72-vs-gb200-nvl72-inference.mdx", + "en": "MW", + "zh": "tok/s", + "pairSha256": "e5d3302f15aa4fd34d25f1de228e9f5a3731cf032d8394613894ced8af3ea1e7", + "reason": "The paired baseline articles differ by three MW occurrences and one tok/s occurrence; the Blog rewrite branch owns that content correction.", + "removeWhen": "Delete this row when the tok/s and one missing Chinese MW mismatch is removed." + }, + { + "rule": "protected-token", + "file": "vera-rubin-nvl72-vs-gb200-nvl72-inference.mdx", + "en": "MW", + "zh": "", + "reason": "The baseline Chinese article is missing two further MW occurrences owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when one further missing Chinese MW occurrence is restored." + }, + { + "rule": "protected-token", + "file": "vera-rubin-nvl72-vs-gb200-nvl72-inference.mdx", + "en": "MW", + "zh": "", + "reason": "The baseline Chinese article is missing two further MW occurrences owned by the Blog rewrite branch.", + "removeWhen": "Delete this row when the final missing Chinese MW occurrence is restored." + }, + { + "rule": "protected-token", + "file": "vera-rubin-nvl72-vs-gb200-nvl72-inference.mdx", + "en": "gpu/hr", + "zh": "", + "reason": "The baseline Chinese JSON-LD renders one GPU-hour occurrence as natural prose; the Blog rewrite branch owns the structured-data correction.", + "removeWhen": "Delete this row when one missing Chinese GPU-hour occurrence is restored in JSON-LD." + }, + { + "rule": "protected-token", + "file": "vera-rubin-nvl72-vs-gb200-nvl72-inference.mdx", + "en": "gpu/hr", + "zh": "", + "reason": "The baseline Chinese JSON-LD renders a second GPU-hour occurrence as natural prose; the Blog rewrite branch owns the structured-data correction.", + "removeWhen": "Delete this row when the second missing Chinese GPU-hour occurrence is restored in JSON-LD." + }, + { + "rule": "protected-token", + "file": "vera-rubin-nvl72-vs-gb200-nvl72-inference.mdx", + "en": "gpu/hr", + "zh": "", + "reason": "The baseline Chinese JSON-LD renders a third GPU-hour occurrence as natural prose; the Blog rewrite branch owns the structured-data correction.", + "removeWhen": "Delete this row when the third missing Chinese GPU-hour occurrence is restored in JSON-LD." + } + ], + "dictionaries": [ + { + "file": "src/components/collectivex/CollectiveXDisplay.tsx", + "mismatchSha256": "8bcba1419a24503eb8188c8cb12bf6aa27b4f3e094c96a987ecf4cc394be634e", + "reason": "The baseline CollectiveX Chinese dictionary contains 73 Chinese-only keys from an unfinished page localization. The data-tools branch owns that rewrite. The digest fingerprints the exact missing-key set, so any added, removed, or renamed mismatch still fails.", + "removeWhen": "Delete this row when the CollectiveX en/zh dictionary has structural key parity." + } + ] +} diff --git a/packages/app/src/lib/zh-objective-guard.test.ts b/packages/app/src/lib/zh-objective-guard.test.ts new file mode 100644 index 000000000..443014ba6 --- /dev/null +++ b/packages/app/src/lib/zh-objective-guard.test.ts @@ -0,0 +1,695 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + compareBlogPair, + compareEnglishSurfaces, + dictionaryViolationFingerprint, + findBlogPairViolations, + findDictionaryParityViolations, + findMechanicalCopyViolations, + findRoutePairViolations, + type BlogGuardException, + type DictionaryGuardException, +} from './zh-objective-guard'; + +const CHINESE_ONLY_SCRIPT = path.resolve( + import.meta.dirname, + '..', + '..', + 'scripts', + 'check-zh-chinese-only.ts', +); + +function fixtureGit(directory: string, ...args: string[]): string { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); +} + +function fixtureCommit(directory: string, message: string): string { + fixtureGit(directory, 'add', '.'); + fixtureGit(directory, 'commit', '--no-verify', '-m', `test: ${message}`); + return fixtureGit(directory, 'rev-parse', 'HEAD'); +} + +function runChineseOnlyGuard(directory: string, base: string, head: string): string { + return execFileSync('bun', ['run', '--cwd', 'packages/app', 'guard:zh-copy:chinese-only'], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, ZH_GUARD_BASE_SHA: base, ZH_GUARD_HEAD_SHA: head }, + }); +} + +function fixtureRepository(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'zh-objective-guard-')); + fixtureGit(directory, 'init', '-q'); + fixtureGit(directory, 'config', 'user.name', 'Guard Test'); + fixtureGit(directory, 'config', 'user.email', 'guard@example.com'); + fs.mkdirSync(path.join(directory, 'packages/app/src'), { recursive: true }); + fs.writeFileSync( + path.join(directory, 'packages/app/package.json'), + `${JSON.stringify( + { + scripts: { + 'guard:zh-copy:chinese-only': `bun ${JSON.stringify(CHINESE_ONLY_SCRIPT)} --chinese-only`, + }, + }, + null, + 2, + )}\n`, + ); + return directory; +} + +function pairSha256(en: string, zh: string): string { + return createHash('sha256').update(JSON.stringify({ en, zh })).digest('hex'); +} + +describe('objective Chinese guard mutations', () => { + describe('route siblings', () => { + const complete = [ + 'src/app/(landing)/page.tsx', + 'src/app/zh/page.tsx', + 'src/app/(dashboard)/feedback/page.tsx', + 'src/app/zh/(dashboard)/feedback/page.tsx', + 'src/app/agentx/[slug]/page.tsx', + 'src/app/zh/agentx/[slug]/page.tsx', + ]; + + it('rejects a missing Chinese page and accepts a complete dynamic/gated route tree', () => { + expect(findRoutePairViolations(complete)).toEqual([]); + expect( + findRoutePairViolations(complete.filter((file) => !file.includes('zh/agentx'))), + ).toEqual([expect.objectContaining({ rule: 'route-sibling', route: '/agentx/[slug]' })]); + }); + + it('rejects an orphan Chinese page', () => { + expect(findRoutePairViolations([...complete, 'src/app/zh/orphan/page.tsx'])).toEqual([ + expect.objectContaining({ rule: 'route-sibling', route: '/orphan' }), + ]); + }); + }); + + describe('generic en/zh dictionaries', () => { + const accepted = ` + const COPY = { + en: { title: 'Title', nested: { retry: 'Retry' } }, + zh: { title: '标题', nested: { retry: '重试' } }, + } as const; + `; + + it('rejects a missing nested key and accepts equal structural keys', () => { + expect(findDictionaryParityViolations('copy.ts', accepted)).toEqual([]); + expect( + findDictionaryParityViolations( + 'copy.ts', + accepted.replace("retry: '重试'", "again: '重试'"), + ), + ).toEqual([ + expect.objectContaining({ + rule: 'dictionary-key-parity', + missingFromEn: ['nested.again'], + missingFromZh: ['nested.retry'], + }), + ]); + }); + + it('rejects removal of an entire locale dictionary', () => { + expect( + findDictionaryParityViolations('copy.ts', `const COPY = { en: { title: 'Title' } };`), + ).toEqual([expect.objectContaining({ rule: 'dictionary-locale-pair' })]); + }); + + it('pairs each containing object independently without file-global cancellation', () => { + const source = ` + const FIRST = { "en" : { title: 'Title' } }; + const SECOND = { 'zh' : { title: '标题' } }; + `; + expect(findDictionaryParityViolations('copy.ts', source)).toEqual([ + expect.objectContaining({ rule: 'dictionary-locale-pair' }), + expect.objectContaining({ rule: 'dictionary-locale-pair' }), + ]); + }); + + it('accepts whitespace and quoted locale keys when both siblings exist', () => { + expect( + findDictionaryParityViolations( + 'copy.ts', + `const COPY = { "en" : { title: 'Title' }, 'zh' : { title: '标题' } };`, + ), + ).toEqual([]); + }); + + it('accepts computed and spread-backed dictionaries it cannot prove structurally', () => { + expect( + findDictionaryParityViolations( + 'dynamic.ts', + `const COPY = { en: { ...common, [key]: 'x' }, zh: { ...common, [key]: '中' } };`, + ), + ).toEqual([]); + }); + + it('includes static shorthand properties in each locale shape', () => { + expect( + findDictionaryParityViolations( + 'shorthand.ts', + `const COPY = { en: { title }, zh: { heading } };`, + ), + ).toEqual([ + expect.objectContaining({ + rule: 'dictionary-key-parity', + missingFromEn: ['heading'], + missingFromZh: ['title'], + }), + ]); + }); + + it('accepts only an exact temporary mismatch fingerprint', () => { + const mismatch = accepted.replace("retry: '重试'", "again: '重试'"); + const [violation] = findDictionaryParityViolations('copy.ts', mismatch); + const exception: DictionaryGuardException = { + file: 'copy.ts', + mismatchSha256: dictionaryViolationFingerprint(violation), + reason: 'Owned by another independent page branch.', + removeWhen: 'Delete when that branch aligns the dictionary.', + }; + expect(findDictionaryParityViolations('copy.ts', mismatch, [exception])).toEqual([]); + expect( + findDictionaryParityViolations( + 'copy.ts', + mismatch.replace("title: '标题'", "heading: '标题'"), + [exception], + ), + ).toHaveLength(1); + }); + + it('consumes one dictionary exception for only one matching object', () => { + const mismatch = `const A = { en: { title: 'A' }, zh: { heading: '甲' } }; + const B = { en: { title: 'B' }, zh: { heading: '乙' } };`; + const [violation] = findDictionaryParityViolations('copy.ts', mismatch); + const exception: DictionaryGuardException = { + file: 'copy.ts', + mismatchSha256: dictionaryViolationFingerprint(violation), + reason: 'One exact legacy object.', + removeWhen: 'Delete when that object is aligned.', + }; + expect(findDictionaryParityViolations('copy.ts', mismatch, [exception])).toHaveLength(1); + }); + }); + + describe('explicit Chinese-only English preservation', () => { + const base = `const COPY = { en: { title: 'Exact English' }, zh: { title: '旧文案' } };`; + + it('accepts a Chinese subtree edit and rejects one changed English byte', () => { + expect(compareEnglishSurfaces('src/copy.ts', base, base.replace('旧文案', '新文案'))).toEqual( + [], + ); + expect( + compareEnglishSurfaces('src/copy.ts', base, base.replace('Exact English', 'Exact english')), + ).toEqual([expect.objectContaining({ rule: 'english-byte-preservation' })]); + }); + + it('protects an English MDX file byte-for-byte but ignores tests and locale plumbing', () => { + expect( + compareEnglishSurfaces('content/blog/post.mdx', '# Exact\n', '# Changed\n'), + ).toHaveLength(1); + expect(compareEnglishSurfaces('src/copy.test.ts', base, 'anything')).toEqual([]); + expect( + compareEnglishSurfaces('src/plumbing.ts', 'export const x = 1;', 'export const x = 2;'), + ).toEqual([]); + expect( + compareEnglishSurfaces( + 'src/lib/zh-objective-guard-exceptions.json', + `{"en":"MW"}`, + `{"en":"GB"}`, + ), + ).toEqual([]); + }); + + it('compares raw JSON en subtree bytes instead of parsed values', () => { + const baseJson = `{"en":{"title":"Exact"},"zh":{"title":"旧"}}`; + expect( + compareEnglishSurfaces('src/copy.json', baseJson, baseJson.replace('旧', '新')), + ).toEqual([]); + expect( + compareEnglishSurfaces( + 'src/copy.json', + baseJson, + baseJson.replace('{"title":"Exact"}', '{ "title":"Exact" }'), + ), + ).toHaveLength(1); + }); + }); + + describe('paired blog invariants', () => { + const en = `--- +title: English +date: '2026-08-23' +publishDate: '2026-08-24' +tags: + - benchmark +--- +[Article](/blog/next) and [section](#english-heading). +PUBLIC_MODEL_ID remains stable. + +\`--tensor-parallel-size=8\` uses \`tok/s/user\`. + +\`\`\`bash +export MODEL_ID=DeepSeek-V4 +\`\`\` + +$$ +x = 8 +$$ + +
+ +{\`{ + "@context": "https://schema.org", + "@type": "TechArticle", + "url": "https://inferencex.com/blog/post", + "position": 8, + "name": "English" +}\`} +`; + const zh = en + .replace('title: English', 'title: 中文') + .replace('[Article](/blog/next)', '[文章](/zh/blog/next)') + .replace('[section](#english-heading)', '[章节](#中文标题)') + .replace(' uses ', ' 使用 ') + .replace('caption="Chart"', 'caption="图表"') + .replace('https://inferencex.com/blog/post', 'https://inferencex.com/zh/blog/post') + .replace('"name": "English"', '"name": "中文"'); + + it('accepts translated prose and documented localized links', () => { + expect(compareBlogPair('post.mdx', en, zh, [])).toEqual([]); + }); + + it('protects MDX component href and src props while allowing /zh localization', () => { + const enMdx = [ + 'Open', + 'Read', + ].join('\n'); + const zhMdx = enMdx + .replace('/blog/next', '/zh/blog/next') + .replace('>Open<', '>打开<') + .replace('>Read<', '>阅读<'); + expect(compareBlogPair('props.mdx', enMdx, zhMdx, [])).toEqual([]); + expect( + compareBlogPair( + 'props.mdx', + enMdx, + zhMdx.replace('https://example.com/reference', 'https://example.com/other'), + [], + ), + ).toContainEqual(expect.objectContaining({ rule: 'link-target' })); + }); + + it('protects static template-literal link props without treating them as inline code', () => { + for (const expression of [ + 'score > 0', + 'score /* } */ > 0', + '/}/.test(score) && score > 0', + 'metrics.return / total > 0', + '[.../}/.exec(score)].length > 0', + String.raw`/[}\]]/.test(score) && score > 0`, + ['`outer $', '{`inner }`}`.length > 0'].join(''), + 'ok', + '() => ok', + '<>ok', + '{score > 0 ? "yes" : "no"}', + 'items.map((item) => {item})', + ]) { + const enMdx = `Open`; + const zhMdx = `打开`; + expect(compareBlogPair('template-prop.mdx', enMdx, zhMdx, [])).toEqual([]); + expect( + compareBlogPair('template-prop.mdx', enMdx, zhMdx.replace('/next', '/wrong'), []), + ).toContainEqual(expect.objectContaining({ rule: 'link-target' })); + } + }); + + it('parses nested, angle, and reference Markdown destinations completely', () => { + const enMdx = [ + '[nested](https://example.com/a_(same)/one)', + '[angle]()', + '[reference][destination]', + '', + '[destination]: https://example.com/reference-one', + ].join('\n'); + expect(compareBlogPair('links.mdx', enMdx, enMdx, [])).toEqual([]); + for (const [kind, mutated] of [ + ['nested', enMdx.replace('/one)', '/two)')], + ['angle', enMdx.replace('/one>)', '/two>)')], + ['reference', enMdx.replace('reference-one', 'reference-two')], + ]) { + expect(compareBlogPair('links.mdx', enMdx, mutated, []), kind).toContainEqual( + expect.objectContaining({ rule: 'link-target' }), + ); + } + }); + + it('protects legal tilde fences and inline code with arbitrary backtick delimiters', () => { + const enMdx = ['~~~bash', 'echo exact', '~~~', '', 'Use `` `literal` exact value ``.'].join( + '\n', + ); + expect(compareBlogPair('delimiters.mdx', enMdx, enMdx, [])).toEqual([]); + expect( + compareBlogPair('delimiters.mdx', enMdx, enMdx.replace('echo exact', 'echo changed'), []), + ).toContainEqual(expect.objectContaining({ rule: 'fenced-code' })); + expect( + compareBlogPair('delimiters.mdx', enMdx, enMdx.replace('exact value', 'changed value'), []), + ).toContainEqual(expect.objectContaining({ rule: 'inline-code' })); + }); + + it('compares normalized protected tokens bidirectionally with multiplicity', () => { + const enMdx = + 'Peak is 100 TFLOP/s at 2 GPU/hr. PUBLIC_MODEL_ID PUBLIC_MODEL_ID uses --safe-flag.'; + const zhMdx = + '峰值为 100 TFLOP/s,成本按 2 GPU/hour 计。PUBLIC_MODEL_ID PUBLIC_MODEL_ID 使用 --safe-flag。'; + expect(compareBlogPair('tokens.mdx', enMdx, zhMdx, [])).toEqual([]); + expect( + compareBlogPair( + 'tokens.mdx', + enMdx, + zhMdx.replace('PUBLIC_MODEL_ID PUBLIC_MODEL_ID', 'PUBLIC_MODEL_ID'), + [], + ), + ).toContainEqual(expect.objectContaining({ rule: 'protected-token' })); + expect(compareBlogPair('tokens.mdx', enMdx, `${zhMdx} EXTRA_MODEL_ID`, [])).toContainEqual( + expect.objectContaining({ rule: 'protected-token' }), + ); + expect( + compareBlogPair('tokens.mdx', enMdx, zhMdx.replace('TFLOP/s', 'PFLOP/s'), []), + ).toContainEqual(expect.objectContaining({ rule: 'protected-token' })); + expect(compareBlogPair('tokens.mdx', enMdx, `${zhMdx} --additional-flag`, [])).toContainEqual( + expect.objectContaining({ rule: 'protected-token' }), + ); + }); + + it('protects real throughput, bandwidth, power, and numeric cost-rate forms', () => { + const enMdx = + 'Rate: 8 tok/sec/user, 400 Gbit/s, 2 kW/GPU, $1.35/M, 3 GPU-hour, and 4 chip-hour.'; + const zhMdx = '速率:8 tok/s/user、400 Gbit/s、2 kW/gpu、$1.35/M、3 GPU/hr 和 4 chip/hr。'; + expect(compareBlogPair('real-units.mdx', enMdx, zhMdx, [])).toEqual([]); + for (const mutated of [ + zhMdx.replace('tok/s/user', 'tok/s/gpu'), + zhMdx.replace('Gbit/s', 'GB/s'), + zhMdx.replace('kW/gpu', 'MW/gpu'), + zhMdx.replace('$1.35/M', '$1.53/M'), + zhMdx.replace('GPU/hr', 'GPU/day'), + zhMdx.replace('chip/hr', 'chip/day'), + ]) { + expect(compareBlogPair('real-units.mdx', enMdx, mutated, [])).toContainEqual( + expect.objectContaining({ rule: 'protected-token' }), + ); + } + }); + + it('protects slash and hyphen GPU-hour forms inside translatable JSON-LD prose', () => { + const enMdx = '{`{"text":"Cost per GPU/hr and chip-hour"}`}'; + const zhMdx = '{`{"text":"成本按 GPU-hour 和 chip/hr 计"}`}'; + expect(compareBlogPair('json-units.mdx', enMdx, zhMdx, [])).toEqual([]); + for (const mutated of [ + zhMdx.replace('GPU-hour', 'GPU/day'), + zhMdx.replace('chip/hr', 'chip/day'), + ]) { + expect(compareBlogPair('json-units.mdx', enMdx, mutated, [])).toContainEqual( + expect.objectContaining({ rule: 'protected-token' }), + ); + } + }); + + it('normalizes GPU-hour cost-rate forms before normalizing the GPU casing', () => { + const enMdx = 'TCO is $/GPU-hour.'; + const zhMdx = 'TCO 为 $/GPU/hr。'; + expect(compareBlogPair('cost-unit.mdx', enMdx, zhMdx, [])).toEqual([]); + expect( + compareBlogPair('cost-unit.mdx', enMdx, zhMdx.replace('GPU/hr', 'GPU/day'), []), + ).toContainEqual(expect.objectContaining({ rule: 'protected-token' })); + }); + + it('accepts only an exact protected-token baseline exception', () => { + const enMdx = 'Capacity is 2 GPU/hr plus 1 GPU/hr.'; + const zhMdx = '容量按 2 GPU/hour 计。'; + const exception: BlogGuardException = { + rule: 'protected-token', + file: 'tokens.mdx', + en: 'gpu/hr', + zh: '', + pairSha256: pairSha256(enMdx, zhMdx), + reason: 'Temporary exact baseline mismatch.', + removeWhen: 'Delete when the missing unit occurrence is restored.', + }; + expect(compareBlogPair('tokens.mdx', enMdx, zhMdx, [])).toContainEqual( + expect.objectContaining({ rule: 'protected-token' }), + ); + expect(compareBlogPair('tokens.mdx', enMdx, zhMdx, [exception])).toEqual([]); + expect(compareBlogPair('tokens.mdx', enMdx, `${zhMdx} EXTRA_ID`, [exception])).toContainEqual( + expect.objectContaining({ rule: 'protected-token' }), + ); + }); + + it('cannot migrate a protected-token exception to a different source occurrence', () => { + const enMdx = 'A MW and B MW.'; + const zhMdx = 'A MW。'; + const exception = { + rule: 'protected-token', + file: 'anchored-token.mdx', + en: 'MW', + zh: '', + pairSha256: pairSha256(enMdx, zhMdx), + reason: 'Temporary exact source snapshot.', + removeWhen: 'Delete when the missing MW is restored.', + } as BlogGuardException; + expect(compareBlogPair('anchored-token.mdx', enMdx, zhMdx, [exception])).toEqual([]); + expect(compareBlogPair('anchored-token.mdx', enMdx, 'B MW。', [exception])).toContainEqual( + expect.objectContaining({ rule: 'protected-token' }), + ); + }); + + it('allows JSON-LD prose translation with multiline wrapper whitespace', () => { + const enMdx = [ + '', + ' {`{"@type":"TechArticle","name":"English"}`}', + '', + ].join('\n'); + const zhMdx = enMdx.replace('English', '中文'); + expect(compareBlogPair('json-whitespace.mdx', enMdx, zhMdx, [])).toEqual([]); + }); + + it('preserves JSON-LD protected values at their exact object and array paths', () => { + const enMdx = `{\`{"@type":"ItemList","items":[{"url":"https://example.com/one","position":1},{"url":"https://example.com/two","position":2}]}\`}`; + const zhMdx = enMdx + .replace('https://example.com/one', 'https://example.com/TEMP') + .replace('https://example.com/two', 'https://example.com/one') + .replace('https://example.com/TEMP', 'https://example.com/two'); + expect(compareBlogPair('json-paths.mdx', enMdx, zhMdx, [])).toContainEqual( + expect.objectContaining({ rule: 'json-ld-protected-value' }), + ); + }); + + it('rejects missing and orphan Blog siblings in both directions', () => { + expect(findBlogPairViolations(['one.mdx'], ['one.mdx'])).toEqual([]); + expect(findBlogPairViolations(['one.mdx', 'two.mdx'], ['one.mdx', 'orphan.mdx'])).toEqual([ + expect.objectContaining({ rule: 'blog-sibling', file: 'orphan.mdx' }), + expect.objectContaining({ rule: 'blog-sibling', file: 'two.mdx' }), + ]); + }); + + it.each([ + ['fenced-code', 'DeepSeek-V4', 'DeepSeek-V4-Pro'], + ['math', 'x = 8', 'x = 9'], + ['figure-src', '/images/chart.png', '/images/other.png'], + ['inline-code', '--tensor-parallel-size=8', '--tensor-parallel-size=4'], + ['protected-token', 'PUBLIC_MODEL_ID remains', 'PUBLIC_MODEL_NAME remains'], + ['link-target', '/zh/blog/next', 'https://example.com/next'], + ['json-ld-shape', '"position": 8', '"rank": 8'], + ['json-ld-protected-value', '"position": 8', '"position": 9'], + ['json-ld-syntax', '"position": 8', '"position":'], + ])('rejects the %s mutation', (rule, before, after) => { + expect(compareBlogPair('post.mdx', en, zh.replace(before, after), [])).toContainEqual( + expect.objectContaining({ rule }), + ); + }); + + it('requires each protected-inline exception to match both exact sides', () => { + const changedZh = zh.replace('`tok/s/user`', '`token/秒/用户`'); + const exception: BlogGuardException = { + rule: 'inline-code', + file: 'post.mdx', + en: 'tok/s/user', + zh: 'token/秒/用户', + reason: 'Temporary migration example.', + removeWhen: 'Delete after the article rewrite lands.', + }; + expect(compareBlogPair('post.mdx', en, changedZh, [exception])).toEqual([]); + expect( + compareBlogPair('post.mdx', en, changedZh.replace('token/秒/用户', '令牌/秒'), [exception]), + ).toContainEqual(expect.objectContaining({ rule: 'inline-code' })); + }); + }); + + describe('conservative terminology and punctuation', () => { + it.each([ + ['chip-untranslated', '每颗 Chip 的吞吐量', '每颗芯片的吞吐量'], + ['duplicated-technical-loanword', '执行 warmup 预热', '执行 warmup'], + ['malformed-chinese-punctuation', '这里有空格 。', '这里没有空格。'], + [ + 'hardcoded-english-label', + "const COPY = { zh: {} }; const x = 'Concurrency:';", + String.raw`const COPY = { zh: {} }; const x = '\${t.concurrency}:';`, + ], + ])('rejects %s and accepts its objective fix', (rule, before, after) => { + expect(findMechanicalCopyViolations('copy.ts', before)).toContainEqual( + expect.objectContaining({ rule }), + ); + expect(findMechanicalCopyViolations('copy.ts', after)).toEqual([]); + }); + + it('keeps markdown link destinations exempt in MDX prose and attributes', () => { + const source = [ + '今年 6 月,[OpenAI 公布了这项计划](https://example.com/inference-chip/)。', + '
', + ].join('\n'); + expect(findMechanicalCopyViolations('content/blog/zh/post.mdx', source)).toEqual([]); + expect( + findMechanicalCopyViolations('content/blog/zh/post.mdx', '这颗 chip 尚未翻译。'), + ).toContainEqual(expect.objectContaining({ rule: 'chip-untranslated' })); + }); + + it('keeps units, identifiers, Chinese-first explanations, and valid punctuation exempt', () => { + expect( + findMechanicalCopyViolations( + 'copy.ts', + '吞吐量为 tok/s/chip,ChipSKU 保持原样;预热(warmup)用于解释概念。', + ), + ).toEqual([]); + }); + + it('keeps acronym and unit hardcoded-label exemptions', () => { + expect( + findMechanicalCopyViolations( + 'copy.tsx', + `const COPY = { zh: {} }; return
TP:tok/s/MW:
;`, + ), + ).toEqual([]); + }); + + it('scans JSX literals and visible prose independently', () => { + const source = `const label = '属性正常。'; export const View = () =>

可见文案 。

;`; + expect(findMechanicalCopyViolations('copy.tsx', source)).toContainEqual( + expect.objectContaining({ rule: 'malformed-chinese-punctuation' }), + ); + }); + + it('scans MDX attributes and visible prose independently', () => { + const source = `可见文案 。`; + expect(findMechanicalCopyViolations('copy.mdx', source)).toContainEqual( + expect.objectContaining({ rule: 'malformed-chinese-punctuation' }), + ); + }); + + it('keeps nested JSON-LD examples inside fenced code out of mechanical prose scans', () => { + const source = [ + '```mdx', + '{`{"name":"示例"}`}', + '后续中文 Chip', + '```', + ].join('\n'); + expect(findMechanicalCopyViolations('copy.mdx', source)).toEqual([]); + }); + }); +}); + +describe('Chinese-only CLI integration', () => { + it('uses the repo-root app pathspec and detects one changed English byte', () => { + const directory = fixtureRepository(); + try { + const file = path.join(directory, 'packages/app/src/copy.ts'); + fs.writeFileSync( + file, + `export const COPY = { en: { title: 'Exact' }, zh: { title: '旧' } };\n`, + ); + const base = fixtureCommit(directory, 'base'); + fs.writeFileSync( + file, + `export const COPY = { en: { title: 'Exact' }, zh: { title: '新' } };\n`, + ); + const zhHead = fixtureCommit(directory, 'zh'); + expect(runChineseOnlyGuard(directory, base, zhHead)).toContain( + 'passed for 1 changed app file(s)', + ); + + fs.writeFileSync( + file, + `export const COPY = { en: { title: 'exact' }, zh: { title: '新' } };\n`, + ); + const enHead = fixtureCommit(directory, 'en'); + expect(() => runChineseOnlyGuard(directory, base, enHead)).toThrow(/copy\.ts/u); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('maps rename sources from the base path to the head path', () => { + const directory = fixtureRepository(); + try { + const original = path.join(directory, 'packages/app/src/original.ts'); + const renamed = path.join(directory, 'packages/app/src/renamed.ts'); + fs.writeFileSync( + original, + [ + `export const COPY = {`, + ` en: { title: 'Exact', description: 'Stable English description' },`, + ` zh: { title: '旧', description: '稳定中文说明' },`, + `};`, + '', + ].join('\n'), + ); + const base = fixtureCommit(directory, 'base'); + fs.renameSync(original, renamed); + const renamedHead = fixtureCommit(directory, 'rename'); + expect(runChineseOnlyGuard(directory, base, renamedHead)).toContain( + 'passed for 1 changed app file(s)', + ); + + fs.writeFileSync( + renamed, + [ + `export const COPY = {`, + ` en: { title: 'Changed', description: 'Stable English description' },`, + ` zh: { title: '旧', description: '稳定中文说明' },`, + `};`, + '', + ].join('\n'), + ); + const changedHead = fixtureCommit(directory, 'change'); + expect(() => runChineseOnlyGuard(directory, base, changedHead)).toThrow(/renamed\.ts/u); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('inspects a Git type change instead of dropping it from the diff', () => { + const directory = fixtureRepository(); + try { + const file = path.join(directory, 'packages/app/src/copy.ts'); + fs.writeFileSync( + file, + `export const COPY = { en: { title: 'Exact' }, zh: { title: '旧' } };\n`, + ); + const base = fixtureCommit(directory, 'base'); + fs.rmSync(file); + fs.symlinkSync('different-target.ts', file); + const head = fixtureCommit(directory, 'type-change'); + expect(() => runChineseOnlyGuard(directory, base, head)).toThrow(/copy\.ts/u); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/app/src/lib/zh-objective-guard.ts b/packages/app/src/lib/zh-objective-guard.ts new file mode 100644 index 000000000..11eba5043 --- /dev/null +++ b/packages/app/src/lib/zh-objective-guard.ts @@ -0,0 +1,855 @@ +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +import { Parser } from 'acorn'; +import jsx from 'acorn-jsx'; +import ts from 'typescript'; + +export interface GuardViolation { + readonly rule: string; + readonly file?: string; + readonly route?: string; + readonly detail?: string; + readonly missingFromEn?: readonly string[]; + readonly missingFromZh?: readonly string[]; +} + +interface BlogGuardExceptionBase { + readonly file: string; + readonly en: string; + readonly zh: string; + readonly reason: string; + readonly removeWhen: string; +} + +export type BlogGuardException = + | (BlogGuardExceptionBase & { readonly rule: 'inline-code' }) + | (BlogGuardExceptionBase & { + readonly rule: 'protected-token'; + readonly pairSha256?: string; + }); + +export interface DictionaryGuardException { + readonly file: string; + readonly mismatchSha256: string; + readonly reason: string; + readonly removeWhen: string; +} + +const HAN = /\p{Script=Han}/u; +const STRING_LITERAL = /'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`/gsu; +const CHIP_UNIT = /(?:tok|tokens?)\/s\/chip|\$\/chip[/-](?:hr|hour)|[A-Za-z]Chip\b|\bChip[A-Z]/giu; +const MDX_EXPRESSION_PARSER = Parser.extend(jsx()); + +function normalizedFile(file: string): string { + return file.split(path.sep).join('/'); +} + +function pageRoute(file: string): { locale: 'en' | 'zh'; route: string } | null { + const normalized = normalizedFile(file); + const marker = 'src/app/'; + const markerIndex = normalized.indexOf(marker); + if (markerIndex === -1 || !normalized.endsWith('/page.tsx')) return null; + + let relative = normalized.slice(markerIndex + marker.length, -'/page.tsx'.length); + const locale = relative === 'zh' || relative.startsWith('zh/') ? 'zh' : 'en'; + if (locale === 'zh') relative = relative === 'zh' ? '' : relative.slice(3); + const segments = relative.split('/').filter((segment) => segment && !/^\(.+\)$/u.test(segment)); + return { locale, route: segments.length === 0 ? '/' : `/${segments.join('/')}` }; +} + +/** Compare real App Router page files after removing route groups. */ +export function findRoutePairViolations( + pageFiles: readonly string[], + exemptRoutes: ReadonlySet = new Set(), +): GuardViolation[] { + const en = new Set(); + const zh = new Set(); + for (const file of pageFiles) { + const page = pageRoute(file); + if (!page || exemptRoutes.has(page.route)) continue; + (page.locale === 'zh' ? zh : en).add(page.route); + } + + return [ + ...[...en] + .filter((route) => !zh.has(route)) + .map((route) => ({ + rule: 'route-sibling', + route, + detail: 'missing Simplified Chinese page', + })), + ...[...zh] + .filter((route) => !en.has(route)) + .map((route) => ({ rule: 'route-sibling', route, detail: 'orphan Simplified Chinese page' })), + ].sort((a, b) => (a.route ?? '').localeCompare(b.route ?? '')); +} + +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isParenthesizedExpression(current) || + ts.isTypeAssertionExpression(current) + ) { + current = current.expression; + } + return current; +} + +function propertyName(name: ts.PropertyName | undefined): string | null { + if (!name || ts.isComputedPropertyName(name)) return null; + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +function propertyInitializer( + object: ts.ObjectLiteralExpression, + key: string, +): ts.Expression | null { + for (const property of object.properties) { + if (!ts.isPropertyAssignment(property) || propertyName(property.name) !== key) continue; + return unwrapExpression(property.initializer); + } + return null; +} + +function structuralPaths(expression: ts.Expression, prefix = ''): Set { + const node = unwrapExpression(expression); + const paths = new Set(); + if (ts.isObjectLiteralExpression(node)) { + for (const property of node.properties) { + const key = + ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property) + ? propertyName(property.name) + : null; + if (!key) continue; + const next = prefix ? `${prefix}.${key}` : key; + paths.add(next); + if (ts.isPropertyAssignment(property)) { + for (const nested of structuralPaths(property.initializer, next)) paths.add(nested); + } + } + } else if (ts.isArrayLiteralExpression(node)) { + for (const element of node.elements) { + if (ts.isSpreadElement(element)) continue; + for (const nested of structuralPaths(element, prefix ? `${prefix}[]` : '[]')) + paths.add(nested); + } + } + return paths; +} + +function parseSource(file: string, source: string): ts.SourceFile { + if (file.endsWith('.json')) return ts.parseJsonText(file, source); + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); +} + +/** Find paired object-literal `en` / `zh` dictionaries and compare their explicit key shape. */ +export function dictionaryViolationFingerprint(violation: GuardViolation): string { + return createHash('sha256') + .update( + JSON.stringify({ + missingFromEn: violation.missingFromEn ?? [], + missingFromZh: violation.missingFromZh ?? [], + }), + ) + .digest('hex'); +} + +export function findDictionaryParityViolations( + file: string, + source: string, + exceptions: readonly DictionaryGuardException[] = [], +): GuardViolation[] { + const sourceFile = parseSource(file, source); + const violations: GuardViolation[] = []; + const visit = (node: ts.Node): void => { + if (ts.isObjectLiteralExpression(node)) { + const en = propertyInitializer(node, 'en'); + const zh = propertyInitializer(node, 'zh'); + const enObject = en && ts.isObjectLiteralExpression(en) ? en : null; + const zhObject = zh && ts.isObjectLiteralExpression(zh) ? zh : null; + if (enObject || zhObject) { + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; + if (!enObject || !zhObject) { + violations.push({ + rule: 'dictionary-locale-pair', + file: `${file}:${line}`, + detail: enObject + ? '`en` object has no `zh` object sibling' + : '`zh` object has no `en` object sibling', + }); + } else { + const enPaths = structuralPaths(enObject); + const zhPaths = structuralPaths(zhObject); + const missingFromEn = [...zhPaths].filter((key) => !enPaths.has(key)).sort(); + const missingFromZh = [...enPaths].filter((key) => !zhPaths.has(key)).sort(); + if (missingFromEn.length > 0 || missingFromZh.length > 0) { + violations.push({ + rule: 'dictionary-key-parity', + file: `${file}:${line}`, + missingFromEn, + missingFromZh, + }); + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + const remainingExceptions = [...exceptions]; + return violations.filter((violation) => { + const index = remainingExceptions.findIndex( + (exception) => + normalizedFile(file).endsWith(exception.file) && + dictionaryViolationFingerprint(violation) === exception.mismatchSha256, + ); + if (index === -1) return true; + remainingExceptions.splice(index, 1); + return false; + }); +} + +/** Count object literals that contain at least one statically declared locale object. */ +export function countLocaleDictionaryObjects(file: string, source: string): number { + const sourceFile = parseSource(file, source); + let count = 0; + const visit = (node: ts.Node): void => { + if (ts.isObjectLiteralExpression(node)) { + const en = propertyInitializer(node, 'en'); + const zh = propertyInitializer(node, 'zh'); + if ((en && ts.isObjectLiteralExpression(en)) || (zh && ts.isObjectLiteralExpression(zh))) { + count += 1; + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return count; +} + +function englishSubtrees(file: string, source: string): string[] { + const sourceFile = parseSource(file, source); + const found: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isPropertyAssignment(node) && propertyName(node.name) === 'en') { + found.push(node.initializer.getText(sourceFile)); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found.sort(); +} + +function isEnglishSurfaceFile(file: string): boolean { + const normalized = normalizedFile(file); + if (normalized.endsWith('src/lib/zh-objective-guard-exceptions.json')) return false; + if (/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(normalized) || normalized.includes('/cypress/')) { + return false; + } + if (normalized.includes('content/blog/zh/')) return false; + if (normalized.includes('content/blog/') && normalized.endsWith('.mdx')) return true; + return /\.(?:[cm]?[jt]sx?|json)$/u.test(normalized); +} + +/** + * Explicit Chinese-only mode protects only provable English surfaces: complete English MDX files + * and raw `en` property initializers. Locale-neutral plumbing is deliberately outside this check. + */ +export function compareEnglishSurfaces( + file: string, + baseSource: string, + headSource: string, +): GuardViolation[] { + if (!isEnglishSurfaceFile(file)) return []; + const normalized = normalizedFile(file); + if (normalized.includes('content/blog/') && normalized.endsWith('.mdx')) { + return baseSource === headSource + ? [] + : [{ rule: 'english-byte-preservation', file, detail: 'English MDX bytes changed' }]; + } + const base = englishSubtrees(file, baseSource); + const head = englishSubtrees(file, headSource); + return JSON.stringify(base) === JSON.stringify(head) + ? [] + : [{ rule: 'english-byte-preservation', file, detail: '`en` subtree bytes changed' }]; +} + +function multiset(values: readonly string[]): Map { + const result = new Map(); + for (const value of values) result.set(value, (result.get(value) ?? 0) + 1); + return result; +} + +function multisetsEqual(left: readonly string[], right: readonly string[]): boolean { + const a = multiset(left); + const b = multiset(right); + return a.size === b.size && [...a].every(([key, count]) => b.get(key) === count); +} + +function multisetRemainder(left: readonly string[], right: readonly string[]): string[] { + const remaining = multiset(right); + return left.filter((value) => { + const count = remaining.get(value) ?? 0; + if (count === 0) return true; + remaining.set(value, count - 1); + return false; + }); +} + +interface TextSpan { + readonly start: number; + readonly end: number; + readonly text: string; +} + +function fencedCodeSpans(raw: string): TextSpan[] { + const lines = [...raw.matchAll(/[^\n]*(?:\n|$)/gu)].filter((match) => match[0] !== ''); + const spans: TextSpan[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const openingText = line[0].replace(/\r?\n$/u, ''); + const opening = /^ {0,3}(?`{3,}|~{3,})(?.*)$/u.exec(openingText); + if (!opening?.groups) continue; + const fence = opening.groups.fence; + if (fence.startsWith('`') && opening.groups.info.includes('`')) continue; + const closing = new RegExp( + `^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}[ \\t]*\\r?$`, + 'u', + ); + let endIndex = lines.length - 1; + for (let candidate = index + 1; candidate < lines.length; candidate += 1) { + if (closing.test(lines[candidate][0].replace(/\n$/u, ''))) { + endIndex = candidate; + break; + } + } + const start = line.index ?? 0; + const endingLine = lines[endIndex]; + const end = (endingLine.index ?? 0) + endingLine[0].length; + spans.push({ start, end, text: raw.slice(start, end) }); + index = endIndex; + } + return spans; +} + +function fencedCode(raw: string): string[] { + return fencedCodeSpans(raw).map((span) => span.text); +} + +function mathBlocks(raw: string): string[] { + return [...raw.matchAll(/^\$\$\s*$[\s\S]*?^\$\$\s*$/gmu)].map((match) => match[0]); +} + +interface JsonLdBlock extends TextSpan { + readonly json: string; +} + +function jsonLdBlocks(raw: string): JsonLdBlock[] { + return [...raw.matchAll(/]*>\s*\{\s*`(?[\s\S]*?)`\s*\}\s*<\/JsonLd\s*>/gu)].map( + (match) => ({ + start: match.index, + end: match.index + match[0].length, + text: match[0], + json: match.groups?.json.trim() ?? '', + }), + ); +} + +function withoutProtectedBlocks(raw: string): string { + let cursor = 0; + const pieces: string[] = []; + const spans = [...fencedCodeSpans(raw), ...jsonLdBlocks(raw)].sort( + (left, right) => left.start - right.start, + ); + for (const span of spans) { + if (span.end <= cursor) continue; + if (span.start < cursor) { + cursor = span.end; + continue; + } + pieces.push(raw.slice(cursor, span.start), '\n'); + cursor = span.end; + } + pieces.push(raw.slice(cursor)); + return pieces.join(''); +} + +function inlineCodeSpans(source: string): TextSpan[] { + const found: TextSpan[] = []; + for (let index = 0; index < source.length;) { + if (source[index] !== '`') { + index += 1; + continue; + } + let delimiterLength = 1; + while (source[index + delimiterLength] === '`') delimiterLength += 1; + const delimiter = '`'.repeat(delimiterLength); + let closing = source.indexOf(delimiter, index + delimiterLength); + while ( + closing !== -1 && + (source[closing - 1] === '`' || source[closing + delimiterLength] === '`') + ) { + closing = source.indexOf(delimiter, closing + delimiterLength); + } + if (closing === -1) { + index += delimiterLength; + continue; + } + found.push({ + start: index, + end: closing + delimiterLength, + text: source.slice(index + delimiterLength, closing), + }); + index = closing + delimiterLength; + } + return found; +} + +function withoutSpans(source: string, spans: readonly TextSpan[]): string { + let cursor = 0; + const pieces: string[] = []; + for (const span of spans) { + pieces.push(source.slice(cursor, span.start), ' '); + cursor = span.end; + } + pieces.push(source.slice(cursor)); + return pieces.join(''); +} + +function mdxExpressionClosingBrace(source: string, openingBrace: number): number | null { + const tokens = MDX_EXPRESSION_PARSER.tokenizer(source.slice(openingBrace + 1), { + ecmaVersion: 'latest', + }); + let nestedBraceDepth = 0; + try { + for (;;) { + const token = tokens.getToken(); + const label = token.type.label; + if (label === 'eof') return null; + if (label === '{' || label === '${') nestedBraceDepth += 1; + else if (label === '}') { + if (nestedBraceDepth === 0) return openingBrace + 1 + token.start; + nestedBraceDepth -= 1; + } + } + } catch { + return null; + } +} + +function mdxTagSpans(source: string): TextSpan[] { + const spans: TextSpan[] = []; + for (let start = 0; start < source.length; start += 1) { + if (source[start] !== '<' || !/[!/A-Za-z]/u.test(source[start + 1] ?? '')) continue; + let quote = ''; + let escaped = false; + for (let index = start + 1; index < source.length; index += 1) { + const character = source[index]; + if (escaped) { + escaped = false; + continue; + } + if (character === '\\') { + escaped = true; + continue; + } + if (quote) { + if (character === quote) quote = ''; + continue; + } + if (character === '"' || character === "'" || character === '`') { + quote = character; + continue; + } + if (character === '{') { + const closingBrace = mdxExpressionClosingBrace(source, index); + if (closingBrace === null) break; + index = closingBrace; + continue; + } + if (character === '>') { + const end = index + 1; + spans.push({ start, end, text: source.slice(start, end) }); + start = index; + break; + } + } + } + return spans; +} + +function inlineCode(raw: string): string[] { + const source = withoutProtectedBlocks(raw); + return inlineCodeSpans(withoutSpans(source, mdxTagSpans(source))).map((span) => span.text); +} + +function figureSources(raw: string): string[] { + return [ + ...raw.matchAll( + /[^"]+)"|'(?[^']+)')[\s\S]*?\/?\s*>/gu, + ), + ].map((match) => match.groups?.double ?? match.groups?.single ?? ''); +} + +function normalizeLocalizedLink(target: string): string { + if (target.startsWith('#')) return '#'; + try { + const url = new URL(target); + if (['inferencex.com', 'inferencex.semianalysis.com'].includes(url.hostname)) { + url.pathname = url.pathname.replace(/^\/zh(?=\/|$)/u, '') || '/'; + return url.toString(); + } + } catch { + // Relative URL; normalize its locale prefix below. + } + return target.replace(/^\/zh(?=\/|$)/u, '') || '/'; +} + +function markdownDestination(source: string, start: number, allowEof = false): string | null { + let index = start; + while (/[ \t\n\r]/u.test(source[index] ?? '')) index += 1; + if (source[index] === '<') { + let target = ''; + for (index += 1; index < source.length; index += 1) { + if (source[index] === '\\' && index + 1 < source.length) { + target += source[index + 1]; + index += 1; + } else if (source[index] === '>') { + return target; + } else if (source[index] === '\n' || source[index] === '\r') { + return null; + } else { + target += source[index]; + } + } + return null; + } + + const targetStart = index; + let depth = 0; + for (; index < source.length; index += 1) { + const character = source[index]; + if (character === '\\') { + index += 1; + continue; + } + if (character === '(') { + depth += 1; + continue; + } + if (character === ')') { + if (depth === 0) return source.slice(targetStart, index); + depth -= 1; + continue; + } + if (/\s/u.test(character) && depth === 0) return source.slice(targetStart, index); + } + return allowEof && depth === 0 ? source.slice(targetStart) : null; +} + +function markdownLinkTargets(raw: string): string[] { + const targets: string[] = []; + for (let index = raw.indexOf(']('); index !== -1; index = raw.indexOf('](', index + 2)) { + const target = markdownDestination(raw, index + 2); + if (target !== null) targets.push(target); + } + for (const match of raw.matchAll(/^ {0,3}\[(?:\\.|[^\\\]\n])+\]:[ \t]*(?.*)$/gmu)) { + const destination = match.groups?.destination ?? ''; + const target = markdownDestination(destination, 0, true); + if (target !== null) targets.push(target); + } + return targets; +} + +function linkTargets(raw: string): string[] { + const markdown = markdownLinkTargets(raw); + const props = [ + ...raw.matchAll( + /\b(?:href|src)\s*=\s*(?:"(?[^"]+)"|'(?[^']+)'|\{\s*"(?[^"]+)"\s*\}|\{\s*'(?[^']+)'\s*\}|\{\s*`(?[^`]+)`\s*\})/gu, + ), + ].map( + (match) => + match.groups?.double ?? + match.groups?.single ?? + match.groups?.expressionDouble ?? + match.groups?.expressionSingle ?? + match.groups?.expressionTemplate ?? + '', + ); + return [...markdown, ...props].map(normalizeLocalizedLink); +} + +function jsonLdValues(raw: string): unknown[] { + const values: unknown[] = []; + for (const block of jsonLdBlocks(raw)) { + try { + values.push(JSON.parse(block.json)); + } catch { + values.push({ __invalidJsonLd: block.json }); + } + } + return values; +} + +function invalidJsonLd(value: unknown): boolean { + return Boolean(value && typeof value === 'object' && '__invalidJsonLd' in value); +} + +function normalizeProtectedToken(value: string): string { + return value + .replace(/^tokens?(?=\/s)/u, 'tok') + .replace(/^tokens?(?=\/sec)/u, 'tok') + .replace('/sec', '/s') + .replace(/GPU[/-](?:hr|hour)/u, 'gpu/hr') + .replace(/chip[/-](?:hr|hour)/u, 'chip/hr') + .replace('/GPU', '/gpu') + .replace(/M\s+tokens?$/u, 'M tok'); +} + +function protectedTokens(raw: string): string[] { + const withoutBlocks = withoutProtectedBlocks(raw); + const prose = withoutSpans(withoutBlocks, inlineCodeSpans(withoutBlocks)); + const pattern = + /--[a-z0-9](?:[\w.-]*[a-z0-9])?(?:=[^\s,,。;;))`]+)?|\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b|\b(?:tokens?|tok)\/(?:s|sec)(?:\/(?:user|gpu|GPU|chip))?\b|\b[KMGTPE]?FLOP\/s\b|\b[KMGT]?bit\/s\b|\b(?:kW|MW)\/(?:gpu|GPU)\b|\b(?:GPU|chip)[/-](?:hr|hour)\b|\b(?:GB\/s|TB\/s|GB|TB|ms|µs|ns|kW|MW)\b|\$\d+(?:\.\d+)?\/M\b|\$\/(?:M\s+(?:tok|tokens?)|(?:GPU|chip)[/-](?:hr|hour))/gu; + const proseTokens = [...prose.matchAll(pattern)].map((match) => + normalizeProtectedToken(match[0]), + ); + const structuredGpuHours = jsonLdBlocks(raw).flatMap((block) => + [...block.json.matchAll(/\b(?:GPU|chip)[/-](?:hr|hour)\b/gu)].map((match) => + normalizeProtectedToken(match[0]), + ), + ); + return [...proseTokens, ...structuredGpuHours]; +} + +function jsonShape(value: unknown): unknown { + if (Array.isArray(value)) return { type: 'array', items: value.map(jsonShape) }; + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, child]) => [key, jsonShape(child)]), + ); + } + return typeof value; +} + +function protectedJsonValues(value: unknown): string[] { + const found: string[] = []; + const visit = (item: unknown, valuePath = '$'): void => { + if (Array.isArray(item)) { + item.forEach((child, index) => visit(child, `${valuePath}[${index}]`)); + } else if (item && typeof item === 'object') { + Object.entries(item).forEach(([childKey, child]) => visit(child, `${valuePath}.${childKey}`)); + } else if (typeof item === 'number' || typeof item === 'boolean') { + found.push(`${valuePath}:${JSON.stringify(item)}`); + } else if (typeof item === 'string') { + if (valuePath.endsWith('.@type')) found.push(`${valuePath}:${item}`); + else if (/^https?:\/\//u.test(item)) { + found.push(`${valuePath}:${normalizeLocalizedLink(item)}`); + } else if (/^(?:\d{4}-\d{2}-\d{2}|--[a-z0-9-]+|[A-Z][A-Z0-9_]{2,})$/u.test(item)) { + found.push(`${valuePath}:${item}`); + } + } + }; + visit(value); + return found.sort(); +} + +function addDifference( + violations: GuardViolation[], + rule: string, + file: string, + enValues: readonly unknown[], + zhValues: readonly unknown[], +): void { + if (JSON.stringify(enValues) !== JSON.stringify(zhValues)) { + violations.push({ rule, file }); + } +} + +function blogPairSha256(en: string, zh: string): string { + return createHash('sha256').update(JSON.stringify({ en, zh })).digest('hex'); +} + +function applyBlogExceptions( + rule: BlogGuardException['rule'], + file: string, + enValues: string[], + zhValues: string[], + exceptions: readonly BlogGuardException[], + pairSha256?: string, +): void { + const protectedPairAuthorized = + rule !== 'protected-token' || + exceptions.some( + (item) => + item.file === file && item.rule === 'protected-token' && item.pairSha256 === pairSha256, + ); + for (const exception of exceptions.filter( + (item) => item.file === file && item.rule === rule && protectedPairAuthorized, + )) { + const enIndex = exception.en === '' ? -1 : enValues.indexOf(exception.en); + const zhIndex = exception.zh === '' ? -1 : zhValues.indexOf(exception.zh); + const enMatched = exception.en === '' || enIndex !== -1; + const zhMatched = exception.zh === '' || zhIndex !== -1; + if (!enMatched || !zhMatched) continue; + if (enIndex !== -1) enValues.splice(enIndex, 1); + if (zhIndex !== -1) zhValues.splice(zhIndex, 1); + } +} + +/** Compare objective, non-editorial invariants in one English/Chinese MDX pair. */ +export function compareBlogPair( + file: string, + en: string, + zh: string, + exceptions: readonly BlogGuardException[], +): GuardViolation[] { + const violations: GuardViolation[] = []; + addDifference(violations, 'fenced-code', file, fencedCode(en), fencedCode(zh)); + addDifference(violations, 'math', file, mathBlocks(en), mathBlocks(zh)); + addDifference(violations, 'figure-src', file, figureSources(en), figureSources(zh)); + + const enInline = inlineCode(en); + const zhInline = inlineCode(zh); + applyBlogExceptions('inline-code', file, enInline, zhInline, exceptions); + if (!multisetsEqual(enInline, zhInline)) violations.push({ rule: 'inline-code', file }); + const enProtected = protectedTokens(en); + const zhProtected = protectedTokens(zh); + applyBlogExceptions( + 'protected-token', + file, + enProtected, + zhProtected, + exceptions, + blogPairSha256(en, zh), + ); + if (!multisetsEqual(enProtected, zhProtected)) { + violations.push({ + rule: 'protected-token', + file, + detail: `missingFromZh=${JSON.stringify(multisetRemainder(enProtected, zhProtected))} missingFromEn=${JSON.stringify(multisetRemainder(zhProtected, enProtected))}`, + }); + } + if (!multisetsEqual(linkTargets(en), linkTargets(zh))) { + violations.push({ rule: 'link-target', file }); + } + + const enJson = jsonLdValues(en); + const zhJson = jsonLdValues(zh); + if (enJson.some(invalidJsonLd) || zhJson.some(invalidJsonLd)) { + violations.push({ rule: 'json-ld-syntax', file }); + } + addDifference(violations, 'json-ld-shape', file, enJson.map(jsonShape), zhJson.map(jsonShape)); + addDifference( + violations, + 'json-ld-protected-value', + file, + enJson.flatMap(protectedJsonValues), + zhJson.flatMap(protectedJsonValues), + ); + return violations; +} + +/** + * Markdown link destinations are verbatim URL slugs — often English words like + * `.../inference-chip/` — not translatable copy, so drop them before scanning. + */ +function withoutMarkdownLinkDestinations(source: string): string { + return source.replaceAll(/\]\([^()\s]*\)/gu, ']'); +} + +function mdxChineseSegments(source: string): string[] { + const scannable = withoutMarkdownLinkDestinations(source); + const literals = (scannable.match(STRING_LITERAL) ?? []).filter((text) => HAN.test(text)); + // Strip MDX/HTML tags to a fixed point so nested or adjacent delimiters cannot + // leave a reassembled tag fragment behind after a single pass. + let stripped = scannable.replaceAll(STRING_LITERAL, 'X'); + for (let previous = ''; previous !== stripped;) { + previous = stripped; + stripped = stripped.replaceAll(/<[^>]*>/gsu, ''); + } + const visible = stripped.split('\n').filter((text) => HAN.test(text)); + return [...literals, ...visible]; +} + +function chineseSegments(file: string, source: string): string[] { + if (!HAN.test(source)) return []; + if (file.endsWith('.mdx')) return mdxChineseSegments(source); + + const sourceFile = parseSource(file, source); + const segments: string[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isStringLiteral(node) || + ts.isNoSubstitutionTemplateLiteral(node) || + ts.isJsxText(node) || + node.kind === ts.SyntaxKind.TemplateHead || + node.kind === ts.SyntaxKind.TemplateMiddle || + node.kind === ts.SyntaxKind.TemplateTail + ) { + const text = node.getText(sourceFile); + if (HAN.test(text)) segments.push(text); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return segments.length > 0 ? segments : [source]; +} + +/** High-confidence copy checks only; no fluency, clause-order, register, or pronoun heuristics. */ +export function findMechanicalCopyViolations(file: string, source: string): GuardViolation[] { + const violations: GuardViolation[] = []; + const scanSource = file.endsWith('.mdx') ? withoutProtectedBlocks(source) : source; + for (const text of chineseSegments(file, scanSource)) { + const withoutUnits = text.replaceAll(CHIP_UNIT, ''); + if (/\b[Cc]hip\b/gu.test(withoutUnits)) { + violations.push({ rule: 'chip-untranslated', file }); + } + if ( + /\b(?:warmup(?:\s+预热|\s*[((]\s*预热\s*[))])|seed(?:\s+随机种子|\s*[((]\s*随机种子\s*[))])|offload(?:\s+卸载|\s*[((]\s*卸载\s*[))]))/iu.test( + text, + ) + ) { + violations.push({ rule: 'duplicated-technical-loanword', file }); + } + if (/\s+[,。!?;:]|(?:,,|,,|\.。|。\.)/u.test(text)) { + violations.push({ + rule: 'malformed-chinese-punctuation', + file, + detail: text.replaceAll(/\s+/gu, ' ').trim().slice(0, 160), + }); + } + } + if (countLocaleDictionaryObjects(file, source) > 0) { + const labels = [...source.matchAll(/(?