verify-translation-pairing.ts 13 KB

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