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:
Tianyi Cui
2026-07-27 00:44:21 +08:00
parent 7c038f275d
commit bf87be0d7d
7 changed files with 823 additions and 13 deletions
+60
View File
@@ -100,6 +100,66 @@ export function parseTranslationPairingManifest(content: string): TranslationPai
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. */
export interface TranslationStructureSignature {
/** Heading depths in document order (h2 -> 2). */