diff --git a/package.json b/package.json index 3797b24efa..b620042b56 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", + "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "docs:dev": "pnpm --filter @deepseek-ai/website run dev", "docs:build": "pnpm --filter @deepseek-ai/website run build", diff --git a/scripts/gen-translation-brief.ts b/scripts/gen-translation-brief.ts new file mode 100644 index 0000000000..5dd175f669 --- /dev/null +++ b/scripts/gen-translation-brief.ts @@ -0,0 +1,197 @@ +/** + * Print the minimal-update briefing for out-of-sync translation pairs: + * `pnpm run gen-translation-brief [pair paths...]`. With no arguments it + * discovers every out-of-sync pair; with arguments (any file of a pair) it + * briefs exactly those pairs and fails loud on in-sync, incomplete, or + * out-of-scope requests. The briefing contract lives in + * `scripts/translation-brief.ts`; the consuming workflow is + * `.agents/skills/dsh-translate-docs/SKILL.md`. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join, resolve, sep } from 'node:path' +import { + isTranslationScopeFile, + pairAnchorOfArgument, + parseTranslationPairingManifest, + TRANSLATION_SCOPE_GLOB_EXCLUDES, +} from './translation-pairing.ts' +import { + changedLinesOfDiff, + extractCounterpartSections, + headingSections, + mapHunksToSections, + matchTerminologyRows, + parseUnifiedDiffHunks, + renderTranslationBrief, + type BriefDirection, + type CounterpartSection, +} from './translation-brief.ts' + +const root = resolve(import.meta.dirname, '..') +const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) +const terminology = readFileSync(join(root, 'docs/i18n/terminology.md'), 'utf8') + +function isExcluded(file: string): boolean { + return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) +} + +/** Recorded hashes of one consistency record: basename → blob hash. */ +function parseMeta(content: string): Map | undefined { + const out = new Map() + for (const line of content.split('\n')) { + if (line === '' || line.startsWith('#')) continue + const match = /^([^:#]+\.md): ([0-9a-f]{40})$/.exec(line) + if (!match?.[1] || !match[2]) return undefined + out.set(match[1], match[2]) + } + return out +} + +function git(args: string[], allowedExitCodes: number[] = [0]): string { + const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 1 << 26 }) + if (result.error) throw result.error + if (!allowedExitCodes.includes(result.status ?? -1)) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`) + } + return result.stdout +} + +function blobText(hash: string): string { + return git(['cat-file', '-p', hash]) +} + +/** Unified diff between two texts, headers stripped, via `git diff --no-index`. */ +function diffTexts(before: string, after: string): string { + const dir = mkdtempSync(join(tmpdir(), 'translation-brief-')) + try { + writeFileSync(join(dir, 'last-confirmed.md'), before) + writeFileSync(join(dir, 'current.md'), after) + const raw = git(['diff', '--no-index', '--unified=2', join(dir, 'last-confirmed.md'), join(dir, 'current.md')], [0, 1]) + return raw.split('\n') + .filter(line => !line.startsWith('diff --git') && !line.startsWith('index ') && !line.startsWith('--- ') && !line.startsWith('+++ ')) + .join('\n') + .trim() + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +interface PairState { + anchor: string + zh: string + meta: string + enDrifted: boolean + zhDrifted: boolean + enLast: string + zhLast: string +} + +/** Load one pair's recorded and current state, or explain why it cannot be briefed. */ +function loadPair(anchor: string): PairState | string { + const zh = anchor.replace(/\.md$/, '.zh.md') + const meta = anchor.replace(/\.md$/, '.i18n.yaml') + if (!isTranslationScopeFile(anchor) || isExcluded(anchor)) { + return `${anchor}: not an in-scope documentation pair (docs/i18n/README.md)` + } + const missing = [anchor, zh, meta].filter(file => !existsSync(join(root, file))) + if (missing.length > 0) { + return `${anchor}: incomplete pair (missing ${missing.join(', ')}) — a new counterpart is whole-document translation work, not a minimal update` + } + const record = parseMeta(readFileSync(join(root, meta), 'utf8')) + const enRecorded = record?.get(basename(anchor)) + const zhRecorded = record?.get(basename(zh)) + if (record === undefined || enRecorded === undefined || zhRecorded === undefined) { + return `${meta}: malformed consistency record` + } + const enCurrent = readFileSync(join(root, anchor), 'utf8') + const zhCurrent = readFileSync(join(root, zh), 'utf8') + const enLast = blobText(enRecorded) + const zhLast = blobText(zhRecorded) + return { + anchor, + zh, + meta, + enDrifted: enCurrent !== enLast, + zhDrifted: zhCurrent !== zhLast, + enLast, + zhLast, + } +} + +/** Whether two documents' heading sequences align one to one. */ +function headingsAligned(a: string, b: string): boolean { + const aHeads = headingSections(a) + const bHeads = headingSections(b) + return aHeads.length === bHeads.length && aHeads.every((heading, index) => heading.depth === bHeads[index]?.depth) +} + +/** Render the briefing for one drifted side of a pair. */ +function briefDirection(pair: PairState, direction: BriefDirection): string { + const sourceIsEnglish = direction === 'en-to-zh' + const sourcePath = sourceIsEnglish ? pair.anchor : pair.zh + const counterpartPath = sourceIsEnglish ? pair.zh : pair.anchor + const sourceLast = sourceIsEnglish ? pair.enLast : pair.zhLast + const sourceCurrent = readFileSync(join(root, sourcePath), 'utf8') + const counterpartCurrent = readFileSync(join(root, counterpartPath), 'utf8') + const diff = diffTexts(sourceLast, sourceCurrent) + const bothDrifted = pair.enDrifted && pair.zhDrifted + + let counterpartSections: CounterpartSection[] | undefined + if (!bothDrifted && headingsAligned(sourceLast, counterpartCurrent)) { + const sections = mapHunksToSections(parseUnifiedDiffHunks(diff), headingSections(sourceLast)) + counterpartSections = extractCounterpartSections(counterpartCurrent, sections) + } + return renderTranslationBrief({ + sourcePath, + counterpartPath, + direction, + diff, + counterpartSections, + bothDrifted, + terminology: matchTerminologyRows(terminology, changedLinesOfDiff(diff)), + }) +} + +const requested = process.argv.slice(2).map(pairAnchorOfArgument) + +let anchors: string[] +if (requested.length > 0) { + anchors = [...new Set(requested)].sort() +} else { + const discovered = new Set() + for (const match of globSync('**/*.i18n.yaml', { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) { + const normalized = match.split(sep).join('/') + if (isTranslationScopeFile(normalized)) discovered.add(normalized.replace(/\.i18n\.yaml$/, '.md')) + } + anchors = [...discovered].sort() +} + +const briefs: string[] = [] +const problems: string[] = [] +const skipped: string[] = [] +for (const anchor of anchors) { + const pair = loadPair(anchor) + if (typeof pair === 'string') { + if (requested.length > 0) problems.push(pair) + continue + } + if (!pair.enDrifted && !pair.zhDrifted) { + if (requested.length > 0) skipped.push(`${anchor}: pair is consistent with its record — nothing to brief`) + continue + } + if (pair.enDrifted) briefs.push(briefDirection(pair, 'en-to-zh')) + if (pair.zhDrifted) briefs.push(briefDirection(pair, 'zh-to-en')) +} + +if (problems.length > 0 || skipped.length > 0) { + for (const message of [...problems, ...skipped]) console.error(`gen-translation-brief: ${message}`) + process.exit(2) +} +if (briefs.length === 0) { + console.log('gen-translation-brief: every recorded pair matches its consistency record; nothing to brief.') + process.exit(0) +} +console.log(briefs.join('\n\n---\n\n')) diff --git a/scripts/translation-brief.spec.ts b/scripts/translation-brief.spec.ts new file mode 100644 index 0000000000..e103de319f --- /dev/null +++ b/scripts/translation-brief.spec.ts @@ -0,0 +1,157 @@ +/** Regression tests for the minimal-update briefing assembly. */ + +import { describe, expect, it } from 'vitest' +import { + changedLinesOfDiff, + extractCounterpartSections, + headingSections, + mapHunksToSections, + matchTerminologyRows, + parseUnifiedDiffHunks, + renderTranslationBrief, +} from './translation-brief.ts' + +const DIFF = [ + '@@ -3,3 +3,3 @@', + ' unchanged context', + '-The agent loop retries once.', + '+The agent loop retries twice.', + '@@ -12 +12,2 @@', + '+A new sentence about the session log.', +].join('\n') + +describe('unified diff parsing', () => { + it('reads hunk starts and counts, defaulting count to 1', () => { + expect(parseUnifiedDiffHunks(DIFF)).toEqual([ + { start: 3, count: 3 }, + { start: 12, count: 1 }, + ]) + }) + + it('collects only changed lines, markers stripped', () => { + expect(changedLinesOfDiff(DIFF)).toBe([ + 'The agent loop retries once.', + 'The agent loop retries twice.', + 'A new sentence about the session log.', + ].join('\n')) + }) + + it('ignores file header lines that also start with +/-', () => { + expect(changedLinesOfDiff('--- a/foo.md\n+++ b/foo.md\n+added')).toBe('added') + }) +}) + +const DOC = [ + 'Preamble line.', + '', + '# Title', + '', + 'Intro paragraph.', + '', + '## First', + '', + 'First body.', + '', + '## Second', + '', + 'Second body.', +].join('\n') + +describe('section mapping', () => { + it('lists headings with lines, depths, and labels', () => { + expect(headingSections(DOC)).toEqual([ + { line: 3, depth: 1, label: 'Title' }, + { line: 7, depth: 2, label: 'First' }, + { line: 11, depth: 2, label: 'Second' }, + ]) + }) + + it('maps hunks to the sections they span, including the preamble', () => { + const headings = headingSections(DOC) + expect(mapHunksToSections([{ start: 1, count: 1 }], headings)).toEqual([0]) + expect(mapHunksToSections([{ start: 9, count: 1 }], headings)).toEqual([2]) + expect(mapHunksToSections([{ start: 9, count: 4 }], headings)).toEqual([2, 3]) + expect(mapHunksToSections([{ start: 0, count: 0 }], headings)).toEqual([0]) + }) + + it('extracts counterpart section text with start lines and labels', () => { + expect(extractCounterpartSections(DOC, [0, 2])).toEqual([ + { label: '(preamble before the first heading)', startLine: 1, text: 'Preamble line.' }, + { label: '## First', startLine: 7, text: '## First\n\nFirst body.' }, + ]) + }) +}) + +const TERMINOLOGY = [ + '| English | 中文 | 首次出现 | 不要译作 | 备注 |', + '|---|---|---|---|---|', + '| agent loop | agent loop | agent loop(智能体循环) | | |', + '| session log | 会话日志 | | 会话记录 | |', + '| gate | 门禁 | | | |', +].join('\n') + +describe('terminology matching', () => { + it('selects rows whose English term appears on a word boundary', () => { + const matches = matchTerminologyRows(TERMINOLOGY, 'The agent loop retries twice.') + expect(matches.rows).toEqual(['| agent loop | agent loop | agent loop(智能体循环) | | |']) + expect(matches.header).toContain('English') + }) + + it('selects rows whose Chinese term appears when the source is Chinese', () => { + expect(matchTerminologyRows(TERMINOLOGY, '门禁在提交时运行。').rows).toEqual(['| gate | 门禁 | | | |']) + }) + + it('does not match substrings inside larger words', () => { + expect(matchTerminologyRows(TERMINOLOGY, 'delegate the work').rows).toEqual([]) + }) +}) + +describe('brief rendering', () => { + const base = { + sourcePath: 'docs/foo.md', + counterpartPath: 'docs/foo.zh.md', + direction: 'en-to-zh' as const, + diff: DIFF, + counterpartSections: [{ label: '## First', startLine: 7, text: '## First\n\n正文。' }], + bothDrifted: false, + terminology: matchTerminologyRows(TERMINOLOGY, changedLinesOfDiff(DIFF)), + } + + it('renders diff, aligned sections, terminology, digest, and finish steps', () => { + const brief = renderTranslationBrief(base) + expect(brief).toContain('# Translation update briefing: docs/foo.md') + expect(brief).toContain('```diff') + expect(brief).toContain('docs/foo.zh.md:7') + expect(brief).toContain('agent loop(智能体循环)') + expect(brief).toContain('| 会话日志 |') + expect(brief).toContain('Rules digest') + expect(brief).toContain('verify-translation-pairing --write docs/foo.md') + expect(brief).toContain('smallest edit that covers the diff') + }) + + it('warns instead of showing sections when both sides drifted', () => { + const brief = renderTranslationBrief({ ...base, bothDrifted: true, counterpartSections: undefined }) + expect(brief).toContain('BOTH sides changed') + expect(brief).toContain('locate the regions yourself') + expect(brief).not.toContain('docs/foo.zh.md:7') + }) + + it('renders the English-target digest for zh-to-en updates', () => { + const brief = renderTranslationBrief({ + ...base, + direction: 'zh-to-en', + sourcePath: 'docs/foo.zh.md', + counterpartPath: 'docs/foo.md', + }) + expect(brief).toContain('exactly what the new Chinese states') + expect(brief).toContain('verify-translation-pairing --write docs/foo.md') + }) + + it('grows the section fence past tilde runs in the body', () => { + const brief = renderTranslationBrief({ + ...base, + counterpartSections: [{ label: '## First', startLine: 7, text: '~~~~\ninner\n~~~~' }], + }) + expect(brief).toContain('~~~~~markdown') + }) +}) diff --git a/scripts/translation-brief.ts b/scripts/translation-brief.ts new file mode 100644 index 0000000000..6ac4c20fc3 --- /dev/null +++ b/scripts/translation-brief.ts @@ -0,0 +1,309 @@ +/** + * Pure assembly of the minimal-update briefing for one out-of-sync + * translation pair: the authored side's diff since the last confirmed + * state, the counterpart sections that diff lands in, the terminology rows + * the diff touches, and a digest of the binding update rules. The CLI + * wrapper is `scripts/gen-translation-brief.ts`; the workflow that consumes + * the briefing is `.agents/skills/dsh-translate-docs/SKILL.md`. + */ + +import type { Nodes } from 'mdast' +import { parseTranslationMarkdown } from './translation-pairing.ts' + +/** One hunk of a unified diff, in old-side line coordinates. */ +export interface DiffHunk { + /** First old-side line the hunk touches (0 for an insertion at the top). */ + start: number + /** Old-side line count (0 for a pure insertion). */ + count: number +} + +/** + * Parse the `@@ -start,count +… @@` hunk headers of a unified diff. + * + * @param diff - Unified diff text. + * @returns Hunks in old-side coordinates, in order of appearance. + */ +export function parseUnifiedDiffHunks(diff: string): DiffHunk[] { + const hunks: DiffHunk[] = [] + for (const line of diff.split('\n')) { + const match = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/.exec(line) + if (match?.[1] === undefined) continue + hunks.push({ start: Number(match[1]), count: match[2] === undefined ? 1 : Number(match[2]) }) + } + return hunks +} + +/** + * Extract the added and removed content lines of a unified diff. + * + * @param diff - Unified diff text. + * @returns The changed lines joined by newlines, diff markers stripped. + */ +export function changedLinesOfDiff(diff: string): string { + const out: string[] = [] + for (const line of diff.split('\n')) { + if (line.startsWith('+++') || line.startsWith('---')) continue + if (line.startsWith('+') || line.startsWith('-')) out.push(line.slice(1)) + } + return out.join('\n') +} + +/** One heading of a Markdown document, in document order. */ +export interface HeadingSection { + /** 1-based source line the heading starts on. */ + line: number + /** Heading depth (`##` is 2). */ + depth: number + /** Concatenated plain text of the heading. */ + label: string +} + +/** + * List a document's headings with their start lines via the pairing-gate parser. + * + * @param markdown - Document text. + * @returns Headings in document order. + */ +export function headingSections(markdown: string): HeadingSection[] { + const out: HeadingSection[] = [] + const visit = (node: Nodes): void => { + if (node.type === 'heading') { + let label = '' + const collect = (child: Nodes): void => { + if ('value' in child && typeof child.value === 'string') label += child.value + if ('children' in child) for (const grandchild of child.children) collect(grandchild) + } + for (const child of node.children) collect(child) + out.push({ line: node.position?.start.line ?? 1, depth: node.depth, label }) + } + if ('children' in node) for (const child of node.children) visit(child) + } + visit(parseTranslationMarkdown(markdown)) + return out +} + +/** Section index containing a 1-based line: 0 is the preamble before the first heading, i is the i-th heading's section. */ +function sectionOf(line: number, headings: HeadingSection[]): number { + let section = 0 + for (let index = 0; index < headings.length; index++) { + const heading = headings[index] + if (heading !== undefined && heading.line <= line) section = index + 1 + } + return section +} + +/** + * Map diff hunks to the section indices they touch in the diffed document. + * + * @param hunks - Hunks in the diffed document's old-side coordinates. + * @param headings - The diffed document's headings at that same old state. + * @returns Ascending section indices (0 = preamble). + */ +export function mapHunksToSections(hunks: DiffHunk[], headings: HeadingSection[]): number[] { + const sections = new Set() + for (const hunk of hunks) { + const first = sectionOf(Math.max(hunk.start, 1), headings) + const last = sectionOf(Math.max(hunk.start + Math.max(hunk.count - 1, 0), 1), headings) + for (let section = first; section <= last; section++) sections.add(section) + } + return [...sections].sort((a, b) => a - b) +} + +/** One counterpart section to update, with its current location. */ +export interface CounterpartSection { + /** Heading label, or the preamble marker for section 0. */ + label: string + /** 1-based line the section starts on in the counterpart file. */ + startLine: number + /** Current section text, trailing blank lines trimmed. */ + text: string +} + +/** + * Extract the counterpart's text for the given section indices. + * + * Callers must only pass indices produced against a structurally aligned + * pair (same heading count and order), which the pairing gate guarantees + * for a recorded-consistent state. + * + * @param counterpart - Current counterpart document text. + * @param sections - Ascending section indices (0 = preamble). + * @returns One entry per requested section. + */ +export function extractCounterpartSections(counterpart: string, sections: number[]): CounterpartSection[] { + const headings = headingSections(counterpart) + const lines = counterpart.split('\n') + return sections.map((section) => { + const heading = section === 0 ? undefined : headings[section - 1] + const startLine = heading?.line ?? 1 + const nextHeading = headings[section] + const endLine = nextHeading === undefined ? lines.length : nextHeading.line - 1 + const body = lines.slice(startLine - 1, endLine) + while (body.length > 0 && body.at(-1) === '') body.pop() + return { + label: heading === undefined ? '(preamble before the first heading)' : `${'#'.repeat(heading.depth)} ${heading.label}`, + startLine, + text: body.join('\n'), + } + }) +} + +/** Terminology rows relevant to one diff, grouped under their table header. */ +export interface TerminologyMatches { + /** The matched rows' shared header row, or undefined when no row matched. */ + header?: string | undefined + /** Matched data rows, verbatim, in table order. */ + rows: string[] +} + +/** Strip Markdown emphasis and code markers from a terminology cell. */ +function plainTerm(cell: string): string { + return cell.replaceAll('`', '').replaceAll('**', '').trim() +} + +/** + * Select the terminology rows whose English or Chinese term occurs in the diff. + * + * English terms match case-insensitively on non-alphanumeric boundaries; + * Chinese terms match by substring. + * + * @param terminology - Full `docs/i18n/terminology.md` contents. + * @param changedText - Changed diff lines (see {@link changedLinesOfDiff}). + * @returns Matched rows under their header. + */ +export function matchTerminologyRows(terminology: string, changedText: string): TerminologyMatches { + const matches: TerminologyMatches = { rows: [] } + let header: string | undefined + for (const line of terminology.split('\n')) { + if (!line.startsWith('|')) continue + if (/^\|[\s:|-]+\|$/.test(line)) continue + const cells = line.split('|').map(cell => cell.trim()) + if (line.includes('English') && line.includes('中文')) { + header = line + continue + } + const english = plainTerm(cells[1] ?? '') + const chinese = plainTerm(cells[2] ?? '') + const escaped = english.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const englishHit = english.length > 1 && new RegExp(`(? longest) longest = run[1].length + } + return mark.repeat(longest + 1) +} + +/** The two update directions a pair supports. */ +export type BriefDirection = 'en-to-zh' | 'zh-to-en' + +/** Inputs for rendering one pair's briefing. */ +export interface TranslationBriefInput { + /** Repo-relative path of the side that changed. */ + sourcePath: string + /** Repo-relative path of the counterpart to update. */ + counterpartPath: string + direction: BriefDirection + /** Unified diff of the changed side, last-confirmed to current. */ + diff: string + /** Counterpart sections the diff maps to, or undefined when alignment is untrusted. */ + counterpartSections?: CounterpartSection[] | undefined + /** Whether both sides drifted since the last confirmed state. */ + bothDrifted: boolean + terminology: TerminologyMatches +} + +const ZH_TARGET_DIGEST = [ + '- Edit ONLY what the diff requires; preserve the reviewed phrasing of everything unchanged.', + '- Nothing added, nothing dropped: the Chinese must state exactly what the new English states.', + '- Write natural institutional technical Chinese, not word-by-word gloss; terse stays terse.', + '- Code fences byte-identical to the English side, comments included; inline code spans verbatim.', + '- Relative links keep the `.md` target; only the switcher line links `.zh.md`.', + '- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.', + '- Typography: one half-width space between Chinese and Latin or digits; full-width punctuation in Chinese prose; 顿号 for enumerations; second person is 你.', + '- One physical line per paragraph; exactly one trailing newline.', +] + +const EN_TARGET_DIGEST = [ + '- Edit ONLY what the diff requires; preserve the reviewed phrasing of everything unchanged.', + '- Nothing added, nothing dropped: the English must state exactly what the new Chinese states.', + '- Write concise professional developer prose, not word-by-word gloss; terse stays terse.', + '- Code fences byte-identical to the Chinese side, comments included; inline code spans verbatim.', + '- Relative links keep the `.md` target; only the switcher line links `.zh.md`.', + '- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.', + '- One physical line per paragraph; exactly one trailing newline.', +] + +/** + * Render the complete briefing for one out-of-sync pair. + * + * @param input - Diff, mapped sections, terminology, and pair identity. + * @returns Markdown briefing text. + */ +export function renderTranslationBrief(input: TranslationBriefInput): string { + const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese' + const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English' + const out: string[] = [] + out.push(`# Translation update briefing: ${input.sourcePath}`) + out.push('') + out.push(input.bothDrifted + ? `WARNING: BOTH sides changed since the pair was last confirmed consistent. Reconcile the two sides by hand — decide which side owns each divergence per docs/i18n/translation-rules.md — before recording. The diff below covers the ${sourceLanguage} side only.` + : `The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the diff. The ${counterpartLanguage} side is untouched since the pair was last confirmed consistent.`) + out.push('') + out.push(`## ${sourceLanguage} diff (last-confirmed → current)`) + out.push('') + const diffFence = fenceFor(input.diff, '`') + out.push(`${diffFence}diff`) + out.push(input.diff.trimEnd()) + out.push(diffFence) + if (input.counterpartSections !== undefined) { + out.push('') + out.push(`## ${counterpartLanguage} text to update (aligned sections, current line numbers)`) + for (const section of input.counterpartSections) { + out.push('') + out.push(`### ${section.label} — ${input.counterpartPath}:${section.startLine}`) + out.push('') + const fence = fenceFor(section.text, '~') + out.push(`${fence}markdown`) + out.push(section.text) + out.push(fence) + } + } else { + out.push('') + out.push(`Counterpart sections are not shown: the pair's heading structures do not align at the compared states, so open \`${input.counterpartPath}\` directly and locate the regions yourself.`) + } + if (input.terminology.rows.length > 0 && input.terminology.header !== undefined) { + out.push('') + out.push('## Binding terminology rows matching this diff (docs/i18n/terminology.md)') + out.push('') + out.push(input.terminology.header) + out.push(`|${' --- |'.repeat(Math.max(input.terminology.header.split('|').length - 2, 1))}`) + for (const row of input.terminology.rows) out.push(row) + out.push('') + out.push('For any term you introduce that is not listed above, consult the full table before inventing a rendering.') + } + out.push('') + out.push('## Rules digest (full rules: docs/i18n/translation-rules.md)') + out.push('') + out.push(...(input.direction === 'en-to-zh' ? ZH_TARGET_DIGEST : EN_TARGET_DIGEST)) + out.push('') + out.push('## Finish') + out.push('') + out.push('1. Apply the smallest counterpart edit that covers the diff, then verify the changed hunks clause by clause against the source.') + out.push(`2. \`pnpm run verify-translation-pairing --write ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``) + out.push(`3. \`pnpm run verify-translation-pairing ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``) + out.push('') + return out.join('\n') +} diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index c158b3020a..da33dfdd6b 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest' import { isTranslationScopeFile, + pairAnchorOfArgument, parseTranslationMarkdown, + parseTranslationPairingCliArgs, parseTranslationPairingManifest, translationStructureDiff, translationStructureSignature, @@ -102,3 +104,40 @@ describe('translation structural signature', () => { ]) }) }) + +describe('pair CLI arguments', () => { + it('normalizes any pair file or bare stem to the English anchor', () => { + expect(pairAnchorOfArgument('docs/foo.md')).toBe('docs/foo.md') + expect(pairAnchorOfArgument('docs/foo.zh.md')).toBe('docs/foo.md') + expect(pairAnchorOfArgument('docs/foo.i18n.yaml')).toBe('docs/foo.md') + expect(pairAnchorOfArgument('docs/foo')).toBe('docs/foo.md') + expect(pairAnchorOfArgument('.\\docs\\foo.zh.md')).toBe('docs/foo.md') + }) + + it('scopes a check to named pairs and dedupes the three spellings', () => { + expect(parseTranslationPairingCliArgs(['docs/foo.zh.md', 'docs/foo.i18n.yaml', 'docs/bar.md'])).toEqual({ + mode: 'check', + scope: 'pairs', + anchors: ['docs/bar.md', 'docs/foo.md'], + }) + expect(parseTranslationPairingCliArgs([])).toEqual({ mode: 'check', scope: 'corpus', anchors: [] }) + }) + + it('requires --write to name confirmed pairs or opt into --all', () => { + expect(() => parseTranslationPairingCliArgs(['--write'])).toThrow('requires the pair(s) you confirmed') + expect(parseTranslationPairingCliArgs(['--write', 'docs/foo.md'])).toEqual({ + mode: 'write', + scope: 'pairs', + anchors: ['docs/foo.md'], + }) + expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({ mode: 'write', scope: 'corpus', anchors: [] }) + expect(() => parseTranslationPairingCliArgs(['--write', '--all', 'docs/foo.md'])).toThrow('not both') + }) + + it('keeps --list corpus-only and rejects unknown flags', () => { + expect(parseTranslationPairingCliArgs(['--list'])).toEqual({ mode: 'list', scope: 'corpus', anchors: [] }) + expect(() => parseTranslationPairingCliArgs(['--list', 'docs/foo.md'])).toThrow('takes no other flags or paths') + expect(() => parseTranslationPairingCliArgs(['--all'])).toThrow('--all only applies to --write') + expect(() => parseTranslationPairingCliArgs(['--frobnicate'])).toThrow('unknown flag(s): --frobnicate') + }) +}) diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index a7c626dddd..9aed5bd7b4 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -100,6 +100,66 @@ export function parseTranslationPairingManifest(content: string): TranslationPai return { excluded: excludedField(record) } } +/** + * Normalize one CLI pair argument to its English anchor path: any of the + * pair's three files (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`) or the bare + * `foo` stem names the same pair, and platform separators are accepted. + * + * @param argument - Repo-relative path as passed on a command line. + * @returns The pair's `foo.md` anchor path with `/` separators. + */ +export function pairAnchorOfArgument(argument: string): string { + const normalized = argument.split('\\').join('/').replace(/^\.\//, '') + if (normalized.endsWith('.zh.md')) return `${normalized.slice(0, -'.zh.md'.length)}.md` + if (normalized.endsWith('.i18n.yaml')) return `${normalized.slice(0, -'.i18n.yaml'.length)}.md` + if (normalized.endsWith('.md')) return normalized + return `${normalized}.md` +} + +/** A parsed `verify-translation-pairing` invocation. */ +export interface TranslationPairingCliRequest { + mode: 'check' | 'list' | 'write' + /** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */ + scope: 'corpus' | 'pairs' + /** English anchor paths, empty for corpus scope. */ + anchors: string[] +} + +/** + * Parse and validate `verify-translation-pairing` CLI arguments. + * + * Check accepts optional pair paths; `--write` requires either pair paths or + * `--all` so a bulk re-record is always an explicit choice — a bare + * `--write` would silently bless every drifted pair in the tree, including + * ones the caller never confirmed. `--list` is corpus-only. + * + * @param argv - Arguments after the script name. + * @returns The validated request. + * @throws Error when flags or their combination are invalid. + */ +export function parseTranslationPairingCliArgs(argv: string[]): TranslationPairingCliRequest { + const flags = argv.filter(argument => argument.startsWith('--')) + const anchors = [...new Set(argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument))].sort() + const unknown = flags.filter(flag => !['--list', '--write', '--all'].includes(flag)) + if (unknown.length > 0) throw new Error(`unknown flag(s): ${unknown.join(', ')}`) + const listMode = flags.includes('--list') + const writeMode = flags.includes('--write') + const allMode = flags.includes('--all') + if (listMode && (writeMode || allMode || anchors.length > 0)) { + throw new Error('--list reports the whole corpus and takes no other flags or paths') + } + if (allMode && !writeMode) throw new Error('--all only applies to --write') + if (writeMode) { + if (anchors.length > 0 && allMode) throw new Error('--write takes either pair paths or --all, not both') + if (anchors.length === 0 && !allMode) { + throw new Error('--write requires the pair(s) you confirmed (any file of a pair), or --all to re-record every complete pair; recording pairs you did not review blesses unconfirmed content') + } + return { mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors } + } + if (listMode) return { mode: 'list', scope: 'corpus', anchors: [] } + return { mode: 'check', scope: anchors.length > 0 ? 'pairs' : 'corpus', anchors } +} + /** The structural surface compared between the two sides of a pair. */ export interface TranslationStructureSignature { /** Heading depths in document order (h2 -> 2). */ diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index d1211c3dd4..afa458f8a5 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -2,8 +2,10 @@ * Enforce complete English/Chinese pairs, matching structure, and recorded git * blob hashes for every in-scope document. The manifest contains only explicit * exclusions, which may have neither a counterpart nor a sidecar. - * `--list` reports state and `--write` records both sides after human review. - * Translation quality remains a review responsibility. + * `--list` reports state; `--write ` records the named confirmed + * pairs (`--write --all` records every complete pair); a check or write named + * with pair paths touches only those pairs, so update iteration does not pay + * for a corpus scan. Translation quality remains a review responsibility. * See `docs/i18n/README.md` for the owning contract. */ @@ -13,6 +15,7 @@ import { basename, join, resolve, sep } from 'node:path' import { linksTo, parseTranslationMarkdown, + parseTranslationPairingCliArgs, parseTranslationPairingManifest, isTranslationScopeFile, TRANSLATION_SCOPE_GLOB_EXCLUDES, @@ -21,8 +24,15 @@ import { } from './translation-pairing.ts' const root = resolve(import.meta.dirname, '..') -const listMode = process.argv.includes('--list') -const writeMode = process.argv.includes('--write') +let request: ReturnType +try { + request = parseTranslationPairingCliArgs(process.argv.slice(2)) +} catch (error) { + console.error(`verify-translation-pairing: ${error instanceof Error ? error.message : String(error)}`) + process.exit(2) +} +const listMode = request.mode === 'list' +const writeMode = request.mode === 'write' /** Discover source Markdown and pairing sidecars before applying the corpus predicate. */ const SCOPE_PATTERNS = [ @@ -77,32 +87,67 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri '# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each', '# side as of the last confirmed-consistent state. Both languages carry equal authority;', '# after editing either side, bring the other along and re-record with:', - '# pnpm run verify-translation-pairing --write', + `# pnpm run verify-translation-pairing --write ${source}`, `${basename(source)}: ${sourceHash}`, `${basename(zh)}: ${zhHash}`, '', ].join('\n') } -// Enumerate the scope once. +// Enumerate the scope once: the whole corpus, or exactly the named pairs' +// three files (a named pair whose files are absent is caught by the same +// completeness rules that cover discovered remnants). const files = new Set() -for (const pattern of SCOPE_PATTERNS) { - for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) { - const normalized = match.split(sep).join('/') - if (isTranslationScopeFile(normalized)) files.add(normalized) +if (request.scope === 'pairs') { + for (const anchor of request.anchors) { + for (const file of [anchor, ...Object.values(pairPaths(anchor))]) { + if (existsSync(join(root, file))) files.add(file) + } + // A named anchor with no files on disk still enters the source list so + // the check reports it instead of silently passing an empty scope. + if (!existsSync(join(root, anchor))) files.add(anchor) + } +} else { + for (const pattern of SCOPE_PATTERNS) { + for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) { + const normalized = match.split(sep).join('/') + if (isTranslationScopeFile(normalized)) files.add(normalized) + } } } const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort() const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort() -// --write: (re)record both hashes for every complete pair, creating missing records. +if (request.scope === 'pairs') { + const rejected = request.anchors.filter(anchor => !isTranslationScopeFile(anchor) || isExcluded(anchor)) + const absent = request.anchors.filter(anchor => ![anchor, ...Object.values(pairPaths(anchor))].some(file => existsSync(join(root, file)))) + if (rejected.length > 0 || absent.length > 0) { + for (const anchor of rejected) { + console.error(`verify-translation-pairing: ${anchor} is not an in-scope pair (excluded or outside the documentation corpus; see docs/i18n/README.md)`) + } + for (const anchor of absent) { + console.error(`verify-translation-pairing: ${anchor} names no pair on disk (none of its three files exist)`) + } + process.exit(2) + } +} + +// --write: (re)record both hashes for the requested complete pairs, creating +// missing records. A named pair that cannot be recorded (missing counterpart) +// fails loud; corpus scope (--all) skips pairless sources as before. if (writeMode) { let written = 0 for (const source of sources) { if (isExcluded(source)) continue const { zh, meta } = pairPaths(source) - if (!existsSync(join(root, zh))) continue + if (!existsSync(join(root, source)) || !existsSync(join(root, zh))) { + if (request.scope === 'pairs') { + console.error(`verify-translation-pairing: cannot record ${source}: missing ${existsSync(join(root, source)) ? zh : source}`) + process.exit(2) + } + continue + } const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh)))) if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue writeFileSync(join(root, meta), record) @@ -204,7 +249,9 @@ if (listMode) { } if (errors.length === 0) { - console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`) + console.log(request.scope === 'pairs' + ? `verify-translation-pairing: ${pairAnchors.size} named pair(s) consistent; the corpus-wide check still runs in doc-sync.` + : `verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`) process.exit(0) }