2026-07-27 00:40:49 +08:00
/**
* Print the minimal-update briefing for out-of-sync translation pairs:
2026-07-27 02:31:07 +08:00
* `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 contract lives in `scripts/translation-brief.ts`; the
* consuming workflow is `.agents/skills/dsh-translate-docs/SKILL.md`.
2026-07-27 00:40:49 +08:00
*/
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 ,
2026-07-27 02:31:07 +08:00
parseTranslationMarkdown ,
2026-07-27 00:40:49 +08:00
parseTranslationPairingManifest ,
TRANSLATION_SCOPE_GLOB_EXCLUDES ,
2026-07-27 02:31:07 +08:00
translationStructureDiff ,
translationStructureSignature ,
2026-07-27 00:40:49 +08:00
} from './translation-pairing.ts'
import {
2026-07-27 02:31:07 +08:00
changedSpanIndices ,
computeMechanicalUpdate ,
firstOccurrenceContext ,
markdownUnits ,
relevantTerminologyRows ,
2026-07-27 00:40:49 +08:00
renderTranslationBrief ,
2026-07-27 02:31:07 +08:00
sectionSpans ,
spansAligned ,
type BriefBundle ,
2026-07-27 00:40:49 +08:00
type BriefDirection ,
2026-07-27 02:31:07 +08:00
type BriefScope ,
type MarkdownSpan ,
2026-07-27 00:40:49 +08:00
} 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 < 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 ,
}
}
2026-07-27 02:31:07 +08:00
/** 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. ` )
2026-07-27 00:40:49 +08:00
}
2026-07-27 02:31:07 +08:00
/** Render (and under `--apply`, apply) the briefing for one drifted side. */
function briefDirection ( pair : PairState , direction : BriefDirection , apply : boolean ) : string {
2026-07-27 00:40:49 +08:00
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 )
2026-07-27 02:31:07 +08:00
const planned = planScope ( sourceLast , sourceCurrent , counterpartCurrent , direction , pair . enDrifted && pair . zhDrifted )
if ( apply && planned . mechanicalResult !== undefined ) {
applyMechanical ( counterpartPath , sourceCurrent , planned . mechanicalResult )
2026-07-27 00:40:49 +08:00
}
return renderTranslationBrief ( {
sourcePath ,
counterpartPath ,
direction ,
diff ,
2026-07-27 02:31:07 +08:00
scope : planned.scope ,
terminology : relevantTerminologyRows ( terminology , direction , planned . changedText ) ,
2026-07-27 00:40:49 +08:00
} )
}
2026-07-27 02:31:07 +08:00
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 )
2026-07-27 00:40:49 +08:00
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
}
2026-07-27 02:31:07 +08:00
if ( pair . enDrifted ) briefs . push ( briefDirection ( pair , 'en-to-zh' , applyMode ) )
if ( pair . zhDrifted ) briefs . push ( briefDirection ( pair , 'zh-to-en' , applyMode ) )
2026-07-27 00:40:49 +08:00
}
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' ) )