verify-translation-pairing.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /**
  2. * Enforce complete English/Chinese pairs, matching structure, and recorded git
  3. * blob hashes under the bilingual manifest. Required files and date-named docs
  4. * at or after `requiredSince` must be paired; excluded docs may have neither a
  5. * counterpart nor sidecar. `--list` reports state and `--write` records both
  6. * sides after human review. Translation quality remains a review responsibility.
  7. * See `docs/i18n/README.md` for the owning contract.
  8. */
  9. import { createHash } from 'node:crypto'
  10. import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
  11. import { basename, join, resolve, sep } from 'node:path'
  12. import {
  13. datedDocumentDate,
  14. linksTo,
  15. parseTranslationMarkdown,
  16. parseTranslationPairingManifest,
  17. requiresPairByDate,
  18. translationStructureDiff,
  19. translationStructureSignature,
  20. } from './translation-pairing.ts'
  21. const root = resolve(import.meta.dirname, '..')
  22. const listMode = process.argv.includes('--list')
  23. const writeMode = process.argv.includes('--write')
  24. /** Scope of the bilingual contract: root docs, Agent Notes, the docs tree, and the Python SDK tree. */
  25. const SCOPE_PATTERNS = [
  26. 'README.md',
  27. 'README.zh.md',
  28. 'README.i18n.yaml',
  29. '.agents/notes/**/*.md',
  30. '.agents/notes/**/*.i18n.yaml',
  31. 'docs/**/*.md',
  32. 'docs/**/*.i18n.yaml',
  33. 'python/**/*.md',
  34. 'python/**/*.i18n.yaml',
  35. ]
  36. const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
  37. /**
  38. * An excluded entry ending in `/` excludes the whole directory. The trailing
  39. * slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a
  40. * sibling like `docs/tool-catalog-notes/x.md` — so directory entries in the
  41. * manifest must keep their trailing slash.
  42. */
  43. function isExcluded(file: string): boolean {
  44. return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
  45. }
  46. /** Full git blob hash (what `git hash-object` prints). */
  47. function blobHash(content: Buffer): string {
  48. const hash = createHash('sha1')
  49. hash.update(`blob ${content.byteLength}\0`)
  50. hash.update(content)
  51. return hash.digest('hex')
  52. }
  53. /** The three paths of a pair, derived from the English-file path. */
  54. function pairPaths(source: string): { zh: string; meta: string } {
  55. return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
  56. }
  57. const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
  58. /** Parse a `foo.i18n.yaml` consistency record: basename → recorded blob hash. */
  59. function parseMeta(content: string): Map<string, string> | undefined {
  60. const out = new Map<string, string>()
  61. for (const line of content.split('\n')) {
  62. if (line === '' || line.startsWith('#')) continue
  63. const match = META_LINE.exec(line)
  64. if (!match?.[1] || !match[2]) return undefined
  65. out.set(match[1], match[2])
  66. }
  67. return out
  68. }
  69. /** Render a `foo.i18n.yaml` consistency record. */
  70. function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
  71. return [
  72. '# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
  73. '# side as of the last confirmed-consistent state. Both languages carry equal authority;',
  74. '# after editing either side, bring the other along and re-record with:',
  75. '# pnpm run verify-translation-pairing --write',
  76. `${basename(source)}: ${sourceHash}`,
  77. `${basename(zh)}: ${zhHash}`,
  78. '',
  79. ].join('\n')
  80. }
  81. // Enumerate the scope once.
  82. const files = new Set<string>()
  83. for (const pattern of SCOPE_PATTERNS) {
  84. for (const match of globSync(pattern, { cwd: root })) files.add(match.split(sep).join('/'))
  85. }
  86. const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
  87. const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
  88. const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
  89. // --write: (re)record both hashes for every complete pair, creating missing records.
  90. if (writeMode) {
  91. let written = 0
  92. for (const source of sources) {
  93. if (isExcluded(source)) continue
  94. const { zh, meta } = pairPaths(source)
  95. if (!existsSync(join(root, zh))) continue
  96. const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
  97. if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
  98. writeFileSync(join(root, meta), record)
  99. console.log(`verify-translation-pairing: recorded ${meta}`)
  100. written++
  101. }
  102. console.log(`verify-translation-pairing: ${written} record(s) written; run the check to validate the pairs.`)
  103. process.exit(0)
  104. }
  105. const errors: string[] = []
  106. const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
  107. // 1. Required pairs exist.
  108. for (const req of manifest.required) {
  109. if (!existsSync(join(root, req))) {
  110. errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`)
  111. continue
  112. }
  113. const { zh } = pairPaths(req)
  114. if (!existsSync(join(root, zh))) {
  115. errors.push(`${req}: required to have a translation, but ${zh} does not exist`)
  116. state.set(req, 'missing')
  117. }
  118. }
  119. // 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge
  120. // bilingual: a new Agent Note lands with its pair or not at all. Deterministic from
  121. // the filename alone — no git history, so it holds on shallow CI checkouts.
  122. for (const source of sources) {
  123. if (isExcluded(source)) continue
  124. const date = datedDocumentDate(source)
  125. if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue
  126. const { zh } = pairPaths(source)
  127. if (!existsSync(join(root, zh))) {
  128. errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
  129. state.set(source, 'missing')
  130. }
  131. }
  132. // 3. Every pair that exists at all is complete and consistent. Anchor on the
  133. // union of .zh.md files and .i18n.yaml records so a half-deleted pair is
  134. // caught from either remnant.
  135. const pairAnchors = new Set<string>()
  136. for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
  137. for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
  138. for (const source of [...pairAnchors].sort()) {
  139. const { zh, meta } = pairPaths(source)
  140. const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) }
  141. if (isExcluded(source)) {
  142. if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
  143. if (have.meta) errors.push(`${meta}: ${source} is excluded from pairing; this consistency record must not exist`)
  144. continue
  145. }
  146. const missing = Object.entries(have).filter(([, ok]) => !ok).map(([k]) => (k === 'source' ? source : k === 'zh' ? zh : meta))
  147. if (missing.length > 0) {
  148. errors.push(`${source}: incomplete pair — missing ${missing.join(', ')} (pairs merge whole: both languages plus the .i18n.yaml record)`)
  149. continue
  150. }
  151. const sourceContent = readFileSync(join(root, source))
  152. const zhContent = readFileSync(join(root, zh))
  153. const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
  154. if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) {
  155. errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
  156. continue
  157. }
  158. let consistent = true
  159. for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
  160. const current = blobHash(content)
  161. if (record.get(basename(file)) !== current) {
  162. errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`)
  163. consistent = false
  164. }
  165. }
  166. if (!consistent) {
  167. state.set(source, 'out-of-sync')
  168. continue
  169. }
  170. const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
  171. const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
  172. if (!linksTo(zhTree, basename(source))) {
  173. errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
  174. }
  175. if (!linksTo(sourceTree, basename(zh))) {
  176. errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
  177. }
  178. for (const divergence of translationStructureDiff(
  179. translationStructureSignature(sourceTree, basename(zh)),
  180. translationStructureSignature(zhTree, basename(source)),
  181. )) {
  182. errors.push(`${source} ↔ ${zh}: ${divergence}`)
  183. }
  184. if (!state.has(source)) state.set(source, 'ok')
  185. }
  186. // Complete the state map for --list: any in-scope, non-excluded document with no pair yet is backlog.
  187. for (const source of sources) {
  188. if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing')
  189. }
  190. if (listMode) {
  191. const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
  192. const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
  193. for (const [file, status] of rows) {
  194. const required = manifest.required.includes(file)
  195. const tag = required ? ' (required)' : requiresPairByDate(file, manifest.requiredSince) ? ' (required by date)' : ' (backlog)'
  196. console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`)
  197. }
  198. const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
  199. for (const status of state.values()) counts[status]++
  200. console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts['out-of-sync']} out-of-sync, ${counts.missing} missing (of ${state.size} in scope)`)
  201. process.exit(0)
  202. }
  203. if (errors.length === 0) {
  204. console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`)
  205. process.exit(0)
  206. }
  207. console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):')
  208. for (const message of errors) console.error(` ${message}`)
  209. process.exit(1)