Merge branch 'worktree/ci-native-windows-20260808' into worktree/ci-native-windows-coverage-20260808

This commit is contained in:
Tianyi Cui
2026-08-09 01:34:01 +08:00
29 changed files with 1712 additions and 102 deletions

View File

@@ -59,6 +59,33 @@ describe('CI workflow', () => {
})
})
describe('E2B e2e workflow', () => {
it('is manual-only and fails loud before running the focused live suite', () => {
const workflow = loadWorkflow('.github/workflows/e2b-e2e.yml')
expect(workflow.on).toEqual({ workflow_dispatch: null })
if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.e2b) || !Array.isArray(workflow.jobs.e2b.steps)) {
throw new TypeError('E2B e2e workflow must define the e2b job steps')
}
const steps = workflow.jobs.e2b.steps.filter(isRecord)
const preflight = steps.find(step => step.name === 'Preflight (require E2B API key)')
const e2b = steps.find(step => step.name === 'E2B tests (live sandbox)')
expect(preflight).toMatchObject({
env: { E2B_API_KEY: '${{ secrets.E2B_API_KEY_EXTERNAL }}' },
})
expect(preflight?.run).toContain('E2B_API_KEY_EXTERNAL repository secret')
expect(e2b).toMatchObject({
env: {
E2B_API_KEY: '${{ secrets.E2B_API_KEY_EXTERNAL }}',
DSH_E2E_MAX_WORKERS: '1',
DSH_EXAMPLE_MODE: 'lib',
},
})
expect(e2b?.run).toContain('packages/e2b/e2b/tests/composition.e2e.ts')
})
})
describe('Issue lifecycle workflow', () => {
it('uses review signals instead of rerunning when a draft becomes ready', () => {
const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')

View File

@@ -27,6 +27,19 @@ const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
const INSTALL_LOCK_POLL_MS = 50
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
const PAIRING_MERGE_DRIVER_CONFIG = [
['merge.dsh-translation-pairing.name', 'DeepSeek Harness bilingual pairing records'],
[
'merge.dsh-translation-pairing.driver',
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
],
]
const PAIRING_MERGE_DRIVER_PROBE = [
'--import',
'tsx/esm',
'scripts/merge-translation-pairing.ts',
'--probe',
]
function errorCode(error) {
return typeof error === 'object' && error !== null && 'code' in error
@@ -595,6 +608,86 @@ function refuseScopedHooksPath(entry) {
)
}
function installPairingMergeDriver(root, worktreeConfigPath) {
const added = []
try {
for (const [key, expected] of PAIRING_MERGE_DRIVER_CONFIG) {
const entries = includedFileConfigEntries(root, worktreeConfigPath, key)
const includedEntry = entries.find(entry => !originIsFile(entry.origin, root, worktreeConfigPath))
if (includedEntry !== undefined) {
throw new Error(
`refusing pairing merge-driver config from an included worktree file (${configSource(includedEntry)})`,
)
}
const existing = assertSingle(entries.map(entry => entry.value), `worktree ${key}`)
const effectiveBefore = effectiveConfigEntry(root, key)
if (effectiveBefore?.scope === 'command') {
throw new Error(
`refusing command-scoped ${key} (${configSource(effectiveBefore)}); `
+ 'transient configuration cannot be replaced by the worktree installer',
)
}
if (existing === undefined && effectiveBefore !== undefined && effectiveBefore.value !== expected) {
throw new Error(
`refusing to mask inherited ${key} (${configSource(effectiveBefore)}); `
+ 'remove or integrate the custom pairing merge driver explicitly',
)
}
if (existing !== undefined && existing !== expected) {
throw new Error(
`refusing to replace worktree ${key} value ${JSON.stringify(existing)}; `
+ 'remove or integrate the custom pairing merge driver explicitly',
)
}
if (existing === undefined) {
git(['config', '--worktree', key, expected], root)
added.push(key)
}
const installed = includedFileConfigEntries(root, worktreeConfigPath, key)
if (
installed.length !== 1
|| installed[0]?.value !== expected
|| !originIsFile(installed[0].origin, root, worktreeConfigPath)
) {
throw new Error(`new worktree-local ${key} did not become the direct worktree value`)
}
const effectiveAfter = effectiveConfigEntry(root, key)
if (
effectiveAfter === undefined
|| effectiveAfter.scope !== 'worktree'
|| effectiveAfter.value !== expected
|| !originIsFile(effectiveAfter.origin, root, worktreeConfigPath)
) {
throw new Error(`new worktree-local ${key} did not become the effective direct worktree value`)
}
}
} catch (error) {
const rollbackErrors = []
for (const key of added.reverse()) {
try {
git(['config', '--worktree', '--unset-all', key], root)
} catch (rollbackError) {
rollbackErrors.push(rollbackError)
}
}
if (rollbackErrors.length > 0) {
throw new AggregateError(
[error, ...rollbackErrors],
`Pairing merge-driver configuration failed: ${String(error)}; `
+ `rollback also failed: ${rollbackErrors.map(String).join('; ')}`,
)
}
throw error
}
return () => {
for (const key of added.reverse()) git(['config', '--worktree', '--unset-all', key], root)
}
}
function probePairingMergeDriver(root) {
capture(process.execPath, PAIRING_MERGE_DRIVER_PROBE, { cwd: root })
}
async function main() {
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
if (typeof lefthookPackage.bin?.lefthook !== 'string') return
@@ -682,7 +775,10 @@ async function main() {
applyWorktreeConfigMigration(root, commonConfigPath, migration)
let pathChanged = false
let rollbackPairingMergeDriver = () => {}
try {
probePairingMergeDriver(root)
rollbackPairingMergeDriver = installPairingMergeDriver(root, worktreeConfigPath)
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
pathChanged = worktreePath !== hooksPath
const installedEntry = effectiveConfigEntry(root, 'core.hooksPath')
@@ -697,6 +793,7 @@ async function main() {
runLefthook(root, lefthook)
updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath)
} catch (error) {
const rollbackErrors = []
if (pathChanged) {
try {
if (worktreePath === undefined) {
@@ -705,13 +802,21 @@ async function main() {
git(['config', '--worktree', 'core.hooksPath', worktreePath], root)
}
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`Lefthook installation failed: ${String(error)}; `
+ `worktree hook rollback also failed: ${String(rollbackError)}`,
)
rollbackErrors.push(rollbackError)
}
}
try {
rollbackPairingMergeDriver()
} catch (rollbackError) {
rollbackErrors.push(rollbackError)
}
if (rollbackErrors.length > 0) {
throw new AggregateError(
[error, ...rollbackErrors],
`Lefthook installation failed: ${String(error)}; `
+ `worktree integration rollback also failed: ${rollbackErrors.map(String).join('; ')}`,
)
}
throw error
}
} catch (error) {

View File

@@ -18,6 +18,9 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P'
const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
const fixtures: string[] = []
// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can
// legitimately exceed Vitest's default deadline without changing the installer behavior.
@@ -95,7 +98,7 @@ if (!shouldFail) {
const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim()
const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\`
for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
for (const name of ['pre-commit', 'pre-merge-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
}
if (existsSync(running)) unlinkSync(running)
if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') {
@@ -122,6 +125,12 @@ function installFakeLefthook(root: string): void {
chmodSync(shim, 0o755)
}
function installPairingProbeFixture(root: string): void {
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
symlinkSync(scriptsDirectory, join(root, 'scripts'), linkType)
symlinkSync(tsxPackageDirectory, join(root, 'node_modules/tsx'), linkType)
}
function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
fixtures.push(container)
@@ -151,6 +160,8 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
installFakeLefthook(main)
installFakeLefthook(linked)
installPairingProbeFixture(main)
installPairingProbeFixture(linked)
return fixture
}
@@ -222,6 +233,9 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0')
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
expect(existsSync(join(common, 'config.worktree'))).toBe(false)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
})
}
@@ -241,6 +255,12 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(mainHooks).not.toBe(linkedHooks)
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
expect(git(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe(pairingMergeDriver)
expect(git(fixture, fixture.linked, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe(pairingMergeDriver)
const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
@@ -252,6 +272,8 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(linkedHook).toContain(`# root=${canonicalLinked}`)
expect(linkedHook).toContain('# config=linked-worktree-config')
expect(linkedHook).not.toContain(canonicalMain)
expect(existsSync(join(mainHooks, 'pre-merge-commit'))).toBe(true)
expect(existsSync(join(linkedHooks, 'pre-merge-commit'))).toBe(true)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
const commonConfig = join(common, 'config')
@@ -275,6 +297,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
installFakeLefthook(lateLinked)
installPairingProbeFixture(lateLinked)
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
const linkedInstall = await runInstaller(fixture, lateLinked)
@@ -677,9 +700,50 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(result.stderr).toContain('command-scoped core.hooksPath')
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it('never replaces a custom worktree pairing merge driver', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
git(fixture, fixture.main, [
'config', '--worktree', 'merge.dsh-translation-pairing.driver', 'custom-driver %A',
])
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('refusing to replace worktree merge.dsh-translation-pairing.driver')
expect(git(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe('custom-driver %A')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
})
it('never masks an inherited custom pairing merge driver', async () => {
const fixture = createFixture()
git(fixture, fixture.main, [
'config', '--local', 'merge.dsh-translation-pairing.driver', 'inherited-driver %A',
])
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('refusing to mask inherited merge.dsh-translation-pairing.driver')
expect(git(fixture, fixture.main, [
'config', '--local', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe('inherited-driver %A')
expect(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
})
it('does not pass unrelated command-scoped Git config to Lefthook', async () => {
const fixture = createFixture()
@@ -729,9 +793,29 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(result.stderr).toContain('exit status 77')
expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.name',
]).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
})
it('does not publish worktree integration when the pairing driver probe fails', async () => {
const fixture = createFixture()
rmSync(join(fixture.main, 'node_modules/tsx'), { recursive: true, force: true })
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('merge-translation-pairing.ts --probe failed')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
})
it('reports installation and hook-path rollback failures together', async () => {
const fixture = createFixture()
@@ -743,8 +827,9 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(result.status).toBe(1)
expect(result.stderr).toContain('Lefthook installation failed')
expect(result.stderr).toContain('exit status 77')
expect(result.stderr).toContain('worktree hook rollback also failed')
expect(result.stderr).toContain('worktree integration rollback also failed')
expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
expect(result.stderr).toContain('git config --worktree --unset-all merge.dsh-translation-pairing.driver failed')
})
it('refuses an unowned directory at the reserved worktree hook path', async () => {

View File

@@ -0,0 +1,35 @@
#!/bin/sh
if [ "$#" -ne 4 ]; then
echo 'merge-translation-pairing: expected <ancestor> <current> <other> <repository-path>' >&2
exit 129
fi
ancestor_path=$1
current_path=$2
other_path=$3
meta_path=$4
driver_directory=$(CDPATH= cd -P "$(dirname "$0")" && pwd) || exit 129
driver_path=$driver_directory/merge-translation-pairing.ts
if command -v node >/dev/null 2>&1 \
&& node --import tsx/esm "$driver_path" --probe >/dev/null 2>&1; then
exec node --import tsx/esm "$driver_path" \
"$ancestor_path" "$current_path" "$other_path" "$meta_path"
fi
echo "merge-translation-pairing: runtime is unavailable; leaving an ordinary text conflict in $meta_path" >&2
git merge-file \
-L "$meta_path:current" \
-L "$meta_path:ancestor" \
-L "$meta_path:other" \
-- "$current_path" "$ancestor_path" "$other_path"
fallback_status=$?
echo 'merge-translation-pairing: restore Node dependencies, then rerun the merge or `pnpm run resolve-translation-pairing-conflicts`; use `git merge --abort` to cancel' >&2
# A clean text merge is still unverified pairing metadata, so the driver must
# leave Git's index stages unresolved until the repository-aware resolver runs.
if [ "$fallback_status" -gt 127 ]; then
exit "$fallback_status"
fi
exit 1

View File

@@ -0,0 +1,51 @@
/** Git merge-driver and explicit conflict-resolver entrypoint for pairing records. */
import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
import {
mergeTranslationPairingRecords,
resolveTranslationPairingConflicts,
} from './translation-pairing-merge.ts'
const args = process.argv.slice(2)
try {
if (args[0] === '--probe') {
if (args.length !== 1) throw new Error('--probe takes no other arguments')
} else {
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
if (args[0] === '--resolve') {
if (args.length !== 1) throw new Error('--resolve takes no paths; it inspects the unmerged index')
const resolved = resolveTranslationPairingConflicts(root)
if (resolved.length === 0) {
console.log('merge-translation-pairing: no unresolved pairing records')
} else {
for (const path of resolved) console.log(`merge-translation-pairing: resolved ${path}`)
}
} else {
if (args.length !== 4) {
throw new Error('merge-driver mode requires <ancestor> <current> <other> <repository-path>')
}
const [ancestorPath, currentPath, otherPath, metaPath] = args
if (ancestorPath === undefined || currentPath === undefined || otherPath === undefined || metaPath === undefined) {
throw new Error('merge-driver arguments are incomplete')
}
const result = mergeTranslationPairingRecords(
root,
metaPath,
readFileSync(ancestorPath, 'utf8'),
readFileSync(currentPath, 'utf8'),
readFileSync(otherPath, 'utf8'),
)
writeFileSync(currentPath, result.record)
}
}
} catch (error) {
console.error(`merge-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
console.error(
'merge-translation-pairing: resolve owner conflicts, then confirm the pair with '
+ '`pnpm run verify-translation-pairing --write <pair>`; rerun '
+ '`pnpm run resolve-translation-pairing-conflicts` for other safe records',
)
process.exitCode = 1
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,9 @@ import { createHash } from 'node:crypto'
const SNAPSHOT_REF_PREFIX = 'refs/dsh/translation-pairing/snapshots'
/** Maximum buffered stdout or stderr for repository-owned Git subprocesses. */
export const GIT_COMMAND_MAX_BUFFER = 1 << 26
/** Full SHA-1 Git blob hash (the 40-hex format used by pairing records). */
export function gitBlobHash(content: Buffer): string {
const hash = createHash('sha1')
@@ -13,10 +16,20 @@ export function gitBlobHash(content: Buffer): string {
return hash.digest('hex')
}
function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
/**
* Run one Git subprocess and return its exact stdout bytes.
*
* @param root - Repository root used as Git's working directory.
* @param args - Arguments following the `git` executable.
* @param operation - Human-readable operation for failure diagnostics.
* @param input - Optional stdin bytes.
* @returns Exact stdout bytes.
* @throws Error when Git cannot start or exits unsuccessfully.
*/
export function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
const result = spawnSync('git', ['-C', root, ...args], {
input,
maxBuffer: 1 << 26,
maxBuffer: GIT_COMMAND_MAX_BUFFER,
})
if (result.error) {
throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
@@ -27,6 +40,39 @@ function runGit(root: string, args: string[], operation: string, input?: Buffer)
return result.stdout
}
/** One regular stage-zero Git index entry and its exact blob bytes. */
export interface GitIndexBlob {
/** Object ID recorded in the index. */
objectId: string
/** Blob bytes stored under that object ID. */
content: Buffer
}
/**
* Read one path from the Git index without consulting working-tree bytes.
*
* @param root - Repository root.
* @param path - Repository-relative path.
* @returns The stage-zero blob, or `undefined` when the path is absent.
* @throws Error when the path is unmerged or has an invalid index shape.
*/
export function readGitIndexBlob(root: string, path: string): GitIndexBlob | undefined {
const output = runGit(
root,
['ls-files', '--stage', '-z', '--', path],
`git ls-files --stage for ${path}`,
).toString('utf8')
const entries = output.split('\0').filter(Boolean)
if (entries.length === 0) return undefined
if (entries.length !== 1) throw new Error(`${path} does not have exactly one resolved index entry`)
const match = /^(?:\d+) ([0-9a-f]+) 0\t[\s\S]+$/.exec(entries[0] ?? '')
if (!match?.[1]) throw new Error(`${path} remains unmerged or has an invalid index entry`)
return {
objectId: match[1],
content: runGit(root, ['cat-file', 'blob', match[1]], `reading staged ${path}`),
}
}
/**
* Persist exact working-tree bytes so a pairing record can later recover them
* with `git cat-file`, even when they have never appeared in the index or a

View File

@@ -0,0 +1,508 @@
/** Integration coverage for automatic and explicit pairing-record conflict resolution. */
import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import {
mergeTranslationPairingRecords,
resolveTranslationPairingConflicts,
} from './translation-pairing-merge.ts'
import {
renderTranslationPairingRecord,
translationPairPaths,
} from './translation-pairing-record.ts'
const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url))
const workspaceRoot = fileURLToPath(new URL('../', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx/esm'))
const fixtures: string[] = []
interface Fixture {
env: NodeJS.ProcessEnv
root: string
}
afterEach(() => {
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
})
function git(fixture: Fixture, args: string[]): string {
return execFileSync('git', ['-C', fixture.root, ...args], {
encoding: 'utf8',
env: fixture.env,
}).trim()
}
function write(root: string, path: string, content: string): void {
const absolute = join(root, path)
mkdirSync(dirname(absolute), { recursive: true })
writeFileSync(absolute, content)
}
function shellQuote(value: string): string {
return `"${value.replace(/["\\$`]/g, '\\$&')}"`
}
function installFixtureRuntime(root: string): void {
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
symlinkSync(
join(workspaceRoot, 'node_modules'),
join(root, 'node_modules'),
linkType,
)
symlinkSync(join(workspaceRoot, 'scripts'), join(root, 'scripts'), linkType)
}
function startMergeWithFakeNode(
fixture: Fixture,
nodeScript = '#!/bin/sh\nexit 72\n',
) {
const fakeBin = join(fixture.root, 'fake-bin')
const fakeNode = join(fakeBin, 'node')
write(fixture.root, 'fake-bin/node', nodeScript)
chmodSync(fakeNode, 0o755)
git(fixture, [
'config',
'merge.dsh-translation-pairing.driver',
`${shellQuote(driverLauncher)} %O %A %B %P`,
])
return spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: {
...fixture.env,
PATH: `${fakeBin}${delimiter}${fixture.env.PATH ?? ''}`,
},
})
}
function createFixture(attributes = true): Fixture {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
fixtures.push(root)
const env: NodeJS.ProcessEnv = {
...process.env,
GIT_AUTHOR_EMAIL: 'pairing@example.test',
GIT_AUTHOR_NAME: 'Pairing Test',
GIT_COMMITTER_EMAIL: 'pairing@example.test',
GIT_COMMITTER_NAME: 'Pairing Test',
GIT_CONFIG_GLOBAL: join(root, 'global.gitconfig'),
GIT_CONFIG_NOSYSTEM: '1',
GIT_DEFAULT_HASH: 'sha1',
}
const fixture = { env, root }
execFileSync('git', ['init', '--quiet', '--initial-branch=master', root], { env })
if (attributes) write(root, '.gitattributes', '*.i18n.yaml merge=dsh-translation-pairing\n')
return fixture
}
function record(root: string, path: string, source: string, zh: string): string {
const paths = translationPairPaths(path)
write(root, paths.source, source)
write(root, paths.zh, zh)
const content = renderTranslationPairingRecord(paths, {
sourceHash: storeGitBlob(root, Buffer.from(source)),
zhHash: storeGitBlob(root, Buffer.from(zh)),
})
write(root, paths.meta, content)
return content
}
const baseSource = '# Guide\n\nEnglish | [中文](guide.zh.md)\n\nAlpha base.\n\nBeta base.\n'
const baseZh = '# 指南\n\n[English](guide.md) | 中文\n\n甲基础。\n\n乙基础。\n'
const currentSource = baseSource.replace('Alpha base.', 'Alpha current.')
const currentZh = baseZh.replace('甲基础。', '甲当前。')
const otherSource = baseSource.replace('Beta base.', 'Beta other.')
const otherZh = baseZh.replace('乙基础。', '乙对侧。')
const mergedSource = currentSource.replace('Beta base.', 'Beta other.')
const mergedZh = currentZh.replace('乙基础。', '乙对侧。')
const manualBaseSource = baseSource.replace('guide.zh.md', 'manual.zh.md')
const manualBaseZh = baseZh.replace('guide.md', 'manual.md')
const manualCurrentSource = manualBaseSource.replace('Alpha base.', 'Alpha current.')
const manualCurrentZh = manualBaseZh.replace('甲基础。', '甲当前。')
const manualOtherSource = manualBaseSource.replace('Alpha base.', 'Alpha other.')
const manualOtherZh = manualBaseZh.replace('甲基础。', '甲对侧。')
function commitPair(fixture: Fixture, source: string, zh: string, message: string): string {
const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
git(fixture, ['add', '.'])
git(fixture, ['commit', '-m', message])
return sidecar
}
function commitTextCleanPair(fixture: Fixture, source: string, zh: string, message: string): void {
const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
write(
fixture.root,
'docs/guide.i18n.yaml',
sidecar.replace('\nguide.zh.md:', '\n# Stable separator for independent line merges.\nguide.zh.md:'),
)
git(fixture, ['add', '.'])
git(fixture, ['commit', '-m', message])
}
function createDivergedPair(fixture: Fixture): { ancestor: string; current: string; other: string } {
const ancestor = commitPair(fixture, baseSource, baseZh, 'base')
git(fixture, ['switch', '-c', 'current'])
const current = commitPair(fixture, currentSource, currentZh, 'current')
git(fixture, ['switch', 'master'])
const other = commitPair(fixture, otherSource, otherZh, 'other')
git(fixture, ['switch', 'current'])
return { ancestor, current, other }
}
function createTextCleanDivergedPair(fixture: Fixture): void {
commitTextCleanPair(fixture, baseSource, baseZh, 'base')
git(fixture, ['switch', '-c', 'current'])
commitTextCleanPair(fixture, currentSource, baseZh, 'current source')
git(fixture, ['switch', 'master'])
commitTextCleanPair(fixture, baseSource, otherZh, 'other translation')
git(fixture, ['switch', 'current'])
}
function startStoppedPairingMerge(fixture: Fixture): void {
createDivergedPair(fixture)
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: fixture.env,
})
expect(merge.status).toBe(1)
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
}
function commitMixedPairs(
fixture: Fixture,
guide: { source: string; zh: string },
manual: { source: string; zh: string },
message: string,
): void {
record(fixture.root, 'docs/guide.md', guide.source, guide.zh)
record(fixture.root, 'docs/manual.md', manual.source, manual.zh)
git(fixture, ['add', '.'])
git(fixture, ['commit', '-m', message])
}
function startMixedPairingMerge(fixture: Fixture): void {
commitMixedPairs(
fixture,
{ source: baseSource, zh: baseZh },
{ source: manualBaseSource, zh: manualBaseZh },
'base',
)
git(fixture, ['switch', '-c', 'current'])
commitMixedPairs(
fixture,
{ source: currentSource, zh: currentZh },
{ source: manualCurrentSource, zh: manualCurrentZh },
'current',
)
git(fixture, ['switch', 'master'])
commitMixedPairs(
fixture,
{ source: otherSource, zh: otherZh },
{ source: manualOtherSource, zh: manualOtherZh },
'other',
)
git(fixture, ['switch', 'current'])
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: fixture.env,
})
expect(merge.status).toBe(1)
}
function expectMergedPair(fixture: Fixture): void {
expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(mergedSource)
expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(mergedZh)
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
sourceHash: gitBlobHash(Buffer.from(mergedSource)),
zhHash: gitBlobHash(Buffer.from(mergedZh)),
}),
)
}
describe('translation pairing merge composition', () => {
it('rejects a pairing-record path outside the repository', () => {
const fixture = createFixture(false)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'../guide.i18n.yaml',
'',
'',
'',
)).toThrow('pairing record escapes the repository')
})
it('merges the owner blobs named by three valid records', () => {
const fixture = createFixture(false)
git(fixture, ['config', 'merge.default', 'text'])
const records = createDivergedPair(fixture)
const result = mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
records.ancestor,
records.current,
records.other,
)
expect(result.sourceContent.toString('utf8')).toBe(mergedSource)
expect(result.zhContent.toString('utf8')).toBe(mergedZh)
expect(result.sourceHash).toBe(gitBlobHash(Buffer.from(mergedSource)))
expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh)))
})
it('leaves owner-content conflicts for a human', () => {
const fixture = createFixture(false)
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
const current = record(
fixture.root,
'docs/guide.md',
baseSource.replace('Alpha base.', 'Alpha current.'),
baseZh.replace('甲基础。', '甲当前。'),
)
const other = record(
fixture.root,
'docs/guide.md',
baseSource.replace('Alpha base.', 'Alpha other.'),
baseZh.replace('甲基础。', '甲对侧。'),
)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
ancestor,
current,
other,
)).toThrow('docs/guide.md has content conflicts')
})
it('rejects structurally divergent clean owner merges', () => {
const fixture = createFixture(false)
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
const current = record(fixture.root, 'docs/guide.md', currentSource, currentZh)
const other = record(
fixture.root,
'docs/guide.md',
`${otherSource}\n## Extra\n`,
otherZh,
)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
ancestor,
current,
other,
)).toThrow('clean merges diverge structurally')
})
it('refuses owners assigned to another merge strategy', () => {
const fixture = createFixture(false)
write(fixture.root, '.gitattributes', 'docs/*.md merge=custom-owner\n')
const records = createDivergedPair(fixture)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
records.ancestor,
records.current,
records.other,
)).toThrow('docs/guide.md uses merge=custom-owner')
})
it('refuses unspecified owners affected by merge.default', () => {
const fixture = createFixture(false)
git(fixture, ['config', 'merge.default', 'custom-owner'])
const records = createDivergedPair(fixture)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
records.ancestor,
records.current,
records.other,
)).toThrow('merge.default=custom-owner')
})
it('runs as Git\'s custom driver and commits a clean composed record', () => {
const fixture = createFixture()
createDivergedPair(fixture)
installFixtureRuntime(fixture.root)
git(fixture, [
'config',
'merge.dsh-translation-pairing.driver',
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
])
git(fixture, ['merge', '--no-edit', 'master'])
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
expectMergedPair(fixture)
})
it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => {
const fixture = createFixture()
const records = createDivergedPair(fixture)
const headBefore = git(fixture, ['rev-parse', 'HEAD'])
const result = startMergeWithFakeNode(fixture)
expect(result.status).toBe(1)
expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
expect(git(fixture, ['ls-files', '--unmerged', '--', 'docs/guide.i18n.yaml']).split('\n')).toHaveLength(3)
const conflicted = readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')
expect(conflicted).toContain('<<<<<<< docs/guide.i18n.yaml:current')
for (const record of [records.current, records.other]) {
const dataLines = record.split('\n').filter(line => line !== '' && !line.startsWith('#')).join('\n')
expect(conflicted).toContain(dataLines)
}
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
expectMergedPair(fixture)
})
it('falls back before a broken driver entrypoint can replace the launcher', () => {
const fixture = createFixture()
createDivergedPair(fixture)
const result = startMergeWithFakeNode(
fixture,
'#!/bin/sh\nif [ "$3" = "--eval" ]; then exit 0; fi\nexit 72\n',
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toContain(
'<<<<<<< docs/guide.i18n.yaml:current',
)
})
it('keeps a clean text fallback unresolved until the explicit resolver confirms it', () => {
const fixture = createFixture()
createTextCleanDivergedPair(fixture)
const result = startMergeWithFakeNode(fixture)
expect(result.status).toBe(1)
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
const canonicalRecord = renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
sourceHash: gitBlobHash(Buffer.from(currentSource)),
zhHash: gitBlobHash(Buffer.from(otherZh)),
})
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
canonicalRecord.replace(
'\nguide.zh.md:',
'\n# Stable separator for independent line merges.\nguide.zh.md:',
),
)
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(currentSource)
expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(otherZh)
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(canonicalRecord)
})
it('leaves a staged merge when the pre-merge-commit hook rejects it', () => {
const fixture = createFixture()
createDivergedPair(fixture)
installFixtureRuntime(fixture.root)
git(fixture, [
'config',
'merge.dsh-translation-pairing.driver',
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
])
const hooks = join(fixture.root, 'hooks')
write(
fixture.root,
'hooks/pre-merge-commit',
'#!/bin/sh\necho "fixture pre-merge-commit rejection" >&2\nexit 77\n',
)
chmodSync(join(hooks, 'pre-merge-commit'), 0o755)
git(fixture, ['config', 'core.hooksPath', hooks])
const headBefore = git(fixture, ['rev-parse', 'HEAD'])
const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
encoding: 'utf8',
env: fixture.env,
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('fixture pre-merge-commit rejection')
expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
expect(git(fixture, ['diff', '--cached', '--name-only']).split('\n')).toContain(
'docs/guide.i18n.yaml',
)
expectMergedPair(fixture)
})
it('prints the recovery path when driver input is not composable', () => {
const fixture = createFixture(false)
const result = spawnSync(process.execPath, ['--import', tsxLoader, driver], {
cwd: fixture.root,
encoding: 'utf8',
env: fixture.env,
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('pnpm run verify-translation-pairing --write <pair>')
expect(result.stderr).toContain('pnpm run resolve-translation-pairing-conflicts')
})
it('resolves an already-stopped generated-only conflict from index stages', () => {
const fixture = createFixture(false)
startStoppedPairingMerge(fixture)
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
expectMergedPair(fixture)
})
it('refuses to confirm unstaged owner bytes after a stopped merge', () => {
const fixture = createFixture(false)
startStoppedPairingMerge(fixture)
write(fixture.root, 'docs/guide.md', `${mergedSource}\nunstaged\n`)
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
'docs/guide.md has unstaged content',
)
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
})
it('refuses to overwrite an edited sidecar after a stopped merge', () => {
const fixture = createFixture(false)
startStoppedPairingMerge(fixture)
write(fixture.root, 'docs/guide.i18n.yaml', 'manually resolved\n')
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
'docs/guide.i18n.yaml has edited conflict content',
)
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe('manually resolved\n')
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
})
it('resolves safe records while leaving an owner-conflicted pair untouched', () => {
const fixture = createFixture(false)
startMixedPairingMerge(fixture)
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
'docs/manual.i18n.yaml: docs/manual.md has content conflicts',
)
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U']).split('\n')).toEqual([
'docs/manual.i18n.yaml',
'docs/manual.md',
'docs/manual.zh.md',
])
expectMergedPair(fixture)
})
})

