The briefing now maps each update at the narrowest safely aligned granularity, widening deterministically on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (--apply splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units — headings, paragraphs, table rows, list items, fences, block quotes, HTML blocks, thematic breaks, link definitions, matched by container-scoped kind sequences — each carry their last-confirmed source, current source, and current counterpart text; units that do not align fall back to depth-matched heading sections (depth only, so translated heading text still maps); and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping. Terminology rows now match the changed spans only, English terms on word boundaries with plural inflections, and Chinese-target briefings track each relevant term's document-wide first occurrence — a moved occurrence pulls the vacated and receiving spans into the briefing with an explanatory note. The unit mapping, mechanical code splice, and first-occurrence tracking adopt the planner design from the incremental prompt-pipeline PR (#684), whose provider-backed bake-off independently validated the same scope ladder; this PR carries those mechanics into the agent-facing briefing path so both consumers of the consistency records behave alike. The prior line-hunk section mapping and its heading-text alignment (which could not map cross-language sections) are replaced wholesale. Docs: SKILL.md update path, i18n README pair, development.md pair, and the briefed-updates Agent Note pair brought along; the development.md fence edit was applied with --apply itself, and the prose updates were made through the new unit/section briefings.
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
/**
|
|
* 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 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,
|
|
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<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 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<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'))
|