translation-pairing-merge.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. /** Fail-closed composition of bilingual pairing records during Git merges. */
  2. import { spawnSync } from 'node:child_process'
  3. import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
  6. import {
  7. GIT_COMMAND_MAX_BUFFER,
  8. gitBlobHash,
  9. gitMergeInputPaths,
  10. readGitIndexBlob,
  11. runGit,
  12. storeGitBlob,
  13. } from './translation-pairing-git.ts'
  14. import {
  15. isTranslationScopeFile,
  16. languageSwitcherTargets,
  17. parseTranslationMarkdown,
  18. parseTranslationPairingManifest,
  19. requiresSourceLanguageSwitcher,
  20. translationPairSourcePredicate,
  21. translationStructureDiff,
  22. translationStructureSignature,
  23. } from './translation-pairing.ts'
  24. import {
  25. hasLanguageSwitcher,
  26. translationLinkLocaleViolations,
  27. } from './translation-links.ts'
  28. import {
  29. parseTranslationPairingRecord,
  30. renderTranslationPairingRecord,
  31. translationPairPathsFromMeta,
  32. type TranslationPairPaths,
  33. type TranslationPairingRecord,
  34. } from './translation-pairing-record.ts'
  35. const UNMERGED_ENTRY = /^(\d+) ([0-9a-f]+) ([123])\t([\s\S]+)$/
  36. /** A mechanically composed record and the exact merged owner contents it names. */
  37. export interface TranslationPairingMergeResult extends TranslationPairingRecord {
  38. /** Canonical generated sidecar text. */
  39. record: string
  40. /** Clean three-way merge of the English owner. */
  41. sourceContent: Buffer
  42. /** Clean three-way merge of the Simplified Chinese owner. */
  43. zhContent: Buffer
  44. }
  45. interface UnmergedStages {
  46. ancestor?: string
  47. current?: string
  48. other?: string
  49. }
  50. function readGitBlob(root: string, objectId: string, owner: string): Buffer {
  51. const content = runGit(root, ['cat-file', 'blob', objectId], `reading ${owner} blob ${objectId}`)
  52. if (gitBlobHash(content) !== objectId) {
  53. throw new Error(`${owner} record names ${objectId}, which is not its SHA-1 git blob hash`)
  54. }
  55. return content
  56. }
  57. function readMergeDefault(root: string): string | undefined {
  58. const result = spawnSync('git', ['-C', root, 'config', '--get', 'merge.default'], {
  59. maxBuffer: GIT_COMMAND_MAX_BUFFER,
  60. })
  61. if (result.error) {
  62. throw new Error(`reading merge.default failed: ${result.error.message}`, { cause: result.error })
  63. }
  64. if (result.status === 1) return undefined
  65. if (result.status !== 0) {
  66. throw new Error(
  67. `reading merge.default failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`,
  68. )
  69. }
  70. return result.stdout.toString('utf8').trim()
  71. }
  72. function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void {
  73. const output = runGit(
  74. root,
  75. ['check-attr', '-z', 'merge', '--', paths.source, paths.zh],
  76. 'checking bilingual owner merge attributes',
  77. ).toString('utf8')
  78. const fields = output.split('\0')
  79. fields.pop()
  80. let mergeDefault: string | undefined
  81. for (let index = 0; index < fields.length; index += 3) {
  82. const path = fields[index]
  83. const value = fields[index + 2]
  84. if (path === undefined || value === undefined) {
  85. throw new Error('git check-attr returned a malformed result')
  86. }
  87. if (!['unspecified', 'set', 'text'].includes(value)) {
  88. throw new Error(`${path} uses merge=${value}; the pairing driver only composes Git's default text merge`)
  89. }
  90. if (value === 'unspecified') {
  91. mergeDefault ??= readMergeDefault(root)
  92. if (mergeDefault !== undefined && mergeDefault !== 'text') {
  93. throw new Error(
  94. `${path} inherits merge.default=${mergeDefault}; the pairing driver only composes Git's default text merge`,
  95. )
  96. }
  97. }
  98. }
  99. }
  100. function runTextMerge(
  101. root: string,
  102. label: string,
  103. ancestor: Buffer | string,
  104. current: Buffer | string,
  105. other: Buffer | string,
  106. ): { output: Buffer; status: number | null } {
  107. const temporary = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
  108. try {
  109. const ancestorPath = join(temporary, 'ancestor')
  110. const currentPath = join(temporary, 'current')
  111. const otherPath = join(temporary, 'other')
  112. writeFileSync(ancestorPath, ancestor)
  113. writeFileSync(currentPath, current)
  114. writeFileSync(otherPath, other)
  115. const result = spawnSync('git', [
  116. '-C', root,
  117. 'merge-file', '-p',
  118. '-L', `${label}:current`,
  119. '-L', `${label}:ancestor`,
  120. '-L', `${label}:other`,
  121. currentPath, ancestorPath, otherPath,
  122. ], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
  123. if (result.error) {
  124. throw new Error(`merging ${label} failed: ${result.error.message}`, { cause: result.error })
  125. }
  126. return { output: result.stdout, status: result.status }
  127. } finally {
  128. rmSync(temporary, { recursive: true, force: true })
  129. }
  130. }
  131. function mergeBlobTriplet(
  132. root: string,
  133. owner: string,
  134. ancestor: Buffer,
  135. current: Buffer,
  136. other: Buffer,
  137. ): Buffer {
  138. const result = runTextMerge(root, owner, ancestor, current, other)
  139. if (result.status !== 0) {
  140. const kind = result.status !== null && result.status > 0 && result.status <= 127
  141. ? 'has content conflicts'
  142. : `failed with status ${String(result.status)}`
  143. throw new Error(`${owner} ${kind}`)
  144. }
  145. return result.output
  146. }
  147. function loadRecordOwners(
  148. root: string,
  149. label: string,
  150. content: string,
  151. paths: TranslationPairPaths,
  152. ): { source: Buffer; zh: Buffer } {
  153. const record = parseTranslationPairingRecord(content, paths)
  154. if (record === undefined) throw new Error(`${label} ${paths.meta} is not a valid two-hash pairing record`)
  155. return {
  156. source: readGitBlob(root, record.sourceHash, `${label} ${paths.source}`),
  157. zh: readGitBlob(root, record.zhHash, `${label} ${paths.zh}`),
  158. }
  159. }
  160. function assertMergedPairStructure(
  161. root: string,
  162. paths: TranslationPairPaths,
  163. source: Buffer,
  164. zh: Buffer,
  165. isTranslationPairSource: (sourcePath: string) => boolean,
  166. ): void {
  167. const sourceText = source.toString('utf8')
  168. const zhText = zh.toString('utf8')
  169. const sourceTree = parseTranslationMarkdown(sourceText)
  170. const zhTree = parseTranslationMarkdown(zhText)
  171. const indexFiles = gitMergeInputPaths(root)
  172. const repositoryFileExists = (path: string): boolean => indexFiles.has(path)
  173. const sourceSwitcherTargets = languageSwitcherTargets(paths.source)
  174. const zhSwitcherTargets = languageSwitcherTargets(paths.zh)
  175. if (requiresSourceLanguageSwitcher(paths.source)
  176. && !hasLanguageSwitcher(sourceTree, sourceText, zhSwitcherTargets)) {
  177. throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
  178. }
  179. if (!hasLanguageSwitcher(zhTree, zhText, sourceSwitcherTargets)) {
  180. throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`)
  181. }
  182. const localeViolations = [
  183. ...translationLinkLocaleViolations(sourceText, {
  184. repoRoot: root,
  185. sourcePath: paths.source,
  186. isTranslationPairSource,
  187. repositoryFileExists,
  188. }, zhSwitcherTargets),
  189. ...translationLinkLocaleViolations(zhText, {
  190. repoRoot: root,
  191. sourcePath: paths.zh,
  192. isTranslationPairSource,
  193. repositoryFileExists,
  194. }, sourceSwitcherTargets),
  195. ]
  196. if (localeViolations.length > 0) {
  197. const violation = localeViolations[0]
  198. if (violation === undefined) throw new Error('translation locale violation disappeared')
  199. throw new Error(`${violation.sourcePath}:${violation.line} clean merge uses ${JSON.stringify(violation.url)}; expected ${JSON.stringify(violation.expectedUrl)}`)
  200. }
  201. const divergences = translationStructureDiff(
  202. translationStructureSignature(sourceTree, zhSwitcherTargets, {
  203. repoRoot: root,
  204. sourcePath: paths.source,
  205. isTranslationPairSource,
  206. repositoryFileExists,
  207. markdown: sourceText,
  208. }),
  209. translationStructureSignature(zhTree, sourceSwitcherTargets, {
  210. repoRoot: root,
  211. sourcePath: paths.zh,
  212. isTranslationPairSource,
  213. repositoryFileExists,
  214. markdown: zhText,
  215. }),
  216. )
  217. if (divergences.length > 0) {
  218. throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`)
  219. }
  220. }
  221. function normalizeMetaPath(root: string, meta: string): string {
  222. if (isAbsolute(meta)) throw new Error(`pairing record must be repository-relative: ${JSON.stringify(meta)}`)
  223. const repositoryRelative = relative(resolve(root), resolve(root, meta))
  224. if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`)) {
  225. throw new Error(`pairing record escapes the repository: ${JSON.stringify(meta)}`)
  226. }
  227. return repositoryRelative.split(sep).join('/')
  228. }
  229. /**
  230. * Compose one generated sidecar from the ancestor, current, and other records.
  231. *
  232. * Each input record is already a confirmation of its two owner blobs. The
  233. * result exists only when Git's default text merge succeeds independently for
  234. * both languages and the composed documents retain the pairing structure.
  235. *
  236. * @param root - Repository root containing the referenced Git objects.
  237. * @param metaPath - Repository-relative sidecar path.
  238. * @param ancestorRecord - Common-ancestor sidecar text.
  239. * @param currentRecord - Current-side sidecar text.
  240. * @param otherRecord - Other-side sidecar text.
  241. * @returns The canonical record and exact merged owner contents.
  242. * @throws Error when the input is not mechanically composable.
  243. */
  244. export function mergeTranslationPairingRecords(
  245. root: string,
  246. metaPath: string,
  247. ancestorRecord: string,
  248. currentRecord: string,
  249. otherRecord: string,
  250. isTranslationPairSource: (sourcePath: string) => boolean,
  251. ): TranslationPairingMergeResult {
  252. const normalizedMeta = normalizeMetaPath(root, metaPath)
  253. if (!isTranslationScopeFile(normalizedMeta)) {
  254. throw new Error(`${normalizedMeta} is outside the active bilingual documentation corpus`)
  255. }
  256. const paths = translationPairPathsFromMeta(normalizedMeta)
  257. if (!isTranslationPairSource(paths.source)) {
  258. throw new Error(`${normalizedMeta} is excluded from the active bilingual documentation corpus`)
  259. }
  260. assertDefaultTextMerge(root, paths)
  261. const ancestor = loadRecordOwners(root, 'ancestor', ancestorRecord, paths)
  262. const current = loadRecordOwners(root, 'current', currentRecord, paths)
  263. const other = loadRecordOwners(root, 'other', otherRecord, paths)
  264. const sourceContent = mergeBlobTriplet(root, paths.source, ancestor.source, current.source, other.source)
  265. const zhContent = mergeBlobTriplet(root, paths.zh, ancestor.zh, current.zh, other.zh)
  266. assertMergedPairStructure(root, paths, sourceContent, zhContent, isTranslationPairSource)
  267. const sourceHash = storeGitBlob(root, sourceContent)
  268. const zhHash = storeGitBlob(root, zhContent)
  269. return {
  270. record: renderTranslationPairingRecord(paths, { sourceHash, zhHash }),
  271. sourceContent,
  272. sourceHash,
  273. zhContent,
  274. zhHash,
  275. }
  276. }
  277. /** Read the repository manifest and return its active bilingual-source predicate. */
  278. export function repositoryTranslationPairSource(root: string): (sourcePath: string) => boolean {
  279. const path = 'scripts/translation-pairing.manifest.json'
  280. const content = readGitIndexBlob(root, path)?.content ?? readFileSync(join(root, path))
  281. const manifest = parseTranslationPairingManifest(
  282. content.toString('utf8'),
  283. )
  284. return translationPairSourcePredicate(manifest)
  285. }
  286. function unmergedSidecars(root: string): Map<string, UnmergedStages> {
  287. const output = runGit(root, ['ls-files', '--unmerged', '-z'], 'listing unresolved merge entries').toString('utf8')
  288. const records = new Map<string, UnmergedStages>()
  289. for (const entry of output.split('\0')) {
  290. if (entry === '') continue
  291. const match = UNMERGED_ENTRY.exec(entry)
  292. if (!match?.[2] || !match[3] || match[4] === undefined) {
  293. throw new Error(`git ls-files returned a malformed unmerged entry: ${JSON.stringify(entry)}`)
  294. }
  295. const path = match[4]
  296. if (!path.endsWith('.i18n.yaml')) continue
  297. const stages = records.get(path) ?? {}
  298. const field = match[3] === '1' ? 'ancestor' : match[3] === '2' ? 'current' : 'other'
  299. stages[field] = match[2]
  300. records.set(path, stages)
  301. }
  302. return records
  303. }
  304. function assertUneditedSidecar(
  305. root: string,
  306. metaPath: string,
  307. ancestorRecord: string,
  308. currentRecord: string,
  309. otherRecord: string,
  310. ): void {
  311. const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
  312. if (worktreeRecord === currentRecord || worktreeRecord === otherRecord) return
  313. const textMerge = runTextMerge(root, metaPath, ancestorRecord, currentRecord, otherRecord)
  314. if (textMerge.status === 0 && textMerge.output.toString('utf8') === worktreeRecord) return
  315. const stageDataLines = [currentRecord, otherRecord]
  316. .flatMap(record => record.split(/\r?\n/))
  317. .filter(line => line !== '' && !line.startsWith('#'))
  318. const hasUneditedConflict = worktreeRecord.includes('<<<<<<<')
  319. && worktreeRecord.includes('=======')
  320. && worktreeRecord.includes('>>>>>>>')
  321. && stageDataLines.every(line => worktreeRecord.includes(line))
  322. if (!hasUneditedConflict) {
  323. throw new Error(`${metaPath} has edited conflict content; refusing to overwrite manual work`)
  324. }
  325. }
  326. /**
  327. * Resolve every mechanically composable `.i18n.yaml` conflict in the index.
  328. *
  329. * The command first proves that Git's already-staged owner merges match the
  330. * independently composed contents, then writes and stages all sidecars as one
  331. * batch. Other conflicts remain untouched; after staging the safe records, an
  332. * aggregate error reports any pairing conflicts that still need manual work.
  333. *
  334. * @param root - Repository root with an in-progress merge-like operation.
  335. * @returns Repository-relative sidecar paths resolved and staged.
  336. */
  337. export function resolveTranslationPairingConflicts(
  338. root: string,
  339. isTranslationPairSource: (sourcePath: string) => boolean,
  340. ): string[] {
  341. const resolutions: { path: string; record: string }[] = []
  342. const failures: { path: string; reason: string }[] = []
  343. for (const [metaPath, stages] of [...unmergedSidecars(root)].sort(([left], [right]) => left.localeCompare(right))) {
  344. try {
  345. if (stages.ancestor === undefined || stages.current === undefined || stages.other === undefined) {
  346. throw new Error('is an add/delete or incomplete-stage conflict and requires manual resolution')
  347. }
  348. const ancestorRecord = readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8')
  349. const currentRecord = readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8')
  350. const otherRecord = readGitBlob(root, stages.other, `other ${metaPath}`).toString('utf8')
  351. assertUneditedSidecar(root, metaPath, ancestorRecord, currentRecord, otherRecord)
  352. const result = mergeTranslationPairingRecords(
  353. root,
  354. metaPath,
  355. ancestorRecord,
  356. currentRecord,
  357. otherRecord,
  358. isTranslationPairSource,
  359. )
  360. const paths = translationPairPathsFromMeta(metaPath)
  361. if (readGitIndexBlob(root, paths.source)?.objectId !== result.sourceHash) {
  362. throw new Error(`${paths.source} staged merge does not match the pairing driver's clean merge`)
  363. }
  364. if (readGitIndexBlob(root, paths.zh)?.objectId !== result.zhHash) {
  365. throw new Error(`${paths.zh} staged merge does not match the pairing driver's clean merge`)
  366. }
  367. for (const [path, expected] of [[paths.source, result.sourceHash], [paths.zh, result.zhHash]] as const) {
  368. if (gitBlobHash(readFileSync(join(root, path))) !== expected) {
  369. throw new Error(`${path} has unstaged content; refusing to confirm bytes outside the merge result`)
  370. }
  371. }
  372. resolutions.push({ path: metaPath, record: result.record })
  373. } catch (error) {
  374. failures.push({ path: metaPath, reason: error instanceof Error ? error.message : String(error) })
  375. }
  376. }
  377. for (const resolution of resolutions) writeFileSync(join(root, resolution.path), resolution.record)
  378. if (resolutions.length > 0) {
  379. runGit(root, ['add', '--', ...resolutions.map(resolution => resolution.path)], 'staging resolved pairing records')
  380. }
  381. if (failures.length > 0) {
  382. const resolved = resolutions.length === 0
  383. ? ''
  384. : `resolved and staged ${resolutions.map(resolution => resolution.path).join(', ')}; `
  385. throw new Error(
  386. `${resolved}left ${String(failures.length)} pairing conflict(s) unresolved:\n`
  387. + failures.map(failure => `- ${failure.path}: ${failure.reason}`).join('\n'),
  388. )
  389. }
  390. return resolutions.map(resolution => resolution.path)
  391. }