blob: 72d6a20d3906275ef28b35da19767f3e6acb398f [file]
const fs = require('node:fs');
const path = require('node:path');
const {createHash} = require('node:crypto');
const {execFileSync} = require('node:child_process');
const hash = value => createHash('sha256').update(value.replace(/\r\n/g, '\n')).digest('hex');
const identity = ({pluginId, version, docId, locale}) => JSON.stringify([pluginId, version, docId, locale]);
const relative = (root, file) => path.relative(root, file).split(path.sep).join('/');
function readJson(file, fallback) {
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : fallback;
}
function loadConfig(root) {
const config = readJson(path.join(root, 'translations/config.json'), {schemaVersion: 1, locales: {}});
if (config.schemaVersion !== 1) throw new Error('Unsupported translation config schemaVersion');
if(config.maintenancePolicy!==undefined&&config.maintenancePolicy!=='source-revisions-v1')
throw new Error('Unsupported translation maintenancePolicy');
for (const [locale, settings] of Object.entries(config.locales)) {
if (!/^[a-z]{2}(?:-[A-Za-z0-9]+)*$/.test(locale) || !['en', 'zh-CN'].includes(settings.sourceLocale)) {
throw new Error(`Invalid translation locale/sourceLocale: ${locale}`);
}
if (!Array.isArray(settings.sourceOverrides)) throw new Error(`Missing sourceOverrides for ${locale}`);
if (settings.searchContextLabels !== undefined
&& (!settings.searchContextLabels || typeof settings.searchContextLabels !== 'object'
|| Object.values(settings.searchContextLabels).some(label => typeof label !== 'string' || !label.trim()))) {
throw new Error(`Invalid searchContextLabels for ${locale}`);
}
if (typeof settings.languageName !== 'string' || !/^[A-Za-z][A-Za-z -]*$/.test(settings.languageName)) {
throw new Error(`Invalid languageName for ${locale}`);
}
// The script a finished translation must contain, so a model that
// answered in the source language is rejected instead of published.
if (typeof settings.scriptPattern !== 'string' || !settings.scriptPattern) {
throw new Error(`Invalid scriptPattern for ${locale}`);
}
try {new RegExp(settings.scriptPattern, 'u');}
catch {throw new Error(`Invalid scriptPattern for ${locale}`);}
const keys = new Set();
for (const override of settings.sourceOverrides) {
const key = identity({...override, locale});
if (keys.has(key) || !['en', 'zh-CN'].includes(override.sourceLocale)) {
throw new Error(`Conflicting source override: ${key}`);
}
keys.add(key);
}
}
return config;
}
// The translation pipeline works on one locale at a time. A configuration with
// a single locale needs no selector, so existing runs are unchanged;
// I18N_LOCALE picks one as soon as several are managed.
function pipelineLocale(root, requested = process.env.I18N_LOCALE) {
const configured = Object.keys(loadConfig(root).locales);
assertConfigured(configured, requested, 'translation locale');
if (requested) return requested;
if (configured.length !== 1) throw new Error(`Set I18N_LOCALE to one of: ${configured.join(', ')}`);
return configured[0];
}
function assertConfigured(configured, requested, what) {
if (requested && !configured.includes(requested)) {
throw new Error(`Unconfigured ${what}: ${requested} (configured: ${configured.join(', ')})`);
}
}
// A locale's own English name, which the pipeline weaves into model prompts,
// the bot identity, branch names and PR titles. It stays a declared setting
// rather than something derived from the code, because renaming it would orphan
// the branches and markers of runs already in flight.
function languageName(root, locale) {
const settings = loadConfig(root).locales[locale];
if (!settings?.languageName) throw new Error(`Missing languageName for ${locale}`);
return settings.languageName;
}
// Locales the render path manages: translation state, status manifests and the
// localized search index exist only for these. Every other configured locale is
// served by the native per-file fallback alone, so it needs none of this.
// Restricting the result to the enabled locales keeps a disabled locale from
// activating the adapter or its status plugin.
function managedLocales(root, enabled) {
const configured = Object.keys(loadConfig(root).locales);
return enabled === undefined ? configured : configured.filter(locale => enabled.includes(locale));
}
// A locale's inventory snapshot, used as the comparison baseline for coverage
// audits. Exactly one is expected, and it stays pinned by name: rebasing an
// audit onto a newer snapshot has to be a deliberate edit, not a silent pick.
function inventoryBaseline(root, locale) {
const base = path.join(root, 'translations/inventory', locale);
const snapshots = fs.existsSync(base)
? fs.readdirSync(base, {withFileTypes: true}).filter(entry => entry.isDirectory()).map(entry => entry.name).sort()
: [];
if (snapshots.length !== 1) throw new Error(`Expected one ${locale} inventory snapshot, found ${snapshots.length}`);
return path.join(base, snapshots[0]);
}
const stateRoot = locale => `translations/state/${locale}`;
const shardRoot = locale => `${stateRoot(locale)}/pages`;
function stateFileFor(record) {
const parts = [record.pluginId, record.version, ...record.docId.split('/')];
if (parts.some(part => !part || part === '.' || part === '..' || /[\\/]/.test(part)))
throw new Error(`Invalid translation state path: ${identity(record)}`);
return `${shardRoot(record.locale)}/${record.pluginId}/${record.version}/${record.docId}.json`;
}
function listShardFiles(root, locale) {
const base = path.join(root, shardRoot(locale));
const files = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile() && entry.name.endsWith('.json')) files.push(relative(root, full));
}
}
walk(base);
return files.sort();
}
function collectRecords(locale, data, shards) {
if (data.schemaVersion !== 1 || !Array.isArray(data.pages)) throw new Error('Invalid translation state schema');
const records = new Map();
for (const record of data.pages) {
const key = identity(record);
if (record.locale !== locale || records.has(key)) throw new Error(`Duplicate or invalid translation identity: ${key}`);
records.set(key, record);
}
const seenShards = new Set();
for (const [file, raw] of shards) {
const shard = JSON.parse(raw);
const record = shard.page;
if (shard.schemaVersion !== 1 || !record || record.locale !== locale
|| stateFileFor(record) !== file || seenShards.has(identity(record)))
throw new Error(`Invalid or duplicate translation shard: ${file}`);
seenShards.add(identity(record));
records.set(identity(record), record); // A shard supersedes a legacy docs.json entry.
}
return records;
}
function loadRecords(root, locale) {
const data = readJson(path.join(root, `${stateRoot(locale)}/docs.json`), {schemaVersion: 1, pages: []});
const shards = listShardFiles(root, locale).map(file => [file, fs.readFileSync(path.join(root, file), 'utf8')]);
const records=collectRecords(locale,data,shards);
return loadConfig(root).maintenancePolicy?require('./source-revisions.cjs').hydrate(root,locale,records):records;
}
function loadRecordsAt(root, locale, ref) {
const read = file => {
try {return execFileSync('git', ['show', `${ref}:${file}`],
{cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024});}
catch {return null;}
};
const base = read(`${stateRoot(locale)}/docs.json`);
const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', ref, '--', shardRoot(locale)],
{cwd: root, encoding: 'utf8'}).split('\0').filter(Boolean).sort();
const shards = paths.map(file => [file, read(file)]);
if (shards.some(([, raw]) => raw === null)) throw new Error('Committed translation shard is unreadable');
const records=collectRecords(locale,base === null ? {schemaVersion:1,pages:[]} : JSON.parse(base),shards);
return loadConfig(root).maintenancePolicy?require('./source-revisions.cjs').hydrate(root,locale,records,{ref,worktree:false}):records;
}
function sourceLocaleFor(settings, page) {
return settings.sourceOverrides.find(override => identity({...override, locale: page.locale}) === identity(page))?.sourceLocale
?? settings.sourceLocale;
}
// Freshness, review identity, and ownership are independent. A prose exception
// requires a human decision bound to BOTH revisions and the unchanged target.
// Even that exception cannot authorize changes in protected technical content.
function decide({page, source, target, record, technicalHash}) {
const sourceHash = hash(source);
const targetHash = target === undefined ? null : hash(target);
const result = (status, useTarget = false, indexable = false) => ({
...page, status, contentLocale: useTarget ? page.locale : 'en',
useTarget, indexable, sourceHash, targetHash,
reviewStatus: record?.reviewStatus ?? 'unverified',
translatedAt: record?.translatedAt ?? null,
});
if (target === undefined) return result('untranslated');
if (!record) return result('unverified');
if (identity(record) !== identity(page) || record.sourcePath !== page.sourcePath
|| record.targetPath !== page.targetPath || record.sourceLocale !== page.sourceLocale
|| !/^[a-f0-9]{64}$/.test(record.sourceHash ?? '')
|| !/^[a-f0-9]{64}$/.test(record.sourceTechnicalHash ?? '')
|| !/^[a-f0-9]{64}$/.test(record.targetHash ?? '')
|| !['machine', 'human', 'manual', 'unverified'].includes(record.reviewStatus)
|| !['machine', 'human'].includes(record.ownership)
|| typeof record.glossaryVersion !== 'string'
|| !/^[a-f0-9]{40}$/.test(record.sourceCommit ?? '')
|| !Number.isFinite(Date.parse(record.translatedAt))) return result('state_conflict');
const control = record[Symbol.for('doris.source-revision-policy')];
if (control) {
if (control.error) return result('state_conflict');
if (record.targetHash !== targetHash) return result('target_modified');
if (control.changed.length) return result('stale_unknown');
return record.sourceHash === sourceHash ? result('current',true,record.reviewStatus !== 'unverified')
: result('state_conflict');
}
if (record.targetHash !== targetHash) return result('target_modified');
if (record.sourceHash === sourceHash) return result('current', true, record.reviewStatus !== 'unverified');
if (record.sourceTechnicalHash !== technicalHash) return result('stale_technical');
const approval = record.safeSourceChange;
if (approval?.kind === 'prose' && approval.fromSourceHash === record.sourceHash
&& approval.toSourceHash === sourceHash && approval.targetHash === targetHash
&& typeof approval.reviewedBy === 'string' && approval.reviewedBy.trim()
&& Number.isFinite(Date.parse(approval.reviewedAt))) return result('stale_prose', true);
return result('stale_unknown');
}
module.exports = {hash, identity, relative, loadConfig, managedLocales, pipelineLocale, languageName, inventoryBaseline, loadRecords, loadRecordsAt,
stateFileFor, listShardFiles, collectRecords, sourceLocaleFor, decide};