View File

@@ -0,0 +1,338 @@
/** 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 {
GIT_COMMAND_MAX_BUFFER,
gitBlobHash,
readGitIndexBlob,
runGit,
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 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 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 readMergeDefault(root: string): string | undefined {
const result = spawnSync('git', ['-C', root, 'config', '--get', 'merge.default'], {
maxBuffer: GIT_COMMAND_MAX_BUFFER,
})
if (result.error) {
throw new Error(`reading merge.default failed: ${result.error.message}`, { cause: result.error })
}
if (result.status === 1) return undefined
if (result.status !== 0) {
throw new Error(
`reading merge.default failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`,
)
}
return result.stdout.toString('utf8').trim()
}
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()
let mergeDefault: string | undefined
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`)
}
if (value === 'unspecified') {
mergeDefault ??= readMergeDefault(root)
if (mergeDefault !== undefined && mergeDefault !== 'text') {
throw new Error(
`${path} inherits merge.default=${mergeDefault}; the pairing driver only composes Git's default text merge`,
)
}
}
}
}
function runTextMerge(
root: string,
label: string,
ancestor: Buffer | string,
current: Buffer | string,
other: Buffer | string,
): { output: Buffer; status: number | null } {
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', `${label}:current`,
'-L', `${label}:ancestor`,
'-L', `${label}:other`,
currentPath, ancestorPath, otherPath,
], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
if (result.error) {
throw new Error(`merging ${label} failed: ${result.error.message}`, { cause: result.error })
}
return { output: result.stdout, status: result.status }
} finally {
rmSync(temporary, { recursive: true, force: true })
}
}
function mergeBlobTriplet(
root: string,
owner: string,
ancestor: Buffer,
current: Buffer,
other: Buffer,
): Buffer {
const result = runTextMerge(root, owner, ancestor, current, other)
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.output
}
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
}
function assertUneditedSidecar(
root: string,
metaPath: string,
ancestorRecord: string,
currentRecord: string,
otherRecord: string,
): void {
const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
if (worktreeRecord === currentRecord || worktreeRecord === otherRecord) return
const textMerge = runTextMerge(root, metaPath, ancestorRecord, currentRecord, otherRecord)
if (textMerge.status === 0 && textMerge.output.toString('utf8') === worktreeRecord) return
const stageDataLines = [currentRecord, otherRecord]
.flatMap(record => record.split(/\r?\n/))
.filter(line => line !== '' && !line.startsWith('#'))
const hasUneditedConflict = worktreeRecord.includes('<<<<<<<')
&& worktreeRecord.includes('=======')
&& worktreeRecord.includes('>>>>>>>')
&& stageDataLines.every(line => worktreeRecord.includes(line))
if (!hasUneditedConflict) {
throw new Error(`${metaPath} has edited conflict content; refusing to overwrite manual work`)
}
}
/**
* 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; after staging the safe records, an
* aggregate error reports any pairing conflicts that still need manual work.
*
* @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 }[] = []
const failures: { path: string; reason: string }[] = []
for (const [metaPath, stages] of [...unmergedSidecars(root)].sort(([left], [right]) => left.localeCompare(right))) {
try {
if (stages.ancestor === undefined || stages.current === undefined || stages.other === undefined) {
throw new Error('is an add/delete or incomplete-stage conflict and requires manual resolution')
}
const ancestorRecord = readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8')
const currentRecord = readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8')
const otherRecord = readGitBlob(root, stages.other, `other ${metaPath}`).toString('utf8')
assertUneditedSidecar(root, metaPath, ancestorRecord, currentRecord, otherRecord)
const result = mergeTranslationPairingRecords(
root,
metaPath,
ancestorRecord,
currentRecord,
otherRecord,
)
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 })
} catch (error) {
failures.push({ path: metaPath, reason: error instanceof Error ? error.message : String(error) })
}
}
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')
}
if (failures.length > 0) {
const resolved = resolutions.length === 0
? ''
: `resolved and staged ${resolutions.map(resolution => resolution.path).join(', ')}; `
throw new Error(
`${resolved}left ${String(failures.length)} pairing conflict(s) unresolved:\n`
+ failures.map(failure => `- ${failure.path}: ${failure.reason}`).join('\n'),
)
}
return resolutions.map(resolution => resolution.path)
}

View File

@@ -0,0 +1,99 @@
/** Canonical paths, parsing, and rendering for bilingual pairing records. */
import { basename } from 'node:path'
/** The three repository-relative paths that form one bilingual pair. */
export interface TranslationPairPaths {
/** English document path. */
source: string
/** Simplified Chinese document path. */
zh: string
/** Generated consistency-record path. */
meta: string
}
/** The two content hashes recorded for a bilingual pair. */
export interface TranslationPairingRecord {
/** Git blob hash of the English document. */
sourceHash: string
/** Git blob hash of the Simplified Chinese document. */
zhHash: string
}
const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
/**
* Derive the counterpart and consistency-record paths from an English document.
*
* @param source - Repository-relative English Markdown path.
* @returns The complete three-path pair.
*/
export function translationPairPaths(source: string): TranslationPairPaths {
if (!source.endsWith('.md') || source.endsWith('.zh.md')) {
throw new Error(`expected an English Markdown path, received ${JSON.stringify(source)}`)
}
return {
source,
zh: source.replace(/\.md$/, '.zh.md'),
meta: source.replace(/\.md$/, '.i18n.yaml'),
}
}
/**
* Derive one pair from its consistency-record path.
*
* @param meta - Repository-relative `foo.i18n.yaml` path.
* @returns The complete three-path pair.
*/
export function translationPairPathsFromMeta(meta: string): TranslationPairPaths {
if (!meta.endsWith('.i18n.yaml')) {
throw new Error(`expected a bilingual consistency-record path, received ${JSON.stringify(meta)}`)
}
return translationPairPaths(meta.replace(/\.i18n\.yaml$/, '.md'))
}
/**
* Parse a consistency record for its expected sibling names.
*
* @param content - Complete sidecar text.
* @param paths - Expected sibling paths.
* @returns The two hashes, or `undefined` for malformed, duplicate, or unexpected keys.
*/
export function parseTranslationPairingRecord(
content: string,
paths: TranslationPairPaths,
): TranslationPairingRecord | undefined {
const hashes = new Map<string, string>()
for (const line of content.split('\n')) {
if (line === '' || line.startsWith('#')) continue
const match = META_LINE.exec(line)
if (!match?.[1] || !match[2] || hashes.has(match[1])) return undefined
hashes.set(match[1], match[2])
}
const sourceHash = hashes.get(basename(paths.source))
const zhHash = hashes.get(basename(paths.zh))
if (hashes.size !== 2 || sourceHash === undefined || zhHash === undefined) return undefined
return { sourceHash, zhHash }
}
/**
* Render the canonical consistency record for a pair.
*
* @param paths - Pair paths written into the record and its recovery command.
* @param record - Confirmed content hashes.
* @returns Canonical YAML text with exactly one trailing newline.
*/
export function renderTranslationPairingRecord(
paths: TranslationPairPaths,
record: TranslationPairingRecord,
): string {
return [
'# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
'# side as of the last confirmed-consistent state. Both languages carry equal authority;',
'# after editing either side, bring the other along and re-record with:',
`# pnpm run verify-translation-pairing --write ${paths.source}`,
`${basename(paths.source)}: ${record.sourceHash}`,
`${basename(paths.zh)}: ${record.zhHash}`,
'',
].join('\n')
}

