verify-translation-pairing.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. /**
  2. * Enforce complete English/Chinese pairs, matching structure, and recorded git
  3. * blob hashes for every in-scope document. The manifest contains only explicit
  4. * exclusions, which may have neither a counterpart nor a sidecar.
  5. * `--list` reports state; `--write <pairs...>` records the named confirmed
  6. * pairs (`--write --all` records every complete pair); a check or write named
  7. * with pair paths touches only those pairs, so update iteration does not pay
  8. * for a corpus scan. Translation quality remains a review responsibility.
  9. * See `docs/i18n/README.md` for the owning contract.
  10. */
  11. import { createHash } from 'node:crypto'
  12. import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
  13. import { basename, join, resolve, sep } from 'node:path'
  14. import {
  15. linksTo,
  16. parseTranslationMarkdown,
  17. parseTranslationPairingCliArgs,
  18. parseTranslationPairingManifest,
  19. isTranslationScopeFile,
  20. TRANSLATION_SCOPE_GLOB_EXCLUDES,
  21. translationStructureDiff,
  22. translationStructureSignature,
  23. } from './translation-pairing.ts'
  24. const root = resolve(import.meta.dirname, '..')
  25. let request: ReturnType<typeof parseTranslationPairingCliArgs>
  26. try {
  27. request = parseTranslationPairingCliArgs(process.argv.slice(2))
  28. } catch (error) {
  29. console.error(`verify-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
  30. process.exit(2)
  31. }
  32. const listMode = request.mode === 'list'
  33. const writeMode = request.mode === 'write'
  34. /** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
  35. const SCOPE_PATTERNS = [
  36. '**/*.md',
  37. '**/*.i18n.yaml',
  38. '.agents/notes/**/*.md',
  39. '.agents/notes/**/*.i18n.yaml',
  40. ]
  41. const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
  42. /**
  43. * An excluded entry ending in `/` excludes the whole directory. The trailing
  44. * slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a
  45. * sibling like `docs/tool-catalog-notes/x.md` — so directory entries in the
  46. * manifest must keep their trailing slash.
  47. */
  48. function isExcluded(file: string): boolean {
  49. return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
  50. }
  51. /** Full git blob hash (what `git hash-object` prints). */
  52. function blobHash(content: Buffer): string {
  53. const hash = createHash('sha1')
  54. hash.update(`blob ${content.byteLength}\0`)
  55. hash.update(content)
  56. return hash.digest('hex')
  57. }
  58. /** The three paths of a pair, derived from the English-file path. */
  59. function pairPaths(source: string): { zh: string; meta: string } {
  60. return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
  61. }
  62. const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
  63. /** Parse a `foo.i18n.yaml` consistency record: basename → recorded blob hash. */
  64. function parseMeta(content: string): Map<string, string> | undefined {
  65. const out = new Map<string, string>()
  66. for (const line of content.split('\n')) {
  67. if (line === '' || line.startsWith('#')) continue
  68. const match = META_LINE.exec(line)
  69. if (!match?.[1] || !match[2]) return undefined
  70. out.set(match[1], match[2])
  71. }
  72. return out
  73. }
  74. /** Render a `foo.i18n.yaml` consistency record. */
  75. function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
  76. return [
  77. '# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
  78. '# side as of the last confirmed-consistent state. Both languages carry equal authority;',
  79. '# after editing either side, bring the other along and re-record with:',
  80. `# pnpm run verify-translation-pairing --write ${source}`,
  81. `${basename(source)}: ${sourceHash}`,
  82. `${basename(zh)}: ${zhHash}`,
  83. '',
  84. ].join('\n')
  85. }
  86. // Enumerate the scope once: the whole corpus, or exactly the named pairs'
  87. // three files (a named pair whose files are absent is caught by the same
  88. // completeness rules that cover discovered remnants).
  89. const files = new Set<string>()
  90. if (request.scope === 'pairs') {
  91. for (const anchor of request.anchors) {
  92. for (const file of [anchor, ...Object.values(pairPaths(anchor))]) {
  93. if (existsSync(join(root, file))) files.add(file)
  94. }
  95. // A named anchor with no files on disk still enters the source list so
  96. // the check reports it instead of silently passing an empty scope.
  97. if (!existsSync(join(root, anchor))) files.add(anchor)
  98. }
  99. } else {
  100. for (const pattern of SCOPE_PATTERNS) {
  101. for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
  102. const normalized = match.split(sep).join('/')
  103. if (isTranslationScopeFile(normalized)) files.add(normalized)
  104. }
  105. }
  106. }
  107. const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
  108. const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
  109. const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
  110. if (request.scope === 'pairs') {
  111. const rejected = request.anchors.filter(anchor => !isTranslationScopeFile(anchor) || isExcluded(anchor))
  112. const absent = request.anchors.filter(anchor => ![anchor, ...Object.values(pairPaths(anchor))].some(file => existsSync(join(root, file))))
  113. if (rejected.length > 0 || absent.length > 0) {
  114. for (const anchor of rejected) {
  115. console.error(`verify-translation-pairing: ${anchor} is not an in-scope pair (excluded or outside the documentation corpus; see docs/i18n/README.md)`)
  116. }
  117. for (const anchor of absent) {
  118. console.error(`verify-translation-pairing: ${anchor} names no pair on disk (none of its three files exist)`)
  119. }
  120. process.exit(2)
  121. }
  122. }
  123. // --write: (re)record both hashes for the requested complete pairs, creating
  124. // missing records. A named pair that cannot be recorded (missing counterpart)
  125. // fails loud; corpus scope (--all) skips pairless sources as before.
  126. if (writeMode) {
  127. let written = 0
  128. for (const source of sources) {
  129. if (isExcluded(source)) continue
  130. const { zh, meta } = pairPaths(source)
  131. if (!existsSync(join(root, source)) || !existsSync(join(root, zh))) {
  132. if (request.scope === 'pairs') {
  133. console.error(`verify-translation-pairing: cannot record ${source}: missing ${existsSync(join(root, source)) ? zh : source}`)
  134. process.exit(2)
  135. }
  136. continue
  137. }
  138. const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
  139. if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
  140. writeFileSync(join(root, meta), record)
  141. console.log(`verify-translation-pairing: recorded ${meta}`)
  142. written++
  143. }
  144. console.log(`verify-translation-pairing: ${written} record(s) written; run the check to validate the pairs.`)
  145. process.exit(0)
  146. }
  147. const errors: string[] = []
  148. const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
  149. // 1. Every discovered, non-excluded source merges bilingual.
  150. for (const source of sources) {
  151. if (isExcluded(source)) continue
  152. const { zh } = pairPaths(source)
  153. if (!existsSync(join(root, zh))) {
  154. errors.push(`${source}: in-scope documentation must merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
  155. state.set(source, 'missing')
  156. }
  157. }
  158. // 2. Every pair that exists at all is complete and consistent. Anchor on the
  159. // union of .zh.md files and .i18n.yaml records so a half-deleted pair is
  160. // caught from either remnant.
  161. const pairAnchors = new Set<string>()
  162. for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
  163. for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
  164. for (const source of [...pairAnchors].sort()) {
  165. const { zh, meta } = pairPaths(source)
  166. const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) }
  167. if (isExcluded(source)) {
  168. if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
  169. if (have.meta) errors.push(`${meta}: ${source} is excluded from pairing; this consistency record must not exist`)
  170. continue
  171. }
  172. const missing = Object.entries(have).filter(([, ok]) => !ok).map(([k]) => (k === 'source' ? source : k === 'zh' ? zh : meta))
  173. if (missing.length > 0) {
  174. errors.push(`${source}: incomplete pair — missing ${missing.join(', ')} (pairs merge whole: both languages plus the .i18n.yaml record)`)
  175. continue
  176. }
  177. const sourceContent = readFileSync(join(root, source))
  178. const zhContent = readFileSync(join(root, zh))
  179. const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
  180. if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) {
  181. errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
  182. continue
  183. }
  184. let consistent = true
  185. for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
  186. const current = blobHash(content)
  187. if (record.get(basename(file)) !== current) {
  188. 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)`)
  189. consistent = false
  190. }
  191. }
  192. if (!consistent) {
  193. state.set(source, 'out-of-sync')
  194. continue
  195. }
  196. const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
  197. const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
  198. if (!linksTo(zhTree, basename(source))) {
  199. errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
  200. }
  201. if (!linksTo(sourceTree, basename(zh))) {
  202. errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
  203. }
  204. for (const divergence of translationStructureDiff(
  205. translationStructureSignature(sourceTree, basename(zh)),
  206. translationStructureSignature(zhTree, basename(source)),
  207. )) {
  208. errors.push(`${source} ↔ ${zh}: ${divergence}`)
  209. }
  210. if (!state.has(source)) state.set(source, 'ok')
  211. }
  212. // Complete the state map for --list: any in-scope, non-excluded document with no pair is missing.
  213. for (const source of sources) {
  214. if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing')
  215. }
  216. if (listMode) {
  217. const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
  218. const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
  219. for (const [file, status] of rows) {
  220. console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? ' (required)' : ''}`)
  221. }
  222. const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
  223. for (const status of state.values()) counts[status]++
  224. console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts['out-of-sync']} out-of-sync, ${counts.missing} missing (of ${state.size} in scope)`)
  225. process.exit(0)
  226. }
  227. if (errors.length === 0) {
  228. console.log(request.scope === 'pairs'
  229. ? `verify-translation-pairing: ${pairAnchors.size} named pair(s) consistent; the corpus-wide check still runs in doc-sync.`
  230. : `verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`)
  231. process.exit(0)
  232. }
  233. console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):')
  234. for (const message of errors) console.error(` ${message}`)
  235. process.exit(1)