From dc14e04c86045e94c605ff7463847bc3589fbbe8 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Sun, 23 Aug 2026 08:44:06 -0700 Subject: [PATCH 1/8] test(zh): enforce objective localization invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mutation-tested route, Blog, dictionary, protected-token, structured-data, and explicit Chinese-only English-byte guards. Document exact temporary exceptions and keep editorial judgment out of deterministic CI. 中文:新增基于 mutation 的路由、Blog、字典、受保护 token、结构化数据及显式中文专属英文逐字节检查。记录精确的临时例外,并确保确定性 CI 不介入编辑性判断。 --- .github/workflows/tests-unit.yml | 9 +- AGENTS.md | 2 +- docs/chinese-copy.md | 73 +++ packages/app/package.json | 3 +- packages/app/scripts/check-zh-chinese-only.ts | 78 +++ packages/app/src/lib/blog-content.test.ts | 36 ++ packages/app/src/lib/i18n.test.ts | 29 ++ packages/app/src/lib/zh-copy.test.ts | 75 ++- .../lib/zh-objective-guard-exceptions.json | 28 ++ .../app/src/lib/zh-objective-guard.test.ts | 247 +++++++++ packages/app/src/lib/zh-objective-guard.ts | 474 ++++++++++++++++++ 11 files changed, 1048 insertions(+), 6 deletions(-) create mode 100644 packages/app/scripts/check-zh-chinese-only.ts create mode 100644 packages/app/src/lib/zh-objective-guard-exceptions.json create mode 100644 packages/app/src/lib/zh-objective-guard.test.ts create mode 100644 packages/app/src/lib/zh-objective-guard.ts 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/docs/chinese-copy.md b/docs/chinese-copy.md index 0e18c3331..31002c773 100644 --- a/docs/chinese-copy.md +++ b/docs/chinese-copy.md @@ -190,3 +190,76 @@ 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, unit kinds, link destinations, and JSON-LD syntax/shape. 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. JSON-LD prose values may + be translated; keys, types, array shape, URLs, numbers, booleans, dates, flags, and identifiers + remain protected. +- Explicit `en`/`zh` object literals must have the same statically declared nested key shape. + Computed keys and spread contents cannot be proven locally and remain outside this structural + check; their explicit sibling keys are still checked. +- 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 + check intentionally permits locale-neutral 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. Units and identifiers 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 的语法和结构。中文站内链接可以添加 + `/zh`(包括 `/blog` → `/zh/blog`);页内标题锚点可以随中文标题本地化;段落重组时链接 + 顺序可以变化。JSON-LD 中的说明性文案可以翻译,但 key、type、数组结构、URL、数字、 + boolean、日期、flag 和 identifier 必须保持不变。 +- 显式 `en`/`zh` object literal 的静态嵌套 key 结构必须一致。computed key 和 spread 的 + 内容无法在本地可靠推断,不纳入该结构检查;同一对象中的其他显式 sibling key 仍会检查。 +- PR 添加 `chinese-copy-only` label 后,会运行基于 merge base 的英文逐字节检查。英文 Blog + MDX 整体受保护;混合语言 TypeScript、TSX 和 JSON 文件则保护原始 `en` subtree。测试和 + 不含英文文案的 locale plumbing 可以修改;如果 PR 本来就要改英文文案,应移除该 label。 +- 高置信度文案规则仅限于已记录的 `Chip` 普通名词、已有中文字典旁绕过本地化的英文说明 + 标签、English-first 重复翻译,以及中文句末标点前多余空格等无歧义错误。单位和 identifier + 继续保留文档规定的英文写法。 + +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 exceptions match the exact English and Chinese spans; +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 例外精确匹配中英文两侧文本;字典例外对完整的 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..9f685d4eb 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", 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..54a73e02d --- /dev/null +++ b/packages/app/scripts/check-zh-chinese-only.ts @@ -0,0 +1,78 @@ +#!/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 { + try { + return execFileSync('git', ['show', `${revision}:${file}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return ''; + } +} + +function changedFiles(base: string, head: string): string[] { + return git([ + 'diff', + '--name-only', + '--diff-filter=ACMRD', + `${base}...${head}`, + '--', + 'packages/app', + ]) + .split('\n') + .filter(Boolean); +} + +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((file) => + compareEnglishSurfaces(file, sourceAt(mergeBase, file), sourceAt(options.head, file)), +); + +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..a088bf27d 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,32 @@ 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', () => { + expect(findRoutePairViolations(pages)).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..cc83c60e2 100644 --- a/packages/app/src/lib/zh-copy.test.ts +++ b/packages/app/src/lib/zh-copy.test.ts @@ -14,6 +14,13 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; +import { + 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 +28,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 +150,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 +177,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 +195,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 +285,56 @@ 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); + if (!/\.tsx?$/u.test(relative) || relative.includes('.test.')) return false; + const source = fs.readFileSync(file, 'utf8'); + return /\b(?:en|zh):\s*\{/u.test(source); + }); + + it('discovers bilingual dictionaries non-vacuously', () => { + expect(dictionaryFiles.length).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..cb642f88a --- /dev/null +++ b/packages/app/src/lib/zh-objective-guard-exceptions.json @@ -0,0 +1,28 @@ +{ + "blog": [ + { + "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." + } + ], + "dictionaries": [ + { + "file": "src/components/collectivex/CollectiveXDisplay.tsx", + "mismatchSha256": "baf66a66ba86581f6b4a990865c5c9fced310c7cb69813f59d163e39fb0dd080", + "reason": "The baseline CollectiveX Chinese dictionary contains 74 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..621e73022 --- /dev/null +++ b/packages/app/src/lib/zh-objective-guard.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; + +import { + compareBlogPair, + compareEnglishSurfaces, + dictionaryViolationFingerprint, + findBlogPairViolations, + findDictionaryParityViolations, + findMechanicalCopyViolations, + findRoutePairViolations, + type BlogGuardException, + type DictionaryGuardException, +} from './zh-objective-guard'; + +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-count' })]); + }); + + 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('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); + }); + }); + + 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([]); + }); + + 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('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 units, identifiers, Chinese-first explanations, and valid punctuation exempt', () => { + expect( + findMechanicalCopyViolations( + 'copy.ts', + '吞吐量为 tok/s/chip,ChipSKU 保持原样;预热(warmup)用于解释概念。', + ), + ).toEqual([]); + }); + }); +}); 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..2fee7c6fc --- /dev/null +++ b/packages/app/src/lib/zh-objective-guard.ts @@ -0,0 +1,474 @@ +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +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[]; +} + +export interface BlogGuardException { + readonly rule: 'inline-code'; + readonly file: string; + readonly en: string; + readonly zh: string; + readonly reason: string; + readonly removeWhen: 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; + +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) { + if (!ts.isPropertyAssignment(property)) continue; + const key = propertyName(property.name); + if (!key) continue; + const next = prefix ? `${prefix}.${key}` : key; + paths.add(next); + 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[] = []; + let enObjectCount = 0; + let zhObjectCount = 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)) enObjectCount += 1; + if (zh && ts.isObjectLiteralExpression(zh)) zhObjectCount += 1; + if (en && zh && ts.isObjectLiteralExpression(en) && ts.isObjectLiteralExpression(zh)) { + const enPaths = structuralPaths(en); + const zhPaths = structuralPaths(zh); + 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) { + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; + violations.push({ + rule: 'dictionary-key-parity', + file: `${file}:${line}`, + missingFromEn, + missingFromZh, + }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (enObjectCount !== zhObjectCount) { + violations.push({ + rule: 'dictionary-locale-count', + file, + detail: `${enObjectCount} en object(s), ${zhObjectCount} zh object(s)`, + }); + } + return violations.filter( + (violation) => + !exceptions.some( + (exception) => + normalizedFile(file).endsWith(exception.file) && + dictionaryViolationFingerprint(violation) === exception.mismatchSha256, + ), + ); +} + +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 (/\.(?: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 fencedCode(raw: string): string[] { + return [...raw.matchAll(/^```[^\n]*\n[\s\S]*?^```\s*$/gmu)].map((match) => match[0]); +} + +function mathBlocks(raw: string): string[] { + return [...raw.matchAll(/^\$\$\s*$[\s\S]*?^\$\$\s*$/gmu)].map((match) => match[0]); +} + +function withoutProtectedBlocks(raw: string): string { + return raw + .replaceAll(/^```[^\n]*\n[\s\S]*?^```\s*$/gmu, '') + .replaceAll(/\{`[\s\S]*?`\}<\/JsonLd>/gu, ''); +} + +function inlineCode(raw: string): string[] { + return [...withoutProtectedBlocks(raw).matchAll(/(?[^`\n]+)`(?!`)/gu)].map( + (match) => match.groups?.code ?? '', + ); +} + +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 linkTargets(raw: string): string[] { + return [...raw.matchAll(/\]\((?[^)\s]+)(?:\s+"[^"]*")?\)/gu)].map((match) => + normalizeLocalizedLink(match.groups?.target ?? ''), + ); +} + +function jsonLdValues(raw: string): unknown[] { + const values: unknown[] = []; + for (const match of raw.matchAll(/\{`(?\{[\s\S]*?\})`\}<\/JsonLd>/gu)) { + try { + values.push(JSON.parse(match.groups?.json ?? '')); + } catch { + values.push({ __invalidJsonLd: match.groups?.json ?? '' }); + } + } + return values; +} + +function invalidJsonLd(value: unknown): boolean { + return Boolean(value && typeof value === 'object' && '__invalidJsonLd' in value); +} + +function protectedTokens(raw: string): string[] { + const prose = withoutProtectedBlocks(raw); + const pattern = + /--[a-z0-9][\w.-]*(?:=[^\s,,。;;))`]+)?|\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b|\b(?:tokens?\/s(?:\/(?:user|gpu|GPU|chip))?|GB\/s|TB\/s|GB|TB|ms|µs|ns|kW|MW)\b|\$\/(?:M\s+tok|chip-hour)/gu; + return [...prose.matchAll(pattern)].map((match) => + match[0].replace(/^tokens?(?=\/s)/u, 'tok').replace('/GPU', '/gpu'), + ); +} + +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, key = ''): void => { + if (Array.isArray(item)) { + item.forEach((child) => visit(child, key)); + } else if (item && typeof item === 'object') { + Object.entries(item).forEach(([childKey, child]) => visit(child, childKey)); + } else if (typeof item === 'number' || typeof item === 'boolean') { + found.push(`${key}:${JSON.stringify(item)}`); + } else if (typeof item === 'string') { + if (key === '@type') found.push(`${key}:${item}`); + else if (/^https?:\/\//u.test(item)) found.push(`${key}:${normalizeLocalizedLink(item)}`); + else if (/^(?:\d{4}-\d{2}-\d{2}|--[a-z0-9-]+|[A-Z][A-Z0-9_]{2,})$/u.test(item)) { + found.push(`${key}:${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 }); + } +} + +/** 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); + for (const exception of exceptions.filter((item) => item.file === file)) { + const enIndex = enInline.indexOf(exception.en); + const zhIndex = zhInline.indexOf(exception.zh); + if (enIndex !== -1 && zhIndex !== -1) { + enInline.splice(enIndex, 1); + zhInline.splice(zhIndex, 1); + } + } + if (!multisetsEqual(enInline, zhInline)) violations.push({ rule: 'inline-code', file }); + const zhProtected = new Set(protectedTokens(zh)); + if (![...new Set(protectedTokens(en))].every((token) => zhProtected.has(token))) { + violations.push({ rule: 'protected-token', file }); + } + 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; +} + +function chineseSegments(source: string): string[] { + if (!HAN.test(source)) return []; + const literals = (source.match(STRING_LITERAL) ?? []).filter((text) => HAN.test(text)); + return literals.length > 0 ? literals : [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(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 }); + } + } + if (/\bzh:\s*\{/u.test(source)) { + const labels = [...source.matchAll(/(?