translation-pairing.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. /**
  2. * Pure parsing and structural helpers for the bilingual-document pairing
  3. * gate. Kept separate from the CLI so cutoff and signature behavior can be
  4. * 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. required: string[]
  13. /** Document classes whose complete in-scope population must be paired. */
  14. requiredClasses: TranslationDocumentClass[]
  15. excluded: string[]
  16. /** Date-named documents on or after this day must merge bilingual. */
  17. requiredSince: string
  18. }
  19. /** Stable classes used to close one translation rollout without enumerating files. */
  20. export type TranslationDocumentClass = 'readme' | 'non-readme'
  21. const TRANSLATION_DOCUMENT_CLASSES: TranslationDocumentClass[] = ['readme', 'non-readme']
  22. const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
  23. const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
  24. const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i
  25. const NON_SOURCE_DIRECTORIES = new Set([
  26. 'node_modules',
  27. 'lib',
  28. '.pnpm-store',
  29. '.cache',
  30. 'coverage',
  31. '.sessions',
  32. '.storages',
  33. 'tmp',
  34. 'dist-exe',
  35. '__pycache__',
  36. '.pytest_cache',
  37. '.artifacts',
  38. 'vendor',
  39. ])
  40. /** Glob traversal exclusions corresponding to the non-source path predicate. */
  41. export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [
  42. '**/node_modules/**',
  43. '**/lib/**',
  44. '**/.pnpm-store/**',
  45. '**/.cache/**',
  46. '**/coverage/**',
  47. '**/.doc-typecheck-*/**',
  48. '**/.node-next-types-*/**',
  49. '**/.sessions/**',
  50. '**/.storages/**',
  51. '**/tmp/**',
  52. '**/dist-exe/**',
  53. '**/__pycache__/**',
  54. '**/.pytest_cache/**',
  55. 'apps/web/dist/**',
  56. '.artifacts/**',
  57. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**',
  58. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**',
  59. 'vendor/**',
  60. ]
  61. /** Whether a repository-relative path belongs to a dependency or generated tree. */
  62. function isTranslationSourceExcluded(file: string): boolean {
  63. const segments = file.split('/')
  64. return segments.some(segment => NON_SOURCE_DIRECTORIES.has(segment)
  65. || segment.startsWith('.doc-typecheck-')
  66. || segment.startsWith('.node-next-types-'))
  67. || file.startsWith('apps/web/dist/')
  68. || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-')
  69. || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/')
  70. }
  71. /** Whether one discovered Markdown or sidecar path belongs to the bilingual source corpus. */
  72. export function isTranslationScopeFile(file: string): boolean {
  73. return !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file)
  74. || file.startsWith('.agents/notes/')
  75. || file.startsWith('docs/')
  76. || file.startsWith('python/'))
  77. }
  78. /** Whether a string names one real calendar day in canonical ISO form. */
  79. export function isIsoDate(value: string): boolean {
  80. if (!ISO_DATE.test(value)) return false
  81. const date = new Date(`${value}T00:00:00.000Z`)
  82. return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
  83. }
  84. /** Read one manifest string-array field or fail before enforcement starts. */
  85. function stringArrayField(record: Record<string, unknown>, field: 'required' | 'excluded'): string[] {
  86. const value = record[field]
  87. if (!Array.isArray(value)) {
  88. throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
  89. }
  90. const entries: unknown[] = value
  91. if (!entries.every((entry): entry is string => typeof entry === 'string')) {
  92. throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
  93. }
  94. return entries
  95. }
  96. /** Read and validate the manifest's closed document-class set. */
  97. function requiredClassesField(record: Record<string, unknown>): TranslationDocumentClass[] {
  98. const value = record.requiredClasses
  99. if (!Array.isArray(value) || !value.every((entry): entry is TranslationDocumentClass =>
  100. typeof entry === 'string' && TRANSLATION_DOCUMENT_CLASSES.includes(entry as TranslationDocumentClass))) {
  101. throw new Error('translation-pairing.manifest.json: requiredClasses must contain only "readme" and "non-readme"')
  102. }
  103. if (new Set(value).size !== value.length) {
  104. throw new Error('translation-pairing.manifest.json: requiredClasses must not contain duplicates')
  105. }
  106. return value
  107. }
  108. /** Parse and validate the checked-in bilingual manifest. */
  109. export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
  110. const value: unknown = JSON.parse(content)
  111. if (typeof value !== 'object' || value === null || Array.isArray(value)) {
  112. throw new Error('translation-pairing.manifest.json: expected an object')
  113. }
  114. const record = value as Record<string, unknown>
  115. const requiredSince = record.requiredSince
  116. if (typeof requiredSince !== 'string' || !isIsoDate(requiredSince)) {
  117. throw new Error(`translation-pairing.manifest.json: requiredSince must be a valid YYYY-MM-DD date; got ${JSON.stringify(requiredSince)}`)
  118. }
  119. return {
  120. required: stringArrayField(record, 'required'),
  121. requiredClasses: requiredClassesField(record),
  122. excluded: stringArrayField(record, 'excluded'),
  123. requiredSince,
  124. }
  125. }
  126. /** Classify a Markdown source by whether its basename is README, case-insensitively. */
  127. export function translationDocumentClass(file: string): TranslationDocumentClass {
  128. return /(?:^|\/)readme\.md$/i.test(file) ? 'readme' : 'non-readme'
  129. }
  130. /** Whether the manifest requires this in-scope source to have a complete pair. */
  131. export function requiresTranslationPair(file: string, manifest: TranslationPairingManifest): boolean {
  132. return manifest.required.includes(file)
  133. || manifest.requiredClasses.includes(translationDocumentClass(file))
  134. || requiresPairByDate(file, manifest.requiredSince)
  135. }
  136. /** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */
  137. export function datedDocumentDate(file: string): string | undefined {
  138. return DATED_DOCUMENT.exec(file)?.[1]
  139. }
  140. /** Whether a date-named document falls on or after the pairing cutoff. */
  141. export function requiresPairByDate(file: string, requiredSince: string): boolean {
  142. const date = datedDocumentDate(file)
  143. return date !== undefined && date >= requiredSince
  144. }
  145. /** The structural surface compared between the two sides of a pair. */
  146. export interface TranslationStructureSignature {
  147. /** Heading depths in document order (h2 -> 2). */
  148. headings: number[]
  149. /** Fenced code blocks verbatim: info string plus content, in order. */
  150. code: string[]
  151. /** Row and column count of each table, in order. */
  152. tables: string[]
  153. /** Kind, ordered-list start, and direct item count of each list, in order. */
  154. lists: string[]
  155. /** Every link target in order; the language switcher is excluded. */
  156. links: string[]
  157. }
  158. /** Parse Markdown with the same GFM extensions used by the pairing gate. */
  159. export function parseTranslationMarkdown(content: string): Nodes {
  160. return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  161. }
  162. /** Whether the tree contains a link to exactly `target`. */
  163. export function linksTo(tree: Nodes, target: string): boolean {
  164. let found = false
  165. const visit = (node: Nodes): void => {
  166. if (node.type === 'link' && node.url === target) found = true
  167. if ('children' in node) for (const child of node.children) visit(child)
  168. }
  169. visit(tree)
  170. return found
  171. }
  172. /** Collect the ordered structural signature, skipping one switcher target. */
  173. export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
  174. const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
  175. const visit = (node: Nodes): void => {
  176. switch (node.type) {
  177. case 'heading':
  178. sig.headings.push(node.depth)
  179. break
  180. case 'code':
  181. sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
  182. break
  183. case 'table':
  184. sig.tables.push(`${node.children.length}x${node.children[0]?.children.length ?? 0}`)
  185. break
  186. case 'list':
  187. sig.lists.push(node.ordered
  188. ? `ordered:start=${node.start ?? 1}:items=${node.children.length}`
  189. : `bullet:items=${node.children.length}`)
  190. break
  191. case 'link':
  192. if (node.url !== switcherTarget) sig.links.push(node.url)
  193. break
  194. default:
  195. // Every other node kind is prose or a container, not part of the signature.
  196. break
  197. }
  198. if ('children' in node) for (const child of node.children) visit(child)
  199. }
  200. visit(tree)
  201. return sig
  202. }
  203. /** Render a signature element for an error message, truncated for readability. */
  204. function show(value: string | number | undefined): string {
  205. if (value === undefined) return 'nothing'
  206. const text = JSON.stringify(value)
  207. return text.length > 72 ? `${text.slice(0, 72)}…` : text
  208. }
  209. /** Return the first divergence for each structural field; empty means equal. */
  210. export function translationStructureDiff(
  211. source: TranslationStructureSignature,
  212. zh: TranslationStructureSignature,
  213. ): string[] {
  214. const out: string[] = []
  215. const fields: [string, (string | number)[], (string | number)[]][] = [
  216. ['heading (depth)', source.headings, zh.headings],
  217. ['code block', source.code, zh.code],
  218. ['table (row x column count)', source.tables, zh.tables],
  219. ['list (kind, start, item count)', source.lists, zh.lists],
  220. ['link target', source.links, zh.links],
  221. ]
  222. for (const [field, sourceValues, zhValues] of fields) {
  223. const length = Math.max(sourceValues.length, zhValues.length)
  224. for (let index = 0; index < length; index++) {
  225. if (sourceValues[index] !== zhValues[index]) {
  226. out.push(`${field} #${index + 1} diverges between the pair: ${show(sourceValues[index])} vs ${show(zhValues[index])}`)
  227. break
  228. }
  229. }
  230. }
  231. return out
  232. }