View File

@@ -1,11 +1,16 @@
/** Regression tests for bilingual snapshots, corpus scope, and structure. */
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
import {
parseTranslationPairingRecord,
renderTranslationPairingRecord,
translationPairPaths,
} from './translation-pairing-record.ts'
import {
isTranslationScopeFile,
pairAnchorOfArgument,
@@ -74,6 +79,28 @@ describe('translation pairing snapshots', () => {
}
})
it('reads staged bytes independently of the working tree', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-index-'))
try {
execFileSync('git', ['init', '--quiet', root], {
env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
})
execFileSync('git', ['-C', root, 'config', 'user.email', 'pairing@example.test'])
execFileSync('git', ['-C', root, 'config', 'user.name', 'Pairing Test'])
writeFileSync(join(root, 'owner.md'), 'staged')
execFileSync('git', ['-C', root, 'add', 'owner.md'])
writeFileSync(join(root, 'owner.md'), 'unstaged')
const indexed = readGitIndexBlob(root, 'owner.md')
expect(indexed?.content.toString('utf8')).toBe('staged')
expect(indexed?.objectId).toBe(gitBlobHash(Buffer.from('staged')))
expect(readGitIndexBlob(root, 'absent.md')).toBeUndefined()
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
try {
@@ -113,6 +140,32 @@ describe('translation pairing manifest', () => {
})
})
describe('translation pairing records', () => {
const paths = translationPairPaths('docs/foo.md')
const record = {
sourceHash: '1'.repeat(40),
zhHash: '2'.repeat(40),
}
it('round-trips the canonical two-hash record', () => {
expect(parseTranslationPairingRecord(renderTranslationPairingRecord(paths, record), paths)).toEqual(record)
})
it('rejects duplicate or unexpected keys', () => {
expect(parseTranslationPairingRecord([
`foo.md: ${'1'.repeat(40)}`,
`foo.md: ${'3'.repeat(40)}`,
`foo.zh.md: ${'2'.repeat(40)}`,
'',
].join('\n'), paths)).toBeUndefined()
expect(parseTranslationPairingRecord([
`foo.md: ${'1'.repeat(40)}`,
`bar.zh.md: ${'2'.repeat(40)}`,
'',
].join('\n'), paths)).toBeUndefined()
})
})
describe('translation scope discovery', () => {
it.each([
'README.md',
@@ -186,28 +239,56 @@ describe('pair CLI arguments', () => {
it('scopes a check to named pairs and dedupes the three spellings', () => {
expect(parseTranslationPairingCliArgs(['docs/foo.zh.md', 'docs/foo.i18n.yaml', 'docs/bar.md'])).toEqual({
input: 'worktree',
mode: 'check',
scope: 'pairs',
anchors: ['docs/bar.md', 'docs/foo.md'],
})
expect(parseTranslationPairingCliArgs([])).toEqual({ mode: 'check', scope: 'corpus', anchors: [] })
expect(parseTranslationPairingCliArgs([])).toEqual({
input: 'worktree',
mode: 'check',
scope: 'corpus',
anchors: [],
})
})
it('requires --write to name confirmed pairs or opt into --all', () => {
expect(() => parseTranslationPairingCliArgs(['--write'])).toThrow('requires the pair(s) you confirmed')
expect(parseTranslationPairingCliArgs(['--write', 'docs/foo.md'])).toEqual({
input: 'worktree',
mode: 'write',
scope: 'pairs',
anchors: ['docs/foo.md'],
})
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({ mode: 'write', scope: 'corpus', anchors: [] })
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({
input: 'worktree',
mode: 'write',
scope: 'corpus',
anchors: [],
})
expect(() => parseTranslationPairingCliArgs(['--write', '--all', 'docs/foo.md'])).toThrow('not both')
})
it('keeps --list corpus-only and rejects unknown flags', () => {
expect(parseTranslationPairingCliArgs(['--list'])).toEqual({ mode: 'list', scope: 'corpus', anchors: [] })
expect(parseTranslationPairingCliArgs(['--list'])).toEqual({
input: 'worktree',
mode: 'list',
scope: 'corpus',
anchors: [],
})
expect(() => parseTranslationPairingCliArgs(['--list', 'docs/foo.md'])).toThrow('takes no other flags or paths')
expect(() => parseTranslationPairingCliArgs(['--all'])).toThrow('--all only applies to --write')
expect(() => parseTranslationPairingCliArgs(['--frobnicate'])).toThrow('unknown flag(s): --frobnicate')
})
it('makes cached verification a named, read-only index check', () => {
expect(parseTranslationPairingCliArgs(['--cached', 'docs/foo.i18n.yaml'])).toEqual({
input: 'index',
mode: 'check',
scope: 'pairs',
anchors: ['docs/foo.md'],
})
expect(() => parseTranslationPairingCliArgs(['--cached'])).toThrow('requires the staged pair paths')
expect(() => parseTranslationPairingCliArgs(['--cached', '--write', 'docs/foo.md'])).toThrow('read-only')
})
})

View File

@@ -120,6 +120,8 @@ export function pairAnchorOfArgument(argument: string): string {
/** A parsed `verify-translation-pairing` invocation. */
export interface TranslationPairingCliRequest {
/** Content plane read by the check. Writes and corpus checks use the working tree. */
input: 'worktree' | 'index'
mode: 'check' | 'list' | 'write'
/** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */
scope: 'corpus' | 'pairs'
@@ -142,24 +144,32 @@ export interface TranslationPairingCliRequest {
export function parseTranslationPairingCliArgs(argv: string[]): TranslationPairingCliRequest {
const flags = argv.filter(argument => argument.startsWith('--'))
const anchors = [...new Set(argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument))].sort()
const unknown = flags.filter(flag => !['--list', '--write', '--all'].includes(flag))
const unknown = flags.filter(flag => !['--list', '--write', '--all', '--cached'].includes(flag))
if (unknown.length > 0) throw new Error(`unknown flag(s): ${unknown.join(', ')}`)
const listMode = flags.includes('--list')
const writeMode = flags.includes('--write')
const allMode = flags.includes('--all')
if (listMode && (writeMode || allMode || anchors.length > 0)) {
const cachedMode = flags.includes('--cached')
if (listMode && (writeMode || allMode || cachedMode || anchors.length > 0)) {
throw new Error('--list reports the whole corpus and takes no other flags or paths')
}
if (allMode && !writeMode) throw new Error('--all only applies to --write')
if (cachedMode && writeMode) throw new Error('--cached is a read-only index check and cannot be combined with --write')
if (cachedMode && anchors.length === 0) throw new Error('--cached requires the staged pair paths to check')
if (writeMode) {
if (anchors.length > 0 && allMode) throw new Error('--write takes either pair paths or --all, not both')
if (anchors.length === 0 && !allMode) {
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')
}
return { mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors }
return { input: 'worktree', mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors }
}
if (listMode) return { input: 'worktree', mode: 'list', scope: 'corpus', anchors: [] }
return {
input: cachedMode ? 'index' : 'worktree',
mode: 'check',
scope: anchors.length > 0 ? 'pairs' : 'corpus',
anchors,
}
if (listMode) return { mode: 'list', scope: 'corpus', anchors: [] }
return { mode: 'check', scope: anchors.length > 0 ? 'pairs' : 'corpus', anchors }
}
/** The structural surface compared between the two sides of a pair. */

View File

@@ -3,15 +3,21 @@
* blob hashes for every in-scope document. The manifest contains only explicit
* exclusions, which may have neither a counterpart nor a sidecar.
* `--list` reports state; `--write <pairs...>` records the named confirmed
* pairs (`--write --all` records every complete pair); a check or write named
* with pair paths touches only those pairs, so update iteration does not pay
* for a corpus scan. Translation quality remains a review responsibility.
* pairs (`--write --all` records every complete pair); `--cached <pairs...>`
* checks exact index bytes for hooks. A check or write named with pair paths
* touches only those pairs, so update iteration does not pay for a corpus
* scan. Translation quality remains a review responsibility.
* See `docs/i18n/README.md` for the owning contract.
*/
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join, resolve, sep } from 'node:path'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
import {
parseTranslationPairingRecord,
renderTranslationPairingRecord,
translationPairPaths,
} from './translation-pairing-record.ts'
import {
linksTo,
parseTranslationMarkdown,
@@ -33,6 +39,24 @@ try {
}
const listMode = request.mode === 'list'
const writeMode = request.mode === 'write'
const indexMode = request.input === 'index'
const contentCache = new Map<string, Buffer | undefined>()
/** Read one repository path from the selected worktree or index plane. */
function readRepositoryFile(file: string): Buffer | undefined {
if (contentCache.has(file)) return contentCache.get(file)
const content = indexMode
? readGitIndexBlob(root, file)?.content
: existsSync(join(root, file)) ? readFileSync(join(root, file)) : undefined
contentCache.set(file, content)
return content
}
/** Whether one path exists in the selected content plane. */
function repositoryFileExists(file: string): boolean {
return readRepositoryFile(file) !== undefined
}
/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
const SCOPE_PATTERNS = [
@@ -42,7 +66,11 @@ const SCOPE_PATTERNS = [
'.agents/notes/**/*.i18n.yaml',
]
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
const manifestContent = readRepositoryFile('scripts/translation-pairing.manifest.json')
if (manifestContent === undefined) {
throw new Error('scripts/translation-pairing.manifest.json is missing from the selected content plane')
}
const manifest = parseTranslationPairingManifest(manifestContent.toString('utf8'))
/**
* An excluded entry ending in `/` excludes the whole directory. The trailing
@@ -54,50 +82,20 @@ function isExcluded(file: string): boolean {
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
}
/** The three paths of a pair, derived from the English-file path. */
function pairPaths(source: string): { zh: string; meta: string } {
return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
}
const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
/** Parse a `foo.i18n.yaml` consistency record: basename → recorded blob hash. */
function parseMeta(content: string): Map<string, string> | undefined {
const out = new Map<string, string>()
for (const line of content.split('\n')) {
if (line === '' || line.startsWith('#')) continue
const match = META_LINE.exec(line)
if (!match?.[1] || !match[2]) return undefined
out.set(match[1], match[2])
}
return out
}
/** Render a `foo.i18n.yaml` consistency record. */
function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
return [
'# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
'# side as of the last confirmed-consistent state. Both languages carry equal authority;',
'# after editing either side, bring the other along and re-record with:',
`# pnpm run verify-translation-pairing --write ${source}`,
`${basename(source)}: ${sourceHash}`,
`${basename(zh)}: ${zhHash}`,
'',
].join('\n')
}
// Enumerate the scope once: the whole corpus, or exactly the named pairs'
// three files (a named pair whose files are absent is caught by the same
// completeness rules that cover discovered remnants).
const files = new Set<string>()
if (request.scope === 'pairs') {
for (const anchor of request.anchors) {
for (const file of [anchor, ...Object.values(pairPaths(anchor))]) {
if (existsSync(join(root, file))) files.add(file)
const { source, zh, meta } = translationPairPaths(anchor)
for (const file of [source, zh, meta]) {
if (repositoryFileExists(file)) files.add(file)
}
// A named anchor with no files on disk still enters the source list so
// the check reports it instead of silently passing an empty scope.
if (!existsSync(join(root, anchor))) files.add(anchor)
// A named worktree anchor with no files still enters the source list so
// an interactive check reports it. An index check accepts a complete
// three-file deletion and still rejects every partial deletion below.
if (!indexMode && !repositoryFileExists(anchor)) files.add(anchor)
}
} else {
for (const pattern of SCOPE_PATTERNS) {
@@ -113,8 +111,11 @@ const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md'
if (request.scope === 'pairs') {
const rejected = request.anchors.filter(anchor => !isTranslationScopeFile(anchor) || isExcluded(anchor))
const absent = request.anchors.filter(anchor => ![anchor, ...Object.values(pairPaths(anchor))].some(file => existsSync(join(root, file))))
if (rejected.length > 0 || absent.length > 0) {
const absent = request.anchors.filter((anchor) => {
const { source, zh, meta } = translationPairPaths(anchor)
return ![source, zh, meta].some(repositoryFileExists)
})
if (rejected.length > 0 || (!indexMode && absent.length > 0)) {
for (const anchor of rejected) {
console.error(`verify-translation-pairing: ${anchor} is not an in-scope pair (excluded or outside the documentation corpus; see docs/i18n/README.md)`)
}
@@ -132,20 +133,25 @@ if (writeMode) {
let written = 0
for (const source of sources) {
if (isExcluded(source)) continue
const { zh, meta } = pairPaths(source)
if (!existsSync(join(root, source)) || !existsSync(join(root, zh))) {
const paths = translationPairPaths(source)
const { zh, meta } = paths
if (!repositoryFileExists(source) || !repositoryFileExists(zh)) {
if (request.scope === 'pairs') {
console.error(`verify-translation-pairing: cannot record ${source}: missing ${existsSync(join(root, source)) ? zh : source}`)
console.error(`verify-translation-pairing: cannot record ${source}: missing ${repositoryFileExists(source) ? zh : source}`)
process.exit(2)
}
continue
}
const sourceContent = readFileSync(join(root, source))
const zhContent = readFileSync(join(root, zh))
const sourceContent = readRepositoryFile(source)
const zhContent = readRepositoryFile(zh)
if (sourceContent === undefined || zhContent === undefined) throw new Error(`${source}: complete pair became unreadable`)
// A consistency record is also a recovery pointer for the briefing
// generator. Persist both snapshots even when the sidecar text is already
// current, because the bytes may exist only in this working tree.
const record = renderMeta(source, storeGitBlob(root, sourceContent), zh, storeGitBlob(root, zhContent))
const record = renderTranslationPairingRecord(paths, {
sourceHash: storeGitBlob(root, sourceContent),
zhHash: storeGitBlob(root, zhContent),
})
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
writeFileSync(join(root, meta), record)
console.log(`verify-translation-pairing: recorded ${meta}`)
@@ -161,8 +167,8 @@ const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
// 1. Every discovered, non-excluded source merges bilingual.
for (const source of sources) {
if (isExcluded(source)) continue
const { zh } = pairPaths(source)
if (!existsSync(join(root, zh))) {
const { zh } = translationPairPaths(source)
if (!repositoryFileExists(zh)) {
errors.push(`${source}: in-scope documentation must merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
state.set(source, 'missing')
}
@@ -176,8 +182,13 @@ for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
for (const source of [...pairAnchors].sort()) {
const { zh, meta } = pairPaths(source)
const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) }
const paths = translationPairPaths(source)
const { zh, meta } = paths
const have = {
source: repositoryFileExists(source),
zh: repositoryFileExists(zh),
meta: repositoryFileExists(meta),
}
if (isExcluded(source)) {
if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
@@ -190,10 +201,14 @@ for (const source of [...pairAnchors].sort()) {
continue
}
const sourceContent = readFileSync(join(root, source))
const zhContent = readFileSync(join(root, zh))
const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) {
const sourceContent = readRepositoryFile(source)
const zhContent = readRepositoryFile(zh)
const metaContent = readRepositoryFile(meta)
if (sourceContent === undefined || zhContent === undefined || metaContent === undefined) {
throw new Error(`${source}: complete pair became unreadable`)
}
const record = parseTranslationPairingRecord(metaContent.toString('utf8'), paths)
if (record === undefined) {
errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
continue
}
@@ -201,7 +216,8 @@ for (const source of [...pairAnchors].sort()) {
let consistent = true
for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
const current = gitBlobHash(content)
if (record.get(basename(file)) !== current) {
const recorded = file === source ? record.sourceHash : record.zhHash
if (recorded !== current) {
errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`)
consistent = false
}
@@ -247,7 +263,7 @@ if (listMode) {
if (errors.length === 0) {
console.log(request.scope === 'pairs'
? `verify-translation-pairing: ${pairAnchors.size} named pair(s) consistent; the corpus-wide check still runs in doc-sync.`
? `verify-translation-pairing: ${pairAnchors.size} named ${indexMode ? 'staged ' : ''}pair(s) consistent; the corpus-wide check still runs in doc-sync.`
: `verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`)
process.exit(0)
}