mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
/** Fail-closed composition of bilingual pairing records during Git merges. */
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
|
|
import {
|
|
linksTo,
|
|
isTranslationScopeFile,
|
|
parseTranslationMarkdown,
|
|
translationStructureDiff,
|
|
translationStructureSignature,
|
|
} from './translation-pairing.ts'
|
|
import {
|
|
parseTranslationPairingRecord,
|
|
renderTranslationPairingRecord,
|
|
translationPairPathsFromMeta,
|
|
type TranslationPairPaths,
|
|
type TranslationPairingRecord,
|
|
} from './translation-pairing-record.ts'
|
|
|
|
const MAX_GIT_OUTPUT = 1 << 26
|
|
const UNMERGED_ENTRY = /^(\d+) ([0-9a-f]+) ([123])\t([\s\S]+)$/
|
|
|
|
/** A mechanically composed record and the exact merged owner contents it names. */
|
|
export interface TranslationPairingMergeResult extends TranslationPairingRecord {
|
|
/** Canonical generated sidecar text. */
|
|
record: string
|
|
/** Clean three-way merge of the English owner. */
|
|
sourceContent: Buffer
|
|
/** Clean three-way merge of the Simplified Chinese owner. */
|
|
zhContent: Buffer
|
|
}
|
|
|
|
interface UnmergedStages {
|
|
ancestor?: string
|
|
current?: string
|
|
other?: string
|
|
}
|
|
|
|
function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
|
|
const result = spawnSync('git', ['-C', root, ...args], {
|
|
input,
|
|
maxBuffer: MAX_GIT_OUTPUT,
|
|
})
|
|
if (result.error) throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
|
|
if (result.status !== 0) {
|
|
throw new Error(`${operation} failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`)
|
|
}
|
|
return result.stdout
|
|
}
|
|
|
|
function readGitBlob(root: string, objectId: string, owner: string): Buffer {
|
|
const content = runGit(root, ['cat-file', 'blob', objectId], `reading ${owner} blob ${objectId}`)
|
|
if (gitBlobHash(content) !== objectId) {
|
|
throw new Error(`${owner} record names ${objectId}, which is not its SHA-1 git blob hash`)
|
|
}
|
|
return content
|
|
}
|
|
|
|
function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void {
|
|
const output = runGit(
|
|
root,
|
|
['check-attr', '-z', 'merge', '--', paths.source, paths.zh],
|
|
'checking bilingual owner merge attributes',
|
|
).toString('utf8')
|
|
const fields = output.split('\0')
|
|
fields.pop()
|
|
for (let index = 0; index < fields.length; index += 3) {
|
|
const path = fields[index]
|
|
const value = fields[index + 2]
|
|
if (path === undefined || value === undefined) {
|
|
throw new Error('git check-attr returned a malformed result')
|
|
}
|
|
if (!['unspecified', 'set', 'text'].includes(value)) {
|
|
throw new Error(`${path} uses merge=${value}; the pairing driver only composes Git's default text merge`)
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeBlobTriplet(
|
|
root: string,
|
|
owner: string,
|
|
ancestor: Buffer,
|
|
current: Buffer,
|
|
other: Buffer,
|
|
): Buffer {
|
|
const temporary = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
|
|
try {
|
|
const ancestorPath = join(temporary, 'ancestor')
|
|
const currentPath = join(temporary, 'current')
|
|
const otherPath = join(temporary, 'other')
|
|
writeFileSync(ancestorPath, ancestor)
|
|
writeFileSync(currentPath, current)
|
|
writeFileSync(otherPath, other)
|
|
const result = spawnSync('git', [
|
|
'-C', root,
|
|
'merge-file', '-p',
|
|
'-L', `${owner}:current`,
|
|
'-L', `${owner}:ancestor`,
|
|
'-L', `${owner}:other`,
|
|
currentPath, ancestorPath, otherPath,
|
|
], { maxBuffer: MAX_GIT_OUTPUT })
|
|
if (result.error) {
|
|
throw new Error(`merging ${owner} failed: ${result.error.message}`, { cause: result.error })
|
|
}
|
|
if (result.status !== 0) {
|
|
const kind = result.status !== null && result.status > 0 && result.status <= 127
|
|
? 'has content conflicts'
|
|
: `failed with status ${String(result.status)}`
|
|
throw new Error(`${owner} ${kind}`)
|
|
}
|
|
return result.stdout
|
|
} finally {
|
|
rmSync(temporary, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
function loadRecordOwners(
|
|
root: string,
|
|
label: string,
|
|
content: string,
|
|
paths: TranslationPairPaths,
|
|
): { source: Buffer; zh: Buffer } {
|
|
const record = parseTranslationPairingRecord(content, paths)
|
|
if (record === undefined) throw new Error(`${label} ${paths.meta} is not a valid two-hash pairing record`)
|
|
return {
|
|
source: readGitBlob(root, record.sourceHash, `${label} ${paths.source}`),
|
|
zh: readGitBlob(root, record.zhHash, `${label} ${paths.zh}`),
|
|
}
|
|
}
|
|
|
|
function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void {
|
|
const sourceTree = parseTranslationMarkdown(source.toString('utf8'))
|
|
const zhTree = parseTranslationMarkdown(zh.toString('utf8'))
|
|
if (!linksTo(sourceTree, basename(paths.zh))) {
|
|
throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
|
|
}
|
|
if (!linksTo(zhTree, basename(paths.source))) {
|
|
throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`)
|
|
}
|
|
const divergences = translationStructureDiff(
|
|
translationStructureSignature(sourceTree, basename(paths.zh)),
|
|
translationStructureSignature(zhTree, basename(paths.source)),
|
|
)
|
|
if (divergences.length > 0) {
|
|
throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`)
|
|
}
|
|
}
|
|
|
|
function normalizeMetaPath(root: string, meta: string): string {
|
|
if (isAbsolute(meta)) throw new Error(`pairing record must be repository-relative: ${JSON.stringify(meta)}`)
|
|
const repositoryRelative = relative(resolve(root), resolve(root, meta))
|
|
if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`)) {
|
|
throw new Error(`pairing record escapes the repository: ${JSON.stringify(meta)}`)
|
|
}
|
|
return repositoryRelative.split(sep).join('/')
|
|
}
|
|
|
|
/**
|
|
* Compose one generated sidecar from the ancestor, current, and other records.
|
|
*
|
|
* Each input record is already a confirmation of its two owner blobs. The
|
|
* result exists only when Git's default text merge succeeds independently for
|
|
* both languages and the composed documents retain the pairing structure.
|
|
*
|
|
* @param root - Repository root containing the referenced Git objects.
|
|
* @param metaPath - Repository-relative sidecar path.
|
|
* @param ancestorRecord - Common-ancestor sidecar text.
|
|
* @param currentRecord - Current-side sidecar text.
|
|
* @param otherRecord - Other-side sidecar text.
|
|
* @returns The canonical record and exact merged owner contents.
|
|
* @throws Error when the input is not mechanically composable.
|
|
*/
|
|
export function mergeTranslationPairingRecords(
|
|
root: string,
|
|
metaPath: string,
|
|
ancestorRecord: string,
|
|
currentRecord: string,
|
|
otherRecord: string,
|
|
): TranslationPairingMergeResult {
|
|
const normalizedMeta = normalizeMetaPath(root, metaPath)
|
|
if (!isTranslationScopeFile(normalizedMeta)) {
|
|
throw new Error(`${normalizedMeta} is outside the active bilingual documentation corpus`)
|
|
}
|
|
const paths = translationPairPathsFromMeta(normalizedMeta)
|
|
assertDefaultTextMerge(root, paths)
|
|
const ancestor = loadRecordOwners(root, 'ancestor', ancestorRecord, paths)
|
|
const current = loadRecordOwners(root, 'current', currentRecord, paths)
|
|
const other = loadRecordOwners(root, 'other', otherRecord, paths)
|
|
const sourceContent = mergeBlobTriplet(root, paths.source, ancestor.source, current.source, other.source)
|
|
const zhContent = mergeBlobTriplet(root, paths.zh, ancestor.zh, current.zh, other.zh)
|
|
assertMergedPairStructure(paths, sourceContent, zhContent)
|
|
const sourceHash = storeGitBlob(root, sourceContent)
|
|
const zhHash = storeGitBlob(root, zhContent)
|
|
return {
|
|
record: renderTranslationPairingRecord(paths, { sourceHash, zhHash }),
|
|
sourceContent,
|
|
sourceHash,
|
|
zhContent,
|
|
zhHash,
|
|
}
|
|
}
|
|
|
|
function unmergedSidecars(root: string): Map<string, UnmergedStages> {
|
|
const output = runGit(root, ['ls-files', '--unmerged', '-z'], 'listing unresolved merge entries').toString('utf8')
|
|
const records = new Map<string, UnmergedStages>()
|
|
for (const entry of output.split('\0')) {
|
|
if (entry === '') continue
|
|
const match = UNMERGED_ENTRY.exec(entry)
|
|
if (!match?.[2] || !match[3] || match[4] === undefined) {
|
|
throw new Error(`git ls-files returned a malformed unmerged entry: ${JSON.stringify(entry)}`)
|
|
}
|
|
const path = match[4]
|
|
if (!path.endsWith('.i18n.yaml')) continue
|
|
const stages = records.get(path) ?? {}
|
|
const field = match[3] === '1' ? 'ancestor' : match[3] === '2' ? 'current' : 'other'
|
|
stages[field] = match[2]
|
|
records.set(path, stages)
|
|
}
|
|
return records
|
|
}
|
|
|
|
/**
|
|
* Resolve every mechanically composable `.i18n.yaml` conflict in the index.
|
|
*
|
|
* The command first proves that Git's already-staged owner merges match the
|
|
* independently composed contents, then writes and stages all sidecars as one
|
|
* batch. Other conflicts remain untouched.
|
|
*
|
|
* @param root - Repository root with an in-progress merge-like operation.
|
|
* @returns Repository-relative sidecar paths resolved and staged.
|
|
*/
|
|
export function resolveTranslationPairingConflicts(root: string): string[] {
|
|
const resolutions: { path: string; record: string }[] = []
|
|
for (const [metaPath, stages] of [...unmergedSidecars(root)].sort(([left], [right]) => left.localeCompare(right))) {
|
|
if (stages.ancestor === undefined || stages.current === undefined || stages.other === undefined) {
|
|
throw new Error(`${metaPath} is an add/delete or incomplete-stage conflict and requires manual resolution`)
|
|
}
|
|
const result = mergeTranslationPairingRecords(
|
|
root,
|
|
metaPath,
|
|
readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8'),
|
|
readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8'),
|
|
readGitBlob(root, stages.other, `other ${metaPath}`).toString('utf8'),
|
|
)
|
|
const paths = translationPairPathsFromMeta(metaPath)
|
|
if (readGitIndexBlob(root, paths.source)?.objectId !== result.sourceHash) {
|
|
throw new Error(`${paths.source} staged merge does not match the pairing driver's clean merge`)
|
|
}
|
|
if (readGitIndexBlob(root, paths.zh)?.objectId !== result.zhHash) {
|
|
throw new Error(`${paths.zh} staged merge does not match the pairing driver's clean merge`)
|
|
}
|
|
for (const [path, expected] of [[paths.source, result.sourceHash], [paths.zh, result.zhHash]] as const) {
|
|
if (gitBlobHash(readFileSync(join(root, path))) !== expected) {
|
|
throw new Error(`${path} has unstaged content; refusing to confirm bytes outside the merge result`)
|
|
}
|
|
}
|
|
resolutions.push({ path: metaPath, record: result.record })
|
|
}
|
|
for (const resolution of resolutions) writeFileSync(join(root, resolution.path), resolution.record)
|
|
if (resolutions.length > 0) {
|
|
runGit(root, ['add', '--', ...resolutions.map(resolution => resolution.path)], 'staging resolved pairing records')
|
|
}
|
|
return resolutions.map(resolution => resolution.path)
|
|
}
|