blob: cba66839dffbb140dff943ea66867147b2840d41 [file]
// Recount the complete native scope on every run. The T03 snapshot is only a
// comparison baseline, never an allowlist for new documents or sidebar keys.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {execFileSync, spawnSync} = require('node:child_process');
const {hash, identity, inventoryBaseline, loadRecordsAt, pipelineLocale} = require('./state.cjs');
const {collect, validateScope, routingFiles} = require('./inventory.cjs');
const {policiesFor, docKey} = require('./release-policy.cjs');
const keyFor = locale => page => identity({...page,locale});
// One `git show` per page is several thousand processes for a full-scope
// audit. Read the same blobs through one `git cat-file --batch` process and
// keep only the SHA-256 the audit compares.
function refHashes(root, ref, files) {
const unique = [...new Set(files)], hashes = new Map();
if (!unique.length) return hashes;
const result = spawnSync('git', ['cat-file', '--batch'], {
cwd: root, maxBuffer: 512 * 1024 * 1024,
input: unique.map(file => `${ref}:${file}`).join('\n') + '\n',
});
if (result.error) throw result.error;
if (result.status !== 0)
throw new Error(`git cat-file --batch failed: ${result.stderr.toString().trim()}`);
let offset = 0;
for (const file of unique) {
const newline = result.stdout.indexOf(0x0a, offset);
assert(newline >= 0, `Truncated git cat-file output at ${file}`);
const header = result.stdout.toString('utf8', offset, newline);
offset = newline + 1;
if (header.endsWith(' missing')) {hashes.set(file, null); continue;}
const match = header.match(/^[0-9a-f]+ blob (\d+)$/);
assert(match, `Unexpected git cat-file header for ${file}: ${header}`);
const size = Number(match[1]);
hashes.set(file, hash(result.stdout.toString('utf8', offset, offset + size)));
offset += size + 1;
}
return hashes;
}
function baselineEntries(root) {
return ['dev','4x','community'].flatMap(group => {
const meta = JSON.parse(fs.readFileSync(path.join(root,`${group}.json`),'utf8'));
return meta.entryFiles.flatMap(shard => JSON.parse(fs.readFileSync(path.join(root,shard),'utf8')).entries);
});
}
function sidebarAudit(native,root,scope,policies = new Map()) {
const result = [];
for (const group of scope.groups) {
if (group.kind) continue; // Source modules/partials have no native sidebar.
const plugin = native.labels.find(item => item.pluginId === group.pluginId);
assert(plugin, `Missing native sidebar plugin ${group.pluginId}`);
const versionFile = plugin.files.find(file => file.path === (group.version === 'current' ? 'current' : `version-${group.version}`));
assert(versionFile, `Missing native sidebar keys ${group.id}`);
const suffix = group.pluginId === 'default' ? '' : `-${group.pluginId}`;
const dir = path.join(root,`i18n/${scope.locale}/docusaurus-plugin-content-docs${suffix}`);
const target = path.join(dir,`${versionFile.path}.json`);
const translated = fs.existsSync(target) ? JSON.parse(fs.readFileSync(target,'utf8')) : {};
const sourceKeys = Object.entries(versionFile.content).filter(([name]) => name.startsWith('sidebar.'));
const retained = scope.retainSidebarLabels || [];
for (const [name, value] of sourceKeys) {
const exception = retained.find(entry => entry.pluginId === group.pluginId && entry.version === group.version && entry.key === name);
if (exception && (!exception.reason?.trim() || value.message !== translated[name]?.message))
throw new Error(`Invalid retained sidebar label ${name}`);
const state = !translated[name]?.message ? 'missing' : translated[name].message === value.message
? exception ? 'retained_technical' : 'untranslated' : 'translated';
const policy = policies.get(group.id);
const docId = name.match(/\.doc\.(.+)$/)?.[1];
const page = policy && native.pages.find(page => page.pluginId === group.pluginId
&& page.version === group.version && page.docId === docId);
// Existing release categories/version labels remain manual native
// navigation; they do not become a historical translation backlog.
const maintenance = policy ? page ? policy.classify(page).kind : 'retained_navigation' : undefined;
result.push({group:group.id, pluginId:group.pluginId,version:group.version,key:name,
source:value.message,translation:translated[name]?.message || null,state,
...(maintenance && {maintenance})});
}
for (const exception of retained.filter(entry => entry.pluginId === group.pluginId && entry.version === group.version))
assert(sourceKeys.some(([name]) => name === exception.key),`Unused sidebar exception ${exception.key}`);
}
return result;
}
async function audit({root,sourceRef,baseline,native}) {
const locale = pipelineLocale(root);
const key = keyFor(locale);
baseline ??= inventoryBaseline(root, locale);
const commit = execFileSync('git',['rev-parse',`${sourceRef}^{commit}`],{cwd:root,encoding:'utf8'}).trim();
if(require('./state.cjs').loadConfig(root).maintenancePolicy){
execFileSync('git',['diff','--quiet',commit,'--','i18n/zh-CN'],{cwd:root});
assert(!execFileSync('git',['ls-files','--others','--exclude-standard','--','i18n/zh-CN'],
{cwd:root,encoding:'utf8'}).trim(),'Uncommitted Chinese source cannot enter the audit');
}
execFileSync('git',['diff','--quiet',commit,'--',`i18n/${locale}`,`translations/state/${locale}`],{cwd:root});
const untracked = execFileSync('git',['ls-files','--others','--exclude-standard','--',`i18n/${locale}`,`translations/state/${locale}`],
{cwd:root,encoding:'utf8'}).trim();
assert(!untracked,`Uncommitted translated targets/state cannot count at ${commit}: ${untracked}`);
const scope = JSON.parse(fs.readFileSync(path.join(root,`translations/scope/${locale}.json`),'utf8'));
validateScope(scope, locale);
const committedRecords = loadRecordsAt(root, locale, commit);
const policies = policiesFor(root, scope, commit);
native ||= await collect(root);
const old = new Map(baselineEntries(baseline).map(page => [key(page),page]));
const current = new Set(), groups = [];
const files = group => execFileSync('git',['ls-tree','-r','--name-only','-z',commit,'--',group.sourceRoot],{cwd:root,encoding:'utf8'})
.split('\0').filter(file => group.kind ? require('./source-groups.cjs').matchesSource(group, file)
&& require('./source-groups.cjs').hasProse(root, group, file) : /\.(md|mdx)$/.test(file));
const scoped = scope.groups.map(group => ({group,
pages:native.pages.filter(page => page.pluginId === group.pluginId && page.version === group.version)}));
const sourceHashes = refHashes(root, commit, scoped.flatMap(({pages}) => pages.map(page => page.englishPath)));
const targetHashes = refHashes(root, commit, scoped.flatMap(({pages}) => pages
.filter(page => page.status === 'current' && page.reviewStatus !== 'unverified' && page.useTarget)
.map(page => page.targetPath)));
for (const {group, pages} of scoped) {
assert(pages.length,`Missing native group ${group.id}`);
const sourceFiles = files(group), seen = new Set(), entries = [];
for (const page of pages) {
assert(page.englishPath.startsWith(group.sourceRoot+'/'),`Wrong source root: ${page.englishPath}`);
assert(sourceFiles.includes(page.englishPath),`Native page missing at source ref: ${page.englishPath}`);
assert.equal(sourceHashes.get(page.englishPath),hash(fs.readFileSync(path.join(root,page.englishPath),'utf8')),
`Source differs from ${commit}: ${page.englishPath}`);
assert(!seen.has(page.englishPath),`Duplicate native source ${page.englishPath}`);
seen.add(page.englishPath); current.add(key(page));
const previous = old.get(key(page));
const eligibility = policies.get(group.id)?.classify(page);
const disposition = page.draft ? 'draft' : scope.retainEnglish.some(entry => key(entry) === key(page)) ? 'retain_english'
: eligibility && eligibility.kind !== 'eligible' ? eligibility.kind : 'translate';
const mergedCurrent = page.status === 'current' && page.reviewStatus !== 'unverified' && page.useTarget
&& targetHashes.get(page.targetPath) !== null
&& targetHashes.get(page.targetPath) === page.targetHash
&& require('./source-revisions.cjs').resolveRecord(root,page,committedRecords.get(key(page)),undefined,
{ref:commit,worktree:false})?.targetHash === page.targetHash;
const state = disposition !== 'translate' ? disposition : page.status === 'current'
? mergedCurrent ? 'current' : 'unverified' : page.status;
entries.push({pluginId:page.pluginId,version:page.version,docId:page.docId,sourcePath:page.sourcePath,
targetPath:page.targetPath,sourceHash:page.sourceHash,targetHash:page.targetHash,
sourceCommit:commit,permalink:page.permalink,reviewStatus:page.reviewStatus,state,
...(require('./state.cjs').loadConfig(root).maintenancePolicy&&{
chineseSourceBlob:require('./source-revisions.cjs').currentSnapshot(root,page,commit)?.['zh-CN']?.blob??null}),
...(eligibility && {eligibility, contentState: page.status}),
change:!previous ? 'new' : previous.sourcePath !== page.sourcePath ? 'moved'
: previous.sourceHash !== page.sourceHash ? 'source_changed' : 'unchanged'});
}
const unregistered = sourceFiles.filter(file => !seen.has(file));
const required = entries.filter(entry => !['draft','retain_english','historical_excluded','policy_review'].includes(entry.state));
groups.push({id:group.id,pluginId:group.pluginId,version:group.version,
counts:{registered:entries.length,required:required.length,current:required.filter(entry => entry.state === 'current').length,
pending:required.filter(entry => entry.state !== 'current').length,new:entries.filter(entry => entry.change === 'new').length,
changed:entries.filter(entry => entry.change === 'source_changed').length,moved:entries.filter(entry => entry.change === 'moved').length,
drafts:entries.filter(entry => entry.state === 'draft').length,
historicalExcluded:entries.filter(entry => entry.state === 'historical_excluded').length,
policyReview:entries.filter(entry => entry.state === 'policy_review').length,unregistered:unregistered.length},
unregistered,entries});
}
const removed = [...old.values()].filter(page => !current.has(key(page)))
.map(page => ({pluginId:page.pluginId,version:page.version,docId:page.docId,sourcePath:page.sourcePath,
likelyMoveTo: groups.flatMap(group => group.entries).find(entry => entry.sourceHash === page.sourceHash
&& entry.pluginId === page.pluginId && entry.version === page.version)?.sourcePath || null}));
const sidebar = sidebarAudit(native,root,scope,policies);
const automaticGroups=groups.filter(group=>!scope.automaticGroups||scope.automaticGroups.includes(group.id));
const automaticCounts=Object.fromEntries(['required','current','pending'].map(name=>[name,
automaticGroups.reduce((sum,group)=>sum+group.counts[name],0)]));
const labels = Object.fromEntries(['translated','untranslated','missing','retained_technical']
.map(state => [state,sidebar.filter(entry => entry.state === state).length]));
const automaticLabels = Object.fromEntries(['translated','untranslated','missing','retained_technical']
.map(state => [state,sidebar.filter(entry => entry.state === state
&& (!scope.automaticGroups || scope.automaticGroups.includes(entry.group))
&& (!entry.maintenance || entry.maintenance === 'eligible')).length]));
const routingInputs = routingFiles(root, scope);
routingInputs.push(...[...policies.values()].map(policy => policy.baselinePath));
routingInputs.push(`translations/state/${locale}/docs.json`, `translations/state/${locale}/resolved-deletions.json`);
const schemaPath = `translations/state/${locale}/shards.schema.json`;
if (refHashes(root, commit, [schemaPath]).get(schemaPath) !== null) routingInputs.push(schemaPath);
routingInputs.push(...execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', commit, '--', `translations/state/${locale}/pages`],
{cwd:root,encoding:'utf8'}).split('\0').filter(Boolean));
const routingHashes = refHashes(root, commit, routingInputs);
for (const file of routingInputs) assert.equal(routingHashes.get(file),hash(fs.readFileSync(path.join(root,file),'utf8')),
`Routing input differs from source ref: ${file}`);
return {schemaVersion:1,sourceCommit:commit,maintenancePolicy:require('./state.cjs').loadConfig(root).maintenancePolicy,
baselineCommit:JSON.parse(fs.readFileSync(path.join(baseline,'summary.json'),'utf8')).sourceCommit,
routingInputHashes:Object.fromEntries(routingInputs.map(file => [file,routingHashes.get(file)])),
groups,removed,labels,automaticLabels,sidebar,reports:native.reports,automaticCounts,
policyExclusions: [...new Map([...old.values(), ...committedRecords.values(), ...native.pages]
.flatMap(page => {
const group = scope.groups.find(group => group.pluginId === page.pluginId && group.version === page.version);
const eligibility = policies.get(group?.id)?.classify(page);
return eligibility && eligibility.kind !== 'eligible'
? [[docKey(page), {pluginId:page.pluginId,version:page.version,docId:page.docId,sourcePath:page.sourcePath, ...eligibility}]] : [];
})).values()],
deferredAutomaticGroups:groups.filter(group=>!automaticGroups.includes(group)).map(group=>group.id),
fallbackOnlyVersions: require('./docs-language-policy.cjs').fallbackOnlyVersions.map(version => ({pluginId:'default',version,
proseLocales:['en','zh-CN'],otherLocaleContent:'en',requiredTranslation:false}))};
}
function hasPending(report) {
const pending = report.automaticCounts?.pending ?? report.groups.some(group => group.counts.pending);
const labels = report.automaticLabels ?? report.labels;
return !!(pending || labels.missing || labels.untranslated);
}
if (require.main === module) {
const args = process.argv.slice(2), option = name => args[args.indexOf(name)+1];
const root = path.resolve(__dirname,'../..');
audit({root,sourceRef:args.includes('--source-ref') ? option('--source-ref') : 'HEAD'})
.then(result => {const output = JSON.stringify(result,null,2)+'\n';
if(args.includes('--output')) fs.writeFileSync(option('--output'),output); else process.stdout.write(output);
console.log(JSON.stringify({sourceCommit:result.sourceCommit,groups:result.groups.map(({id,counts}) => ({id,counts})),
labels:result.labels,removed:result.removed.length}));
if(args.includes('--fail-on-pending') && hasPending(result)) process.exitCode=1;
}).catch(error => {console.error(error);process.exitCode=1;});
}
module.exports = {audit,sidebarAudit,hasPending};