verify-translation-pairing.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. /**
  2. * Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md).
  3. * English and Chinese carry EQUAL authority — either language may be authored
  4. * first — so consistency is recorded per pair in a sidecar metadata file,
  5. * `foo.i18n.yaml`, holding the full git blob hash of BOTH files as of the last
  6. * time a human confirmed the two say the same thing:
  7. *
  8. * foo.md: <40-hex blob hash>
  9. * foo.zh.md: <40-hex blob hash>
  10. *
  11. * The gate checks, mechanically, the checkable half of the contract:
  12. *
  13. * 1. Every file in the manifest's `required` list has a COMPLETE pair
  14. * (the enforcement frontier — grows batch by batch).
  15. * 2. Every pair that exists at all is complete and consistent: all three
  16. * files present (a `.zh.md` or a `.i18n.yaml` without its counterparts
  17. * is an error — pairs merge whole, never half), each side's current
  18. * blob hash equals the recorded one (an edit to EITHER side without a
  19. * re-confirmed counterpart goes red), both sides carry the language
  20. * switcher, and the structural signatures match one to one — heading
  21. * depths in order, fenced code blocks VERBATIM (info string + content),
  22. * table column counts, list kinds, and every link target except the
  23. * switcher itself.
  24. * 3. `excluded` files (generated docs, agent instructions, the bilingual
  25. * terminology table) have no `.zh.md` and no `.i18n.yaml` at all.
  26. *
  27. * What it deliberately does NOT check is translation quality or which side
  28. * is "right": a green gate means the pair was confirmed consistent at these
  29. * exact contents, not that the confirmation was sound — accuracy,
  30. * terminology, and tone are the human reviewer's half of the contract
  31. * (docs/i18n/translation-rules.md).
  32. *
  33. * Blob hashes, not commit hashes, so a pair edited in the same PR verifies
  34. * without any history lookup: consistency is a pure content comparison,
  35. * computed here directly (sha1 of `blob <size>\0<content>`) without spawning
  36. * git. The recorded hash also recovers the last-confirmed text of either
  37. * side (`git cat-file -p <hash>`) for diff-based minimal updates.
  38. *
  39. * Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to
  40. * print the pairing state of every in-scope document as a work list (always
  41. * exits 0), or with `--write` to (re)record both hashes for every complete
  42. * pair after you have brought the two sides back in line (the resulting
  43. * yaml diff is the reviewable act of confirming consistency).
  44. */
  45. import { createHash } from 'node:crypto'
  46. import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
  47. import { basename, join, resolve } from 'node:path'
  48. import { fromMarkdown } from 'mdast-util-from-markdown'
  49. import { gfmFromMarkdown } from 'mdast-util-gfm'
  50. import { gfm } from 'micromark-extension-gfm'
  51. import type { Nodes } from 'mdast'
  52. const root = resolve(import.meta.dirname, '..')
  53. const listMode = process.argv.includes('--list')
  54. const writeMode = process.argv.includes('--write')
  55. /** Scope of the bilingual contract: the root README and the docs tree. */
  56. const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml']
  57. /** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */
  58. interface Manifest {
  59. required: string[]
  60. excluded: string[]
  61. }
  62. const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest
  63. /**
  64. * An excluded entry ending in `/` excludes the whole directory. The trailing
  65. * slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a
  66. * sibling like `docs/tool-catalog-notes/x.md` — so directory entries in the
  67. * manifest must keep their trailing slash.
  68. */
  69. function isExcluded(file: string): boolean {
  70. return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
  71. }
  72. /** Full git blob hash (what `git hash-object` prints). */
  73. function blobHash(content: Buffer): string {
  74. const hash = createHash('sha1')
  75. hash.update(`blob ${content.byteLength}\0`)
  76. hash.update(content)
  77. return hash.digest('hex')
  78. }
  79. /** The three paths of a pair, derived from the English-file path. */
  80. function pairPaths(source: string): { zh: string; meta: string } {
  81. return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
  82. }
  83. const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
  84. /** Parse a `foo.i18n.yaml` consistency record: basename → recorded blob hash. */
  85. function parseMeta(content: string): Map<string, string> | undefined {
  86. const out = new Map<string, string>()
  87. for (const line of content.split('\n')) {
  88. if (line === '' || line.startsWith('#')) continue
  89. const match = META_LINE.exec(line)
  90. if (!match?.[1] || !match[2]) return undefined
  91. out.set(match[1], match[2])
  92. }
  93. return out
  94. }
  95. /** Render a `foo.i18n.yaml` consistency record. */
  96. function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
  97. return [
  98. '# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
  99. '# side as of the last confirmed-consistent state. Both languages carry equal authority;',
  100. '# after editing either side, bring the other along and re-record with:',
  101. '# pnpm run verify-translation-pairing --write',
  102. `${basename(source)}: ${sourceHash}`,
  103. `${basename(zh)}: ${zhHash}`,
  104. '',
  105. ].join('\n')
  106. }
  107. /**
  108. * The structural signature the two sides must share, as ordered sequences so
  109. * a swap or a level change is caught, not just a count change. Prose is
  110. * deliberately absent: the gate checks shape, never wording.
  111. */
  112. interface Signature {
  113. /** Heading depths in document order (h2 → 2). */
  114. headings: number[]
  115. /** Fenced code blocks verbatim: info string + content, in order. */
  116. code: string[]
  117. /** Column count of each table, in order. */
  118. tables: number[]
  119. /** Each list's kind (ordered vs bullet), in order. */
  120. lists: string[]
  121. /** Every link target in order, the language switcher's excluded. */
  122. links: string[]
  123. }
  124. /** Whether the tree contains a link to exactly `target` (the switcher check). */
  125. function linksTo(tree: Nodes, target: string): boolean {
  126. let found = false
  127. const visit = (node: Nodes): void => {
  128. if (node.type === 'link' && node.url === target) found = true
  129. if ('children' in node) for (const child of node.children) visit(child)
  130. }
  131. visit(tree)
  132. return found
  133. }
  134. /** Collect the structural signature, skipping links to `switcherTarget`. */
  135. function signatureOf(tree: Nodes, switcherTarget: string): Signature {
  136. const sig: Signature = { headings: [], code: [], tables: [], lists: [], links: [] }
  137. const visit = (node: Nodes): void => {
  138. switch (node.type) {
  139. case 'heading':
  140. sig.headings.push(node.depth)
  141. break
  142. case 'code':
  143. sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
  144. break
  145. case 'table':
  146. sig.tables.push(node.children[0]?.children.length ?? 0)
  147. break
  148. case 'list':
  149. sig.lists.push(node.ordered ? 'ordered' : 'bullet')
  150. break
  151. case 'link':
  152. if (node.url !== switcherTarget) sig.links.push(node.url)
  153. break
  154. default:
  155. // Every other node kind is prose or container — not part of the signature.
  156. break
  157. }
  158. if ('children' in node) for (const child of node.children) visit(child)
  159. }
  160. visit(tree)
  161. return sig
  162. }
  163. /** Render a signature element for an error message, truncated for readability. */
  164. function show(value: string | number | undefined): string {
  165. if (value === undefined) return 'nothing'
  166. const text = JSON.stringify(value)
  167. return text.length > 72 ? `${text.slice(0, 72)}…` : text
  168. }
  169. /** First divergence between two signatures, as messages; empty when identical. */
  170. function signatureDiff(source: Signature, zh: Signature): string[] {
  171. const out: string[] = []
  172. const fields: [string, (string | number)[], (string | number)[]][] = [
  173. ['heading (depth)', source.headings, zh.headings],
  174. ['code block', source.code, zh.code],
  175. ['table (column count)', source.tables, zh.tables],
  176. ['list (kind)', source.lists, zh.lists],
  177. ['link target', source.links, zh.links],
  178. ]
  179. for (const [field, s, z] of fields) {
  180. const length = Math.max(s.length, z.length)
  181. for (let i = 0; i < length; i++) {
  182. if (s[i] !== z[i]) {
  183. out.push(`${field} #${i + 1} diverges between the pair: ${show(s[i])} vs ${show(z[i])}`)
  184. break
  185. }
  186. }
  187. }
  188. return out
  189. }
  190. function parse(content: string): Nodes {
  191. return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  192. }
  193. // Enumerate the scope once.
  194. const files = new Set<string>()
  195. for (const pattern of SCOPE_PATTERNS) {
  196. for (const match of globSync(pattern, { cwd: root })) files.add(match)
  197. }
  198. const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
  199. const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
  200. const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
  201. // --write: (re)record both hashes for every complete pair, creating missing records.
  202. if (writeMode) {
  203. let written = 0
  204. for (const source of sources) {
  205. if (isExcluded(source)) continue
  206. const { zh, meta } = pairPaths(source)
  207. if (!existsSync(join(root, zh))) continue
  208. const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
  209. if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
  210. writeFileSync(join(root, meta), record)
  211. console.log(`verify-translation-pairing: recorded ${meta}`)
  212. written++
  213. }
  214. console.log(`verify-translation-pairing: ${written} record(s) written; run the check to validate the pairs.`)
  215. process.exit(0)
  216. }
  217. const errors: string[] = []
  218. const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
  219. // 1. Required pairs exist.
  220. for (const req of manifest.required) {
  221. if (!existsSync(join(root, req))) {
  222. errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`)
  223. continue
  224. }
  225. const { zh } = pairPaths(req)
  226. if (!existsSync(join(root, zh))) {
  227. errors.push(`${req}: required to have a translation, but ${zh} does not exist`)
  228. state.set(req, 'missing')
  229. }
  230. }
  231. // 2. Every pair that exists at all is complete and consistent. Anchor on the
  232. // union of .zh.md files and .i18n.yaml records so a half-deleted pair is
  233. // caught from either remnant.
  234. const pairAnchors = new Set<string>()
  235. for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
  236. for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
  237. for (const source of [...pairAnchors].sort()) {
  238. const { zh, meta } = pairPaths(source)
  239. const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) }
  240. if (isExcluded(source)) {
  241. if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
  242. if (have.meta) errors.push(`${meta}: ${source} is excluded from pairing; this consistency record must not exist`)
  243. continue
  244. }
  245. const missing = Object.entries(have).filter(([, ok]) => !ok).map(([k]) => (k === 'source' ? source : k === 'zh' ? zh : meta))
  246. if (missing.length > 0) {
  247. errors.push(`${source}: incomplete pair — missing ${missing.join(', ')} (pairs merge whole: both languages plus the .i18n.yaml record)`)
  248. continue
  249. }
  250. const sourceContent = readFileSync(join(root, source))
  251. const zhContent = readFileSync(join(root, zh))
  252. const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
  253. if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) {
  254. errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
  255. continue
  256. }
  257. let consistent = true
  258. for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
  259. const current = blobHash(content)
  260. if (record.get(basename(file)) !== current) {
  261. 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)`)
  262. consistent = false
  263. }
  264. }
  265. if (!consistent) {
  266. state.set(source, 'out-of-sync')
  267. continue
  268. }
  269. const sourceTree = parse(sourceContent.toString('utf8'))
  270. const zhTree = parse(zhContent.toString('utf8'))
  271. if (!linksTo(zhTree, basename(source))) {
  272. errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
  273. }
  274. if (!linksTo(sourceTree, basename(zh))) {
  275. errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
  276. }
  277. for (const divergence of signatureDiff(signatureOf(sourceTree, basename(zh)), signatureOf(zhTree, basename(source)))) {
  278. errors.push(`${source} ↔ ${zh}: ${divergence}`)
  279. }
  280. if (!state.has(source)) state.set(source, 'ok')
  281. }
  282. // Complete the state map for --list: any in-scope, non-excluded document with no pair yet is backlog.
  283. for (const source of sources) {
  284. if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing')
  285. }
  286. if (listMode) {
  287. const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
  288. const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
  289. for (const [file, status] of rows) {
  290. const required = manifest.required.includes(file)
  291. console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`)
  292. }
  293. const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
  294. for (const status of state.values()) counts[status]++
  295. console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts['out-of-sync']} out-of-sync, ${counts.missing} missing (of ${state.size} in scope)`)
  296. process.exit(0)
  297. }
  298. if (errors.length === 0) {
  299. console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`)
  300. process.exit(0)
  301. }
  302. console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):')
  303. for (const message of errors) console.error(` ${message}`)
  304. process.exit(1)