/** * Print the minimal-update briefing for out-of-sync translation pairs: * `pnpm run gen-translation-brief [--apply] [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. Each briefing maps the change at * the narrowest safe granularity — code-fence-only splice, changed * Markdown units, heading sections, whole document — and `--apply` writes * the computed counterpart for pairs whose change is code-fence-only. * The briefing rules live 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, parseTranslationMarkdown, parseTranslationPairingManifest, TRANSLATION_SCOPE_GLOB_EXCLUDES, translationStructureDiff, translationStructureSignature, } from './translation-pairing.ts' import { changedSpanIndices, computeMechanicalUpdate, firstOccurrenceContext, markdownUnits, relevantTerminologyRows, renderTranslationBrief, sectionSpans, spansAligned, type BriefBundle, type BriefDirection, type BriefScope, type MarkdownSpan, } 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, } } /** Assemble bundles for the given changed + first-occurrence span indices. */ function bundlesFor( indices: number[], extraIndices: number[], confirmed: MarkdownSpan[], current: MarkdownSpan[], counterpart: MarkdownSpan[], ): BriefBundle[] { const extras = new Set(extraIndices) return [...new Set([...indices, ...extraIndices])].sort((left, right) => left - right).map((index) => { const confirmedSpan = confirmed[index] const currentSpan = current[index] const counterpartSpan = counterpart[index] if (confirmedSpan === undefined || currentSpan === undefined || counterpartSpan === undefined) { throw new Error(`gen-translation-brief: span ${index} is unmapped despite alignment`) } return { index, label: currentSpan.label, reason: extras.has(index) && confirmedSpan.text === currentSpan.text ? 'first-occurrence' as const : undefined, confirmedSourceText: confirmedSpan.text, currentSourceText: currentSpan.text, counterpartText: counterpartSpan.text, counterpartStartLine: counterpartSpan.startLine, } }) } interface PlannedBrief { scope: BriefScope /** Old + new text of the changed spans, for terminology matching. */ changedText: string /** Computed counterpart for a mechanical scope, for `--apply`. */ mechanicalResult?: string | undefined } /** Choose the narrowest safely mapped granularity for one drifted side. */ function planScope( sourceLast: string, sourceCurrent: string, counterpartCurrent: string, direction: BriefDirection, bothDrifted: boolean, ): PlannedBrief { const wholeChangedText = `${sourceLast}\n${sourceCurrent}` if (bothDrifted) { return { scope: { kind: 'document', reason: 'BOTH sides changed since the pair was last confirmed consistent, so no side is a trustworthy mapping anchor; decide which side owns each divergence.' }, changedText: wholeChangedText, } } const mechanical = computeMechanicalUpdate(sourceLast, sourceCurrent, counterpartCurrent) if (mechanical !== undefined) { return { scope: { kind: 'mechanical' }, changedText: wholeChangedText, mechanicalResult: mechanical } } for (const [kind, spansOf] of [['units', markdownUnits], ['sections', sectionSpans]] as const) { const confirmed = spansOf(sourceLast) const current = spansOf(sourceCurrent) const counterpart = spansOf(counterpartCurrent) if (!spansAligned(confirmed, current) || !spansAligned(confirmed, counterpart)) continue const changed = changedSpanIndices(confirmed, current) if (changed.length === 0) continue const changedText = changed.map(index => `${confirmed[index]?.text ?? ''}\n${current[index]?.text ?? ''}`).join('\n') const rows = relevantTerminologyRows(terminology, direction, changedText) const occurrence = direction === 'en-to-zh' ? firstOccurrenceContext(sourceLast, sourceCurrent, confirmed, current, rows, new Set(changed)) : { notes: [], extraSpanIndices: [] } return { scope: { kind, bundles: bundlesFor(changed, occurrence.extraSpanIndices, confirmed, current, counterpart), firstOccurrenceNotes: occurrence.notes, }, changedText, } } return { scope: { kind: 'document', reason: 'Neither fine-grained units nor heading sections align one to one across the last-confirmed source, current source, and current counterpart.' }, changedText: wholeChangedText, } } /** Validate a computed mechanical counterpart and write it. */ function applyMechanical(counterpartPath: string, sourceCurrent: string, result: string): void { const counterpartBase = basename(counterpartPath) const sourceBase = counterpartBase.endsWith('.zh.md') ? counterpartBase.replace(/\.zh\.md$/, '.md') : counterpartBase.replace(/\.md$/, '.zh.md') const errors = translationStructureDiff( translationStructureSignature(parseTranslationMarkdown(sourceCurrent), counterpartBase), translationStructureSignature(parseTranslationMarkdown(result), sourceBase), ) if (errors.length > 0) { throw new Error(`gen-translation-brief: computed mechanical update for ${counterpartPath} violates the pair structure: ${errors.join('; ')}`) } writeFileSync(join(root, counterpartPath), result) console.error(`gen-translation-brief: applied code-fence splice to ${counterpartPath}; review the diff, then record the pair.`) } /** Render (and under `--apply`, apply) the briefing for one drifted side. */ function briefDirection(pair: PairState, direction: BriefDirection, apply: boolean): 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 planned = planScope(sourceLast, sourceCurrent, counterpartCurrent, direction, pair.enDrifted && pair.zhDrifted) if (apply && planned.mechanicalResult !== undefined) { applyMechanical(counterpartPath, sourceCurrent, planned.mechanicalResult) } return renderTranslationBrief({ sourcePath, counterpartPath, direction, diff, scope: planned.scope, terminology: relevantTerminologyRows(terminology, direction, planned.changedText), }) } const argv = process.argv.slice(2) const flags = argv.filter(argument => argument.startsWith('--')) const unknownFlags = flags.filter(flag => flag !== '--apply') if (unknownFlags.length > 0) { console.error(`gen-translation-brief: unknown flag(s): ${unknownFlags.join(', ')} (only --apply is supported)`) process.exit(2) } const applyMode = flags.includes('--apply') const requested = argv.filter(argument => !argument.startsWith('--')).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', applyMode)) if (pair.zhDrifted) briefs.push(briefDirection(pair, 'zh-to-en', applyMode)) } 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'))