mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(i18n): unit-mapped briefings with mechanical --apply, adopting the #684 planner mechanics
The briefing now maps each update at the narrowest safely aligned granularity, widening deterministically on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (--apply splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units — headings, paragraphs, table rows, list items, fences, block quotes, HTML blocks, thematic breaks, link definitions, matched by container-scoped kind sequences — each carry their last-confirmed source, current source, and current counterpart text; units that do not align fall back to depth-matched heading sections (depth only, so translated heading text still maps); and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping. Terminology rows now match the changed spans only, English terms on word boundaries with plural inflections, and Chinese-target briefings track each relevant term's document-wide first occurrence — a moved occurrence pulls the vacated and receiving spans into the briefing with an explanatory note. The unit mapping, mechanical code splice, and first-occurrence tracking adopt the planner design from the incremental prompt-pipeline PR (#684), whose provider-backed bake-off independently validated the same scope ladder; this PR carries those mechanics into the agent-facing briefing path so both consumers of the consistency records behave alike. The prior line-hunk section mapping and its heading-text alignment (which could not map cross-language sections) are replaced wholesale. Docs: SKILL.md update path, i18n README pair, development.md pair, and the briefed-updates Agent Note pair brought along; the development.md fence edit was applied with --apply itself, and the prose updates were made through the new unit/section briefings.
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* 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`.
|
||||
* `pnpm run gen-translation-brief [--apply] [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. Each briefing maps the change at
|
||||
* the narrowest safe granularity — code-fence-only splice, changed
|
||||
* Markdown units, heading sections, whole document — and `--apply` writes
|
||||
* the computed counterpart for pairs whose change is code-fence-only.
|
||||
* 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'
|
||||
@@ -15,19 +18,25 @@ import { basename, join, resolve, sep } from 'node:path'
|
||||
import {
|
||||
isTranslationScopeFile,
|
||||
pairAnchorOfArgument,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingManifest,
|
||||
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
import {
|
||||
changedLinesOfDiff,
|
||||
extractCounterpartSections,
|
||||
headingSections,
|
||||
mapHunksToSections,
|
||||
matchTerminologyRows,
|
||||
parseUnifiedDiffHunks,
|
||||
changedSpanIndices,
|
||||
computeMechanicalUpdate,
|
||||
firstOccurrenceContext,
|
||||
markdownUnits,
|
||||
relevantTerminologyRows,
|
||||
renderTranslationBrief,
|
||||
sectionSpans,
|
||||
spansAligned,
|
||||
type BriefBundle,
|
||||
type BriefDirection,
|
||||
type CounterpartSection,
|
||||
type BriefScope,
|
||||
type MarkdownSpan,
|
||||
} from './translation-brief.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -121,15 +130,107 @@ function loadPair(anchor: string): PairState | string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
/** Assemble bundles for the given changed + first-occurrence span indices. */
|
||||
function bundlesFor(
|
||||
indices: number[],
|
||||
extraIndices: number[],
|
||||
confirmed: MarkdownSpan[],
|
||||
current: MarkdownSpan[],
|
||||
counterpart: MarkdownSpan[],
|
||||
): BriefBundle[] {
|
||||
const extras = new Set(extraIndices)
|
||||
return [...new Set([...indices, ...extraIndices])].sort((left, right) => left - right).map((index) => {
|
||||
const confirmedSpan = confirmed[index]
|
||||
const currentSpan = current[index]
|
||||
const counterpartSpan = counterpart[index]
|
||||
if (confirmedSpan === undefined || currentSpan === undefined || counterpartSpan === undefined) {
|
||||
throw new Error(`gen-translation-brief: span ${index} is unmapped despite alignment`)
|
||||
}
|
||||
return {
|
||||
index,
|
||||
label: currentSpan.label,
|
||||
reason: extras.has(index) && confirmedSpan.text === currentSpan.text ? 'first-occurrence' as const : undefined,
|
||||
confirmedSourceText: confirmedSpan.text,
|
||||
currentSourceText: currentSpan.text,
|
||||
counterpartText: counterpartSpan.text,
|
||||
counterpartStartLine: counterpartSpan.startLine,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Render the briefing for one drifted side of a pair. */
|
||||
function briefDirection(pair: PairState, direction: BriefDirection): string {
|
||||
interface PlannedBrief {
|
||||
scope: BriefScope
|
||||
/** Old + new text of the changed spans, for terminology matching. */
|
||||
changedText: string
|
||||
/** Computed counterpart for a mechanical scope, for `--apply`. */
|
||||
mechanicalResult?: string | undefined
|
||||
}
|
||||
|
||||
/** Choose the narrowest safely mapped granularity for one drifted side. */
|
||||
function planScope(
|
||||
sourceLast: string,
|
||||
sourceCurrent: string,
|
||||
counterpartCurrent: string,
|
||||
direction: BriefDirection,
|
||||
bothDrifted: boolean,
|
||||
): PlannedBrief {
|
||||
const wholeChangedText = `${sourceLast}\n${sourceCurrent}`
|
||||
if (bothDrifted) {
|
||||
return {
|
||||
scope: { kind: 'document', reason: 'BOTH sides changed since the pair was last confirmed consistent, so no side is a trustworthy mapping anchor; decide which side owns each divergence.' },
|
||||
changedText: wholeChangedText,
|
||||
}
|
||||
}
|
||||
const mechanical = computeMechanicalUpdate(sourceLast, sourceCurrent, counterpartCurrent)
|
||||
if (mechanical !== undefined) {
|
||||
return { scope: { kind: 'mechanical' }, changedText: wholeChangedText, mechanicalResult: mechanical }
|
||||
}
|
||||
for (const [kind, spansOf] of [['units', markdownUnits], ['sections', sectionSpans]] as const) {
|
||||
const confirmed = spansOf(sourceLast)
|
||||
const current = spansOf(sourceCurrent)
|
||||
const counterpart = spansOf(counterpartCurrent)
|
||||
if (!spansAligned(confirmed, current) || !spansAligned(confirmed, counterpart)) continue
|
||||
const changed = changedSpanIndices(confirmed, current)
|
||||
if (changed.length === 0) continue
|
||||
const changedText = changed.map(index => `${confirmed[index]?.text ?? ''}\n${current[index]?.text ?? ''}`).join('\n')
|
||||
const rows = relevantTerminologyRows(terminology, direction, changedText)
|
||||
const occurrence = direction === 'en-to-zh'
|
||||
? firstOccurrenceContext(sourceLast, sourceCurrent, confirmed, current, rows, new Set(changed))
|
||||
: { notes: [], extraSpanIndices: [] }
|
||||
return {
|
||||
scope: {
|
||||
kind,
|
||||
bundles: bundlesFor(changed, occurrence.extraSpanIndices, confirmed, current, counterpart),
|
||||
firstOccurrenceNotes: occurrence.notes,
|
||||
},
|
||||
changedText,
|
||||
}
|
||||
}
|
||||
return {
|
||||
scope: { kind: 'document', reason: 'Neither fine-grained units nor heading sections align one to one across the last-confirmed source, current source, and current counterpart.' },
|
||||
changedText: wholeChangedText,
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a computed mechanical counterpart and write it. */
|
||||
function applyMechanical(counterpartPath: string, sourceCurrent: string, result: string): void {
|
||||
const counterpartBase = basename(counterpartPath)
|
||||
const sourceBase = counterpartBase.endsWith('.zh.md')
|
||||
? counterpartBase.replace(/\.zh\.md$/, '.md')
|
||||
: counterpartBase.replace(/\.md$/, '.zh.md')
|
||||
const errors = translationStructureDiff(
|
||||
translationStructureSignature(parseTranslationMarkdown(sourceCurrent), counterpartBase),
|
||||
translationStructureSignature(parseTranslationMarkdown(result), sourceBase),
|
||||
)
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`gen-translation-brief: computed mechanical update for ${counterpartPath} violates the pair structure: ${errors.join('; ')}`)
|
||||
}
|
||||
writeFileSync(join(root, counterpartPath), result)
|
||||
console.error(`gen-translation-brief: applied code-fence splice to ${counterpartPath}; review the diff, then record the pair.`)
|
||||
}
|
||||
|
||||
/** Render (and under `--apply`, apply) the briefing for one drifted side. */
|
||||
function briefDirection(pair: PairState, direction: BriefDirection, apply: boolean): string {
|
||||
const sourceIsEnglish = direction === 'en-to-zh'
|
||||
const sourcePath = sourceIsEnglish ? pair.anchor : pair.zh
|
||||
const counterpartPath = sourceIsEnglish ? pair.zh : pair.anchor
|
||||
@@ -137,25 +238,29 @@ function briefDirection(pair: PairState, direction: BriefDirection): string {
|
||||
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)
|
||||
const planned = planScope(sourceLast, sourceCurrent, counterpartCurrent, direction, pair.enDrifted && pair.zhDrifted)
|
||||
if (apply && planned.mechanicalResult !== undefined) {
|
||||
applyMechanical(counterpartPath, sourceCurrent, planned.mechanicalResult)
|
||||
}
|
||||
return renderTranslationBrief({
|
||||
sourcePath,
|
||||
counterpartPath,
|
||||
direction,
|
||||
diff,
|
||||
counterpartSections,
|
||||
bothDrifted,
|
||||
terminology: matchTerminologyRows(terminology, changedLinesOfDiff(diff)),
|
||||
scope: planned.scope,
|
||||
terminology: relevantTerminologyRows(terminology, direction, planned.changedText),
|
||||
})
|
||||
}
|
||||
|
||||
const requested = process.argv.slice(2).map(pairAnchorOfArgument)
|
||||
const argv = process.argv.slice(2)
|
||||
const flags = argv.filter(argument => argument.startsWith('--'))
|
||||
const unknownFlags = flags.filter(flag => flag !== '--apply')
|
||||
if (unknownFlags.length > 0) {
|
||||
console.error(`gen-translation-brief: unknown flag(s): ${unknownFlags.join(', ')} (only --apply is supported)`)
|
||||
process.exit(2)
|
||||
}
|
||||
const applyMode = flags.includes('--apply')
|
||||
const requested = argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument)
|
||||
|
||||
let anchors: string[]
|
||||
if (requested.length > 0) {
|
||||
@@ -182,8 +287,8 @@ for (const anchor of anchors) {
|
||||
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 (pair.enDrifted) briefs.push(briefDirection(pair, 'en-to-zh', applyMode))
|
||||
if (pair.zhDrifted) briefs.push(briefDirection(pair, 'zh-to-en', applyMode))
|
||||
}
|
||||
|
||||
if (problems.length > 0 || skipped.length > 0) {
|
||||
|
||||
@@ -2,45 +2,18 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
changedLinesOfDiff,
|
||||
extractCounterpartSections,
|
||||
headingSections,
|
||||
mapHunksToSections,
|
||||
matchTerminologyRows,
|
||||
parseUnifiedDiffHunks,
|
||||
changedSpanIndices,
|
||||
computeMechanicalUpdate,
|
||||
firstOccurrenceContext,
|
||||
markdownUnits,
|
||||
parseTerminologyRows,
|
||||
relevantTerminologyRows,
|
||||
renderTranslationBrief,
|
||||
sectionSpans,
|
||||
spansAligned,
|
||||
termOffsets,
|
||||
} 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.',
|
||||
'',
|
||||
@@ -52,57 +25,163 @@ const DOC = [
|
||||
'',
|
||||
'First body.',
|
||||
'',
|
||||
'```ts',
|
||||
'const value = 1',
|
||||
'```',
|
||||
'',
|
||||
'## Second',
|
||||
'',
|
||||
'Second body.',
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
'- item one',
|
||||
'- item two',
|
||||
].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' },
|
||||
describe('markdown spans', () => {
|
||||
it('lists units with container-scoped kinds in document order', () => {
|
||||
const kinds = markdownUnits(DOC).map(span => span.kind)
|
||||
expect(kinds).toEqual([
|
||||
'root.0:paragraph',
|
||||
'root.1:heading:1',
|
||||
'root.2:paragraph',
|
||||
'root.3:heading:2',
|
||||
'root.4:paragraph',
|
||||
'root.5:code',
|
||||
'root.6:heading:2',
|
||||
'root.7.0:tableRow',
|
||||
'root.7.1:tableRow',
|
||||
'root.8.0:listItem',
|
||||
'root.8.1:listItem',
|
||||
])
|
||||
})
|
||||
|
||||
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('lists heading sections with a preamble span and heading labels', () => {
|
||||
const sections = sectionSpans(DOC)
|
||||
expect(sections.map(span => span.label)).toEqual([
|
||||
'(preamble before the first heading)',
|
||||
'Title',
|
||||
'First',
|
||||
'Second',
|
||||
])
|
||||
expect(sections[0]).toMatchObject({ startLine: 1, endLine: 2 })
|
||||
expect(sections[2]).toMatchObject({ startLine: 7, endLine: 14 })
|
||||
})
|
||||
|
||||
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.' },
|
||||
])
|
||||
it('labels units by their node type', () => {
|
||||
const units = markdownUnits(DOC)
|
||||
expect(units[0]!.label).toBe('paragraph')
|
||||
expect(units[1]!.label).toBe('heading')
|
||||
expect(units[7]!.label).toBe('tableRow')
|
||||
})
|
||||
|
||||
it('aligns sections by depth only, so translated heading text still maps', () => {
|
||||
const zh = DOC.replace('## First', '## 第一节').replace('## Second', '## 第二节').replace('# Title', '# 标题')
|
||||
expect(spansAligned(sectionSpans(DOC), sectionSpans(zh))).toBe(true)
|
||||
})
|
||||
|
||||
it('aligns span lists only on equal non-empty kind sequences', () => {
|
||||
const zh = DOC.replace('First body.', '第一段。').replace('item one', '第一项').replace('Intro paragraph.', '导语。')
|
||||
expect(spansAligned(markdownUnits(DOC), markdownUnits(zh))).toBe(true)
|
||||
const reshaped = DOC.replace('- item one\n- item two', 'merged paragraph')
|
||||
expect(spansAligned(markdownUnits(DOC), markdownUnits(reshaped))).toBe(false)
|
||||
expect(spansAligned([], [])).toBe(false)
|
||||
})
|
||||
|
||||
it('reports the indices whose text changed', () => {
|
||||
const edited = DOC.replace('First body.', 'First body, revised.').replace('| 1 | 2 |', '| 1 | 3 |')
|
||||
expect(changedSpanIndices(markdownUnits(DOC), markdownUnits(edited))).toEqual([4, 8])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mechanical code updates', () => {
|
||||
const en = '# T\n\nProse.\n\n```sh\nrun one\n```\n'
|
||||
const zh = '# T\n\n中文。\n\n```sh\nrun one\n```\n'
|
||||
|
||||
it('splices a fence-only edit into the counterpart', () => {
|
||||
const edited = en.replace('run one', 'run two')
|
||||
expect(computeMechanicalUpdate(en, edited, zh)).toBe(zh.replace('run one', 'run two'))
|
||||
})
|
||||
|
||||
it('refuses when prose changed too', () => {
|
||||
const edited = en.replace('Prose.', 'Prose!').replace('run one', 'run two')
|
||||
expect(computeMechanicalUpdate(en, edited, zh)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses when the counterpart fences already diverge from last-confirmed', () => {
|
||||
const edited = en.replace('run one', 'run two')
|
||||
expect(computeMechanicalUpdate(en, edited, zh.replace('run one', 'run stale'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses when fence counts differ or nothing changed', () => {
|
||||
expect(computeMechanicalUpdate(en, `${en}\n\`\`\`sh\nextra\n\`\`\`\n`, zh)).toBeUndefined()
|
||||
expect(computeMechanicalUpdate(en, en, zh)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const TERMINOLOGY = [
|
||||
'| English | 中文 | 首次出现 | 不要译作 | 备注 |',
|
||||
'|---|---|---|---|---|',
|
||||
'| agent loop | agent loop | agent loop(智能体循环) | | |',
|
||||
'| agent | agent | agent(智能体) | 智能体 | |',
|
||||
'| session log | 会话日志 | | 会话记录 | |',
|
||||
'| gate | 门禁 | | | |',
|
||||
'| registry | 注册表 | | | |',
|
||||
].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')
|
||||
describe('terminology', () => {
|
||||
it('parses data rows and skips the header and separator', () => {
|
||||
const rows = parseTerminologyRows(TERMINOLOGY)
|
||||
expect(rows.map(row => row.english)).toEqual(['agent', 'session log', 'gate', 'registry'])
|
||||
expect(rows[0]).toMatchObject({ chinese: 'agent', first: 'agent(智能体)' })
|
||||
})
|
||||
|
||||
it('selects rows whose Chinese term appears when the source is Chinese', () => {
|
||||
expect(matchTerminologyRows(TERMINOLOGY, '门禁在提交时运行。').rows).toEqual(['| gate | 门禁 | | | |'])
|
||||
it('matches English terms on word boundaries with plural inflections', () => {
|
||||
expect(termOffsets('two agents met', 'agent', true)).toEqual([4])
|
||||
expect(termOffsets('two registries', 'registry', true)).toEqual([4])
|
||||
expect(termOffsets('reagents', 'agent', true)).toEqual([])
|
||||
expect(termOffsets('', 'agent', true)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not match substrings inside larger words', () => {
|
||||
expect(matchTerminologyRows(TERMINOLOGY, 'delegate the work').rows).toEqual([])
|
||||
it('selects rows for the changed text per direction', () => {
|
||||
expect(relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'All agents write a session log.').map(row => row.english))
|
||||
.toEqual(['agent', 'session log'])
|
||||
expect(relevantTerminologyRows(TERMINOLOGY, 'zh-to-en', '门禁在提交时运行。').map(row => row.english))
|
||||
.toEqual(['gate'])
|
||||
expect(relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'delegate the work')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('first-occurrence tracking', () => {
|
||||
const before = '# T\n\nAlpha paragraph.\n\nThe agent runs.\n'
|
||||
const after = '# T\n\nAlpha paragraph with an agent.\n\nThe agent runs.\n'
|
||||
const rows = parseTerminologyRows(TERMINOLOGY).filter(row => row.english === 'agent')
|
||||
|
||||
it('flags a moved first occurrence and pulls the vacated span in', () => {
|
||||
const context = firstOccurrenceContext(
|
||||
before, after, markdownUnits(before), markdownUnits(after), rows, new Set([1]),
|
||||
)
|
||||
expect(context.notes).toHaveLength(1)
|
||||
expect(context.notes[0]).toContain('moved from #2 to #1')
|
||||
expect(context.extraSpanIndices).toEqual([2])
|
||||
})
|
||||
|
||||
it('stays silent when the first occurrence does not move', () => {
|
||||
const unmoved = before.replace('Alpha paragraph.', 'Alpha paragraph, revised.')
|
||||
const context = firstOccurrenceContext(
|
||||
before, unmoved, markdownUnits(before), markdownUnits(unmoved), rows, new Set([1]),
|
||||
)
|
||||
expect(context.notes).toEqual([])
|
||||
expect(context.extraSpanIndices).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores rows without a first-occurrence rendering', () => {
|
||||
const bare = parseTerminologyRows(TERMINOLOGY).filter(row => row.english === 'gate')
|
||||
const withGate = after.replace('The agent runs.', 'The gate runs.')
|
||||
const context = firstOccurrenceContext(
|
||||
before, withGate, markdownUnits(before), markdownUnits(withGate), bare, new Set([2]),
|
||||
)
|
||||
expect(context.notes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,29 +190,71 @@ describe('brief rendering', () => {
|
||||
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)),
|
||||
diff: '@@ -5 +5 @@\n-old text about the agent\n+new text about the agent',
|
||||
terminology: relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'the agent'),
|
||||
}
|
||||
const bundle = {
|
||||
index: 4,
|
||||
label: 'paragraph',
|
||||
confirmedSourceText: 'old text about the agent\n',
|
||||
currentSourceText: 'new text about the agent\n',
|
||||
counterpartText: '关于 agent 的旧文本\n',
|
||||
counterpartStartLine: 9,
|
||||
}
|
||||
|
||||
it('renders diff, aligned sections, terminology, digest, and finish steps', () => {
|
||||
const brief = renderTranslationBrief(base)
|
||||
it('renders unit bundles with three-way context and line anchors', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: ['agent: the document-wide first occurrence moved from #2 to #1; the agent(智能体) form moves with it (later occurrences drop the annotation).'] },
|
||||
})
|
||||
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('## Changed units')
|
||||
expect(brief).toContain('### #4 paragraph — counterpart at docs/foo.zh.md:9')
|
||||
expect(brief).toContain('Last-confirmed English:')
|
||||
expect(brief).toContain('Current Chinese (bring this along):')
|
||||
expect(brief).toContain('## First-occurrence notes')
|
||||
expect(brief).toContain('agent(智能体)')
|
||||
expect(brief).toContain('首次出现 annotations attach to the document-wide first occurrence only')
|
||||
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 })
|
||||
it('marks first-occurrence bundles and omits their unchanged confirmed text', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: {
|
||||
kind: 'units',
|
||||
bundles: [{ ...bundle, reason: 'first-occurrence', confirmedSourceText: bundle.currentSourceText }],
|
||||
firstOccurrenceNotes: [],
|
||||
},
|
||||
})
|
||||
expect(brief).toContain('unchanged; included for a first-occurrence move')
|
||||
expect(brief).not.toContain('Last-confirmed English:')
|
||||
})
|
||||
|
||||
it('renders the mechanical scope with the --apply command', () => {
|
||||
const brief = renderTranslationBrief({ ...base, scope: { kind: 'mechanical' } })
|
||||
expect(brief).toContain('## Mechanical update — no translation judgment involved')
|
||||
expect(brief).toContain('gen-translation-brief --apply docs/foo.md')
|
||||
expect(brief).not.toContain('## Changed units')
|
||||
})
|
||||
|
||||
it('renders the section fallback under its own heading', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: { kind: 'sections', bundles: [bundle], firstOccurrenceNotes: [] },
|
||||
})
|
||||
expect(brief).toContain('## Changed sections')
|
||||
expect(brief).toContain('fine-grained units do not align')
|
||||
})
|
||||
|
||||
it('renders the document fallback with its reason and no bundles', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: { kind: 'document', reason: 'BOTH sides changed since the pair was last confirmed consistent, so no side is a trustworthy mapping anchor; decide which side owns each divergence.' },
|
||||
})
|
||||
expect(brief).toContain('## Whole-document update required')
|
||||
expect(brief).toContain('BOTH sides changed')
|
||||
expect(brief).toContain('locate the regions yourself')
|
||||
expect(brief).not.toContain('docs/foo.zh.md:7')
|
||||
expect(brief).toContain('locate the affected regions yourself')
|
||||
})
|
||||
|
||||
it('renders the English-target digest for zh-to-en updates', () => {
|
||||
@@ -142,15 +263,20 @@ describe('brief rendering', () => {
|
||||
direction: 'zh-to-en',
|
||||
sourcePath: 'docs/foo.zh.md',
|
||||
counterpartPath: 'docs/foo.md',
|
||||
scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: [] },
|
||||
})
|
||||
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', () => {
|
||||
it('grows bundle fences past tilde runs in the text', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
counterpartSections: [{ label: '## First', startLine: 7, text: '~~~~\ninner\n~~~~' }],
|
||||
scope: {
|
||||
kind: 'units',
|
||||
bundles: [{ ...bundle, counterpartText: '~~~~\ninner\n~~~~\n' }],
|
||||
firstOccurrenceNotes: [],
|
||||
},
|
||||
})
|
||||
expect(brief).toContain('~~~~~markdown')
|
||||
})
|
||||
|
||||
@@ -1,160 +1,216 @@
|
||||
/**
|
||||
* 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`.
|
||||
* translation pair: the authored side's changes since the last confirmed
|
||||
* state at the narrowest safely mapped granularity (code-fence-only splice,
|
||||
* changed Markdown units, heading sections, whole document), the terminology
|
||||
* rows those changes touch, first-occurrence movement notes, and a digest of
|
||||
* the binding update rules. The unit mapping, mechanical code splice, and
|
||||
* first-occurrence tracking adopt the planner mechanics validated in the
|
||||
* incremental-pipeline work (PR #684). 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. */
|
||||
/** One block-level span of a Markdown document, in document order. */
|
||||
export interface MarkdownSpan {
|
||||
/** Position in the span list; briefing ids derive from it. */
|
||||
index: number
|
||||
/**
|
||||
* Structural kind compared for alignment, language-neutral: container path
|
||||
* plus node type for units (`root.3:tableRow`), depth for sections (`section:2`).
|
||||
*/
|
||||
kind: string
|
||||
/** Reader-facing label: heading text for sections, node type for units. */
|
||||
label: string
|
||||
/** 1-based first source line. */
|
||||
startLine: number
|
||||
/** 1-based last source line. */
|
||||
endLine: number
|
||||
/** The span's text, trailing newline normalized to exactly one. */
|
||||
text: string
|
||||
}
|
||||
|
||||
function linesOf(markdown: string): string[] {
|
||||
const lines = markdown.replaceAll('\r\n', '\n').split('\n')
|
||||
if (lines.at(-1) === '') lines.pop()
|
||||
return lines
|
||||
}
|
||||
|
||||
function sliceLines(lines: string[], startLine: number, endLine: number): string {
|
||||
return `${lines.slice(startLine - 1, endLine).join('\n')}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* List a document's headings with their start lines via the pairing-gate parser.
|
||||
* List a document's translation units: the outermost block nodes a minimal
|
||||
* update can replace independently. Headings, paragraphs, code fences, table
|
||||
* rows, list items, block quotes, HTML blocks, thematic breaks, and link
|
||||
* definitions are units; the container path is part of the kind so kind
|
||||
* sequences only align when container membership also aligns.
|
||||
*
|
||||
* @param markdown - Document text.
|
||||
* @returns Headings in document order.
|
||||
* @returns Units in document order.
|
||||
*/
|
||||
export function headingSections(markdown: string): HeadingSection[] {
|
||||
const out: HeadingSection[] = []
|
||||
export function markdownUnits(markdown: string): MarkdownSpan[] {
|
||||
const positions: Array<{ kind: string; label: string; startLine: number; endLine: number }> = []
|
||||
const visit = (node: Nodes, path: string): void => {
|
||||
let kind: string | undefined
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
kind = `${path}:heading:${node.depth}`
|
||||
break
|
||||
case 'paragraph':
|
||||
case 'code':
|
||||
case 'tableRow':
|
||||
case 'listItem':
|
||||
case 'blockquote':
|
||||
case 'html':
|
||||
case 'thematicBreak':
|
||||
case 'definition':
|
||||
kind = `${path}:${node.type}`
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (kind !== undefined && node.position !== undefined) {
|
||||
positions.push({ kind, label: node.type, startLine: node.position.start.line, endLine: node.position.end.line })
|
||||
return
|
||||
}
|
||||
if ('children' in node) for (const [index, child] of node.children.entries()) visit(child, `${path}.${index}`)
|
||||
}
|
||||
visit(parseTranslationMarkdown(markdown), 'root')
|
||||
positions.sort((left, right) => left.startLine - right.startLine)
|
||||
const lines = linesOf(markdown)
|
||||
return positions.map((position, index) => ({
|
||||
index,
|
||||
...position,
|
||||
text: sliceLines(lines, position.startLine, position.endLine),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* List a document's heading-delimited sections, including a leading
|
||||
* `preamble` span when content precedes the first heading.
|
||||
*
|
||||
* @param markdown - Document text.
|
||||
* @returns Sections in document order.
|
||||
*/
|
||||
export function sectionSpans(markdown: string): MarkdownSpan[] {
|
||||
const headings: Array<{ depth: number; line: number; label: string }> = []
|
||||
const visit = (node: Nodes): void => {
|
||||
if (node.type === 'heading') {
|
||||
if (node.type === 'heading' && node.position !== undefined) {
|
||||
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 })
|
||||
headings.push({ depth: node.depth, line: node.position.start.line, 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
|
||||
headings.sort((left, right) => left.line - right.line)
|
||||
const lines = linesOf(markdown)
|
||||
const spans: MarkdownSpan[] = []
|
||||
const firstHeadingLine = headings[0]?.line ?? lines.length + 1
|
||||
if (firstHeadingLine > 1) {
|
||||
spans.push({ index: 0, kind: 'preamble', label: '(preamble before the first heading)', startLine: 1, endLine: firstHeadingLine - 1, text: sliceLines(lines, 1, firstHeadingLine - 1) })
|
||||
}
|
||||
return section
|
||||
for (const [order, heading] of headings.entries()) {
|
||||
const endLine = (headings[order + 1]?.line ?? lines.length + 1) - 1
|
||||
spans.push({
|
||||
index: spans.length,
|
||||
// Depth only: heading TEXT is translated across a pair, so it cannot
|
||||
// participate in cross-language alignment.
|
||||
kind: `section:${heading.depth}`,
|
||||
label: heading.label === '' ? '(untitled section)' : heading.label,
|
||||
startLine: heading.line,
|
||||
endLine,
|
||||
text: sliceLines(lines, heading.line, endLine),
|
||||
})
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
/**
|
||||
* Map diff hunks to the section indices they touch in the diffed document.
|
||||
* Whether two span lists map one to one: same non-zero length and the same
|
||||
* kind at every position.
|
||||
*
|
||||
* @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).
|
||||
* @param left - One document's spans.
|
||||
* @param right - The other document's spans.
|
||||
* @returns True when index-wise mapping is sound.
|
||||
*/
|
||||
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
|
||||
export function spansAligned(left: MarkdownSpan[], right: MarkdownSpan[]): boolean {
|
||||
return left.length > 0
|
||||
&& left.length === right.length
|
||||
&& left.every((span, index) => span.kind === right[index]?.kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the counterpart's text for the given section indices.
|
||||
* Indices whose text differs between two aligned span lists.
|
||||
*
|
||||
* 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.
|
||||
* @param before - Spans of the earlier state.
|
||||
* @param after - Spans of the later state, aligned with `before`.
|
||||
* @returns Ascending changed indices.
|
||||
*/
|
||||
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'),
|
||||
}
|
||||
})
|
||||
export function changedSpanIndices(before: MarkdownSpan[], after: MarkdownSpan[]): number[] {
|
||||
return before.filter((span, index) => span.text !== after[index]?.text).map(span => span.index)
|
||||
}
|
||||
|
||||
/** 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[]
|
||||
function codeSpansOf(markdown: string): MarkdownSpan[] {
|
||||
return markdownUnits(markdown).filter(span => span.kind.endsWith(':code'))
|
||||
.map((span, index) => ({ ...span, index }))
|
||||
}
|
||||
|
||||
function replaceSpanTexts(markdown: string, spans: MarkdownSpan[], replacements: Map<number, string>): string {
|
||||
const lines = linesOf(markdown)
|
||||
for (const [index, replacement] of [...replacements.entries()].sort((left, right) => right[0] - left[0])) {
|
||||
const span = spans[index]
|
||||
if (span === undefined) throw new Error(`translation brief: unknown replacement span ${index}`)
|
||||
lines.splice(span.startLine - 1, span.endLine - span.startLine + 1, ...linesOf(replacement))
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function maskCodeSpans(markdown: string, spans: MarkdownSpan[]): string {
|
||||
return replaceSpanTexts(markdown, spans, new Map(spans.map(span => [span.index, `DSH_TRANSLATION_CODE_${span.index}\n`])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the counterpart update for a change confined to fenced code
|
||||
* blocks. Fences are byte-identical across a pair, so when the source's
|
||||
* prose is untouched and the counterpart's fences match the last-confirmed
|
||||
* source, splicing the edited fences into the counterpart is the complete
|
||||
* update — no translation judgment is involved.
|
||||
*
|
||||
* @param confirmedSource - The changed side's last-confirmed text.
|
||||
* @param currentSource - The changed side's current text.
|
||||
* @param counterpart - The other side's current text.
|
||||
* @returns The updated counterpart, or undefined when the change is not code-only.
|
||||
*/
|
||||
export function computeMechanicalUpdate(confirmedSource: string, currentSource: string, counterpart: string): string | undefined {
|
||||
const confirmed = codeSpansOf(confirmedSource)
|
||||
const current = codeSpansOf(currentSource)
|
||||
const target = codeSpansOf(counterpart)
|
||||
if (confirmed.length === 0 || confirmed.length !== current.length || confirmed.length !== target.length) return undefined
|
||||
if (maskCodeSpans(confirmedSource, confirmed) !== maskCodeSpans(currentSource, current)) return undefined
|
||||
if (confirmed.some((span, index) => span.text !== target[index]?.text)) return undefined
|
||||
const changed = current.filter((span, index) => span.text !== confirmed[index]?.text)
|
||||
if (changed.length === 0) return undefined
|
||||
return replaceSpanTexts(counterpart, target, new Map(changed.map(span => [span.index, span.text])))
|
||||
}
|
||||
|
||||
/** One parsed terminology-table data row. */
|
||||
export interface TerminologyRow {
|
||||
english: string
|
||||
chinese: string
|
||||
/** The 首次出现 cell (first-occurrence rendering), possibly empty. */
|
||||
first: string
|
||||
/** The verbatim table row. */
|
||||
line: string
|
||||
}
|
||||
|
||||
/** Strip Markdown emphasis and code markers from a terminology cell. */
|
||||
@@ -163,37 +219,122 @@ function plainTerm(cell: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Parse the data rows of the terminology table.
|
||||
*
|
||||
* @param terminology - Full `docs/i18n/terminology.md` contents.
|
||||
* @param changedText - Changed diff lines (see {@link changedLinesOfDiff}).
|
||||
* @returns Matched rows under their header.
|
||||
* @returns Rows in table order.
|
||||
*/
|
||||
export function matchTerminologyRows(terminology: string, changedText: string): TerminologyMatches {
|
||||
const matches: TerminologyMatches = { rows: [] }
|
||||
let header: string | undefined
|
||||
export function parseTerminologyRows(terminology: string): TerminologyRow[] {
|
||||
const rows: TerminologyRow[] = []
|
||||
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)
|
||||
}
|
||||
if (english === '' || english === 'English') continue
|
||||
rows.push({ english, chinese: plainTerm(cells[2] ?? ''), first: plainTerm(cells[3] ?? ''), line })
|
||||
}
|
||||
return matches
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Character offsets of a term's occurrences. English word-like terms match
|
||||
* on word boundaries and accept plural inflections (`agents`, `registries`);
|
||||
* other terms match as case-insensitive substrings.
|
||||
*
|
||||
* @param text - Text to search.
|
||||
* @param term - The term to find.
|
||||
* @param englishInflections - Whether to accept English plural forms.
|
||||
* @returns Ascending match offsets.
|
||||
*/
|
||||
export function termOffsets(text: string, term: string, englishInflections = false): number[] {
|
||||
if (term === '') return []
|
||||
const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const wordLike = /^[A-Za-z0-9][A-Za-z0-9 ._-]*[A-Za-z0-9]$/.test(term)
|
||||
const inflected = englishInflections && wordLike
|
||||
? /[^aeiou]y$/i.test(term)
|
||||
? `${escape(term.slice(0, -1))}(?:y|ies)`
|
||||
: `${escape(term)}(?:s|es)?`
|
||||
: escape(term)
|
||||
const expression = new RegExp(wordLike ? `(?<![A-Za-z0-9_])${inflected}(?![A-Za-z0-9_])` : inflected, 'gi')
|
||||
return [...text.matchAll(expression)].map(match => match.index)
|
||||
}
|
||||
|
||||
/** The two update directions a pair supports. */
|
||||
export type BriefDirection = 'en-to-zh' | 'zh-to-en'
|
||||
|
||||
/** Whether a row's source-language term occurs in the given text. */
|
||||
function rowOccurs(row: TerminologyRow, direction: BriefDirection, text: string): boolean {
|
||||
const terms = direction === 'en-to-zh' ? [row.english] : [row.first, row.chinese].filter(term => /[一-鿿]/.test(term))
|
||||
return terms.some(term => termOffsets(text, term, direction === 'en-to-zh').length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the terminology rows whose source-language term occurs in the
|
||||
* changed text (old and new states combined).
|
||||
*
|
||||
* @param terminology - Full `docs/i18n/terminology.md` contents.
|
||||
* @param direction - Update direction; decides which columns to match.
|
||||
* @param changedText - Concatenated old and new text of the changed spans.
|
||||
* @returns Matched rows in table order.
|
||||
*/
|
||||
export function relevantTerminologyRows(terminology: string, direction: BriefDirection, changedText: string): TerminologyRow[] {
|
||||
return parseTerminologyRows(terminology).filter(row => rowOccurs(row, direction, changedText))
|
||||
}
|
||||
|
||||
function lineAtOffset(text: string, offset: number): number {
|
||||
return text.slice(0, offset).split('\n').length
|
||||
}
|
||||
|
||||
function spanIndexAtOffset(text: string, spans: MarkdownSpan[], offset: number | undefined): number | undefined {
|
||||
if (offset === undefined) return undefined
|
||||
const line = lineAtOffset(text, offset)
|
||||
return spans.find(span => line >= span.startLine && line <= span.endLine)?.index
|
||||
}
|
||||
|
||||
/** First-occurrence guidance computed for a Chinese-target update. */
|
||||
export interface FirstOccurrenceContext {
|
||||
/** Human-readable notes for the briefing. */
|
||||
notes: string[]
|
||||
/** Unchanged span indices that must join the briefing because a first occurrence moved into or out of them. */
|
||||
extraSpanIndices: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Track document-wide first occurrences of the relevant English terms. The
|
||||
* 首次出现 rendering attaches to a term's first occurrence, so when an edit
|
||||
* moves that occurrence across spans, both the old and new spans need
|
||||
* counterpart edits even when only one of them changed.
|
||||
*
|
||||
* @param confirmedSource - Last-confirmed English text.
|
||||
* @param currentSource - Current English text.
|
||||
* @param confirmedSpans - Spans of the last-confirmed English text.
|
||||
* @param currentSpans - Spans of the current English text, aligned with `confirmedSpans`.
|
||||
* @param rows - The relevant terminology rows.
|
||||
* @param changed - Span indices already in the briefing.
|
||||
* @returns Notes and extra span indices to include.
|
||||
*/
|
||||
export function firstOccurrenceContext(
|
||||
confirmedSource: string,
|
||||
currentSource: string,
|
||||
confirmedSpans: MarkdownSpan[],
|
||||
currentSpans: MarkdownSpan[],
|
||||
rows: TerminologyRow[],
|
||||
changed: Set<number>,
|
||||
): FirstOccurrenceContext {
|
||||
const notes: string[] = []
|
||||
const extra = new Set<number>()
|
||||
for (const row of rows) {
|
||||
if (row.first === '') continue
|
||||
const oldIndex = spanIndexAtOffset(confirmedSource, confirmedSpans, termOffsets(confirmedSource, row.english, true)[0])
|
||||
const newIndex = spanIndexAtOffset(currentSource, currentSpans, termOffsets(currentSource, row.english, true)[0])
|
||||
if (oldIndex === newIndex) continue
|
||||
for (const index of [oldIndex, newIndex]) {
|
||||
if (index !== undefined && !changed.has(index)) extra.add(index)
|
||||
}
|
||||
notes.push(`${row.english}: the document-wide first occurrence moved from ${oldIndex === undefined ? 'absent' : `#${oldIndex}`} to ${newIndex === undefined ? 'absent' : `#${newIndex}`}; the ${row.first} form moves with it (later occurrences drop the annotation).`)
|
||||
}
|
||||
return { notes, extraSpanIndices: [...extra].sort((left, right) => left - right) }
|
||||
}
|
||||
|
||||
/** Smallest fence of `mark` characters that safely wraps `body`. */
|
||||
@@ -206,8 +347,27 @@ function fenceFor(body: string, mark: '`' | '~'): string {
|
||||
return mark.repeat(longest + 1)
|
||||
}
|
||||
|
||||
/** The two update directions a pair supports. */
|
||||
export type BriefDirection = 'en-to-zh' | 'zh-to-en'
|
||||
/** One changed (or first-occurrence) span with its three-way context. */
|
||||
export interface BriefBundle {
|
||||
/** Span index shared by the aligned documents. */
|
||||
index: number
|
||||
/** Human label: heading text or node type. */
|
||||
label: string
|
||||
/** Why the bundle is present when its source text did not change. */
|
||||
reason?: 'first-occurrence' | undefined
|
||||
confirmedSourceText: string
|
||||
currentSourceText: string
|
||||
counterpartText: string
|
||||
/** 1-based line the counterpart span starts on. */
|
||||
counterpartStartLine: number
|
||||
}
|
||||
|
||||
/** The granularities a briefing can map the change at, narrowest first. */
|
||||
export type BriefScope =
|
||||
| { kind: 'mechanical' }
|
||||
| { kind: 'units'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
|
||||
| { kind: 'sections'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
|
||||
| { kind: 'document'; reason: string }
|
||||
|
||||
/** Inputs for rendering one pair's briefing. */
|
||||
export interface TranslationBriefInput {
|
||||
@@ -218,26 +378,24 @@ export interface TranslationBriefInput {
|
||||
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
|
||||
scope: BriefScope
|
||||
terminology: TerminologyRow[]
|
||||
}
|
||||
|
||||
const ZH_TARGET_DIGEST = [
|
||||
'- Edit ONLY what the diff requires; preserve the reviewed phrasing of everything unchanged.',
|
||||
'- Edit ONLY what the change 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.',
|
||||
'- 首次出现 annotations attach to the document-wide first occurrence only; later occurrences use the bare form, and an empty 首次出现 cell means never gloss.',
|
||||
'- 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.',
|
||||
'- Edit ONLY what the change 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.',
|
||||
@@ -246,10 +404,46 @@ const EN_TARGET_DIGEST = [
|
||||
'- One physical line per paragraph; exactly one trailing newline.',
|
||||
]
|
||||
|
||||
function renderBundles(out: string[], input: TranslationBriefInput, bundles: BriefBundle[], firstOccurrenceNotes: string[]): void {
|
||||
const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
|
||||
const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
|
||||
for (const bundle of bundles) {
|
||||
out.push('')
|
||||
out.push(`### #${bundle.index} ${bundle.label}${bundle.reason === 'first-occurrence' ? ' — unchanged; included for a first-occurrence move' : ''} — counterpart at ${input.counterpartPath}:${bundle.counterpartStartLine}`)
|
||||
const fence = fenceFor([bundle.confirmedSourceText, bundle.currentSourceText, bundle.counterpartText].join('\n'), '~')
|
||||
if (bundle.confirmedSourceText !== bundle.currentSourceText) {
|
||||
out.push('')
|
||||
out.push(`Last-confirmed ${sourceLanguage}:`)
|
||||
out.push('')
|
||||
out.push(`${fence}markdown`)
|
||||
out.push(bundle.confirmedSourceText.trimEnd())
|
||||
out.push(fence)
|
||||
}
|
||||
out.push('')
|
||||
out.push(`Current ${sourceLanguage}:`)
|
||||
out.push('')
|
||||
out.push(`${fence}markdown`)
|
||||
out.push(bundle.currentSourceText.trimEnd())
|
||||
out.push(fence)
|
||||
out.push('')
|
||||
out.push(`Current ${counterpartLanguage} (bring this along):`)
|
||||
out.push('')
|
||||
out.push(`${fence}markdown`)
|
||||
out.push(bundle.counterpartText.trimEnd())
|
||||
out.push(fence)
|
||||
}
|
||||
if (firstOccurrenceNotes.length > 0) {
|
||||
out.push('')
|
||||
out.push('## First-occurrence notes')
|
||||
out.push('')
|
||||
for (const note of firstOccurrenceNotes) out.push(`- ${note}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete briefing for one out-of-sync pair.
|
||||
*
|
||||
* @param input - Diff, mapped sections, terminology, and pair identity.
|
||||
* @param input - Diff, mapped scope, terminology, and pair identity.
|
||||
* @returns Markdown briefing text.
|
||||
*/
|
||||
export function renderTranslationBrief(input: TranslationBriefInput): string {
|
||||
@@ -258,9 +452,13 @@ export function renderTranslationBrief(input: TranslationBriefInput): string {
|
||||
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(`The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the change.`)
|
||||
if (input.scope.kind === 'mechanical') {
|
||||
out.push('')
|
||||
out.push('## Mechanical update — no translation judgment involved')
|
||||
out.push('')
|
||||
out.push(`Every change since the last confirmed state is inside fenced code blocks, which are byte-identical across the pair. Run \`pnpm run gen-translation-brief --apply ${input.sourcePath}\` to splice the updated fences into the counterpart (the result is structure-validated before writing), then record per the Finish steps.`)
|
||||
}
|
||||
out.push('')
|
||||
out.push(`## ${sourceLanguage} diff (last-confirmed → current)`)
|
||||
out.push('')
|
||||
@@ -268,29 +466,35 @@ export function renderTranslationBrief(input: TranslationBriefInput): string {
|
||||
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) {
|
||||
switch (input.scope.kind) {
|
||||
case 'mechanical':
|
||||
break
|
||||
case 'units':
|
||||
out.push('')
|
||||
out.push(`### ${section.label} — ${input.counterpartPath}:${section.startLine}`)
|
||||
out.push(`## Changed units (last-confirmed ${sourceLanguage} → current ${sourceLanguage}, with the current ${counterpartLanguage})`)
|
||||
renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
|
||||
break
|
||||
case 'sections':
|
||||
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.`)
|
||||
out.push('## Changed sections (fine-grained units do not align across the pair; whole heading sections shown)')
|
||||
renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
|
||||
break
|
||||
case 'document':
|
||||
out.push('')
|
||||
out.push('## Whole-document update required')
|
||||
out.push('')
|
||||
out.push(`${input.scope.reason} Open \`${input.counterpartPath}\` directly, locate the affected regions yourself, and reconcile under docs/i18n/translation-rules.md.`)
|
||||
break
|
||||
default:
|
||||
input.scope satisfies never
|
||||
}
|
||||
if (input.terminology.rows.length > 0 && input.terminology.header !== undefined) {
|
||||
if (input.terminology.length > 0) {
|
||||
out.push('')
|
||||
out.push('## Binding terminology rows matching this diff (docs/i18n/terminology.md)')
|
||||
out.push('## Binding terminology rows matching this change (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('| English | 中文 | 首次出现 | 不要译作 | 备注 |')
|
||||
out.push('|---|---|---|---|---|')
|
||||
for (const row of input.terminology) out.push(row.line)
|
||||
out.push('')
|
||||
out.push('For any term you introduce that is not listed above, consult the full table before inventing a rendering.')
|
||||
}
|
||||
@@ -301,7 +505,7 @@ export function renderTranslationBrief(input: TranslationBriefInput): string {
|
||||
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('1. Apply the smallest counterpart edit that covers the change, then verify the changed spans 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('')
|
||||
|
||||
Reference in New Issue
Block a user