feat(i18n): briefing generator and pair-scoped pairing gate
gen-translation-brief assembles the minimal-update working set for an out-of-sync pair from its consistency record: the authored side's diff since last confirmation, the counterpart sections that diff lands in (heading-mapped only where the last-confirmed structures align), the terminology rows the diff touches, and a per-direction rules digest. verify-translation-pairing now accepts pair paths to check just the named pairs during update iteration; --write requires naming the confirmed pairs (--write --all is the explicit corpus form) so a bulk re-record can no longer silently bless drifted pairs the caller never reviewed. Each record's comment names its own scoped command.
This commit is contained in:
@@ -56,6 +56,7 @@
|
|||||||
"verify-type-equiv": "tsx scripts/verify-type-equiv.ts",
|
"verify-type-equiv": "tsx scripts/verify-type-equiv.ts",
|
||||||
"verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts",
|
"verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts",
|
||||||
"verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts",
|
"verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts",
|
||||||
|
"gen-translation-brief": "tsx scripts/gen-translation-brief.ts",
|
||||||
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
|
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
|
||||||
"docs:dev": "pnpm --filter @deepseek-ai/website run dev",
|
"docs:dev": "pnpm --filter @deepseek-ai/website run dev",
|
||||||
"docs:build": "pnpm --filter @deepseek-ai/website run build",
|
"docs:build": "pnpm --filter @deepseek-ai/website run build",
|
||||||
|
|||||||
197
scripts/gen-translation-brief.ts
Normal file
197
scripts/gen-translation-brief.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Print the minimal-update briefing for out-of-sync translation pairs:
|
||||||
|
* `pnpm run gen-translation-brief [pair paths...]`. With no arguments it
|
||||||
|
* discovers every out-of-sync pair; with arguments (any file of a pair) it
|
||||||
|
* briefs exactly those pairs and fails loud on in-sync, incomplete, or
|
||||||
|
* out-of-scope requests. The briefing contract lives in
|
||||||
|
* `scripts/translation-brief.ts`; the consuming workflow is
|
||||||
|
* `.agents/skills/dsh-translate-docs/SKILL.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { existsSync, globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { basename, join, resolve, sep } from 'node:path'
|
||||||
|
import {
|
||||||
|
isTranslationScopeFile,
|
||||||
|
pairAnchorOfArgument,
|
||||||
|
parseTranslationPairingManifest,
|
||||||
|
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||||
|
} from './translation-pairing.ts'
|
||||||
|
import {
|
||||||
|
changedLinesOfDiff,
|
||||||
|
extractCounterpartSections,
|
||||||
|
headingSections,
|
||||||
|
mapHunksToSections,
|
||||||
|
matchTerminologyRows,
|
||||||
|
parseUnifiedDiffHunks,
|
||||||
|
renderTranslationBrief,
|
||||||
|
type BriefDirection,
|
||||||
|
type CounterpartSection,
|
||||||
|
} from './translation-brief.ts'
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..')
|
||||||
|
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
|
||||||
|
const terminology = readFileSync(join(root, 'docs/i18n/terminology.md'), 'utf8')
|
||||||
|
|
||||||
|
function isExcluded(file: string): boolean {
|
||||||
|
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recorded hashes of one consistency record: basename → 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 = /^([^:#]+\.md): ([0-9a-f]{40})$/.exec(line)
|
||||||
|
if (!match?.[1] || !match[2]) return undefined
|
||||||
|
out.set(match[1], match[2])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(args: string[], allowedExitCodes: number[] = [0]): string {
|
||||||
|
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 1 << 26 })
|
||||||
|
if (result.error) throw result.error
|
||||||
|
if (!allowedExitCodes.includes(result.status ?? -1)) {
|
||||||
|
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
|
||||||
|
}
|
||||||
|
return result.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobText(hash: string): string {
|
||||||
|
return git(['cat-file', '-p', hash])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unified diff between two texts, headers stripped, via `git diff --no-index`. */
|
||||||
|
function diffTexts(before: string, after: string): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'translation-brief-'))
|
||||||
|
try {
|
||||||
|
writeFileSync(join(dir, 'last-confirmed.md'), before)
|
||||||
|
writeFileSync(join(dir, 'current.md'), after)
|
||||||
|
const raw = git(['diff', '--no-index', '--unified=2', join(dir, 'last-confirmed.md'), join(dir, 'current.md')], [0, 1])
|
||||||
|
return raw.split('\n')
|
||||||
|
.filter(line => !line.startsWith('diff --git') && !line.startsWith('index ') && !line.startsWith('--- ') && !line.startsWith('+++ '))
|
||||||
|
.join('\n')
|
||||||
|
.trim()
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PairState {
|
||||||
|
anchor: string
|
||||||
|
zh: string
|
||||||
|
meta: string
|
||||||
|
enDrifted: boolean
|
||||||
|
zhDrifted: boolean
|
||||||
|
enLast: string
|
||||||
|
zhLast: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load one pair's recorded and current state, or explain why it cannot be briefed. */
|
||||||
|
function loadPair(anchor: string): PairState | string {
|
||||||
|
const zh = anchor.replace(/\.md$/, '.zh.md')
|
||||||
|
const meta = anchor.replace(/\.md$/, '.i18n.yaml')
|
||||||
|
if (!isTranslationScopeFile(anchor) || isExcluded(anchor)) {
|
||||||
|
return `${anchor}: not an in-scope documentation pair (docs/i18n/README.md)`
|
||||||
|
}
|
||||||
|
const missing = [anchor, zh, meta].filter(file => !existsSync(join(root, file)))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return `${anchor}: incomplete pair (missing ${missing.join(', ')}) — a new counterpart is whole-document translation work, not a minimal update`
|
||||||
|
}
|
||||||
|
const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
|
||||||
|
const enRecorded = record?.get(basename(anchor))
|
||||||
|
const zhRecorded = record?.get(basename(zh))
|
||||||
|
if (record === undefined || enRecorded === undefined || zhRecorded === undefined) {
|
||||||
|
return `${meta}: malformed consistency record`
|
||||||
|
}
|
||||||
|
const enCurrent = readFileSync(join(root, anchor), 'utf8')
|
||||||
|
const zhCurrent = readFileSync(join(root, zh), 'utf8')
|
||||||
|
const enLast = blobText(enRecorded)
|
||||||
|
const zhLast = blobText(zhRecorded)
|
||||||
|
return {
|
||||||
|
anchor,
|
||||||
|
zh,
|
||||||
|
meta,
|
||||||
|
enDrifted: enCurrent !== enLast,
|
||||||
|
zhDrifted: zhCurrent !== zhLast,
|
||||||
|
enLast,
|
||||||
|
zhLast,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two documents' heading sequences align one to one. */
|
||||||
|
function headingsAligned(a: string, b: string): boolean {
|
||||||
|
const aHeads = headingSections(a)
|
||||||
|
const bHeads = headingSections(b)
|
||||||
|
return aHeads.length === bHeads.length && aHeads.every((heading, index) => heading.depth === bHeads[index]?.depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the briefing for one drifted side of a pair. */
|
||||||
|
function briefDirection(pair: PairState, direction: BriefDirection): string {
|
||||||
|
const sourceIsEnglish = direction === 'en-to-zh'
|
||||||
|
const sourcePath = sourceIsEnglish ? pair.anchor : pair.zh
|
||||||
|
const counterpartPath = sourceIsEnglish ? pair.zh : pair.anchor
|
||||||
|
const sourceLast = sourceIsEnglish ? pair.enLast : pair.zhLast
|
||||||
|
const sourceCurrent = readFileSync(join(root, sourcePath), 'utf8')
|
||||||
|
const counterpartCurrent = readFileSync(join(root, counterpartPath), 'utf8')
|
||||||
|
const diff = diffTexts(sourceLast, sourceCurrent)
|
||||||
|
const bothDrifted = pair.enDrifted && pair.zhDrifted
|
||||||
|
|
||||||
|
let counterpartSections: CounterpartSection[] | undefined
|
||||||
|
if (!bothDrifted && headingsAligned(sourceLast, counterpartCurrent)) {
|
||||||
|
const sections = mapHunksToSections(parseUnifiedDiffHunks(diff), headingSections(sourceLast))
|
||||||
|
counterpartSections = extractCounterpartSections(counterpartCurrent, sections)
|
||||||
|
}
|
||||||
|
return renderTranslationBrief({
|
||||||
|
sourcePath,
|
||||||
|
counterpartPath,
|
||||||
|
direction,
|
||||||
|
diff,
|
||||||
|
counterpartSections,
|
||||||
|
bothDrifted,
|
||||||
|
terminology: matchTerminologyRows(terminology, changedLinesOfDiff(diff)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = process.argv.slice(2).map(pairAnchorOfArgument)
|
||||||
|
|
||||||
|
let anchors: string[]
|
||||||
|
if (requested.length > 0) {
|
||||||
|
anchors = [...new Set(requested)].sort()
|
||||||
|
} else {
|
||||||
|
const discovered = new Set<string>()
|
||||||
|
for (const match of globSync('**/*.i18n.yaml', { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
|
||||||
|
const normalized = match.split(sep).join('/')
|
||||||
|
if (isTranslationScopeFile(normalized)) discovered.add(normalized.replace(/\.i18n\.yaml$/, '.md'))
|
||||||
|
}
|
||||||
|
anchors = [...discovered].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const briefs: string[] = []
|
||||||
|
const problems: string[] = []
|
||||||
|
const skipped: string[] = []
|
||||||
|
for (const anchor of anchors) {
|
||||||
|
const pair = loadPair(anchor)
|
||||||
|
if (typeof pair === 'string') {
|
||||||
|
if (requested.length > 0) problems.push(pair)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!pair.enDrifted && !pair.zhDrifted) {
|
||||||
|
if (requested.length > 0) skipped.push(`${anchor}: pair is consistent with its record — nothing to brief`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (pair.enDrifted) briefs.push(briefDirection(pair, 'en-to-zh'))
|
||||||
|
if (pair.zhDrifted) briefs.push(briefDirection(pair, 'zh-to-en'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0 || skipped.length > 0) {
|
||||||
|
for (const message of [...problems, ...skipped]) console.error(`gen-translation-brief: ${message}`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
if (briefs.length === 0) {
|
||||||
|
console.log('gen-translation-brief: every recorded pair matches its consistency record; nothing to brief.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
console.log(briefs.join('\n\n---\n\n'))
|
||||||
157
scripts/translation-brief.spec.ts
Normal file
157
scripts/translation-brief.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/** Regression tests for the minimal-update briefing assembly. */
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
changedLinesOfDiff,
|
||||||
|
extractCounterpartSections,
|
||||||
|
headingSections,
|
||||||
|
mapHunksToSections,
|
||||||
|
matchTerminologyRows,
|
||||||
|
parseUnifiedDiffHunks,
|
||||||
|
renderTranslationBrief,
|
||||||
|
} from './translation-brief.ts'
|
||||||
|
|
||||||
|
const DIFF = [
|
||||||
|
'@@ -3,3 +3,3 @@',
|
||||||
|
' unchanged context',
|
||||||
|
'-The agent loop retries once.',
|
||||||
|
'+The agent loop retries twice.',
|
||||||
|
'@@ -12 +12,2 @@',
|
||||||
|
'+A new sentence about the session log.',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
describe('unified diff parsing', () => {
|
||||||
|
it('reads hunk starts and counts, defaulting count to 1', () => {
|
||||||
|
expect(parseUnifiedDiffHunks(DIFF)).toEqual([
|
||||||
|
{ start: 3, count: 3 },
|
||||||
|
{ start: 12, count: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects only changed lines, markers stripped', () => {
|
||||||
|
expect(changedLinesOfDiff(DIFF)).toBe([
|
||||||
|
'The agent loop retries once.',
|
||||||
|
'The agent loop retries twice.',
|
||||||
|
'A new sentence about the session log.',
|
||||||
|
].join('\n'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores file header lines that also start with +/-', () => {
|
||||||
|
expect(changedLinesOfDiff('--- a/foo.md\n+++ b/foo.md\n+added')).toBe('added')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const DOC = [
|
||||||
|
'Preamble line.',
|
||||||
|
'',
|
||||||
|
'# Title',
|
||||||
|
'',
|
||||||
|
'Intro paragraph.',
|
||||||
|
'',
|
||||||
|
'## First',
|
||||||
|
'',
|
||||||
|
'First body.',
|
||||||
|
'',
|
||||||
|
'## Second',
|
||||||
|
'',
|
||||||
|
'Second body.',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
describe('section mapping', () => {
|
||||||
|
it('lists headings with lines, depths, and labels', () => {
|
||||||
|
expect(headingSections(DOC)).toEqual([
|
||||||
|
{ line: 3, depth: 1, label: 'Title' },
|
||||||
|
{ line: 7, depth: 2, label: 'First' },
|
||||||
|
{ line: 11, depth: 2, label: 'Second' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps hunks to the sections they span, including the preamble', () => {
|
||||||
|
const headings = headingSections(DOC)
|
||||||
|
expect(mapHunksToSections([{ start: 1, count: 1 }], headings)).toEqual([0])
|
||||||
|
expect(mapHunksToSections([{ start: 9, count: 1 }], headings)).toEqual([2])
|
||||||
|
expect(mapHunksToSections([{ start: 9, count: 4 }], headings)).toEqual([2, 3])
|
||||||
|
expect(mapHunksToSections([{ start: 0, count: 0 }], headings)).toEqual([0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts counterpart section text with start lines and labels', () => {
|
||||||
|
expect(extractCounterpartSections(DOC, [0, 2])).toEqual([
|
||||||
|
{ label: '(preamble before the first heading)', startLine: 1, text: 'Preamble line.' },
|
||||||
|
{ label: '## First', startLine: 7, text: '## First\n\nFirst body.' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const TERMINOLOGY = [
|
||||||
|
'| English | 中文 | 首次出现 | 不要译作 | 备注 |',
|
||||||
|
'|---|---|---|---|---|',
|
||||||
|
'| agent loop | agent loop | agent loop(智能体循环) | | |',
|
||||||
|
'| session log | 会话日志 | | 会话记录 | |',
|
||||||
|
'| gate | 门禁 | | | |',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
describe('terminology matching', () => {
|
||||||
|
it('selects rows whose English term appears on a word boundary', () => {
|
||||||
|
const matches = matchTerminologyRows(TERMINOLOGY, 'The agent loop retries twice.')
|
||||||
|
expect(matches.rows).toEqual(['| agent loop | agent loop | agent loop(智能体循环) | | |'])
|
||||||
|
expect(matches.header).toContain('English')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects rows whose Chinese term appears when the source is Chinese', () => {
|
||||||
|
expect(matchTerminologyRows(TERMINOLOGY, '门禁在提交时运行。').rows).toEqual(['| gate | 门禁 | | | |'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not match substrings inside larger words', () => {
|
||||||
|
expect(matchTerminologyRows(TERMINOLOGY, 'delegate the work').rows).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('brief rendering', () => {
|
||||||
|
const base = {
|
||||||
|
sourcePath: 'docs/foo.md',
|
||||||
|
counterpartPath: 'docs/foo.zh.md',
|
||||||
|
direction: 'en-to-zh' as const,
|
||||||
|
diff: DIFF,
|
||||||
|
counterpartSections: [{ label: '## First', startLine: 7, text: '## First\n\n正文。' }],
|
||||||
|
bothDrifted: false,
|
||||||
|
terminology: matchTerminologyRows(TERMINOLOGY, changedLinesOfDiff(DIFF)),
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders diff, aligned sections, terminology, digest, and finish steps', () => {
|
||||||
|
const brief = renderTranslationBrief(base)
|
||||||
|
expect(brief).toContain('# Translation update briefing: docs/foo.md')
|
||||||
|
expect(brief).toContain('```diff')
|
||||||
|
expect(brief).toContain('docs/foo.zh.md:7')
|
||||||
|
expect(brief).toContain('agent loop(智能体循环)')
|
||||||
|
expect(brief).toContain('| 会话日志 |')
|
||||||
|
expect(brief).toContain('Rules digest')
|
||||||
|
expect(brief).toContain('verify-translation-pairing --write docs/foo.md')
|
||||||
|
expect(brief).toContain('smallest edit that covers the diff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns instead of showing sections when both sides drifted', () => {
|
||||||
|
const brief = renderTranslationBrief({ ...base, bothDrifted: true, counterpartSections: undefined })
|
||||||
|
expect(brief).toContain('BOTH sides changed')
|
||||||
|
expect(brief).toContain('locate the regions yourself')
|
||||||
|
expect(brief).not.toContain('docs/foo.zh.md:7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the English-target digest for zh-to-en updates', () => {
|
||||||
|
const brief = renderTranslationBrief({
|
||||||
|
...base,
|
||||||
|
direction: 'zh-to-en',
|
||||||
|
sourcePath: 'docs/foo.zh.md',
|
||||||
|
counterpartPath: 'docs/foo.md',
|
||||||
|
})
|
||||||
|
expect(brief).toContain('exactly what the new Chinese states')
|
||||||
|
expect(brief).toContain('verify-translation-pairing --write docs/foo.md')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('grows the section fence past tilde runs in the body', () => {
|
||||||
|
const brief = renderTranslationBrief({
|
||||||
|
...base,
|
||||||
|
counterpartSections: [{ label: '## First', startLine: 7, text: '~~~~\ninner\n~~~~' }],
|
||||||
|
})
|
||||||
|
expect(brief).toContain('~~~~~markdown')
|
||||||
|
})
|
||||||
|
})
|
||||||
309
scripts/translation-brief.ts
Normal file
309
scripts/translation-brief.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* Pure assembly of the minimal-update briefing for one out-of-sync
|
||||||
|
* translation pair: the authored side's diff since the last confirmed
|
||||||
|
* state, the counterpart sections that diff lands in, the terminology rows
|
||||||
|
* the diff touches, and a digest of the binding update rules. The CLI
|
||||||
|
* wrapper is `scripts/gen-translation-brief.ts`; the workflow that consumes
|
||||||
|
* the briefing is `.agents/skills/dsh-translate-docs/SKILL.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Nodes } from 'mdast'
|
||||||
|
import { parseTranslationMarkdown } from './translation-pairing.ts'
|
||||||
|
|
||||||
|
/** One hunk of a unified diff, in old-side line coordinates. */
|
||||||
|
export interface DiffHunk {
|
||||||
|
/** First old-side line the hunk touches (0 for an insertion at the top). */
|
||||||
|
start: number
|
||||||
|
/** Old-side line count (0 for a pure insertion). */
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the `@@ -start,count +… @@` hunk headers of a unified diff.
|
||||||
|
*
|
||||||
|
* @param diff - Unified diff text.
|
||||||
|
* @returns Hunks in old-side coordinates, in order of appearance.
|
||||||
|
*/
|
||||||
|
export function parseUnifiedDiffHunks(diff: string): DiffHunk[] {
|
||||||
|
const hunks: DiffHunk[] = []
|
||||||
|
for (const line of diff.split('\n')) {
|
||||||
|
const match = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/.exec(line)
|
||||||
|
if (match?.[1] === undefined) continue
|
||||||
|
hunks.push({ start: Number(match[1]), count: match[2] === undefined ? 1 : Number(match[2]) })
|
||||||
|
}
|
||||||
|
return hunks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the added and removed content lines of a unified diff.
|
||||||
|
*
|
||||||
|
* @param diff - Unified diff text.
|
||||||
|
* @returns The changed lines joined by newlines, diff markers stripped.
|
||||||
|
*/
|
||||||
|
export function changedLinesOfDiff(diff: string): string {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const line of diff.split('\n')) {
|
||||||
|
if (line.startsWith('+++') || line.startsWith('---')) continue
|
||||||
|
if (line.startsWith('+') || line.startsWith('-')) out.push(line.slice(1))
|
||||||
|
}
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One heading of a Markdown document, in document order. */
|
||||||
|
export interface HeadingSection {
|
||||||
|
/** 1-based source line the heading starts on. */
|
||||||
|
line: number
|
||||||
|
/** Heading depth (`##` is 2). */
|
||||||
|
depth: number
|
||||||
|
/** Concatenated plain text of the heading. */
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List a document's headings with their start lines via the pairing-gate parser.
|
||||||
|
*
|
||||||
|
* @param markdown - Document text.
|
||||||
|
* @returns Headings in document order.
|
||||||
|
*/
|
||||||
|
export function headingSections(markdown: string): HeadingSection[] {
|
||||||
|
const out: HeadingSection[] = []
|
||||||
|
const visit = (node: Nodes): void => {
|
||||||
|
if (node.type === 'heading') {
|
||||||
|
let label = ''
|
||||||
|
const collect = (child: Nodes): void => {
|
||||||
|
if ('value' in child && typeof child.value === 'string') label += child.value
|
||||||
|
if ('children' in child) for (const grandchild of child.children) collect(grandchild)
|
||||||
|
}
|
||||||
|
for (const child of node.children) collect(child)
|
||||||
|
out.push({ line: node.position?.start.line ?? 1, depth: node.depth, label })
|
||||||
|
}
|
||||||
|
if ('children' in node) for (const child of node.children) visit(child)
|
||||||
|
}
|
||||||
|
visit(parseTranslationMarkdown(markdown))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section index containing a 1-based line: 0 is the preamble before the first heading, i is the i-th heading's section. */
|
||||||
|
function sectionOf(line: number, headings: HeadingSection[]): number {
|
||||||
|
let section = 0
|
||||||
|
for (let index = 0; index < headings.length; index++) {
|
||||||
|
const heading = headings[index]
|
||||||
|
if (heading !== undefined && heading.line <= line) section = index + 1
|
||||||
|
}
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map diff hunks to the section indices they touch in the diffed document.
|
||||||
|
*
|
||||||
|
* @param hunks - Hunks in the diffed document's old-side coordinates.
|
||||||
|
* @param headings - The diffed document's headings at that same old state.
|
||||||
|
* @returns Ascending section indices (0 = preamble).
|
||||||
|
*/
|
||||||
|
export function mapHunksToSections(hunks: DiffHunk[], headings: HeadingSection[]): number[] {
|
||||||
|
const sections = new Set<number>()
|
||||||
|
for (const hunk of hunks) {
|
||||||
|
const first = sectionOf(Math.max(hunk.start, 1), headings)
|
||||||
|
const last = sectionOf(Math.max(hunk.start + Math.max(hunk.count - 1, 0), 1), headings)
|
||||||
|
for (let section = first; section <= last; section++) sections.add(section)
|
||||||
|
}
|
||||||
|
return [...sections].sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One counterpart section to update, with its current location. */
|
||||||
|
export interface CounterpartSection {
|
||||||
|
/** Heading label, or the preamble marker for section 0. */
|
||||||
|
label: string
|
||||||
|
/** 1-based line the section starts on in the counterpart file. */
|
||||||
|
startLine: number
|
||||||
|
/** Current section text, trailing blank lines trimmed. */
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the counterpart's text for the given section indices.
|
||||||
|
*
|
||||||
|
* Callers must only pass indices produced against a structurally aligned
|
||||||
|
* pair (same heading count and order), which the pairing gate guarantees
|
||||||
|
* for a recorded-consistent state.
|
||||||
|
*
|
||||||
|
* @param counterpart - Current counterpart document text.
|
||||||
|
* @param sections - Ascending section indices (0 = preamble).
|
||||||
|
* @returns One entry per requested section.
|
||||||
|
*/
|
||||||
|
export function extractCounterpartSections(counterpart: string, sections: number[]): CounterpartSection[] {
|
||||||
|
const headings = headingSections(counterpart)
|
||||||
|
const lines = counterpart.split('\n')
|
||||||
|
return sections.map((section) => {
|
||||||
|
const heading = section === 0 ? undefined : headings[section - 1]
|
||||||
|
const startLine = heading?.line ?? 1
|
||||||
|
const nextHeading = headings[section]
|
||||||
|
const endLine = nextHeading === undefined ? lines.length : nextHeading.line - 1
|
||||||
|
const body = lines.slice(startLine - 1, endLine)
|
||||||
|
while (body.length > 0 && body.at(-1) === '') body.pop()
|
||||||
|
return {
|
||||||
|
label: heading === undefined ? '(preamble before the first heading)' : `${'#'.repeat(heading.depth)} ${heading.label}`,
|
||||||
|
startLine,
|
||||||
|
text: body.join('\n'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminology rows relevant to one diff, grouped under their table header. */
|
||||||
|
export interface TerminologyMatches {
|
||||||
|
/** The matched rows' shared header row, or undefined when no row matched. */
|
||||||
|
header?: string | undefined
|
||||||
|
/** Matched data rows, verbatim, in table order. */
|
||||||
|
rows: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip Markdown emphasis and code markers from a terminology cell. */
|
||||||
|
function plainTerm(cell: string): string {
|
||||||
|
return cell.replaceAll('`', '').replaceAll('**', '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the terminology rows whose English or Chinese term occurs in the diff.
|
||||||
|
*
|
||||||
|
* English terms match case-insensitively on non-alphanumeric boundaries;
|
||||||
|
* Chinese terms match by substring.
|
||||||
|
*
|
||||||
|
* @param terminology - Full `docs/i18n/terminology.md` contents.
|
||||||
|
* @param changedText - Changed diff lines (see {@link changedLinesOfDiff}).
|
||||||
|
* @returns Matched rows under their header.
|
||||||
|
*/
|
||||||
|
export function matchTerminologyRows(terminology: string, changedText: string): TerminologyMatches {
|
||||||
|
const matches: TerminologyMatches = { rows: [] }
|
||||||
|
let header: string | undefined
|
||||||
|
for (const line of terminology.split('\n')) {
|
||||||
|
if (!line.startsWith('|')) continue
|
||||||
|
if (/^\|[\s:|-]+\|$/.test(line)) continue
|
||||||
|
const cells = line.split('|').map(cell => cell.trim())
|
||||||
|
if (line.includes('English') && line.includes('中文')) {
|
||||||
|
header = line
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const english = plainTerm(cells[1] ?? '')
|
||||||
|
const chinese = plainTerm(cells[2] ?? '')
|
||||||
|
const escaped = english.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const englishHit = english.length > 1 && new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, 'i').test(changedText)
|
||||||
|
const chineseHit = /[一-鿿]/.test(chinese) && changedText.includes(chinese)
|
||||||
|
if (englishHit || chineseHit) {
|
||||||
|
matches.header ??= header
|
||||||
|
matches.rows.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smallest fence of `mark` characters that safely wraps `body`. */
|
||||||
|
function fenceFor(body: string, mark: '`' | '~'): string {
|
||||||
|
let longest = 2
|
||||||
|
for (const line of body.split('\n')) {
|
||||||
|
const run = new RegExp(`^\\s*(${mark === '`' ? '`' : '~'}{3,})`).exec(line)
|
||||||
|
if (run?.[1] !== undefined && run[1].length > longest) longest = run[1].length
|
||||||
|
}
|
||||||
|
return mark.repeat(longest + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two update directions a pair supports. */
|
||||||
|
export type BriefDirection = 'en-to-zh' | 'zh-to-en'
|
||||||
|
|
||||||
|
/** Inputs for rendering one pair's briefing. */
|
||||||
|
export interface TranslationBriefInput {
|
||||||
|
/** Repo-relative path of the side that changed. */
|
||||||
|
sourcePath: string
|
||||||
|
/** Repo-relative path of the counterpart to update. */
|
||||||
|
counterpartPath: string
|
||||||
|
direction: BriefDirection
|
||||||
|
/** Unified diff of the changed side, last-confirmed to current. */
|
||||||
|
diff: string
|
||||||
|
/** Counterpart sections the diff maps to, or undefined when alignment is untrusted. */
|
||||||
|
counterpartSections?: CounterpartSection[] | undefined
|
||||||
|
/** Whether both sides drifted since the last confirmed state. */
|
||||||
|
bothDrifted: boolean
|
||||||
|
terminology: TerminologyMatches
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZH_TARGET_DIGEST = [
|
||||||
|
'- Edit ONLY what the diff requires; preserve the reviewed phrasing of everything unchanged.',
|
||||||
|
'- Nothing added, nothing dropped: the Chinese must state exactly what the new English states.',
|
||||||
|
'- Write natural institutional technical Chinese, not word-by-word gloss; terse stays terse.',
|
||||||
|
'- Code fences byte-identical to the English side, comments included; inline code spans verbatim.',
|
||||||
|
'- Relative links keep the `.md` target; only the switcher line links `.zh.md`.',
|
||||||
|
'- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
|
||||||
|
'- Typography: one half-width space between Chinese and Latin or digits; full-width punctuation in Chinese prose; 顿号 for enumerations; second person is 你.',
|
||||||
|
'- One physical line per paragraph; exactly one trailing newline.',
|
||||||
|
]
|
||||||
|
|
||||||
|
const EN_TARGET_DIGEST = [
|
||||||
|
'- Edit ONLY what the diff requires; preserve the reviewed phrasing of everything unchanged.',
|
||||||
|
'- Nothing added, nothing dropped: the English must state exactly what the new Chinese states.',
|
||||||
|
'- Write concise professional developer prose, not word-by-word gloss; terse stays terse.',
|
||||||
|
'- Code fences byte-identical to the Chinese side, comments included; inline code spans verbatim.',
|
||||||
|
'- Relative links keep the `.md` target; only the switcher line links `.zh.md`.',
|
||||||
|
'- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
|
||||||
|
'- One physical line per paragraph; exactly one trailing newline.',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the complete briefing for one out-of-sync pair.
|
||||||
|
*
|
||||||
|
* @param input - Diff, mapped sections, terminology, and pair identity.
|
||||||
|
* @returns Markdown briefing text.
|
||||||
|
*/
|
||||||
|
export function renderTranslationBrief(input: TranslationBriefInput): string {
|
||||||
|
const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
|
||||||
|
const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
|
||||||
|
const out: string[] = []
|
||||||
|
out.push(`# Translation update briefing: ${input.sourcePath}`)
|
||||||
|
out.push('')
|
||||||
|
out.push(input.bothDrifted
|
||||||
|
? `WARNING: BOTH sides changed since the pair was last confirmed consistent. Reconcile the two sides by hand — decide which side owns each divergence per docs/i18n/translation-rules.md — before recording. The diff below covers the ${sourceLanguage} side only.`
|
||||||
|
: `The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the diff. The ${counterpartLanguage} side is untouched since the pair was last confirmed consistent.`)
|
||||||
|
out.push('')
|
||||||
|
out.push(`## ${sourceLanguage} diff (last-confirmed → current)`)
|
||||||
|
out.push('')
|
||||||
|
const diffFence = fenceFor(input.diff, '`')
|
||||||
|
out.push(`${diffFence}diff`)
|
||||||
|
out.push(input.diff.trimEnd())
|
||||||
|
out.push(diffFence)
|
||||||
|
if (input.counterpartSections !== undefined) {
|
||||||
|
out.push('')
|
||||||
|
out.push(`## ${counterpartLanguage} text to update (aligned sections, current line numbers)`)
|
||||||
|
for (const section of input.counterpartSections) {
|
||||||
|
out.push('')
|
||||||
|
out.push(`### ${section.label} — ${input.counterpartPath}:${section.startLine}`)
|
||||||
|
out.push('')
|
||||||
|
const fence = fenceFor(section.text, '~')
|
||||||
|
out.push(`${fence}markdown`)
|
||||||
|
out.push(section.text)
|
||||||
|
out.push(fence)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push('')
|
||||||
|
out.push(`Counterpart sections are not shown: the pair's heading structures do not align at the compared states, so open \`${input.counterpartPath}\` directly and locate the regions yourself.`)
|
||||||
|
}
|
||||||
|
if (input.terminology.rows.length > 0 && input.terminology.header !== undefined) {
|
||||||
|
out.push('')
|
||||||
|
out.push('## Binding terminology rows matching this diff (docs/i18n/terminology.md)')
|
||||||
|
out.push('')
|
||||||
|
out.push(input.terminology.header)
|
||||||
|
out.push(`|${' --- |'.repeat(Math.max(input.terminology.header.split('|').length - 2, 1))}`)
|
||||||
|
for (const row of input.terminology.rows) out.push(row)
|
||||||
|
out.push('')
|
||||||
|
out.push('For any term you introduce that is not listed above, consult the full table before inventing a rendering.')
|
||||||
|
}
|
||||||
|
out.push('')
|
||||||
|
out.push('## Rules digest (full rules: docs/i18n/translation-rules.md)')
|
||||||
|
out.push('')
|
||||||
|
out.push(...(input.direction === 'en-to-zh' ? ZH_TARGET_DIGEST : EN_TARGET_DIGEST))
|
||||||
|
out.push('')
|
||||||
|
out.push('## Finish')
|
||||||
|
out.push('')
|
||||||
|
out.push('1. Apply the smallest counterpart edit that covers the diff, then verify the changed hunks clause by clause against the source.')
|
||||||
|
out.push(`2. \`pnpm run verify-translation-pairing --write ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
|
||||||
|
out.push(`3. \`pnpm run verify-translation-pairing ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
|
||||||
|
out.push('')
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
isTranslationScopeFile,
|
isTranslationScopeFile,
|
||||||
|
pairAnchorOfArgument,
|
||||||
parseTranslationMarkdown,
|
parseTranslationMarkdown,
|
||||||
|
parseTranslationPairingCliArgs,
|
||||||
parseTranslationPairingManifest,
|
parseTranslationPairingManifest,
|
||||||
translationStructureDiff,
|
translationStructureDiff,
|
||||||
translationStructureSignature,
|
translationStructureSignature,
|
||||||
@@ -102,3 +104,40 @@ describe('translation structural signature', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('pair CLI arguments', () => {
|
||||||
|
it('normalizes any pair file or bare stem to the English anchor', () => {
|
||||||
|
expect(pairAnchorOfArgument('docs/foo.md')).toBe('docs/foo.md')
|
||||||
|
expect(pairAnchorOfArgument('docs/foo.zh.md')).toBe('docs/foo.md')
|
||||||
|
expect(pairAnchorOfArgument('docs/foo.i18n.yaml')).toBe('docs/foo.md')
|
||||||
|
expect(pairAnchorOfArgument('docs/foo')).toBe('docs/foo.md')
|
||||||
|
expect(pairAnchorOfArgument('.\\docs\\foo.zh.md')).toBe('docs/foo.md')
|
||||||
|
})
|
||||||
|
|
||||||
|
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({
|
||||||
|
mode: 'check',
|
||||||
|
scope: 'pairs',
|
||||||
|
anchors: ['docs/bar.md', 'docs/foo.md'],
|
||||||
|
})
|
||||||
|
expect(parseTranslationPairingCliArgs([])).toEqual({ 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({
|
||||||
|
mode: 'write',
|
||||||
|
scope: 'pairs',
|
||||||
|
anchors: ['docs/foo.md'],
|
||||||
|
})
|
||||||
|
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({ 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', '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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -100,6 +100,66 @@ export function parseTranslationPairingManifest(content: string): TranslationPai
|
|||||||
return { excluded: excludedField(record) }
|
return { excluded: excludedField(record) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one CLI pair argument to its English anchor path: any of the
|
||||||
|
* pair's three files (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`) or the bare
|
||||||
|
* `foo` stem names the same pair, and platform separators are accepted.
|
||||||
|
*
|
||||||
|
* @param argument - Repo-relative path as passed on a command line.
|
||||||
|
* @returns The pair's `foo.md` anchor path with `/` separators.
|
||||||
|
*/
|
||||||
|
export function pairAnchorOfArgument(argument: string): string {
|
||||||
|
const normalized = argument.split('\\').join('/').replace(/^\.\//, '')
|
||||||
|
if (normalized.endsWith('.zh.md')) return `${normalized.slice(0, -'.zh.md'.length)}.md`
|
||||||
|
if (normalized.endsWith('.i18n.yaml')) return `${normalized.slice(0, -'.i18n.yaml'.length)}.md`
|
||||||
|
if (normalized.endsWith('.md')) return normalized
|
||||||
|
return `${normalized}.md`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parsed `verify-translation-pairing` invocation. */
|
||||||
|
export interface TranslationPairingCliRequest {
|
||||||
|
mode: 'check' | 'list' | 'write'
|
||||||
|
/** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */
|
||||||
|
scope: 'corpus' | 'pairs'
|
||||||
|
/** English anchor paths, empty for corpus scope. */
|
||||||
|
anchors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate `verify-translation-pairing` CLI arguments.
|
||||||
|
*
|
||||||
|
* Check accepts optional pair paths; `--write` requires either pair paths or
|
||||||
|
* `--all` so a bulk re-record is always an explicit choice — a bare
|
||||||
|
* `--write` would silently bless every drifted pair in the tree, including
|
||||||
|
* ones the caller never confirmed. `--list` is corpus-only.
|
||||||
|
*
|
||||||
|
* @param argv - Arguments after the script name.
|
||||||
|
* @returns The validated request.
|
||||||
|
* @throws Error when flags or their combination are invalid.
|
||||||
|
*/
|
||||||
|
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))
|
||||||
|
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)) {
|
||||||
|
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 (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 }
|
||||||
|
}
|
||||||
|
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. */
|
/** The structural surface compared between the two sides of a pair. */
|
||||||
export interface TranslationStructureSignature {
|
export interface TranslationStructureSignature {
|
||||||
/** Heading depths in document order (h2 -> 2). */
|
/** Heading depths in document order (h2 -> 2). */
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* Enforce complete English/Chinese pairs, matching structure, and recorded git
|
* Enforce complete English/Chinese pairs, matching structure, and recorded git
|
||||||
* blob hashes for every in-scope document. The manifest contains only explicit
|
* blob hashes for every in-scope document. The manifest contains only explicit
|
||||||
* exclusions, which may have neither a counterpart nor a sidecar.
|
* exclusions, which may have neither a counterpart nor a sidecar.
|
||||||
* `--list` reports state and `--write` records both sides after human review.
|
* `--list` reports state; `--write <pairs...>` records the named confirmed
|
||||||
* Translation quality remains a review responsibility.
|
* 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.
|
||||||
* See `docs/i18n/README.md` for the owning contract.
|
* See `docs/i18n/README.md` for the owning contract.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ import { basename, join, resolve, sep } from 'node:path'
|
|||||||
import {
|
import {
|
||||||
linksTo,
|
linksTo,
|
||||||
parseTranslationMarkdown,
|
parseTranslationMarkdown,
|
||||||
|
parseTranslationPairingCliArgs,
|
||||||
parseTranslationPairingManifest,
|
parseTranslationPairingManifest,
|
||||||
isTranslationScopeFile,
|
isTranslationScopeFile,
|
||||||
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||||
@@ -21,8 +24,15 @@ import {
|
|||||||
} from './translation-pairing.ts'
|
} from './translation-pairing.ts'
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, '..')
|
const root = resolve(import.meta.dirname, '..')
|
||||||
const listMode = process.argv.includes('--list')
|
let request: ReturnType<typeof parseTranslationPairingCliArgs>
|
||||||
const writeMode = process.argv.includes('--write')
|
try {
|
||||||
|
request = parseTranslationPairingCliArgs(process.argv.slice(2))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`verify-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const listMode = request.mode === 'list'
|
||||||
|
const writeMode = request.mode === 'write'
|
||||||
|
|
||||||
/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
|
/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
|
||||||
const SCOPE_PATTERNS = [
|
const SCOPE_PATTERNS = [
|
||||||
@@ -77,32 +87,67 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri
|
|||||||
'# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
|
'# 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;',
|
'# 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:',
|
'# after editing either side, bring the other along and re-record with:',
|
||||||
'# pnpm run verify-translation-pairing --write',
|
`# pnpm run verify-translation-pairing --write ${source}`,
|
||||||
`${basename(source)}: ${sourceHash}`,
|
`${basename(source)}: ${sourceHash}`,
|
||||||
`${basename(zh)}: ${zhHash}`,
|
`${basename(zh)}: ${zhHash}`,
|
||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enumerate the scope once.
|
// 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>()
|
const files = new Set<string>()
|
||||||
for (const pattern of SCOPE_PATTERNS) {
|
if (request.scope === 'pairs') {
|
||||||
for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
|
for (const anchor of request.anchors) {
|
||||||
const normalized = match.split(sep).join('/')
|
for (const file of [anchor, ...Object.values(pairPaths(anchor))]) {
|
||||||
if (isTranslationScopeFile(normalized)) files.add(normalized)
|
if (existsSync(join(root, 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)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const pattern of SCOPE_PATTERNS) {
|
||||||
|
for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
|
||||||
|
const normalized = match.split(sep).join('/')
|
||||||
|
if (isTranslationScopeFile(normalized)) files.add(normalized)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
|
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
|
||||||
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
|
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
|
||||||
const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
|
const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
|
||||||
|
|
||||||
// --write: (re)record both hashes for every complete pair, creating missing records.
|
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) {
|
||||||
|
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)`)
|
||||||
|
}
|
||||||
|
for (const anchor of absent) {
|
||||||
|
console.error(`verify-translation-pairing: ${anchor} names no pair on disk (none of its three files exist)`)
|
||||||
|
}
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --write: (re)record both hashes for the requested complete pairs, creating
|
||||||
|
// missing records. A named pair that cannot be recorded (missing counterpart)
|
||||||
|
// fails loud; corpus scope (--all) skips pairless sources as before.
|
||||||
if (writeMode) {
|
if (writeMode) {
|
||||||
let written = 0
|
let written = 0
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
if (isExcluded(source)) continue
|
if (isExcluded(source)) continue
|
||||||
const { zh, meta } = pairPaths(source)
|
const { zh, meta } = pairPaths(source)
|
||||||
if (!existsSync(join(root, zh))) continue
|
if (!existsSync(join(root, source)) || !existsSync(join(root, zh))) {
|
||||||
|
if (request.scope === 'pairs') {
|
||||||
|
console.error(`verify-translation-pairing: cannot record ${source}: missing ${existsSync(join(root, source)) ? zh : source}`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
|
const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
|
||||||
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
|
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
|
||||||
writeFileSync(join(root, meta), record)
|
writeFileSync(join(root, meta), record)
|
||||||
@@ -204,7 +249,9 @@ if (listMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length === 0) {
|
if (errors.length === 0) {
|
||||||
console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`)
|
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} pair(s) checked across all in-scope documentation, all consistent.`)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user