This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git
The following commit(s) were added to refs/heads/master by this push:
new 56b6478a7b5 [fix](i18n) Protect Korean metadata and staged translation
baselines (#4356)
56b6478a7b5 is described below
commit 56b6478a7b5d563becce7e112b46926757cf9d2b
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sun Oct 18 17:01:46 2026 +0100
[fix](i18n) Protect Korean metadata and staged translation baselines (#4356)
A Korean document could retain `language: ja` or drop visible front
matter without receiving validation feedback. Manual staged application
could also overwrite a newer machine translation when its target and
state were updated consistently after staging. This PR reports metadata
defects during generation and admission, and binds staged application to
the target, effective state file, and shard-layout marker captured
before generation.
Manual OpenAI staging manifests now use schema version 2; regenerate
older stages before applying them. Page-state and Codex workflow
artifact schemas remain version 1. The existing three-attempt Codex
policy still retains final content issues as explicit review warnings.
Korean fixtures cover native identities and dynamic plans, active PR
exclusions, source changes and moves, MDX, retry feedback, Dev/4.x
reuse, legacy state and page shards, human ownership, concurrent writes,
and exact staged application. Operational instructions explain explicit
locale selection and script-driven planning without fixed batch
registration.
Validation: Node 20 ran 190 tests: 189 passed, no failures, and one
existing Apache integration test was skipped. Both the metadata gap and
consistent target/state replacement were reproduced before their fixes.
Fixtures use injected model processes and synthetic staging manifests;
no real model calls or credentials were used. A read-only native Korean
scan at this committed implementation inspected 38 open PRs: 3,336
required pages, 4 current, 3,332 pending, 0 active candidates or
exceptions, and 10 dynamically selected tasks; generation remained
disabled.
## Versions
Shared translation infrastructure applies to the configured Dev, 4.x,
and community translation scopes. No document content or routes change.
## Languages
- [x] Japanese
- [x] Korean
## Docs Checklist
- [x] Checked by AI
- [x] Test Cases Built
- [x] Updated required version and language counterparts, or explained
why not
- [x] If only one language changed, confirmed whether source/translation
counterparts need sync
Japanese regression fixtures pass with the shared validation and staging
changes. English/Chinese source documents and existing translation
records are unchanged. Production Korean registration, scheduled
generation, and bulk translation are outside this PR.
Co-authored-by: morningman <[email protected]>
---
.github/workflows/build-check.yml | 1 +
scripts/i18n/__tests__/korean-toolchain.test.cjs | 292 +++++++++++++++++++++
scripts/i18n/__tests__/translation-engine.test.cjs | 6 +-
scripts/i18n/check-translations.cjs | 3 +-
scripts/i18n/document-checks.cjs | 22 +-
scripts/i18n/translate-docs.cjs | 29 +-
scripts/i18n/workflow-generate.cjs | 5 +-
translations/README.md | 62 ++++-
8 files changed, 405 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/build-check.yml
b/.github/workflows/build-check.yml
index 3528ea8df34..8b85a056ce0 100644
--- a/.github/workflows/build-check.yml
+++ b/.github/workflows/build-check.yml
@@ -107,6 +107,7 @@ jobs:
node --test scripts/i18n/__tests__/inventory.test.cjs
node --test scripts/i18n/__tests__/planner.test.cjs
node --test
scripts/i18n/__tests__/translation-engine.test.cjs
+ node --test scripts/i18n/__tests__/korean-toolchain.test.cjs
node --test scripts/i18n/__tests__/workflow.test.cjs
node --test scripts/i18n/__tests__/codex-adapter.test.cjs
node --test
scripts/i18n/__tests__/translate-document.test.cjs
diff --git a/scripts/i18n/__tests__/korean-toolchain.test.cjs
b/scripts/i18n/__tests__/korean-toolchain.test.cjs
new file mode 100644
index 00000000000..4c9702e85a2
--- /dev/null
+++ b/scripts/i18n/__tests__/korean-toolchain.test.cjs
@@ -0,0 +1,292 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const {inspectDocument} = require('../document-checks.cjs');
+const {loadConfig} = require('../state.cjs');
+const settings = loadConfig(path.resolve(__dirname,'../../..')).locales.ko;
+const glossary = require('../../../translations/glossary/ko.json');
+const source = '---\ntitle: Guide\ndescription: Doris guide\nsidebar_label:
Guide\nlanguage: en\nkeywords: [Doris, guide]\nslug: /guide\n---\n#
Guide\n\nDoris guide.\n';
+const translated = '---\ntitle: 가이드\ndescription: Doris 사용 안내\nsidebar_label:
가이드\nlanguage: ko\nkeywords: [Doris, 가이드]\nslug: /guide\n---\n# 가이드\n\nDoris 사용
안내입니다.\n';
+const inspect = translation =>
inspectDocument({source,translation,extension:'.md',glossary,targetLocale:'ko',targetScriptPattern:settings.scriptPattern});
+
+test('Korean prose cannot silently retain a Japanese or English front-matter
language', async () => {
+ assert.deepEqual((await inspect(translated)).issues,[]);
+ for (const language of ['ja','en']) {
+ const result = await inspect(translated.replace('language:
ko',`language: ${language}`));
+ assert(result.issues.some(issue => issue.check === 'frontmatter' &&
issue.message.includes('language')));
+ }
+});
+
+test('complete Korean documents retain visible metadata fields and keyword
coverage', async () => {
+ for (const changed of [translated.replace('title: 가이드\n',''),
translated.replace('description: Doris 사용 안내\n',''),
+ translated.replace('keywords: [Doris, 가이드]','keywords: [Doris]')]) {
+ assert((await inspect(changed)).issues.some(issue => issue.check ===
'frontmatter'));
+ }
+});
+
+const fs = require('node:fs');
+const os = require('node:os');
+const {execFileSync} = require('node:child_process');
+const {hash,identity,stateFileFor,loadRecords} = require('../state.cjs');
+const {plan: makePlan} = require('../plan-translations.cjs');
+const {prepare,applyCandidates,applyStaged,readState,targetFor} =
require('../translate-docs.cjs');
+const {pairFor,copyPair} = require('../workflow-generate.cjs');
+const {translateWholeDocument} = require('../translate-document.cjs');
+const {codexAdapter} = require('../codex-adapter.cjs');
+const {checkLinks} = require('../document-checks.cjs');
+const {structure} = require('../structure.cjs');
+const scope = require('../../../translations/scope/ko.json');
+const repo = path.resolve(__dirname,'../../..');
+const mdxSource = '---\ntitle: Guide\nlanguage: en\nslug: /guide\n---\nimport
Card from "@site/src/Card";\n\n# Guide\n\nDoris guide.
[Next](./next.md)\n\n<Card title="Details" mode={1} />\n\n```sql\nSELECT
1;\n```\n';
+const mdxTarget = mdxSource.replace('title: Guide','title:
가이드').replace('language: en','language: ko')
+ .replace('# Guide','# 가이드').replace('Doris guide.','Doris 사용
안내입니다.').replace('[Next]','[다음]')
+ .replace('title="Details"','title="세부 정보"');
+
+function taskFor(docId,
{version='current',pluginId='default',sourcePath,content=mdxSource,state='untranslated'}
= {}) {
+ const group = scope.groups.find(group => group.pluginId === pluginId &&
group.version === version);
+ sourcePath ??= `${group.sourceRoot}/${docId}.mdx`;
+ const relative = sourcePath.slice(group.sourceRoot.length+1);
+ const plugin = pluginId==='default'?'':`-${pluginId}`;
+ const directory = version==='current'?'current':`version-${version}`;
+ return
{pluginId,version,docId,sourcePath,locale:'ko',sourceHash:hash(content),state,reviewStatus:'unverified',
+
targetHash:null,targetPath:`i18n/ko/docusaurus-plugin-content-docs${plugin}/${directory}/${relative}`,
+
permalink:`/ko/${pluginId==='default'?`docs/${version==='current'?'dev':version}`:'community'}/${docId}/`};
+}
+
+async function planFor(pages,{baseline=[],records=new
Map(),active,reports=[],chineseChangedPaths=[],files={},historical={}}={}) {
+ return
makePlan({scope,report:{schemaVersion:1,sourceCommit:'a'.repeat(40),baselineCommit:'b'.repeat(40),reports,
+
groups:scope.groups.map(group=>({...group,unregistered:[],entries:pages.filter(page=>page.pluginId===group.pluginId&&page.version===group.version)}))},
+ baseline,records,active,chineseChangedPaths,limit:20,
+
readCurrent:file=>files[file]??(pages.some(page=>page.sourcePath===file)?mdxSource:null),readAt:(ref,file)=>historical[`${ref}:${file}`]??null});
+}
+
+test('Korean dynamic plans isolate native identities, source updates, moves
and active PRs', async () => {
+ const
pages=[taskFor('new'),taskFor('same'),taskFor('same',{version:'4.x'}),taskFor('same',{pluginId:'community'}),
+
taskFor('done',{state:'current'}),taskFor('edited',{state:'stale_technical'}),
+
taskFor('moved',{sourcePath:'docs/new/moved.mdx',state:'state_conflict'}),taskFor('human',{state:'target_modified'})];
+ const baseline=[{...pages[4],disposition:'translate'},
+ {...pages[5],sourceHash:hash('old source'),disposition:'translate'},
+
{...pages[6],sourcePath:'docs/old/moved.mdx',disposition:'translate',targetHash:hash('이전
번역')},
+ {...taskFor('gone'),sourceHash:hash('removed
source'),disposition:'translate'}];
+ const
active={schemaVersion:1,candidates:[{...pages[0],sourceHash:hash('older
candidate'),pr:'#fixture-open'}]};
+ const
options={baseline,active,files:{'i18n/ko/docusaurus-plugin-content-docs/current/new/moved.mdx':'이전
번역'},
+
chineseChangedPaths:['i18n/zh-CN/docusaurus-plugin-content-docs/current/done.mdx'],
+
reports:[{kind:'unsupported_source_route',pluginId:'default',version:'current',sourceLocale:'zh-CN',sourcePath:'i18n/zh-CN/only.mdx'}]};
+ const result=await planFor(pages,options);
+ assert.deepEqual(result,await planFor(pages,options));
+ assert.equal(result.counts.mergedCurrent,1);
+ assert.equal(result.tasks.find(task=>task.docId==='edited').kind,'update');
+ assert.equal(result.tasks.find(task=>task.docId==='moved').kind,'move');
+ assert.equal(result.tasks.find(task=>task.docId==='gone').kind,'delete');
+
assert.equal(result.tasks.find(task=>task.docId==='human').requiresHumanReview,true);
+
assert(!result.selected.includes(result.tasks.find(task=>task.docId==='new').id));
+ assert(result.exceptions.some(item=>item.kind==='stale_active_candidate'));
+ assert(result.exceptions.some(item=>item.kind==='zh_only_change'));
+
assert(result.exceptions.some(item=>item.kind==='unsupported_source_route'));
+ assert.equal(new
Set(result.tasks.filter(task=>task.docId==='same').map(task=>task.id)).size,3);
+
assert(result.tasks.every(task=>task.locale==='ko'&&task.targetPath.startsWith('i18n/ko/')));
+ const afterClose=await
planFor(pages,{...options,active:{schemaVersion:1,candidates:[]}});
+
assert(afterClose.selected.includes(afterClose.tasks.find(task=>task.docId==='new').id));
+ assert.equal(pairFor({...pages[0],pluginId:'other'},pages,scope),null);
+});
+
+function fixture(t) {
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'ko-toolchain-'));
+ t.after(()=>fs.rmSync(root,{recursive:true,force:true}));
+ const write=(file,text)=>{const
full=path.join(root,file);fs.mkdirSync(path.dirname(full),{recursive:true});fs.writeFileSync(full,text);};
+ write('.gitignore','.i18n-build/\n.codex/\n');
+
write('translations/config.json',fs.readFileSync(path.join(repo,'translations/config.json'),'utf8'));
+ write('translations/scope/ko.json',JSON.stringify(scope));
+ write('translations/scope/ja.json',JSON.stringify({...scope,locale:'ja'}));
+
write('translations/state/ko/docs.json','{"schemaVersion":1,"pages":[]}\n');
+
write('translations/state/ko/shards.schema.json','{"schemaVersion":1,"format":"page-record-v1"}\n');
+
write('translations/state/ja/docs.json','{"schemaVersion":1,"pages":[],"sentinel":"unchanged"}\n');
+
write('scripts/check_md_links_single.py',fs.readFileSync(path.join(repo,'scripts/check_md_links_single.py'),'utf8'));
+ fs.mkdirSync(path.join(root,'static'));
+ write('docs/guide.mdx',mdxSource);write('docs/next.md','# Next\n');
+
write('versioned_docs/version-4.x/guide.mdx',mdxSource);write('versioned_docs/version-4.x/next.md','#
Next\n');
+ const
git=(...args)=>execFileSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
+
git('init','-q');git('add','.');git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','source');
+ const sourceCommit=git('rev-parse','HEAD');
+ const
primary={...taskFor('guide'),id:'T06-DEV-000000000000001',kind:'translate_new'};
+ const
partner={...taskFor('guide',{version:'4.x'}),id:'T06-4X-000000000000002',kind:'translate_new'};
+ const
plan={schemaVersion:1,locale:'ko',sourceCommit,tasks:[primary,partner],selected:[primary.id,partner.id]};
+ write('.codex/auth.json','{}'); // Fake snapshot for an injected process;
no real credentials/model.
+ return {root,write,git,plan,primary,partner};
+}
+
+function model(root,answer) {
+ let calls=0;const prompts=[];
+ const
adapter=codexAdapter({language:settings.languageName,locale:'ko',codexHome:path.join(root,'.codex'),
+ spawnImpl:(_bin,args,options)=>{
+
assert(args.includes('read-only')&&args.includes('--ignore-user-config'));
+ assert(!Object.hasOwn(options.env,'OPENAI_API_KEY'));
+ prompts.push(options.input);calls++;
+ const translation=typeof answer==='function'?answer(calls):answer;
+
fs.writeFileSync(args[args.indexOf('--output-last-message')+1],JSON.stringify({translation}));
+ return
{status:0,stdout:JSON.stringify({type:'turn.completed',usage:{input_tokens:10,output_tokens:10}})+'\n'};
+ }});
+ return {adapter,prompts,get calls(){return calls;}};
+}
+
+const generate = (data,state,adapter) =>
prepare({root:data.root,plan:data.plan,taskId:data.primary.id,scope,state,adapter,
+ glossary,target:targetFor(data.root,'ko')});
+
+test('Korean Codex fixtures retry metadata and SQL feedback, then apply
per-page shards and a safe Dev/4.x copy', async t => {
+ const data=fixture(t), state=readState(data.root,'ko');
+ const
mock=model(data.root,attempt=>attempt===1?mdxTarget.replace('language:
ko','language: ja')
+ :attempt===2?mdxTarget.replace('SELECT 1','SELECT 2'):mdxTarget);
+ const candidate=await generate(data,state,mock.adapter);
+ assert.equal(mock.calls,3);assert.equal(mock.adapter.usedTokens,60);
+ assert(mock.prompts.every(prompt=>prompt.includes('natural Korean')));
+ assert.equal(candidate.record.generation.acceptedWithWarnings,false);
+
assert(candidate.record.generation.history[0].issues.some(issue=>issue.check==='frontmatter'));
+
assert(candidate.record.generation.history[1].issues.some(issue=>issue.check==='protected-content'));
+ const
partner=pairFor(data.primary,data.plan.tasks,scope);assert.equal(partner.id,data.partner.id);
+
assert.equal(pairFor(data.primary,[{...data.partner,sourceHash:hash('different')}],scope),null);
+
assert.equal(pairFor(data.primary,[{...data.partner,sourcePath:'versioned_docs/version-4.x/other/guide.mdx'}],scope),null);
+ const copied=copyPair(candidate,partner,scope,state,data.root);
+
checkLinks({root:data.root,targetPath:copied.record.targetPath,target:copied.target});
+ const
ja=fs.readFileSync(path.join(data.root,'translations/state/ja/docs.json'),'utf8');
+ applyCandidates(state,[candidate,copied]);
+ const records=loadRecords(data.root,'ko');assert.equal(records.size,2);
+ for(const record of records.values()) {
+
assert.equal(record.locale,'ko');assert.equal(record.reviewStatus,'machine');assert.equal(record.ownership,'machine');
+ assert.equal(record.sourceCommit,data.plan.sourceCommit);
+ assert(fs.existsSync(path.join(data.root,stateFileFor(record))));
+ }
+
assert.equal(fs.readFileSync(path.join(data.root,'translations/state/ja/docs.json'),'utf8'),ja);
+
assert.equal(fs.readFileSync(path.join(data.root,'translations/state/ko/docs.json'),'utf8'),'{"schemaVersion":1,"pages":[]}\n');
+
assert.equal(fs.readFileSync(path.join(data.root,'docs/guide.mdx'),'utf8'),mdxSource);
+
assert.equal(fs.readFileSync(path.join(data.root,'versioned_docs/version-4.x/guide.mdx'),'utf8'),mdxSource);
+});
+
+test('Korean generation rejects source races, human ownership and cross-locale
tasks', async t => {
+ const data=fixture(t), mock=model(data.root,mdxTarget);
+ data.write('docs/guide.mdx',mdxSource+'\nChanged\n');
+ await
assert.rejects(generate(data,readState(data.root,'ko'),mock.adapter),/Source
changed/);
+ data.write('docs/guide.mdx',mdxSource);
+ const
human=readState(data.root,'ko');human.data.pages.push({...data.primary,ownership:'human'});
+ await assert.rejects(generate(data,human,mock.adapter),/Human maintained/);
+ const
wrong={...data,plan:{...data.plan,tasks:[{...data.primary,locale:'ja'}]}};
+ await
assert.rejects(generate(wrong,readState(data.root,'ko'),mock.adapter),/outside
the managed/);
+ assert.equal(mock.calls,0);
+});
+
+test('Korean candidate application refuses target edits and concurrent shard
creation', async t => {
+ const data=fixture(t), state=readState(data.root,'ko');
+ const candidate=await
generate(data,state,model(data.root,mdxTarget).adapter);
+ data.write(data.primary.targetPath,'human edit');
+ assert.throws(()=>applyCandidates(state,[candidate]),/Target changed/);
+
assert.equal(fs.readFileSync(path.join(data.root,data.primary.targetPath),'utf8'),'human
edit');
+ fs.rmSync(path.join(data.root,data.primary.targetPath));
+
data.write(stateFileFor(candidate.record),JSON.stringify({schemaVersion:1,page:{...candidate.record,ownership:'human'}}));
+ assert.throws(()=>applyCandidates(state,[candidate]),/State changed/);
+ assert(!fs.existsSync(path.join(data.root,data.primary.targetPath)));
+});
+
+test('a third wrong-language Korean fixture remains an explicitly warned
review candidate', async () => {
+ const result=await translateWholeDocument({source:'# Guide\n\nDoris
guide.\n',extension:'.md',glossary,
+
targetLocale:'ko',targetScriptPattern:settings.scriptPattern,adapter:{translateDocument:async()=>({translation:'#
ガイド\n\nDoris のガイド。\n'})}});
+
assert.equal(result.requests,3);assert.equal(result.generation.acceptedWithWarnings,true);
+ assert(result.generation.warnings.some(issue=>issue.check==='language'));
+ assert.equal(result.generation.history.length,3);
+});
+
+test('Korean staged application checks the exact bytes once, uses Ko shards
and rejects a Ja selector', async t => {
+ const data=fixture(t), state=readState(data.root,'ko');
+ const words={Guide:'가이드','Doris guide.':'Doris 사용
안내입니다.',Next:'다음',Details:'세부 정보'};
+ const
adapter={model:'fixture',translate:async({units})=>({translations:units.map(unit=>({id:unit.id,text:words[unit.text]??unit.text}))})};
+ const candidate=await generate(data,state,adapter);
+ assert.equal(candidate.record.generation,undefined,'Legacy unit-based
provider must not claim a Codex retry history');
+ const directory='.i18n-build/reviewed';
+ const planFile=path.join(data.root,'.i18n-build/plan.json');
+ data.write('.i18n-build/plan.json',JSON.stringify(data.plan));
+ const candidatePath=`${directory}/${data.primary.id}/candidate.mdx`;
+ data.write(candidatePath,candidate.target);
+ // Synthetic staging protocol, not a live provider call or live
verification.
+
data.write(`${directory}/manifest.json`,JSON.stringify({schemaVersion:2,provider:'openai',liveCall:true,liveVerified:false,
+
applied:false,sourceCommit:data.plan.sourceCommit,glossaryVersion:glossary.version,candidates:[{
+
id:data.primary.id,sourceHash:data.primary.sourceHash,sourcePath:data.primary.sourcePath,
+
targetPath:data.primary.targetPath,targetHash:candidate.record.targetHash,targetChanged:true,
+ baseline:candidate.baseline,record:candidate.record}]}));
+ const previous=process.env.I18N_LOCALE;
+ try {
+ process.env.I18N_LOCALE='ja';
+ await
assert.rejects(applyStaged(data.root,planFile,directory),/Invalid translation
plan/);
+ process.env.I18N_LOCALE='ko';
+ data.write(candidatePath,candidate.target+'\n나중에 편집\n');
+ await assert.rejects(applyStaged(data.root,planFile,directory),/edited
after validation/);
+ data.write(candidatePath,candidate.target);
+ const result=await applyStaged(data.root,planFile,directory);
+
assert.equal(result.applied,true);assert.equal(result.liveVerified,false);
+
assert.equal(fs.readFileSync(path.join(data.root,data.primary.targetPath),'utf8'),candidate.target);
+
assert.equal(loadRecords(data.root,'ko').get(identity(candidate.record)).targetHash,hash(candidate.target));
+ assert.equal(loadRecords(data.root,'ja').size,0);
+
assert.equal(fs.readFileSync(path.join(data.root,'translations/state/ko/docs.json'),'utf8'),'{"schemaVersion":1,"pages":[]}\n');
+ await
assert.rejects(applyStaged(data.root,planFile,directory),/Invalid staged live
candidates/);
+ } finally {if(previous===undefined)delete process.env.I18N_LOCALE;else
process.env.I18N_LOCALE=previous;}
+});
+
+test('Korean staged candidates cannot replace a newer consistent machine
target and state', async t => {
+ const data=fixture(t), state=readState(data.root,'ko');
+ const words={Guide:'가이드','Doris guide.':'Doris 사용
안내입니다.',Next:'다음',Details:'세부 정보'};
+ const candidate=await
generate(data,state,{model:'fixture',translate:async({units})=>({
+
translations:units.map(unit=>({id:unit.id,text:words[unit.text]??unit.text}))})});
+ const directory='.i18n-build/older-candidate';
+ data.write('.i18n-build/plan.json',JSON.stringify(data.plan));
+
data.write(`${directory}/${data.primary.id}/candidate.mdx`,candidate.target);
+
data.write(`${directory}/manifest.json`,JSON.stringify({schemaVersion:2,provider:'openai',liveCall:true,
+
liveVerified:false,applied:false,sourceCommit:data.plan.sourceCommit,glossaryVersion:glossary.version,
+
candidates:[{id:data.primary.id,sourceHash:data.primary.sourceHash,sourcePath:data.primary.sourcePath,
+
targetPath:data.primary.targetPath,targetHash:candidate.record.targetHash,
+
targetChanged:true,baseline:candidate.baseline,record:candidate.record}]}));
+ const newer=candidate.target.replace('Doris 사용 안내입니다.','Doris의 새로운 사용
안내입니다.');
+ data.write(data.primary.targetPath,newer);
+
data.write(stateFileFor(candidate.record),JSON.stringify({schemaVersion:1,page:{...candidate.record,targetHash:hash(newer)}}));
+ const previous=process.env.I18N_LOCALE;
+ try {
+ process.env.I18N_LOCALE='ko';
+ await
assert.rejects(applyStaged(data.root,path.join(data.root,'.i18n-build/plan.json'),directory),/baseline
changed since staging/);
+
assert.equal(fs.readFileSync(path.join(data.root,data.primary.targetPath),'utf8'),newer);
+
assert.equal(loadRecords(data.root,'ko').get(identity(candidate.record)).targetHash,hash(newer));
+ } finally {if(previous===undefined)delete process.env.I18N_LOCALE;else
process.env.I18N_LOCALE=previous;}
+});
+
+test('Korean formatting-only source changes require review and keep the
recorded hashes', async () => {
+ const before=mdxSource, after=mdxSource+'\n';
+ const page=taskFor('formatted',{content:after,state:'stale_unknown'});
+ const
old={...page,sourceHash:hash(before),sourceCommit:'b'.repeat(40),targetHash:hash('이전
번역'),disposition:'translate'};
+ const result=await
planFor([page],{baseline:[old],files:{[page.sourcePath]:after},historical:{[`${old.sourceCommit}:${page.sourcePath}`]:before}});
+ assert.equal(result.tasks[0].kind,'formatting_review');
+ assert.equal(result.tasks[0].requiresHumanReview,true);
+ assert.deepEqual(result.selected,[]);
+ assert.equal(old.sourceHash,hash(before));
+});
+
+test('Korean legacy state remains readable and an existing page shard wins
without a marker', async t => {
+ const data=fixture(t);
+ fs.rmSync(path.join(data.root,'translations/state/ko/shards.schema.json'));
+
data.git('add','-u');data.git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','legacy
layout');
+ data.plan.sourceCommit=data.git('rev-parse','HEAD');
+ const state=readState(data.root,'ko');
+ const candidate=await
generate(data,state,model(data.root,mdxTarget).adapter);
+ applyCandidates(state,[candidate]);
+
assert(!fs.existsSync(path.join(data.root,stateFileFor(candidate.record))));
+ assert.equal(loadRecords(data.root,'ko').size,1);
+
data.write(stateFileFor(candidate.record),JSON.stringify({schemaVersion:1,page:{...candidate.record,ownership:'human'}}));
+
assert.equal(loadRecords(data.root,'ko').get(identity(candidate.record)).ownership,'human');
+ await
assert.rejects(generate(data,readState(data.root,'ko'),model(data.root,mdxTarget).adapter),/Human
maintained/);
+});
+
+test('Korean generation rejects versions outside scope, path drift and symlink
targets without a model call', async t => {
+ const data=fixture(t), mock=model(data.root,mdxTarget);
+ for (const changed of [{version:'3.x'},
{targetPath:'i18n/ja/docusaurus-plugin-content-docs/current/guide.mdx'},
+ {sourcePath:'docs/../guide.mdx'}]) {
+ const plan={...data.plan,tasks:[{...data.primary,...changed}]};
+ await
assert.rejects(generate({...data,plan},readState(data.root,'ko'),mock.adapter),/outside|managed|target|Invalid/i);
+ }
+ fs.symlinkSync(os.tmpdir(),path.join(data.root,'i18n'));
+ await
assert.rejects(generate(data,readState(data.root,'ko'),mock.adapter),/Symlink/);
+ assert.equal(mock.calls,0);
+});
diff --git a/scripts/i18n/__tests__/translation-engine.test.cjs
b/scripts/i18n/__tests__/translation-engine.test.cjs
index 7d98b66201d..e775076ad58 100644
--- a/scripts/i18n/__tests__/translation-engine.test.cjs
+++ b/scripts/i18n/__tests__/translation-engine.test.cjs
@@ -174,10 +174,12 @@ test('staged live candidate applies the exact reviewed
bytes and state once', as
sourceCommit, sourceHash: hash(source), sourceTechnicalHash:
(await structure(source, '.md')).technicalHash,
targetHash: hash(generated.target), reviewStatus: 'machine',
ownership: 'machine',
translatedAt: '2026-10-13T00:00:00.000Z', glossaryVersion:
glossary.version};
- fs.writeFileSync(path.join(stage, 'manifest.json'),
JSON.stringify({schemaVersion: 1, provider: 'openai',
+ fs.writeFileSync(path.join(stage, 'manifest.json'),
JSON.stringify({schemaVersion: 2, provider: 'openai',
liveCall: true, liveVerified: false, applied: false, sourceCommit,
glossaryVersion: glossary.version,
candidates: [{id, sourceHash: hash(source), sourcePath:
planned.sourcePath,
- targetPath: planned.targetPath, targetHash:
hash(generated.target), targetChanged: true, record}]}));
+ targetPath: planned.targetPath, targetHash:
hash(generated.target), targetChanged: true,
+ baseline: {targetHash: null, statePath:
'translations/state/ja/docs.json',
+ stateHash: hash('{"schemaVersion":1,"pages":[]}\n'),
schemaHash: null}, record}]}));
fs.writeFileSync(path.join(root, 'docs/a.md'), '# Changed\n');
await assert.rejects(applyStaged(root, planFile,
'.i18n-build/staged'), /Source changed since planning/);
assert.equal(fs.existsSync(path.join(root, planned.targetPath)),
false);
diff --git a/scripts/i18n/check-translations.cjs
b/scripts/i18n/check-translations.cjs
index f88fd593977..5ff70333cbd 100644
--- a/scripts/i18n/check-translations.cjs
+++ b/scripts/i18n/check-translations.cjs
@@ -6,7 +6,7 @@ const {identity, hash, loadRecords, loadRecordsAt,
pipelineLocale} = require('./
const {structure, alignHeadings} = require('./structure.cjs');
const {collect} = require('./inventory.cjs');
const {protectionSignature} = require('./protection.cjs');
-const {signature, isDocumentGeneration, acceptedFallback} =
require('./document-checks.cjs');
+const {signature, isDocumentGeneration, acceptedFallback, checkFrontMatter} =
require('./document-checks.cjs');
async function checkTranslations({root, locale = pipelineLocale(root), pages,
reports = [], files, records, previousRecords = new Map()}) {
const changed = new Set(files);
@@ -55,6 +55,7 @@ async function checkTranslations({root, locale =
pipelineLocale(root), pages, re
const ext = path.extname(page.sourcePath);
const parsed = await structure(source, ext);
const fallback = acceptedFallback(record, source, target);
+ if (!fallback) checkFrontMatter(source, target, locale);
let aligned = target;
try {aligned = await alignHeadings(parsed, target, ext);}
catch (error) {if (!fallback) throw error;}
diff --git a/scripts/i18n/document-checks.cjs b/scripts/i18n/document-checks.cjs
index 49cfc6e748b..249a4a748bf 100644
--- a/scripts/i18n/document-checks.cjs
+++ b/scripts/i18n/document-checks.cjs
@@ -1,6 +1,8 @@
// Shared by generation retries and the PR preflight. Link resolution is the
// same Python implementation used by check-md-links.yml.
+const assert = require('node:assert/strict');
const fs = require('node:fs');
+const matter = require('gray-matter');
const os = require('node:os');
const path = require('node:path');
const {spawnSync} = require('node:child_process');
@@ -71,6 +73,23 @@ async function checkSyntax(target, extension) {
}
}
+function checkFrontMatter(source, target, locale) {
+ const before = matter(source).data;
+ const after = matter(target).data;
+ for (const key of ['title','description','sidebar_label','keywords']) {
+ if (!Object.hasOwn(before,key)) continue;
+ assert(Object.hasOwn(after,key), `Restore front matter field: ${key}`);
+ if (Array.isArray(before[key])) {
+ assert(Array.isArray(after[key]) && after[key].length ===
before[key].length
+ && after[key].every(value => typeof value === 'string' &&
value.trim()), `Restore complete front matter field: ${key}`);
+ } else if (typeof before[key] === 'string') {
+ assert(typeof after[key] === 'string' && (!before[key].trim() ||
after[key].trim()), `Restore front matter field: ${key}`);
+ } else assert.deepEqual(after[key],before[key],`Restore front matter
field: ${key}`);
+ }
+ if (Object.hasOwn(before,'language') || Object.hasOwn(after,'language'))
+ assert.equal(after.language,locale,`Set front matter language to
${locale}`);
+}
+
async function inspectDocument({source, translation, extension, glossary,
targetLocale, targetScriptPattern,
root, targetPath, preserveFormatting = false}) {
let target =
translation.replace(/^```(?:markdown|mdx?)\s*\n([\s\S]*)\n```\s*$/, '$1');
@@ -88,6 +107,7 @@ async function inspectDocument({source, translation,
extension, glossary, target
if (!preserveFormatting) target = aligned;
});
await check('syntax', () => checkSyntax(target, extension));
+ await check('frontmatter', () => checkFrontMatter(source, target,
targetLocale));
await check('protected-content', async () => {
const translated = await structure(aligned, extension);
if (signature(translated) !== signature(parsed))
@@ -112,4 +132,4 @@ async function inspectDocument({source, translation,
extension, glossary, target
return {target, issues};
}
-module.exports = {policy, signature, isDocumentGeneration, acceptedFallback,
checkLinks, checkSyntax, inspectDocument};
+module.exports = {policy, signature, isDocumentGeneration, acceptedFallback,
checkLinks, checkSyntax, checkFrontMatter, inspectDocument};
diff --git a/scripts/i18n/translate-docs.cjs b/scripts/i18n/translate-docs.cjs
index c4136741c26..026f8555d4d 100644
--- a/scripts/i18n/translate-docs.cjs
+++ b/scripts/i18n/translate-docs.cjs
@@ -9,6 +9,7 @@ const {structure} = require('./structure.cjs');
const {protectionSignature} = require('./protection.cjs');
const {checkedTask, safeFile, openAIAdapter, mockAdapter, translateDocument} =
require('./translation-engine.cjs');
const {translateManagedDocument} = require('./translate-document.cjs');
+const {checkFrontMatter} = require('./document-checks.cjs');
function optionsFrom(argv) {
const values = {}, tasks = [];
@@ -76,6 +77,17 @@ function statePathForRecord(state, record) {
return state.sharded || state.shards.has(shard) ? shard :
relative(state.root, state.file);
}
+// Capture the inputs from the generation snapshot, before awaiting a model.
+// A later apply must not overwrite a newer, internally consistent translation.
+function stagedBaseline(state, task, previousTarget) {
+ const shard = stateFileFor(task);
+ const statePath = state.shards.has(shard) ? shard : relative(state.root,
state.file);
+ const raw = state.shards.has(shard) ? state.shards.get(shard) : state.raw;
+ return {targetHash: previousTarget === null ? null : hash(previousTarget),
statePath,
+ stateHash: raw === null ? null : hash(raw),
+ schemaHash: state.schemaRaw === null ? null : hash(state.schemaRaw)};
+}
+
function writeAtomic(file, value) {
const temp = path.join(path.dirname(file),
`.translation-${randomUUID()}.tmp`);
try {
@@ -96,6 +108,7 @@ async function prepare({root, plan, taskId, scope, state,
adapter, glossary, tar
&& previousTarget !== null && hash(previousTarget) ===
existing.targetHash,
'Existing target or identity changed; manual review required');
}
+ const baseline = stagedBaseline(state, task, previousTarget);
const wholeDocument = typeof adapter.translateDocument === 'function';
let previousSource = null, baselineReason;
if (existing) {
@@ -119,6 +132,7 @@ async function prepare({root, plan, taskId, scope, state,
adapter, glossary, tar
previousTarget: canReuse ? previousTarget : null,
targetLocale: target.locale, targetScriptPattern: target.scriptPattern,
maxDocumentChars, maxChunkChars});
+ if (!wholeDocument) checkFrontMatter(source, generated.target,
target.locale);
if (!wholeDocument && adapter.model !== 'fixture') {
assert(generated.targetChanged,
`Live candidate contains no ${target.language} text; review
manually`);
@@ -131,7 +145,7 @@ async function prepare({root, plan, taskId, scope, state,
adapter, glossary, tar
reviewStatus: 'machine', ownership: 'machine', translatedAt: new
Date().toISOString(),
glossaryVersion: glossary.version,
...(generated.generation ? {generation: generated.generation} : {})};
- return {task, targetFile, previousTarget, target: generated.target, record,
+ return {task, targetFile, previousTarget, baseline, target:
generated.target, record,
units: generated.units, reusedUnits: generated.reusedUnits, requests:
generated.requests,
targetChanged: generated.targetChanged};
}
@@ -197,7 +211,7 @@ async function applyStaged(root, planFile, relativeStage) {
const scope = JSON.parse(fs.readFileSync(scopeFile(root, locale), 'utf8'));
const stage = stageDirectory(root, relativeStage);
const manifest = JSON.parse(fs.readFileSync(path.join(stage,
'manifest.json'), 'utf8'));
- assert(manifest.schemaVersion === 1 && manifest.provider === 'openai' &&
manifest.liveCall === true
+ assert(manifest.schemaVersion === 2 && manifest.provider === 'openai' &&
manifest.liveCall === true
&& manifest.applied === false && manifest.sourceCommit ===
plan.sourceCommit
&& Array.isArray(manifest.candidates) && manifest.candidates.length > 0
&& manifest.candidates.length <= scope.maxBatchPages, 'Invalid staged
live candidates');
@@ -225,11 +239,14 @@ async function applyStaged(root, planFile, relativeStage)
{
'Staged candidate was edited after validation');
const parsed = await structure(source, path.extname(task.sourcePath));
const translated = await structure(target,
path.extname(task.targetPath));
+ checkFrontMatter(source, target, locale);
assert.equal(protectionSignature(translated),
protectionSignature(parsed), 'Staged protected content changed');
assert.equal(item.record.sourceTechnicalHash, parsed.technicalHash,
'Staged source technical hash changed');
const existing = state.data.pages.find(page => identity(page) ===
identity(task));
const targetFile = safeFile(root, task.targetPath);
const previousTarget = fs.existsSync(targetFile) ?
fs.readFileSync(targetFile, 'utf8') : null;
+ assert.deepEqual(item.baseline, stagedBaseline(state, task,
previousTarget),
+ 'Translation baseline changed since staging; regenerate and review
the candidate');
assert(!previousTarget || existing, 'Untracked target needs human
review');
if (existing) assert(existing.sourcePath === task.sourcePath &&
existing.targetPath === task.targetPath
&& previousTarget !== null && hash(previousTarget) ===
existing.targetHash,
@@ -282,14 +299,14 @@ async function main() {
const stageRelative = options['--stage-dir'] ??
'.i18n-build/translation-candidates';
const stage = stageDirectory(root, stageRelative);
fs.mkdirSync(stage, {recursive: true});
- const manifest = {schemaVersion: 1, sourceCommit: plan.sourceCommit,
provider,
+ const manifest = {schemaVersion: 2, sourceCommit: plan.sourceCommit,
provider,
model: adapter.model, glossaryVersion: glossary.version,
liveCall: provider === 'openai', liveVerified: false,
applied: false, usedTokens: adapter.usedTokens,
- candidates: candidates.map(({task, record, units, reusedUnits,
requests, targetChanged}) => ({
+ candidates: candidates.map(({task, record, baseline, units,
reusedUnits, requests, targetChanged}) => ({
id: task.id, kind: task.kind, sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceHash: record.sourceHash, targetHash: record.targetHash,
units, reusedUnits, requests,
- targetChanged, record,
+ targetChanged, baseline, record,
}))};
for (const candidate of candidates) {
const folder = path.join(stage, candidate.task.id);
@@ -301,4 +318,4 @@ async function main() {
}
if (require.main === module) main().catch(error =>
{console.error(error.message); process.exitCode = 1;});
-module.exports = {optionsFrom, prepare, applyCandidates, applyStaged,
readState, statePathForRecord, targetFor, scopeFile, glossaryFile};
+module.exports = {optionsFrom, prepare, applyCandidates, applyStaged,
readState, stagedBaseline, statePathForRecord, targetFor, scopeFile,
glossaryFile};
diff --git a/scripts/i18n/workflow-generate.cjs
b/scripts/i18n/workflow-generate.cjs
index 0b7109b4ce1..9a6b4bde2f9 100644
--- a/scripts/i18n/workflow-generate.cjs
+++ b/scripts/i18n/workflow-generate.cjs
@@ -1,5 +1,5 @@
// Trusted-master automation only. The workflow supplies the requested mode and
-// model credential; this module writes only validated managed ja files/state.
+// model credential; this module writes only validated files/state for the
selected managed locale.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
@@ -7,7 +7,7 @@ const {execFileSync} = require('node:child_process');
const {hash, identity, languageName, pipelineLocale} = require('./state.cjs');
const {checkedTask, managedPath, safeFile} =
require('./translation-engine.cjs');
const {codexAdapter} = require('./codex-adapter.cjs');
-const {prepare, applyCandidates, readState, statePathForRecord, targetFor} =
require('./translate-docs.cjs');
+const {prepare, applyCandidates, readState, stagedBaseline,
statePathForRecord, targetFor} = require('./translate-docs.cjs');
const {markerPrefixFor} = require('./automation-naming.cjs');
const root = path.resolve(__dirname, '../..');
@@ -51,6 +51,7 @@ function copyPair(primary, partner, scope, state, rootDir =
root) {
docId: partner.docId, sourcePath: partner.sourcePath, targetPath:
partner.targetPath,
sourceHash: partner.sourceHash};
return {...primary, task: partner, targetFile, previousTarget, record,
+ baseline: stagedBaseline(state, partner, previousTarget),
requests: 0, reusedFrom: primary.task.id};
}
diff --git a/translations/README.md b/translations/README.md
index 069c11bd858..c710e71a3fb 100644
--- a/translations/README.md
+++ b/translations/README.md
@@ -47,6 +47,62 @@ Translation commands must select their locale explicitly now
that both `ja`
and `ko` are configured, for example `I18N_LOCALE=ko`. The site build flags
select rendered languages; they do not select the translation pipeline locale.
+## Korean toolchain and script-driven execution
+
+Korean uses its own `scope/ko.json`, `glossary/ko.json`, inventory and page
state.
+Select the pipeline explicitly with `I18N_LOCALE=ko`; rendering flags do not
+select a translation pipeline. A loop can recount and plan from the current
+committed checkout without fixed batch registration:
+
+```bash
+I18N_LOCALE=ko node scripts/i18n/audit-coverage.cjs --source-ref HEAD --output
/tmp/ko-coverage.json
+I18N_LOCALE=ko node scripts/i18n/plan-translations.cjs --source-ref HEAD
--limit 10 \
+ --active-candidates /tmp/ko-active-candidates.json --output /tmp/ko-plan.json
+```
+
+The active-candidates file must come from the current open PR scan. A
standalone
+plan without it does not establish cross-PR deduplication. Replan after source,
+target/state, HEAD or active PR changes; use the selected native task IDs
rather
+than historical inventory batch IDs. Reports on a local feature-branch commit
+can include local candidates; merged coverage is recounted on merged master.
+The loop is implemented separately in its own worktree.
+
+The trusted-master Codex workflow translates whole documents or eligible
changed
+blocks, with at most three attempts and cumulative feedback. A third-attempt
+candidate can retain content warnings: inspect
`generation.acceptedWithWarnings`,
+`generation.warnings` and `generation.history` before publication. This is a
+review candidate, not a language/technical quality pass. Review status remains
+machine until actual human proofreading.
+
+The independent `translate-docs.cjs --provider openai` entry uses protected
text
+units and its own validation; it is not the Codex workflow. It stages live
bytes
+for explicit application. The locale selector also applies to that step:
+
+```bash
+I18N_LOCALE=ko node scripts/i18n/translate-docs.cjs \
+ --apply-staged .i18n-build/<reviewed-stage> --plan /tmp/ko-plan.json
+```
+
+Application binds current HEAD, source bytes, reviewed target bytes, state and
+ownership. Manual staging manifests use schema version 2 and freeze the
original
+target, effective state file and shard-layout marker hashes before generation.
+Application refuses a newer matching target/state pair as well as inconsistent
+edits. Regenerate older version-1 stages before applying them; page state and
+Codex workflow artifact schemas remain version 1.
+
+Korean starts with the page-record marker and writes independent
+shards. Legacy records remain readable and existing shards retain precedence.
+Human-owned targets, edited target/state bytes, path drift and symlinks require
+review. Dev/4.x reuse requires the same doc ID, relative filename and full
source
+hash; an existing partner is updated against its own baseline, not overwritten
+by a copy. Each reused target still receives its own link/state admission
checks.
+
+Generation and admission retain visible front matter and keyword entries, and
+check any declared `language` against the target locale. These checks leave the
+existing source/technical hash algorithms unchanged. Offline Korean fixtures
+exercise these boundaries without real credentials or model calls; actual
+single-page generation is a separate validation step.
+
## State schema, version 1
`state/ja/docs.json` is the legacy shared ledger. New Japanese records can live
@@ -173,7 +229,7 @@ commit. The 2026-10-12 inventory is only a comparison
baseline. New registered
pages enter the plan even when they have never appeared in that inventory.
```bash
-node scripts/i18n/plan-translations.cjs --source-ref HEAD \
+I18N_LOCALE=ja node scripts/i18n/plan-translations.cjs --source-ref HEAD \
--limit 20 --output /tmp/japanese-plan.json
```
@@ -309,7 +365,7 @@ node scripts/i18n/plan-translations.cjs --source-ref HEAD \
--output /tmp/japanese-plan.json
export OPENAI_API_KEY='<secret>'
export I18N_MODEL='<explicit-model-id>'
-node scripts/i18n/translate-docs.cjs --plan /tmp/japanese-plan.json \
+I18N_LOCALE=ja node scripts/i18n/translate-docs.cjs --plan
/tmp/japanese-plan.json \
--task '<selected-task-id>' --stage-dir .i18n-build/translation-candidates
```
@@ -319,7 +375,7 @@ Review the candidate and manifest, then apply those **same
bytes** without
another model call:
```bash
-node scripts/i18n/translate-docs.cjs \
+I18N_LOCALE=ja node scripts/i18n/translate-docs.cjs \
--apply-staged .i18n-build/translation-candidates \
--plan /tmp/japanese-plan.json
```
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]