translation-pairing.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * Pure parsing and structural helpers for the bilingual-document pairing
  3. * gate. Kept separate from the CLI so corpus discovery and signature behavior
  4. * can be regression-tested without reading or mutating the repository tree.
  5. */
  6. import { fromMarkdown } from 'mdast-util-from-markdown'
  7. import { gfmFromMarkdown } from 'mdast-util-gfm'
  8. import { gfm } from 'micromark-extension-gfm'
  9. import type { Nodes } from 'mdast'
  10. /** Validated shape of `scripts/translation-pairing.manifest.json`. */
  11. export interface TranslationPairingManifest {
  12. /** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */
  13. excluded: string[]
  14. }
  15. const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i
  16. const NON_SOURCE_DIRECTORIES = new Set([
  17. 'node_modules',
  18. 'lib',
  19. '.pnpm-store',
  20. '.cache',
  21. 'coverage',
  22. '.sessions',
  23. '.storages',
  24. 'tmp',
  25. 'dist-exe',
  26. '__pycache__',
  27. '.pytest_cache',
  28. '.artifacts',
  29. 'vendor',
  30. ])
  31. /** Glob traversal exclusions corresponding to the non-source path predicate. */
  32. export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [
  33. '.agents/notes/archived/**',
  34. '**/node_modules/**',
  35. '**/lib/**',
  36. '**/.pnpm-store/**',
  37. '**/.cache/**',
  38. '**/coverage/**',
  39. '**/.doc-typecheck-*/**',
  40. '**/.node-next-types-*/**',
  41. '**/.sessions/**',
  42. '**/.storages/**',
  43. '**/tmp/**',
  44. '**/dist-exe/**',
  45. '**/__pycache__/**',
  46. '**/.pytest_cache/**',
  47. 'apps/web/dist/**',
  48. '.artifacts/**',
  49. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**',
  50. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**',
  51. 'vendor/**',
  52. ]
  53. /** Whether a repository-relative path belongs to a dependency or generated tree. */
  54. function isTranslationSourceExcluded(file: string): boolean {
  55. const segments = file.split('/')
  56. return segments.some(segment => NON_SOURCE_DIRECTORIES.has(segment)
  57. || segment.startsWith('.doc-typecheck-')
  58. || segment.startsWith('.node-next-types-'))
  59. || file.startsWith('apps/web/dist/')
  60. || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-')
  61. || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/')
  62. }
  63. /** Whether one discovered Markdown or sidecar path belongs to the bilingual source corpus. */
  64. export function isTranslationScopeFile(file: string): boolean {
  65. return !file.startsWith('.agents/notes/archived/')
  66. && !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file)
  67. || file.startsWith('.agents/notes/')
  68. || file.startsWith('docs/')
  69. || file.startsWith('python/'))
  70. }
  71. /** Read the manifest exclusion list or fail before enforcement starts. */
  72. function excludedField(record: Record<string, unknown>): string[] {
  73. const value = record.excluded
  74. if (!Array.isArray(value)) {
  75. throw new Error('translation-pairing.manifest.json: excluded must be an array of strings')
  76. }
  77. const entries: unknown[] = value
  78. if (!entries.every((entry): entry is string => typeof entry === 'string')) {
  79. throw new Error('translation-pairing.manifest.json: excluded must be an array of strings')
  80. }
  81. return entries
  82. }
  83. /** Parse and validate the checked-in bilingual manifest. */
  84. export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
  85. const value: unknown = JSON.parse(content)
  86. if (typeof value !== 'object' || value === null || Array.isArray(value)) {
  87. throw new Error('translation-pairing.manifest.json: expected an object')
  88. }
  89. const record = value as Record<string, unknown>
  90. const unsupported = Object.keys(record).filter(field => field !== 'excluded')
  91. if (unsupported.length > 0) {
  92. throw new Error(`translation-pairing.manifest.json: unsupported field(s): ${unsupported.join(', ')}; every in-scope document is required`)
  93. }
  94. return { excluded: excludedField(record) }
  95. }
  96. /**
  97. * Normalize one CLI pair argument to its English anchor path: any of the
  98. * pair's three files (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`) or the bare
  99. * `foo` stem names the same pair, and platform separators are accepted.
  100. *
  101. * @param argument - Repo-relative path as passed on a command line.
  102. * @returns The pair's `foo.md` anchor path with `/` separators.
  103. */
  104. export function pairAnchorOfArgument(argument: string): string {
  105. const normalized = argument.split('\\').join('/').replace(/^\.\//, '')
  106. if (normalized.endsWith('.zh.md')) return `${normalized.slice(0, -'.zh.md'.length)}.md`
  107. if (normalized.endsWith('.i18n.yaml')) return `${normalized.slice(0, -'.i18n.yaml'.length)}.md`
  108. if (normalized.endsWith('.md')) return normalized
  109. return `${normalized}.md`
  110. }
  111. /** A parsed `verify-translation-pairing` invocation. */
  112. export interface TranslationPairingCliRequest {
  113. /** Content plane read by the check. Writes and corpus checks use the working tree. */
  114. input: 'worktree' | 'index'
  115. mode: 'check' | 'list' | 'write'
  116. /** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */
  117. scope: 'corpus' | 'pairs'
  118. /** English anchor paths, empty for corpus scope. */
  119. anchors: string[]
  120. }
  121. /**
  122. * Parse and validate `verify-translation-pairing` CLI arguments.
  123. *
  124. * Check accepts optional pair paths; `--write` requires either pair paths or
  125. * `--all` so a bulk re-record is always an explicit choice — a bare
  126. * `--write` would silently bless every drifted pair in the tree, including
  127. * ones the caller never confirmed. `--list` is corpus-only.
  128. *
  129. * @param argv - Arguments after the script name.
  130. * @returns The validated request.
  131. * @throws Error when flags or their combination are invalid.
  132. */
  133. export function parseTranslationPairingCliArgs(argv: string[]): TranslationPairingCliRequest {
  134. const flags = argv.filter(argument => argument.startsWith('--'))
  135. const anchors = [...new Set(argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument))].sort()
  136. const unknown = flags.filter(flag => !['--list', '--write', '--all', '--cached'].includes(flag))
  137. if (unknown.length > 0) throw new Error(`unknown flag(s): ${unknown.join(', ')}`)
  138. const listMode = flags.includes('--list')
  139. const writeMode = flags.includes('--write')
  140. const allMode = flags.includes('--all')
  141. const cachedMode = flags.includes('--cached')
  142. if (listMode && (writeMode || allMode || cachedMode || anchors.length > 0)) {
  143. throw new Error('--list reports the whole corpus and takes no other flags or paths')
  144. }
  145. if (allMode && !writeMode) throw new Error('--all only applies to --write')
  146. if (cachedMode && writeMode) throw new Error('--cached is a read-only index check and cannot be combined with --write')
  147. if (cachedMode && anchors.length === 0) throw new Error('--cached requires the staged pair paths to check')
  148. if (writeMode) {
  149. if (anchors.length > 0 && allMode) throw new Error('--write takes either pair paths or --all, not both')
  150. if (anchors.length === 0 && !allMode) {
  151. throw new Error('--write requires the pair(s) you confirmed (any file of a pair), or --all to re-record every complete pair; recording pairs you did not review blesses unconfirmed content')
  152. }
  153. return { input: 'worktree', mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors }
  154. }
  155. if (listMode) return { input: 'worktree', mode: 'list', scope: 'corpus', anchors: [] }
  156. return {
  157. input: cachedMode ? 'index' : 'worktree',
  158. mode: 'check',
  159. scope: anchors.length > 0 ? 'pairs' : 'corpus',
  160. anchors,
  161. }
  162. }
  163. /** The structural surface compared between the two sides of a pair. */
  164. export interface TranslationStructureSignature {
  165. /** Heading depths in document order (h2 -> 2). */
  166. headings: number[]
  167. /** Fenced code blocks verbatim: info string plus content, in order. */
  168. code: string[]
  169. /** Row and column count of each table, in order. */
  170. tables: string[]
  171. /** Kind, ordered-list start, and direct item count of each list, in order. */
  172. lists: string[]
  173. /** Every link target in order; the language switcher is excluded. */
  174. links: string[]
  175. }
  176. /** Parse Markdown with the same GFM extensions used by the pairing gate. */
  177. export function parseTranslationMarkdown(content: string): Nodes {
  178. return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  179. }
  180. /** Whether the tree contains a link to exactly `target`. */
  181. export function linksTo(tree: Nodes, target: string): boolean {
  182. let found = false
  183. const visit = (node: Nodes): void => {
  184. if (node.type === 'link' && node.url === target) found = true
  185. if ('children' in node) for (const child of node.children) visit(child)
  186. }
  187. visit(tree)
  188. return found
  189. }
  190. /** Collect the ordered structural signature, skipping one switcher target. */
  191. export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
  192. const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
  193. const visit = (node: Nodes): void => {
  194. switch (node.type) {
  195. case 'heading':
  196. sig.headings.push(node.depth)
  197. break
  198. case 'code':
  199. sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
  200. break
  201. case 'table':
  202. sig.tables.push(`${node.children.length}x${node.children[0]?.children.length ?? 0}`)
  203. break
  204. case 'list':
  205. sig.lists.push(node.ordered
  206. ? `ordered:start=${node.start ?? 1}:items=${node.children.length}`
  207. : `bullet:items=${node.children.length}`)
  208. break
  209. case 'link':
  210. if (node.url !== switcherTarget) sig.links.push(node.url)
  211. break
  212. default:
  213. // Every other node kind is prose or a container, not part of the signature.
  214. break
  215. }
  216. if ('children' in node) for (const child of node.children) visit(child)
  217. }
  218. visit(tree)
  219. return sig
  220. }
  221. /** Render a signature element for an error message, truncated for readability. */
  222. function show(value: string | number | undefined): string {
  223. if (value === undefined) return 'nothing'
  224. const text = JSON.stringify(value)
  225. return text.length > 72 ? `${text.slice(0, 72)}…` : text
  226. }
  227. /** Return the first divergence for each structural field; empty means equal. */
  228. export function translationStructureDiff(
  229. source: TranslationStructureSignature,
  230. zh: TranslationStructureSignature,
  231. ): string[] {
  232. const out: string[] = []
  233. const fields: [string, (string | number)[], (string | number)[]][] = [
  234. ['heading (depth)', source.headings, zh.headings],
  235. ['code block', source.code, zh.code],
  236. ['table (row x column count)', source.tables, zh.tables],
  237. ['list (kind, start, item count)', source.lists, zh.lists],
  238. ['link target', source.links, zh.links],
  239. ]
  240. for (const [field, sourceValues, zhValues] of fields) {
  241. const length = Math.max(sourceValues.length, zhValues.length)
  242. for (let index = 0; index < length; index++) {
  243. if (sourceValues[index] !== zhValues[index]) {
  244. out.push(`${field} #${index + 1} diverges between the pair: ${show(sourceValues[index])} vs ${show(zhValues[index])}`)
  245. break
  246. }
  247. }
  248. }
  249. return out
  250. }