translation-pairing.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  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. * Also the one home of the generated-region grammar and the pair-record
  6. * primitives, shared by the pairing gate and the region-injecting generators.
  7. */
  8. import { createHash } from 'node:crypto'
  9. import { basename } from 'node:path'
  10. import { fromMarkdown } from 'mdast-util-from-markdown'
  11. import { gfmFromMarkdown } from 'mdast-util-gfm'
  12. import { gfm } from 'micromark-extension-gfm'
  13. import type { Nodes } from 'mdast'
  14. import {
  15. languageSwitcherLinkOffset,
  16. semanticTranslationLinkNodeTarget,
  17. type TranslationLinkContext,
  18. } from './translation-links.ts'
  19. /** Complete opening marker line: `<!-- BEGIN GENERATED <slug> … -->` (slug captured). */
  20. const GENERATED_REGION_BEGIN_LINE = /^<!-- BEGIN GENERATED (\S+)(?: [^>]*)? -->$/
  21. /** Complete closing marker line: `<!-- END GENERATED <slug> -->` (slug captured). */
  22. const GENERATED_REGION_END_LINE = /^<!-- END GENERATED (\S+) -->$/
  23. /** Loose marker detector: any line that LOOKS like a region marker must parse as one. */
  24. const GENERATED_REGION_MARKER_HINT = /^<!-- (?:BEGIN|END) GENERATED /
  25. /**
  26. * Extract every generated region (markers included) and the document with
  27. * those regions removed. Regions are line-delimited: a marker occupies its
  28. * whole line, must be a complete well-formed marker, and the closing slug
  29. * must match the opener. The stripped form is what "human content" means for
  30. * the region-aware pair-record guard.
  31. *
  32. * @param content - Full Markdown document text.
  33. * @returns The regions in document order and the region-free remainder.
  34. * @throws Error on an unopened END, unclosed BEGIN, nested BEGIN, malformed
  35. * marker line, or a closing slug that does not match its opener.
  36. */
  37. export function partitionGeneratedRegions(content: string): { regions: string[]; stripped: string } {
  38. const lines = content.split('\n')
  39. const regions: string[] = []
  40. const kept: string[] = []
  41. let open: { slug: string; lines: string[] } | null = null
  42. for (const line of lines) {
  43. const begin = GENERATED_REGION_BEGIN_LINE.exec(line)
  44. if (begin?.[1]) {
  45. if (open) throw new Error('generated region BEGIN marker nested inside an open region')
  46. open = { slug: begin[1], lines: [line] }
  47. continue
  48. }
  49. const end = GENERATED_REGION_END_LINE.exec(line)
  50. if (end?.[1]) {
  51. if (!open) throw new Error('generated region END marker without a BEGIN')
  52. if (end[1] !== open.slug) throw new Error(`generated region END slug '${end[1]}' does not match its BEGIN slug '${open.slug}'`)
  53. open.lines.push(line)
  54. regions.push(open.lines.join('\n'))
  55. open = null
  56. continue
  57. }
  58. if (GENERATED_REGION_MARKER_HINT.test(line)) {
  59. throw new Error(`malformed generated region marker line: ${JSON.stringify(line)}`)
  60. }
  61. if (open) open.lines.push(line)
  62. else kept.push(line)
  63. }
  64. if (open) throw new Error('generated region BEGIN marker without an END')
  65. return { regions, stripped: kept.join('\n') }
  66. }
  67. /**
  68. * Full git blob hash of file content (what `git hash-object` prints).
  69. * @param content - Exact file bytes.
  70. * @returns The 40-hex-digit SHA-1 blob hash.
  71. */
  72. export function blobHash(content: Buffer): string {
  73. const hash = createHash('sha1')
  74. hash.update(`blob ${content.byteLength}\0`)
  75. hash.update(content)
  76. return hash.digest('hex')
  77. }
  78. const PAIR_META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
  79. /**
  80. * Parse a `foo.i18n.yaml` consistency record into basename → recorded blob
  81. * hash, or undefined when any non-comment line deviates from the exact
  82. * `<basename>.md: <40-hex>` format or repeats a key. Consumers must
  83. * additionally require exactly the two expected basenames — a renamed key is
  84. * a malformed record, never a silently-missing entry.
  85. * @param content - Sidecar file text.
  86. * @returns The recorded map, or undefined for a malformed record.
  87. */
  88. export function parsePairMeta(content: string): Map<string, string> | undefined {
  89. const out = new Map<string, string>()
  90. for (const line of content.split('\n')) {
  91. if (line === '' || line.startsWith('#')) continue
  92. const match = PAIR_META_LINE.exec(line)
  93. if (!match?.[1] || !match[2]) return undefined
  94. if (out.has(match[1])) return undefined
  95. out.set(match[1], match[2])
  96. }
  97. return out
  98. }
  99. /**
  100. * Render a `foo.i18n.yaml` consistency record.
  101. * @param source - Repo-relative English path.
  102. * @param sourceHash - Blob hash of the English side.
  103. * @param zh - Repo-relative Chinese path.
  104. * @param zhHash - Blob hash of the Chinese side.
  105. * @returns The exact sidecar file content.
  106. */
  107. export function renderPairMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
  108. return [
  109. '# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
  110. '# side as of the last confirmed-consistent state. Both languages carry equal authority;',
  111. '# after editing either side, bring the other along and re-record with:',
  112. `# pnpm run verify-translation-pairing --write ${source}`,
  113. `${basename(source)}: ${sourceHash}`,
  114. `${basename(zh)}: ${zhHash}`,
  115. '',
  116. ].join('\n')
  117. }
  118. /** Validated fields of `scripts/translation-pairing.manifest.json`. */
  119. export interface TranslationPairingManifest {
  120. /** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */
  121. excluded: string[]
  122. }
  123. const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i
  124. const ROOT_PAIRED_DOCUMENT_ARTIFACT = /^(?:brand_guidelines|contributing|safety)(?:\.md|\.zh\.md|\.i18n\.yaml)$/i
  125. const NON_SOURCE_DIRECTORIES = new Set([
  126. 'node_modules',
  127. 'lib',
  128. '.pnpm-store',
  129. '.cache',
  130. 'coverage',
  131. '.sessions',
  132. '.storages',
  133. 'tmp',
  134. 'dist-exe',
  135. '__pycache__',
  136. '.pytest_cache',
  137. '.artifacts',
  138. 'vendor',
  139. ])
  140. /** Glob traversal exclusions corresponding to the non-source path predicate. */
  141. export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [
  142. '.agents/notes/archived/**',
  143. '**/node_modules/**',
  144. '**/lib/**',
  145. '**/.pnpm-store/**',
  146. '**/.cache/**',
  147. '**/coverage/**',
  148. '**/.doc-typecheck-*/**',
  149. '**/.node-next-types-*/**',
  150. '**/.sessions/**',
  151. '**/.storages/**',
  152. '**/tmp/**',
  153. '**/dist-exe/**',
  154. '**/__pycache__/**',
  155. '**/.pytest_cache/**',
  156. 'apps/web/dist/**',
  157. '.artifacts/**',
  158. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*/**',
  159. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**',
  160. 'vendor/**',
  161. ]
  162. /** Whether a repository-relative path belongs to a dependency or generated tree. */
  163. function isTranslationSourceExcluded(file: string): boolean {
  164. const segments = file.split('/')
  165. return segments.some(segment => NON_SOURCE_DIRECTORIES.has(segment)
  166. || segment.startsWith('.doc-typecheck-')
  167. || segment.startsWith('.node-next-types-'))
  168. || file.startsWith('apps/web/dist/')
  169. || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-')
  170. || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/')
  171. }
  172. /** Whether one discovered Markdown or sidecar path belongs to the bilingual source corpus. */
  173. export function isTranslationScopeFile(file: string): boolean {
  174. return !file.startsWith('.agents/notes/archived/')
  175. && !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file)
  176. || ROOT_PAIRED_DOCUMENT_ARTIFACT.test(file)
  177. || file.startsWith('.agents/notes/')
  178. || file.startsWith('docs/')
  179. || file.startsWith('python/'))
  180. }
  181. /** Read the manifest exclusion list or fail before enforcement starts. */
  182. function excludedField(record: Record<string, unknown>): string[] {
  183. const value = record.excluded
  184. if (!Array.isArray(value)) {
  185. throw new Error('translation-pairing.manifest.json: excluded must be an array of strings')
  186. }
  187. const entries: unknown[] = value
  188. if (!entries.every((entry): entry is string => typeof entry === 'string')) {
  189. throw new Error('translation-pairing.manifest.json: excluded must be an array of strings')
  190. }
  191. return entries
  192. }
  193. /** Parse and validate the checked-in bilingual manifest. */
  194. export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
  195. const value: unknown = JSON.parse(content)
  196. if (typeof value !== 'object' || value === null || Array.isArray(value)) {
  197. throw new Error('translation-pairing.manifest.json: expected an object')
  198. }
  199. const record = value as Record<string, unknown>
  200. const unsupported = Object.keys(record).filter(field => field !== 'excluded')
  201. if (unsupported.length > 0) {
  202. throw new Error(`translation-pairing.manifest.json: unsupported field(s): ${unsupported.join(', ')}; every in-scope document is required`)
  203. }
  204. return { excluded: excludedField(record) }
  205. }
  206. /** Whether a manifest entry excludes one exact file or a directory subtree. */
  207. export function isTranslationPairingManifestExcluded(
  208. file: string,
  209. manifest: TranslationPairingManifest,
  210. ): boolean {
  211. return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
  212. }
  213. /** Build the active bilingual-source predicate shared by every link consumer. */
  214. export function translationPairSourcePredicate(
  215. manifest: TranslationPairingManifest,
  216. ): (sourcePath: string) => boolean {
  217. return sourcePath => isTranslationScopeFile(sourcePath)
  218. && !isTranslationPairingManifestExcluded(sourcePath, manifest)
  219. }
  220. /**
  221. * Normalize one CLI pair argument to its English anchor path: any of the
  222. * pair's three files (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`) or the bare
  223. * `foo` stem names the same pair, and platform separators are accepted.
  224. *
  225. * @param argument - Repo-relative path as passed on a command line.
  226. * @returns The pair's `foo.md` anchor path with `/` separators.
  227. */
  228. export function pairAnchorOfArgument(argument: string): string {
  229. const normalized = argument.split('\\').join('/').replace(/^\.\//, '')
  230. if (normalized.endsWith('.zh.md')) return `${normalized.slice(0, -'.zh.md'.length)}.md`
  231. if (normalized.endsWith('.i18n.yaml')) return `${normalized.slice(0, -'.i18n.yaml'.length)}.md`
  232. if (normalized.endsWith('.md')) return normalized
  233. return `${normalized}.md`
  234. }
  235. /** A parsed `verify-translation-pairing` invocation. */
  236. export interface TranslationPairingCliRequest {
  237. /** Content plane read by the check. Writes and corpus checks use the working tree. */
  238. input: 'worktree' | 'index'
  239. mode: 'check' | 'list' | 'write'
  240. /** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */
  241. scope: 'corpus' | 'pairs'
  242. /** English anchor paths, empty for corpus scope. */
  243. anchors: string[]
  244. }
  245. /**
  246. * Parse and validate `verify-translation-pairing` CLI arguments.
  247. *
  248. * Check accepts optional pair paths; `--write` requires either pair paths or
  249. * `--all` so a bulk re-record is always an explicit choice — a bare
  250. * `--write` would silently bless every drifted pair in the tree, including
  251. * ones the caller never confirmed. `--list` is corpus-only.
  252. *
  253. * @param argv - Arguments after the script name.
  254. * @returns The validated request.
  255. * @throws Error when flags or their combination are invalid.
  256. */
  257. export function parseTranslationPairingCliArgs(argv: string[]): TranslationPairingCliRequest {
  258. const flags = argv.filter(argument => argument.startsWith('--'))
  259. const anchors = [...new Set(argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument))].sort()
  260. const unknown = flags.filter(flag => !['--list', '--write', '--all', '--cached'].includes(flag))
  261. if (unknown.length > 0) throw new Error(`unknown flag(s): ${unknown.join(', ')}`)
  262. const listMode = flags.includes('--list')
  263. const writeMode = flags.includes('--write')
  264. const allMode = flags.includes('--all')
  265. const cachedMode = flags.includes('--cached')
  266. if (listMode && (writeMode || allMode || cachedMode || anchors.length > 0)) {
  267. throw new Error('--list reports the whole corpus and takes no other flags or paths')
  268. }
  269. if (allMode && !writeMode) throw new Error('--all only applies to --write')
  270. if (cachedMode && writeMode) throw new Error('--cached is a read-only index check and cannot be combined with --write')
  271. if (cachedMode && anchors.length === 0) throw new Error('--cached requires the staged pair paths to check')
  272. if (writeMode) {
  273. if (anchors.length > 0 && allMode) throw new Error('--write takes either pair paths or --all, not both')
  274. if (anchors.length === 0 && !allMode) {
  275. 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')
  276. }
  277. return { input: 'worktree', mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors }
  278. }
  279. if (listMode) return { input: 'worktree', mode: 'list', scope: 'corpus', anchors: [] }
  280. return {
  281. input: cachedMode ? 'index' : 'worktree',
  282. mode: 'check',
  283. scope: anchors.length > 0 ? 'pairs' : 'corpus',
  284. anchors,
  285. }
  286. }
  287. /** The structural signature compared between the two sides of a pair. */
  288. export interface TranslationStructureSignature {
  289. /** Heading depths in document order (h2 -> 2). */
  290. headings: number[]
  291. /** Fenced code blocks verbatim: info string plus content, in order. */
  292. code: string[]
  293. /** Row and column count of each table, in order. */
  294. tables: string[]
  295. /** Kind, ordered-list start, and direct item count of each list, in order. */
  296. lists: string[]
  297. /** Every link target in order; the language switcher is excluded. */
  298. links: string[]
  299. }
  300. /** Parse Markdown with the same GFM extensions used by the pairing gate. */
  301. export function parseTranslationMarkdown(content: string): Nodes {
  302. return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  303. }
  304. const PUBLIC_REPOSITORY_BLOB_ROOT = 'https://github.com/deepseek-ai/deepseek-harness/blob/master/'
  305. /** Return the accepted relative and public-repository links to one counterpart. */
  306. export function languageSwitcherTargets(counterpart: string): string[] {
  307. return [basename(counterpart), `${PUBLIC_REPOSITORY_BLOB_ROOT}${counterpart}`]
  308. }
  309. /** Generated English sources cannot carry a switcher without making their generator stale. */
  310. export function requiresSourceLanguageSwitcher(source: string): boolean {
  311. return ![
  312. 'docs/agent-lifecycle.md',
  313. 'docs/capability-seams.md',
  314. 'docs/config-catalog.md',
  315. 'docs/cordis-api/context.md',
  316. 'docs/cordis-api/events.md',
  317. 'docs/cordis-api/fiber.md',
  318. // Excluded from pairing, but kept here for generated-category completeness and direct spec coverage.
  319. 'docs/cordis-api/inherited.md',
  320. 'docs/cordis-api/registry.md',
  321. 'docs/cordis-api/service.md',
  322. 'docs/event-producer-consumer.md',
  323. 'docs/graph-atlas.md',
  324. 'docs/module-graph.md',
  325. 'docs/persistence-catalog.md',
  326. 'docs/tool-catalog.md',
  327. 'docs/tool-execution-pipeline.md',
  328. ].includes(source)
  329. }
  330. /** Collect the ordered structural signature, skipping accepted switcher targets. */
  331. export function translationStructureSignature(
  332. tree: Nodes,
  333. switcherTargets: string | readonly string[],
  334. linkContext: TranslationLinkContext & { markdown: string },
  335. ): TranslationStructureSignature {
  336. const switcherOffset = languageSwitcherLinkOffset(tree, linkContext.markdown, switcherTargets)
  337. const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
  338. const definitions = new Map<string, Extract<Nodes, { type: 'definition' }>>()
  339. const collectDefinitions = (node: Nodes): void => {
  340. if (node.type === 'definition' && !definitions.has(node.identifier)) {
  341. definitions.set(node.identifier, node)
  342. }
  343. if ('children' in node) for (const child of node.children) collectDefinitions(child)
  344. }
  345. collectDefinitions(tree)
  346. const linkTarget = (node: Extract<Nodes, { type: 'link' | 'definition' }>): string => (
  347. semanticTranslationLinkNodeTarget(node, linkContext.markdown, linkContext)
  348. )
  349. const visit = (node: Nodes): void => {
  350. switch (node.type) {
  351. case 'heading':
  352. sig.headings.push(node.depth)
  353. break
  354. case 'code':
  355. sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
  356. break
  357. case 'table':
  358. sig.tables.push(`${node.children.length}x${node.children[0]?.children.length ?? 0}`)
  359. break
  360. case 'list':
  361. sig.lists.push(node.ordered
  362. ? `ordered:start=${node.start ?? 1}:items=${node.children.length}`
  363. : `bullet:items=${node.children.length}`)
  364. break
  365. case 'link':
  366. if (node.position?.start.offset !== switcherOffset) {
  367. sig.links.push(linkTarget(node))
  368. }
  369. break
  370. case 'linkReference': {
  371. const definition = definitions.get(node.identifier)
  372. if (definition !== undefined) {
  373. sig.links.push(linkTarget(definition))
  374. }
  375. break
  376. }
  377. default:
  378. // Every other node kind is prose or a container, not part of the signature.
  379. break
  380. }
  381. if ('children' in node) for (const child of node.children) visit(child)
  382. }
  383. visit(tree)
  384. return sig
  385. }
  386. /** Render a signature element for an error message, truncated for readability. */
  387. function show(value: string | number | undefined): string {
  388. if (value === undefined) return 'nothing'
  389. const text = JSON.stringify(value)
  390. return text.length > 72 ? `${text.slice(0, 72)}…` : text
  391. }
  392. /** Return the first divergence for each structural field; empty means equal. */
  393. export function translationStructureDiff(
  394. source: TranslationStructureSignature,
  395. zh: TranslationStructureSignature,
  396. ): string[] {
  397. const out: string[] = []
  398. const fields: [string, (string | number)[], (string | number)[]][] = [
  399. ['heading (depth)', source.headings, zh.headings],
  400. ['code block', source.code, zh.code],
  401. ['table (row x column count)', source.tables, zh.tables],
  402. ['list (kind, start, item count)', source.lists, zh.lists],
  403. ['link target', source.links, zh.links],
  404. ]
  405. for (const [field, sourceValues, zhValues] of fields) {
  406. const length = Math.max(sourceValues.length, zhValues.length)
  407. for (let index = 0; index < length; index++) {
  408. if (sourceValues[index] !== zhValues[index]) {
  409. out.push(`${field} #${index + 1} diverges between the pair: ${show(sourceValues[index])} vs ${show(zhValues[index])}`)
  410. break
  411. }
  412. }
  413. }
  414. return out
  415. }