translation-pairing.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. /** Regression tests for bilingual snapshots, corpus scope, and structure. */
  2. import { execFileSync, spawnSync } from 'node:child_process'
  3. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { describe, expect, it } from 'vitest'
  7. import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
  8. import {
  9. parseTranslationPairingRecord,
  10. renderTranslationPairingRecord,
  11. translationPairPaths,
  12. } from './translation-pairing-record.ts'
  13. import {
  14. blobHash,
  15. isTranslationScopeFile,
  16. languageSwitcherTargets,
  17. linksTo,
  18. pairAnchorOfArgument,
  19. parseTranslationMarkdown,
  20. parseTranslationPairingCliArgs,
  21. parseTranslationPairingManifest,
  22. partitionGeneratedRegions,
  23. requiresSourceLanguageSwitcher,
  24. translationStructureDiff,
  25. translationStructureSignature,
  26. } from './translation-pairing.ts'
  27. function signature(markdown: string) {
  28. return translationStructureSignature(parseTranslationMarkdown(markdown), 'counterpart.zh.md')
  29. }
  30. function gitSupportsObjectFormat(format: 'sha256'): boolean {
  31. const root = mkdtempSync(join(tmpdir(), 'dsh-git-object-format-'))
  32. try {
  33. return spawnSync('git', ['init', '--quiet', `--object-format=${format}`, root], {
  34. stdio: 'ignore',
  35. }).status === 0
  36. } finally {
  37. rmSync(root, { recursive: true, force: true })
  38. }
  39. }
  40. const supportsSha256ObjectFormat = gitSupportsObjectFormat('sha256')
  41. describe('translation pairing snapshots', () => {
  42. it('stores exact uncommitted bytes for later recovery by object ID', () => {
  43. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  44. try {
  45. execFileSync('git', ['init', '--quiet', root], {
  46. env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
  47. })
  48. const content = Buffer.from([0x75, 0x6e, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x0a, 0xff])
  49. const objectId = storeGitBlob(root, content)
  50. expect(objectId).toBe(gitBlobHash(content))
  51. expect(execFileSync('git', [
  52. '-C', root, 'rev-parse', `refs/dsh/translation-pairing/snapshots/${objectId}`,
  53. ], { encoding: 'utf8' }).trim()).toBe(objectId)
  54. execFileSync('git', ['-C', root, 'gc', '--prune=now'])
  55. expect(execFileSync('git', ['-C', root, 'cat-file', '-p', objectId])).toEqual(content)
  56. } finally {
  57. rmSync(root, { recursive: true, force: true })
  58. }
  59. })
  60. it('fails before a sidecar can reference an unavailable object', () => {
  61. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  62. try {
  63. expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
  64. } finally {
  65. rmSync(root, { recursive: true, force: true })
  66. }
  67. })
  68. it('fails clearly when Git cannot be started', () => {
  69. const previousPath = process.env.PATH
  70. try {
  71. process.env.PATH = ''
  72. expect(() => storeGitBlob('.', Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
  73. } finally {
  74. process.env.PATH = previousPath
  75. }
  76. })
  77. it('reads staged bytes independently of the working tree', () => {
  78. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-index-'))
  79. try {
  80. execFileSync('git', ['init', '--quiet', root], {
  81. env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
  82. })
  83. execFileSync('git', ['-C', root, 'config', 'user.email', 'pairing@example.test'])
  84. execFileSync('git', ['-C', root, 'config', 'user.name', 'Pairing Test'])
  85. writeFileSync(join(root, 'owner.md'), 'staged')
  86. execFileSync('git', ['-C', root, 'add', 'owner.md'])
  87. writeFileSync(join(root, 'owner.md'), 'unstaged')
  88. const indexed = readGitIndexBlob(root, 'owner.md')
  89. expect(indexed?.content.toString('utf8')).toBe('staged')
  90. expect(indexed?.objectId).toBe(gitBlobHash(Buffer.from('staged')))
  91. expect(readGitIndexBlob(root, 'absent.md')).toBeUndefined()
  92. } finally {
  93. rmSync(root, { recursive: true, force: true })
  94. }
  95. })
  96. it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => {
  97. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  98. try {
  99. execFileSync('git', ['init', '--quiet', '--object-format=sha256', root])
  100. expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('returned unexpected object ID')
  101. } finally {
  102. rmSync(root, { recursive: true, force: true })
  103. }
  104. })
  105. })
  106. describe('translation pairing manifest', () => {
  107. it('accepts an exclusions-only manifest', () => {
  108. expect(parseTranslationPairingManifest(JSON.stringify({
  109. excluded: ['docs/generated/'],
  110. }))).toEqual({
  111. excluded: ['docs/generated/'],
  112. })
  113. })
  114. it.each([
  115. ['required', ['packages/README.md']],
  116. ['requiredClasses', ['readme']],
  117. ['requiredSince', '2026-07-14'],
  118. ] as const)('rejects obsolete policy field %s instead of accepting an inert requirement', (field, value) => {
  119. expect(() => parseTranslationPairingManifest(JSON.stringify({
  120. excluded: [],
  121. [field]: value,
  122. }))).toThrow(`unsupported field(s): ${field}; every in-scope document is required`)
  123. })
  124. it('rejects a missing or non-string exclusion list', () => {
  125. expect(() => parseTranslationPairingManifest('{}')).toThrow('excluded must be an array of strings')
  126. expect(() => parseTranslationPairingManifest(JSON.stringify({
  127. excluded: [42],
  128. }))).toThrow('excluded must be an array of strings')
  129. })
  130. })
  131. describe('translation pairing switchers', () => {
  132. it('exempts only paired generated English sources from reciprocal switchers', () => {
  133. expect(requiresSourceLanguageSwitcher('docs/config-catalog.md')).toBe(false)
  134. expect(requiresSourceLanguageSwitcher('docs/cordis-api/context.md')).toBe(false)
  135. expect(requiresSourceLanguageSwitcher('docs/cordis-api/inherited.md')).toBe(false)
  136. expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true)
  137. expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true)
  138. })
  139. it('accepts only the canonical public URL for an absolute switcher', () => {
  140. const targets = languageSwitcherTargets('python/sdk/README.zh.md')
  141. const canonical = parseTranslationMarkdown(
  142. '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)',
  143. )
  144. const wrongPath = parseTranslationMarkdown(
  145. '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md)',
  146. )
  147. expect(linksTo(canonical, targets)).toBe(true)
  148. expect(translationStructureSignature(canonical, targets).links).toEqual([])
  149. expect(linksTo(wrongPath, targets)).toBe(false)
  150. })
  151. })
  152. describe('translation pairing records', () => {
  153. const paths = translationPairPaths('docs/foo.md')
  154. const record = {
  155. sourceHash: '1'.repeat(40),
  156. zhHash: '2'.repeat(40),
  157. }
  158. it('round-trips the canonical two-hash record', () => {
  159. expect(parseTranslationPairingRecord(renderTranslationPairingRecord(paths, record), paths)).toEqual(record)
  160. })
  161. it('rejects duplicate or unexpected keys', () => {
  162. expect(parseTranslationPairingRecord([
  163. `foo.md: ${'1'.repeat(40)}`,
  164. `foo.md: ${'3'.repeat(40)}`,
  165. `foo.zh.md: ${'2'.repeat(40)}`,
  166. '',
  167. ].join('\n'), paths)).toBeUndefined()
  168. expect(parseTranslationPairingRecord([
  169. `foo.md: ${'1'.repeat(40)}`,
  170. `bar.zh.md: ${'2'.repeat(40)}`,
  171. '',
  172. ].join('\n'), paths)).toBeUndefined()
  173. })
  174. })
  175. describe('translation scope discovery', () => {
  176. it.each([
  177. 'README.md',
  178. 'CONTRIBUTING.md',
  179. 'CONTRIBUTING.zh.md',
  180. 'CONTRIBUTING.i18n.yaml',
  181. 'apps/cli/README.md',
  182. 'future/subtree/readme.md',
  183. 'packages/example/README.zh.md',
  184. 'native/example/README.i18n.yaml',
  185. '.agents/notes/proposed/feature.md',
  186. 'docs/guide.md',
  187. 'python/guide.md',
  188. ])('includes %s', (file) => {
  189. expect(isTranslationScopeFile(file)).toBe(true)
  190. })
  191. it.each([
  192. 'packages/example/guide.md',
  193. 'packages/example/CONTRIBUTING.md',
  194. 'examples/tutorial.md',
  195. 'website/reference.md',
  196. 'packages/example/README.txt',
  197. 'vendor/example/README.md',
  198. 'packages/example/node_modules/dependency/README.md',
  199. 'packages/example/lib/README.md',
  200. 'coverage/report/README.md',
  201. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-macos-arm64/README.md',
  202. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md',
  203. ])('excludes non-source or non-README path %s', (file) => {
  204. expect(isTranslationScopeFile(file)).toBe(false)
  205. })
  206. })
  207. describe('translation structural signature', () => {
  208. it('accepts matching list kinds, starts, and item counts', () => {
  209. const source = signature('3. One\n4. Two\n\n- A\n- B\n')
  210. const counterpart = signature('3. 一\n4. 二\n\n- 甲\n- 乙\n')
  211. expect(translationStructureDiff(source, counterpart)).toEqual([])
  212. })
  213. it('rejects an altered ordered-list start', () => {
  214. const source = signature('3. One\n4. Two\n\n- A\n- B\n')
  215. const counterpart = signature('1. 一\n2. 二\n\n- 甲\n- 乙\n')
  216. expect(translationStructureDiff(source, counterpart)).toEqual([
  217. 'list (kind, start, item count) #1 diverges between the pair: "ordered:start=3:items=2" vs "ordered:start=1:items=2"',
  218. ])
  219. })
  220. it('rejects a missing list item', () => {
  221. const source = signature('- A\n- B\n')
  222. const counterpart = signature('- 甲\n')
  223. expect(translationStructureDiff(source, counterpart)).toEqual([
  224. 'list (kind, start, item count) #1 diverges between the pair: "bullet:items=2" vs "bullet:items=1"',
  225. ])
  226. })
  227. it('rejects altered table row or column counts', () => {
  228. const source = signature('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n')
  229. const counterpart = signature('| 甲 | 乙 |\n|---|---|\n| 一 | 二 |\n')
  230. expect(translationStructureDiff(source, counterpart)).toEqual([
  231. 'table (row x column count) #1 diverges between the pair: "3x2" vs "2x2"',
  232. ])
  233. })
  234. })
  235. describe('pair CLI arguments', () => {
  236. it('normalizes any pair file or bare stem to the English anchor', () => {
  237. expect(pairAnchorOfArgument('docs/foo.md')).toBe('docs/foo.md')
  238. expect(pairAnchorOfArgument('docs/foo.zh.md')).toBe('docs/foo.md')
  239. expect(pairAnchorOfArgument('docs/foo.i18n.yaml')).toBe('docs/foo.md')
  240. expect(pairAnchorOfArgument('docs/foo')).toBe('docs/foo.md')
  241. expect(pairAnchorOfArgument('.\\docs\\foo.zh.md')).toBe('docs/foo.md')
  242. })
  243. it('scopes a check to named pairs and dedupes the three spellings', () => {
  244. expect(parseTranslationPairingCliArgs(['docs/foo.zh.md', 'docs/foo.i18n.yaml', 'docs/bar.md'])).toEqual({
  245. input: 'worktree',
  246. mode: 'check',
  247. scope: 'pairs',
  248. anchors: ['docs/bar.md', 'docs/foo.md'],
  249. })
  250. expect(parseTranslationPairingCliArgs([])).toEqual({
  251. input: 'worktree',
  252. mode: 'check',
  253. scope: 'corpus',
  254. anchors: [],
  255. })
  256. })
  257. it('requires --write to name confirmed pairs or opt into --all', () => {
  258. expect(() => parseTranslationPairingCliArgs(['--write'])).toThrow('requires the pair(s) you confirmed')
  259. expect(parseTranslationPairingCliArgs(['--write', 'docs/foo.md'])).toEqual({
  260. input: 'worktree',
  261. mode: 'write',
  262. scope: 'pairs',
  263. anchors: ['docs/foo.md'],
  264. })
  265. expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({
  266. input: 'worktree',
  267. mode: 'write',
  268. scope: 'corpus',
  269. anchors: [],
  270. })
  271. expect(() => parseTranslationPairingCliArgs(['--write', '--all', 'docs/foo.md'])).toThrow('not both')
  272. })
  273. it('keeps --list corpus-only and rejects unknown flags', () => {
  274. expect(parseTranslationPairingCliArgs(['--list'])).toEqual({
  275. input: 'worktree',
  276. mode: 'list',
  277. scope: 'corpus',
  278. anchors: [],
  279. })
  280. expect(() => parseTranslationPairingCliArgs(['--list', 'docs/foo.md'])).toThrow('takes no other flags or paths')
  281. expect(() => parseTranslationPairingCliArgs(['--all'])).toThrow('--all only applies to --write')
  282. expect(() => parseTranslationPairingCliArgs(['--frobnicate'])).toThrow('unknown flag(s): --frobnicate')
  283. })
  284. it('makes cached verification a named, read-only index check', () => {
  285. expect(parseTranslationPairingCliArgs(['--cached', 'docs/foo.i18n.yaml'])).toEqual({
  286. input: 'index',
  287. mode: 'check',
  288. scope: 'pairs',
  289. anchors: ['docs/foo.md'],
  290. })
  291. expect(() => parseTranslationPairingCliArgs(['--cached'])).toThrow('requires the staged pair paths')
  292. expect(() => parseTranslationPairingCliArgs(['--cached', '--write', 'docs/foo.md'])).toThrow('read-only')
  293. })
  294. })
  295. describe('generated regions', () => {
  296. const BEGIN = '<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->'
  297. const END = '<!-- END GENERATED cordis-surface -->'
  298. it('partitions marker-delimited regions from the hand-owned remainder', () => {
  299. const doc = `# T\n\nprose\n\n${BEGIN}\ninjected\n${END}\ntail\n`
  300. const { regions, stripped } = partitionGeneratedRegions(doc)
  301. expect(regions).toEqual([`${BEGIN}\ninjected\n${END}`])
  302. expect(stripped).toBe('# T\n\nprose\n\ntail\n')
  303. })
  304. it('treats a document without markers as one hand-owned remainder', () => {
  305. const { regions, stripped } = partitionGeneratedRegions('# T\n\nprose\n')
  306. expect(regions).toEqual([])
  307. expect(stripped).toBe('# T\n\nprose\n')
  308. })
  309. it('rejects unbalanced or nested markers', () => {
  310. expect(() => partitionGeneratedRegions(`${END}\n`)).toThrow('without a BEGIN')
  311. expect(() => partitionGeneratedRegions(`${BEGIN}\n`)).toThrow('without an END')
  312. expect(() => partitionGeneratedRegions(`${BEGIN}\n${BEGIN}\n${END}\n`)).toThrow('nested')
  313. })
  314. it('rejects mismatched slugs and malformed marker lines', () => {
  315. expect(() => partitionGeneratedRegions('<!-- BEGIN GENERATED a -->\nx\n<!-- END GENERATED b -->\n'))
  316. .toThrow("END slug 'b' does not match its BEGIN slug 'a'")
  317. expect(() => partitionGeneratedRegions('<!-- BEGIN GENERATED a --> trailing\nx\n<!-- END GENERATED a -->\n'))
  318. .toThrow('malformed generated region marker line')
  319. expect(() => partitionGeneratedRegions('x\n<!-- END GENERATED a --> tail\n'))
  320. .toThrow('malformed generated region marker line')
  321. })
  322. it('computes the exact git blob hash', () => {
  323. // `git hash-object` of the empty file and of "x\n" — pinned upstream values.
  324. expect(blobHash(Buffer.from(''))).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391')
  325. expect(blobHash(Buffer.from('x\n'))).toBe('587be6b4c3f93f93c489c0111bba5596147a26cb')
  326. })
  327. })