verify-translation-pairing.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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); `--cached <pairs...>`
  7. * checks exact index bytes for hooks. A check or write named with pair paths
  8. * touches only those pairs, so update iteration does not pay for a corpus
  9. * scan. Translation quality remains a review responsibility.
  10. * See `docs/i18n/README.md` for the owning contract.
  11. */
  12. import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
  13. import { basename, join, resolve, sep } from 'node:path'
  14. import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
  15. import {
  16. parseTranslationPairingRecord,
  17. renderTranslationPairingRecord,
  18. translationPairPaths,
  19. } from './translation-pairing-record.ts'
  20. import {
  21. languageSwitcherTargets,
  22. linksTo,
  23. parseTranslationMarkdown,
  24. parseTranslationPairingCliArgs,
  25. parseTranslationPairingManifest,
  26. partitionGeneratedRegions,
  27. requiresSourceLanguageSwitcher,
  28. isTranslationScopeFile,
  29. TRANSLATION_SCOPE_GLOB_EXCLUDES,
  30. translationStructureDiff,
  31. translationStructureSignature,
  32. } from './translation-pairing.ts'
  33. const root = resolve(import.meta.dirname, '..')
  34. let request: ReturnType<typeof parseTranslationPairingCliArgs>
  35. try {
  36. request = parseTranslationPairingCliArgs(process.argv.slice(2))
  37. } catch (error) {
  38. console.error(`verify-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
  39. process.exit(2)
  40. }
  41. const listMode = request.mode === 'list'
  42. const writeMode = request.mode === 'write'
  43. const indexMode = request.input === 'index'
  44. const contentCache = new Map<string, Buffer | undefined>()
  45. /** Read one repository path from the selected worktree or index plane. */
  46. function readRepositoryFile(file: string): Buffer | undefined {
  47. if (contentCache.has(file)) return contentCache.get(file)
  48. const content = indexMode
  49. ? readGitIndexBlob(root, file)?.content
  50. : existsSync(join(root, file)) ? readFileSync(join(root, file)) : undefined
  51. contentCache.set(file, content)
  52. return content
  53. }
  54. /** Whether one path exists in the selected content plane. */
  55. function repositoryFileExists(file: string): boolean {
  56. return readRepositoryFile(file) !== undefined
  57. }
  58. /** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
  59. const SCOPE_PATTERNS = [
  60. '**/*.md',
  61. '**/*.i18n.yaml',
  62. '.agents/notes/**/*.md',
  63. '.agents/notes/**/*.i18n.yaml',
  64. ]
  65. const manifestContent = readRepositoryFile('scripts/translation-pairing.manifest.json')
  66. if (manifestContent === undefined) {
  67. throw new Error('scripts/translation-pairing.manifest.json is missing from the selected content plane')
  68. }
  69. const manifest = parseTranslationPairingManifest(manifestContent.toString('utf8'))
  70. /**
  71. * An excluded entry ending in `/` excludes the whole directory. The trailing
  72. * slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a
  73. * sibling like `docs/tool-catalog-notes/x.md` — so directory entries in the
  74. * manifest must keep their trailing slash.
  75. */
  76. function isExcluded(file: string): boolean {
  77. return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
  78. }
  79. // Enumerate the scope once: the whole corpus, or exactly the named pairs'
  80. // three files (a named pair whose files are absent is caught by the same
  81. // completeness rules that cover discovered remnants).
  82. const files = new Set<string>()
  83. if (request.scope === 'pairs') {
  84. for (const anchor of request.anchors) {
  85. const { source, zh, meta } = translationPairPaths(anchor)
  86. for (const file of [source, zh, meta]) {
  87. if (repositoryFileExists(file)) files.add(file)
  88. }
  89. // A named worktree anchor with no files still enters the source list so
  90. // an interactive check reports it. An index check accepts a complete
  91. // three-file deletion and still rejects every partial deletion below.
  92. if (!indexMode && !repositoryFileExists(anchor)) files.add(anchor)
  93. }
  94. } else {
  95. for (const pattern of SCOPE_PATTERNS) {
  96. for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
  97. const normalized = match.split(sep).join('/')
  98. if (isTranslationScopeFile(normalized)) files.add(normalized)
  99. }
  100. }
  101. }
  102. const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
  103. const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
  104. const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
  105. if (request.scope === 'pairs') {
  106. const rejected = request.anchors.filter(anchor => !isTranslationScopeFile(anchor) || isExcluded(anchor))
  107. const absent = request.anchors.filter((anchor) => {
  108. const { source, zh, meta } = translationPairPaths(anchor)
  109. return ![source, zh, meta].some(repositoryFileExists)
  110. })
  111. if (rejected.length > 0 || (!indexMode && absent.length > 0)) {
  112. for (const anchor of rejected) {
  113. console.error(`verify-translation-pairing: ${anchor} is not an in-scope pair (excluded or outside the documentation corpus; see docs/i18n/README.md)`)
  114. }
  115. for (const anchor of absent) {
  116. console.error(`verify-translation-pairing: ${anchor} names no pair on disk (none of its three files exist)`)
  117. }
  118. process.exit(2)
  119. }
  120. }
  121. // --write: (re)record both hashes for the requested complete pairs, creating
  122. // missing records. A named pair that cannot be recorded (missing counterpart)
  123. // fails loud; corpus scope (--all) skips pairless sources as before.
  124. if (writeMode) {
  125. let written = 0
  126. for (const source of sources) {
  127. if (isExcluded(source)) continue
  128. const paths = translationPairPaths(source)
  129. const { zh, meta } = paths
  130. if (!repositoryFileExists(source) || !repositoryFileExists(zh)) {
  131. if (request.scope === 'pairs') {
  132. console.error(`verify-translation-pairing: cannot record ${source}: missing ${repositoryFileExists(source) ? zh : source}`)
  133. process.exit(2)
  134. }
  135. continue
  136. }
  137. const sourceContent = readRepositoryFile(source)
  138. const zhContent = readRepositoryFile(zh)
  139. if (sourceContent === undefined || zhContent === undefined) throw new Error(`${source}: complete pair became unreadable`)
  140. // A consistency record is also a recovery pointer for the briefing
  141. // generator. Persist both snapshots even when the sidecar text is already
  142. // current, because the bytes may exist only in this working tree.
  143. const record = renderTranslationPairingRecord(paths, {
  144. sourceHash: storeGitBlob(root, sourceContent),
  145. zhHash: storeGitBlob(root, zhContent),
  146. })
  147. if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
  148. writeFileSync(join(root, meta), record)
  149. console.log(`verify-translation-pairing: recorded ${meta}`)
  150. written++
  151. }
  152. console.log(`verify-translation-pairing: ${written} record(s) written; run the check to validate the pairs.`)
  153. process.exit(0)
  154. }
  155. const errors: string[] = []
  156. const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
  157. // 1. Every discovered, non-excluded source merges bilingual.
  158. for (const source of sources) {
  159. if (isExcluded(source)) continue
  160. const { zh } = translationPairPaths(source)
  161. if (!repositoryFileExists(zh)) {
  162. errors.push(`${source}: in-scope documentation must merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
  163. state.set(source, 'missing')
  164. }
  165. }
  166. // 2. Every pair that exists at all is complete and consistent. Anchor on the
  167. // union of .zh.md files and .i18n.yaml records so a half-deleted pair is
  168. // caught from either remnant.
  169. const pairAnchors = new Set<string>()
  170. for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
  171. for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
  172. for (const source of [...pairAnchors].sort()) {
  173. const paths = translationPairPaths(source)
  174. const { zh, meta } = paths
  175. const have = {
  176. source: repositoryFileExists(source),
  177. zh: repositoryFileExists(zh),
  178. meta: repositoryFileExists(meta),
  179. }
  180. if (isExcluded(source)) {
  181. if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
  182. if (have.meta) errors.push(`${meta}: ${source} is excluded from pairing; this consistency record must not exist`)
  183. continue
  184. }
  185. const missing = Object.entries(have).filter(([, ok]) => !ok).map(([k]) => (k === 'source' ? source : k === 'zh' ? zh : meta))
  186. if (missing.length > 0) {
  187. errors.push(`${source}: incomplete pair — missing ${missing.join(', ')} (pairs merge whole: both languages plus the .i18n.yaml record)`)
  188. continue
  189. }
  190. const sourceContent = readRepositoryFile(source)
  191. const zhContent = readRepositoryFile(zh)
  192. const metaContent = readRepositoryFile(meta)
  193. if (sourceContent === undefined || zhContent === undefined || metaContent === undefined) {
  194. throw new Error(`${source}: complete pair became unreadable`)
  195. }
  196. const record = parseTranslationPairingRecord(metaContent.toString('utf8'), paths)
  197. if (record === undefined) {
  198. errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
  199. continue
  200. }
  201. let consistent = true
  202. for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
  203. const current = gitBlobHash(content)
  204. const recorded = file === source ? record.sourceHash : record.zhHash
  205. if (recorded !== current) {
  206. 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)`)
  207. consistent = false
  208. }
  209. }
  210. if (!consistent) {
  211. state.set(source, 'out-of-sync')
  212. continue
  213. }
  214. // Generated regions are language-invariant: the exact same generator output
  215. // (markers included) must appear in both sides, in the same order. The
  216. // structural signature below compares the region content again as part of
  217. // the whole document; this dedicated check exists to name the divergence
  218. // precisely and to reject a region grammar violation on either side.
  219. let sourceRegions: { regions: string[]; stripped: string }
  220. let zhRegions: { regions: string[]; stripped: string }
  221. try {
  222. sourceRegions = partitionGeneratedRegions(sourceContent.toString('utf8'))
  223. zhRegions = partitionGeneratedRegions(zhContent.toString('utf8'))
  224. } catch (error) {
  225. errors.push(`${source} ↔ ${zh}: ${error instanceof Error ? error.message : String(error)}`)
  226. state.set(source, 'out-of-sync')
  227. continue
  228. }
  229. if (sourceRegions.regions.length !== zhRegions.regions.length
  230. || sourceRegions.regions.some((region, index) => region !== zhRegions.regions[index])) {
  231. errors.push(`${source} ↔ ${zh}: generated regions differ between the pair — regenerate (the generator writes both sides byte-identically)`)
  232. state.set(source, 'out-of-sync')
  233. }
  234. const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
  235. const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
  236. const sourceSwitcherTargets = languageSwitcherTargets(source)
  237. const zhSwitcherTargets = languageSwitcherTargets(zh)
  238. if (!linksTo(zhTree, sourceSwitcherTargets)) {
  239. errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
  240. }
  241. if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, zhSwitcherTargets)) {
  242. errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
  243. }
  244. for (const divergence of translationStructureDiff(
  245. translationStructureSignature(sourceTree, zhSwitcherTargets),
  246. translationStructureSignature(zhTree, sourceSwitcherTargets),
  247. )) {
  248. errors.push(`${source} ↔ ${zh}: ${divergence}`)
  249. }
  250. if (!state.has(source)) state.set(source, 'ok')
  251. }
  252. // Complete the state map for --list: any in-scope, non-excluded document with no pair is missing.
  253. for (const source of sources) {
  254. if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing')
  255. }
  256. if (listMode) {
  257. const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
  258. const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
  259. for (const [file, status] of rows) {
  260. console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? ' (required)' : ''}`)
  261. }
  262. const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
  263. for (const status of state.values()) counts[status]++
  264. console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts['out-of-sync']} out-of-sync, ${counts.missing} missing (of ${state.size} in scope)`)
  265. process.exit(0)
  266. }
  267. if (errors.length === 0) {
  268. console.log(request.scope === 'pairs'
  269. ? `verify-translation-pairing: ${pairAnchors.size} named ${indexMode ? 'staged ' : ''}pair(s) consistent; the corpus-wide check still runs in doc-sync.`
  270. : `verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`)
  271. process.exit(0)
  272. }
  273. console.error('verify-translation-pairing: bilingual pairing rules violated (see docs/i18n/README.md):')
  274. for (const message of errors) console.error(` ${message}`)
  275. process.exit(1)