| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- /**
- * 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 {
- isTranslationPairingManifestExcluded,
- isTranslationScopeFile,
- pairAnchorOfArgument,
- parseTranslationMarkdown,
- parseTranslationPairingManifest,
- TRANSLATION_SCOPE_GLOB_EXCLUDES,
- translationPairSourcePredicate,
- 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 isTranslationPairSource = translationPairSourcePredicate(manifest)
- const terminology = readFileSync(join(root, 'docs/i18n/terminology.md'), 'utf8')
- function isExcluded(file: string): boolean {
- return isTranslationPairingManifestExcluded(file, manifest)
- }
- /** Recorded hashes of one consistency record: basename → blob hash. */
- function parseMeta(content: string): Map<string, string> | undefined {
- const out = new Map<string, string>()
- 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 sourcePath = counterpartPath.endsWith('.zh.md')
- ? counterpartPath.replace(/\.zh\.md$/, '.md')
- : counterpartPath.replace(/\.md$/, '.zh.md')
- const sourceBase = counterpartBase.endsWith('.zh.md')
- ? counterpartBase.replace(/\.zh\.md$/, '.md')
- : counterpartBase.replace(/\.md$/, '.zh.md')
- const errors = translationStructureDiff(
- translationStructureSignature(
- parseTranslationMarkdown(sourceCurrent),
- counterpartBase,
- { repoRoot: root, sourcePath, isTranslationPairSource, markdown: sourceCurrent },
- ),
- translationStructureSignature(
- parseTranslationMarkdown(result),
- sourceBase,
- { repoRoot: root, sourcePath: counterpartPath, isTranslationPairSource, markdown: result },
- ),
- )
- 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<string>()
- 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'))
|