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:
@@ -2,8 +2,10 @@
|
||||
* Enforce complete English/Chinese pairs, matching structure, and recorded git
|
||||
* blob hashes for every in-scope document. The manifest contains only explicit
|
||||
* exclusions, which may have neither a counterpart nor a sidecar.
|
||||
* `--list` reports state and `--write` records both sides after human review.
|
||||
* Translation quality remains a review responsibility.
|
||||
* `--list` reports state; `--write <pairs...>` records the named confirmed
|
||||
* pairs (`--write --all` records every complete pair); a check or write named
|
||||
* with pair paths touches only those pairs, so update iteration does not pay
|
||||
* for a corpus scan. Translation quality remains a review responsibility.
|
||||
* See `docs/i18n/README.md` for the owning contract.
|
||||
*/
|
||||
|
||||
@@ -13,6 +15,7 @@ import { basename, join, resolve, sep } from 'node:path'
|
||||
import {
|
||||
linksTo,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
isTranslationScopeFile,
|
||||
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||
@@ -21,8 +24,15 @@ import {
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const listMode = process.argv.includes('--list')
|
||||
const writeMode = process.argv.includes('--write')
|
||||
let request: ReturnType<typeof parseTranslationPairingCliArgs>
|
||||
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. */
|
||||
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',
|
||||
'# side as of the last confirmed-consistent state. Both languages carry equal authority;',
|
||||
'# after editing either side, bring the other along and re-record with:',
|
||||
'# pnpm run verify-translation-pairing --write',
|
||||
`# pnpm run verify-translation-pairing --write ${source}`,
|
||||
`${basename(source)}: ${sourceHash}`,
|
||||
`${basename(zh)}: ${zhHash}`,
|
||||
'',
|
||||
].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>()
|
||||
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)
|
||||
if (request.scope === 'pairs') {
|
||||
for (const anchor of request.anchors) {
|
||||
for (const file of [anchor, ...Object.values(pairPaths(anchor))]) {
|
||||
if (existsSync(join(root, file))) files.add(file)
|
||||
}
|
||||
// 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 metas = [...files].filter(f => f.endsWith('.i18n.yaml')).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) {
|
||||
let written = 0
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
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))))
|
||||
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
|
||||
writeFileSync(join(root, meta), record)
|
||||
@@ -204,7 +249,9 @@ if (listMode) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user