gen-translation-brief.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. /**
  2. * Print the minimal-update briefing for out-of-sync translation pairs:
  3. * `pnpm run gen-translation-brief [--apply] [pair paths...]`. With no
  4. * arguments it discovers every out-of-sync pair; with arguments (any file
  5. * of a pair) it briefs exactly those pairs and fails loud on in-sync,
  6. * incomplete, or out-of-scope requests. Each briefing maps the change at
  7. * the narrowest safe granularity — code-fence-only splice, changed
  8. * Markdown units, heading sections, whole document — and `--apply` writes
  9. * the computed counterpart for pairs whose change is code-fence-only.
  10. * The briefing rules live in `scripts/translation-brief.ts`; the
  11. * consuming workflow is `.agents/skills/dsh-translate-docs/SKILL.md`.
  12. */
  13. import { spawnSync } from 'node:child_process'
  14. import { existsSync, globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  15. import { tmpdir } from 'node:os'
  16. import { basename, join, resolve, sep } from 'node:path'
  17. import {
  18. isTranslationPairingManifestExcluded,
  19. isTranslationScopeFile,
  20. pairAnchorOfArgument,
  21. parseTranslationMarkdown,
  22. parseTranslationPairingManifest,
  23. TRANSLATION_SCOPE_GLOB_EXCLUDES,
  24. translationPairSourcePredicate,
  25. translationStructureDiff,
  26. translationStructureSignature,
  27. } from './translation-pairing.ts'
  28. import {
  29. changedSpanIndices,
  30. computeMechanicalUpdate,
  31. firstOccurrenceContext,
  32. markdownUnits,
  33. relevantTerminologyRows,
  34. renderTranslationBrief,
  35. sectionSpans,
  36. spansAligned,
  37. type BriefBundle,
  38. type BriefDirection,
  39. type BriefScope,
  40. type MarkdownSpan,
  41. } from './translation-brief.ts'
  42. const root = resolve(import.meta.dirname, '..')
  43. const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
  44. const isTranslationPairSource = translationPairSourcePredicate(manifest)
  45. const terminology = readFileSync(join(root, 'docs/i18n/terminology.md'), 'utf8')
  46. function isExcluded(file: string): boolean {
  47. return isTranslationPairingManifestExcluded(file, manifest)
  48. }
  49. /** Recorded hashes of one consistency record: basename → blob hash. */
  50. function parseMeta(content: string): Map<string, string> | undefined {
  51. const out = new Map<string, string>()
  52. for (const line of content.split('\n')) {
  53. if (line === '' || line.startsWith('#')) continue
  54. const match = /^([^:#]+\.md): ([0-9a-f]{40})$/.exec(line)
  55. if (!match?.[1] || !match[2]) return undefined
  56. out.set(match[1], match[2])
  57. }
  58. return out
  59. }
  60. function git(args: string[], allowedExitCodes: number[] = [0]): string {
  61. const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 1 << 26 })
  62. if (result.error) throw result.error
  63. if (!allowedExitCodes.includes(result.status ?? -1)) {
  64. throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
  65. }
  66. return result.stdout
  67. }
  68. function blobText(hash: string): string {
  69. return git(['cat-file', '-p', hash])
  70. }
  71. /** Unified diff between two texts, headers stripped, via `git diff --no-index`. */
  72. function diffTexts(before: string, after: string): string {
  73. const dir = mkdtempSync(join(tmpdir(), 'translation-brief-'))
  74. try {
  75. writeFileSync(join(dir, 'last-confirmed.md'), before)
  76. writeFileSync(join(dir, 'current.md'), after)
  77. const raw = git(['diff', '--no-index', '--unified=2', join(dir, 'last-confirmed.md'), join(dir, 'current.md')], [0, 1])
  78. return raw.split('\n')
  79. .filter(line => !line.startsWith('diff --git') && !line.startsWith('index ') && !line.startsWith('--- ') && !line.startsWith('+++ '))
  80. .join('\n')
  81. .trim()
  82. } finally {
  83. rmSync(dir, { recursive: true, force: true })
  84. }
  85. }
  86. interface PairState {
  87. anchor: string
  88. zh: string
  89. meta: string
  90. enDrifted: boolean
  91. zhDrifted: boolean
  92. enLast: string
  93. zhLast: string
  94. }
  95. /** Load one pair's recorded and current state, or explain why it cannot be briefed. */
  96. function loadPair(anchor: string): PairState | string {
  97. const zh = anchor.replace(/\.md$/, '.zh.md')
  98. const meta = anchor.replace(/\.md$/, '.i18n.yaml')
  99. if (!isTranslationScopeFile(anchor) || isExcluded(anchor)) {
  100. return `${anchor}: not an in-scope documentation pair (docs/i18n/README.md)`
  101. }
  102. const missing = [anchor, zh, meta].filter(file => !existsSync(join(root, file)))
  103. if (missing.length > 0) {
  104. return `${anchor}: incomplete pair (missing ${missing.join(', ')}) — a new counterpart is whole-document translation work, not a minimal update`
  105. }
  106. const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
  107. const enRecorded = record?.get(basename(anchor))
  108. const zhRecorded = record?.get(basename(zh))
  109. if (record === undefined || enRecorded === undefined || zhRecorded === undefined) {
  110. return `${meta}: malformed consistency record`
  111. }
  112. const enCurrent = readFileSync(join(root, anchor), 'utf8')
  113. const zhCurrent = readFileSync(join(root, zh), 'utf8')
  114. const enLast = blobText(enRecorded)
  115. const zhLast = blobText(zhRecorded)
  116. return {
  117. anchor,
  118. zh,
  119. meta,
  120. enDrifted: enCurrent !== enLast,
  121. zhDrifted: zhCurrent !== zhLast,
  122. enLast,
  123. zhLast,
  124. }
  125. }
  126. /** Assemble bundles for the given changed + first-occurrence span indices. */
  127. function bundlesFor(
  128. indices: number[],
  129. extraIndices: number[],
  130. confirmed: MarkdownSpan[],
  131. current: MarkdownSpan[],
  132. counterpart: MarkdownSpan[],
  133. ): BriefBundle[] {
  134. const extras = new Set(extraIndices)
  135. return [...new Set([...indices, ...extraIndices])].sort((left, right) => left - right).map((index) => {
  136. const confirmedSpan = confirmed[index]
  137. const currentSpan = current[index]
  138. const counterpartSpan = counterpart[index]
  139. if (confirmedSpan === undefined || currentSpan === undefined || counterpartSpan === undefined) {
  140. throw new Error(`gen-translation-brief: span ${index} is unmapped despite alignment`)
  141. }
  142. return {
  143. index,
  144. label: currentSpan.label,
  145. reason: extras.has(index) && confirmedSpan.text === currentSpan.text ? 'first-occurrence' as const : undefined,
  146. confirmedSourceText: confirmedSpan.text,
  147. currentSourceText: currentSpan.text,
  148. counterpartText: counterpartSpan.text,
  149. counterpartStartLine: counterpartSpan.startLine,
  150. }
  151. })
  152. }
  153. interface PlannedBrief {
  154. scope: BriefScope
  155. /** Old + new text of the changed spans, for terminology matching. */
  156. changedText: string
  157. /** Computed counterpart for a mechanical scope, for `--apply`. */
  158. mechanicalResult?: string | undefined
  159. }
  160. /** Choose the narrowest safely mapped granularity for one drifted side. */
  161. function planScope(
  162. sourceLast: string,
  163. sourceCurrent: string,
  164. counterpartCurrent: string,
  165. direction: BriefDirection,
  166. bothDrifted: boolean,
  167. ): PlannedBrief {
  168. const wholeChangedText = `${sourceLast}\n${sourceCurrent}`
  169. if (bothDrifted) {
  170. return {
  171. 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.' },
  172. changedText: wholeChangedText,
  173. }
  174. }
  175. const mechanical = computeMechanicalUpdate(sourceLast, sourceCurrent, counterpartCurrent)
  176. if (mechanical !== undefined) {
  177. return { scope: { kind: 'mechanical' }, changedText: wholeChangedText, mechanicalResult: mechanical }
  178. }
  179. for (const [kind, spansOf] of [['units', markdownUnits], ['sections', sectionSpans]] as const) {
  180. const confirmed = spansOf(sourceLast)
  181. const current = spansOf(sourceCurrent)
  182. const counterpart = spansOf(counterpartCurrent)
  183. if (!spansAligned(confirmed, current) || !spansAligned(confirmed, counterpart)) continue
  184. const changed = changedSpanIndices(confirmed, current)
  185. if (changed.length === 0) continue
  186. const changedText = changed.map(index => `${confirmed[index]?.text ?? ''}\n${current[index]?.text ?? ''}`).join('\n')
  187. const rows = relevantTerminologyRows(terminology, direction, changedText)
  188. const occurrence = direction === 'en-to-zh'
  189. ? firstOccurrenceContext(sourceLast, sourceCurrent, confirmed, current, rows, new Set(changed))
  190. : { notes: [], extraSpanIndices: [] }
  191. return {
  192. scope: {
  193. kind,
  194. bundles: bundlesFor(changed, occurrence.extraSpanIndices, confirmed, current, counterpart),
  195. firstOccurrenceNotes: occurrence.notes,
  196. },
  197. changedText,
  198. }
  199. }
  200. return {
  201. 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.' },
  202. changedText: wholeChangedText,
  203. }
  204. }
  205. /** Validate a computed mechanical counterpart and write it. */
  206. function applyMechanical(counterpartPath: string, sourceCurrent: string, result: string): void {
  207. const counterpartBase = basename(counterpartPath)
  208. const sourcePath = counterpartPath.endsWith('.zh.md')
  209. ? counterpartPath.replace(/\.zh\.md$/, '.md')
  210. : counterpartPath.replace(/\.md$/, '.zh.md')
  211. const sourceBase = counterpartBase.endsWith('.zh.md')
  212. ? counterpartBase.replace(/\.zh\.md$/, '.md')
  213. : counterpartBase.replace(/\.md$/, '.zh.md')
  214. const errors = translationStructureDiff(
  215. translationStructureSignature(
  216. parseTranslationMarkdown(sourceCurrent),
  217. counterpartBase,
  218. { repoRoot: root, sourcePath, isTranslationPairSource, markdown: sourceCurrent },
  219. ),
  220. translationStructureSignature(
  221. parseTranslationMarkdown(result),
  222. sourceBase,
  223. { repoRoot: root, sourcePath: counterpartPath, isTranslationPairSource, markdown: result },
  224. ),
  225. )
  226. if (errors.length > 0) {
  227. throw new Error(`gen-translation-brief: computed mechanical update for ${counterpartPath} violates the pair structure: ${errors.join('; ')}`)
  228. }
  229. writeFileSync(join(root, counterpartPath), result)
  230. console.error(`gen-translation-brief: applied code-fence splice to ${counterpartPath}; review the diff, then record the pair.`)
  231. }
  232. /** Render (and under `--apply`, apply) the briefing for one drifted side. */
  233. function briefDirection(pair: PairState, direction: BriefDirection, apply: boolean): string {
  234. const sourceIsEnglish = direction === 'en-to-zh'
  235. const sourcePath = sourceIsEnglish ? pair.anchor : pair.zh
  236. const counterpartPath = sourceIsEnglish ? pair.zh : pair.anchor
  237. const sourceLast = sourceIsEnglish ? pair.enLast : pair.zhLast
  238. const sourceCurrent = readFileSync(join(root, sourcePath), 'utf8')
  239. const counterpartCurrent = readFileSync(join(root, counterpartPath), 'utf8')
  240. const diff = diffTexts(sourceLast, sourceCurrent)
  241. const planned = planScope(sourceLast, sourceCurrent, counterpartCurrent, direction, pair.enDrifted && pair.zhDrifted)
  242. if (apply && planned.mechanicalResult !== undefined) {
  243. applyMechanical(counterpartPath, sourceCurrent, planned.mechanicalResult)
  244. }
  245. return renderTranslationBrief({
  246. sourcePath,
  247. counterpartPath,
  248. direction,
  249. diff,
  250. scope: planned.scope,
  251. terminology: relevantTerminologyRows(terminology, direction, planned.changedText),
  252. })
  253. }
  254. const argv = process.argv.slice(2)
  255. const flags = argv.filter(argument => argument.startsWith('--'))
  256. const unknownFlags = flags.filter(flag => flag !== '--apply')
  257. if (unknownFlags.length > 0) {
  258. console.error(`gen-translation-brief: unknown flag(s): ${unknownFlags.join(', ')} (only --apply is supported)`)
  259. process.exit(2)
  260. }
  261. const applyMode = flags.includes('--apply')
  262. const requested = argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument)
  263. let anchors: string[]
  264. if (requested.length > 0) {
  265. anchors = [...new Set(requested)].sort()
  266. } else {
  267. const discovered = new Set<string>()
  268. for (const match of globSync('**/*.i18n.yaml', { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
  269. const normalized = match.split(sep).join('/')
  270. if (isTranslationScopeFile(normalized)) discovered.add(normalized.replace(/\.i18n\.yaml$/, '.md'))
  271. }
  272. anchors = [...discovered].sort()
  273. }
  274. const briefs: string[] = []
  275. const problems: string[] = []
  276. const skipped: string[] = []
  277. for (const anchor of anchors) {
  278. const pair = loadPair(anchor)
  279. if (typeof pair === 'string') {
  280. if (requested.length > 0) problems.push(pair)
  281. continue
  282. }
  283. if (!pair.enDrifted && !pair.zhDrifted) {
  284. if (requested.length > 0) skipped.push(`${anchor}: pair is consistent with its record — nothing to brief`)
  285. continue
  286. }
  287. if (pair.enDrifted) briefs.push(briefDirection(pair, 'en-to-zh', applyMode))
  288. if (pair.zhDrifted) briefs.push(briefDirection(pair, 'zh-to-en', applyMode))
  289. }
  290. if (problems.length > 0 || skipped.length > 0) {
  291. for (const message of [...problems, ...skipped]) console.error(`gen-translation-brief: ${message}`)
  292. process.exit(2)
  293. }
  294. if (briefs.length === 0) {
  295. console.log('gen-translation-brief: every recorded pair matches its consistency record; nothing to brief.')
  296. process.exit(0)
  297. }
  298. console.log(briefs.join('\n\n---\n\n'))