diff --git a/.gitattributes b/.gitattributes index 2efafac8b8..e0a432111d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,7 @@ * text=auto eol=lf *.pdf binary + +# Pairing records are generated from the three confirmed owner-blob pairs. +# The worktree-local installer registers the fail-closed driver command. +*.i18n.yaml merge=dsh-translation-pairing diff --git a/lefthook.yml b/lefthook.yml index c2249adbde..0e78d16252 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,6 +4,10 @@ pre-commit: jobs: + - name: translation pairing (staged records) + glob: '*.i18n.yaml' + run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} + - name: format (staged) glob: '*.{ts,tsx,mts,cts,mjs}' exclude: @@ -34,6 +38,12 @@ pre-commit: - name: vendor manifest guard run: scripts/check-vendor-manifest.sh +pre-merge-commit: + jobs: + - name: translation pairing (staged records) + glob: '*.i18n.yaml' + run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} + pre-push: jobs: - name: typecheck diff --git a/package.json b/package.json index 423cbf813c..864814ffe6 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", + "resolve-translation-pairing-conflicts": "tsx scripts/merge-translation-pairing.ts --resolve", "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "docs:dev": "pnpm --filter @deepseek-ai/website run dev", diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 0f840de9c8..b928582a8a 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -27,6 +27,13 @@ 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', + 'node --import tsx/esm scripts/merge-translation-pairing.ts %O %A %B %P', + ], +] function errorCode(error) { return typeof error === 'object' && error !== null && 'code' in error @@ -595,6 +602,82 @@ 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) + } +} + async function main() { if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return if (typeof lefthookPackage.bin?.lefthook !== 'string') return @@ -682,7 +765,9 @@ async function main() { applyWorktreeConfigMigration(root, commonConfigPath, migration) let pathChanged = false + let rollbackPairingMergeDriver = () => {} try { + rollbackPairingMergeDriver = installPairingMergeDriver(root, worktreeConfigPath) git(['config', '--worktree', 'core.hooksPath', hooksPath], root) pathChanged = worktreePath !== hooksPath const installedEntry = effectiveConfigEntry(root, 'core.hooksPath') @@ -697,6 +782,7 @@ async function main() { runLefthook(root, lefthook) updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath) } catch (error) { + const rollbackErrors = [] if (pathChanged) { try { if (worktreePath === undefined) { @@ -705,13 +791,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) { diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index ca74b91404..df9f1cd6d8 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -18,6 +18,7 @@ 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 = 'node --import tsx/esm scripts/merge-translation-pairing.ts %O %A %B %P' 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 +96,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') { @@ -222,6 +223,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 +245,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 +262,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') @@ -677,9 +689,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,6 +782,12 @@ 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') }) @@ -743,8 +802,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 () => { diff --git a/scripts/merge-translation-pairing.ts b/scripts/merge-translation-pairing.ts new file mode 100644 index 0000000000..8d193ab9f9 --- /dev/null +++ b/scripts/merge-translation-pairing.ts @@ -0,0 +1,42 @@ +/** 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 { + 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 ') + } + 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)}`) + process.exitCode = 1 +} diff --git a/scripts/translation-pairing-git.ts b/scripts/translation-pairing-git.ts index 992f0abbb9..b18b2d5778 100644 --- a/scripts/translation-pairing-git.ts +++ b/scripts/translation-pairing-git.ts @@ -27,6 +27,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 diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts new file mode 100644 index 0000000000..c6a9a5bbb7 --- /dev/null +++ b/scripts/translation-pairing-merge.spec.ts @@ -0,0 +1,255 @@ +/** Integration coverage for automatic and explicit pairing-record conflict resolution. */ + +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { 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 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 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('乙基础。', '乙对侧。') + +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 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 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) + 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('runs as Git\'s custom driver and commits a clean composed record', () => { + const fixture = createFixture() + createDivergedPair(fixture) + const command = [ + shellQuote(process.execPath), + '--import', shellQuote(tsxLoader), + shellQuote(driver), + '%O', '%A', '%B', '%P', + ].join(' ') + git(fixture, ['config', 'merge.dsh-translation-pairing.driver', command]) + + git(fixture, ['merge', '--no-edit', 'master']) + + expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('') + expectMergedPair(fixture) + }) + + it('resolves an already-stopped generated-only conflict from index stages', () => { + const fixture = createFixture(false) + 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') + + 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) + createDivergedPair(fixture) + const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], { + encoding: 'utf8', + env: fixture.env, + }) + expect(merge.status).toBe(1) + 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') + }) +}) diff --git a/scripts/translation-pairing-merge.ts b/scripts/translation-pairing-merge.ts new file mode 100644 index 0000000000..3297e30ea2 --- /dev/null +++ b/scripts/translation-pairing-merge.ts @@ -0,0 +1,267 @@ +/** 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 { + const output = runGit(root, ['ls-files', '--unmerged', '-z'], 'listing unresolved merge entries').toString('utf8') + const records = new Map() + 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) +} diff --git a/scripts/translation-pairing-record.ts b/scripts/translation-pairing-record.ts new file mode 100644 index 0000000000..d4481ea5b4 --- /dev/null +++ b/scripts/translation-pairing-record.ts @@ -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() + 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') +} diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index 4d44266887..1073495d08 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -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') + }) }) diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 7f24d9bce7..6d6893f843 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -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. */ diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 4ebd664cb7..59818183e3 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -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 ` 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 ` + * 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() + +/** 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 | undefined { - const out = new Map() - 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() 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() // 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) }