ms-ai-architect/scripts/kb-update/classify-ref-type.mjs

135 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
// classify-ref-type.mjs — Spor 1 Step 3: IO-shell CLI around lib/classify-ref-type.mjs.
//
// Walks skills/*/references/**/*.md (ALL five skills — advisor is classified, never
// mutated), classifies each file, and persists the shared migration artifact
// scripts/kb-update/data/ref-type-manifest.json on --write.
//
// Re-entrance guard (plan v1.8): Steps 4/6 enrich the manifest in place
// (resolvedBy / source / deferred + adjudicated type/reviewFlag). A naive re-run of
// --write would clobber that judgment. Therefore --write REFUSES (exit 3) when the
// existing manifest carries any enriched entry, unless --merge is passed. Under
// --merge: enriched entries are preserved verbatim, unenriched entries are
// re-classified fresh, disk-new files are added, and manifest-only entries whose
// file no longer exists are dropped (the walk is ground truth).
//
// Usage:
// node scripts/kb-update/classify-ref-type.mjs # stdout summary
// node scripts/kb-update/classify-ref-type.mjs --json # manifest to stdout
// node scripts/kb-update/classify-ref-type.mjs --write [--merge] [--out <path>]
//
// Exit codes: 0 ok · 2 usage error · 3 re-entrance guard (enriched manifest, no --merge)
import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { classifyCorpus } from './lib/classify-ref-type.mjs';
import { atomicWriteJson } from './lib/atomic-write.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const SKILLS_DIR = join(PLUGIN_ROOT, 'skills');
// Pinned everywhere in the plan — the single shared artifact downstream steps read.
const DEFAULT_OUT = join(__dirname, 'data', 'ref-type-manifest.json');
const KNOWN_FLAGS = new Set(['--json', '--write', '--merge', '--out']);
// Walk directory recursively for .md files (build-registry.mjs shape).
function walkMd(dir) {
const results = [];
if (!existsSync(dir)) return results;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkMd(full));
} else if (entry.name.endsWith('.md') && entry.name !== 'SKILL.md') {
results.push(full);
}
}
return results;
}
/** An entry is enriched when Step 4/6 judgment has been written into it. */
function isEnriched(entry) {
return entry && (entry.resolvedBy !== undefined || entry.source !== undefined || entry.deferred !== undefined);
}
/**
* Merge an existing (possibly enriched) manifest with a fresh classification.
* Fresh walk is ground truth for WHICH files exist; enrichment is truth for WHAT
* an adjudicated entry says. Key-sorted output.
*/
function mergeManifests(existing, fresh) {
const merged = {};
for (const path of Object.keys(fresh).sort()) {
const prev = existing[path];
merged[path] = isEnriched(prev) ? prev : fresh[path];
}
return merged;
}
function main(argv) {
const args = argv.slice(2);
let outPath = DEFAULT_OUT;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--out') {
if (!args[i + 1]) {
process.stderr.write('usage: classify-ref-type.mjs [--json] [--write] [--merge] [--out <path>]\n');
process.exit(2);
}
outPath = args[++i];
continue;
}
if (!KNOWN_FLAGS.has(args[i])) {
process.stderr.write(`unknown flag: ${args[i]}\nusage: classify-ref-type.mjs [--json] [--write] [--merge] [--out <path>]\n`);
process.exit(2);
}
}
const json = args.includes('--json');
const write = args.includes('--write');
const merge = args.includes('--merge');
const skillDirs = readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const entries = skillDirs
.flatMap((skill) => walkMd(join(SKILLS_DIR, skill, 'references')))
.map((abs) => ({ path: relative(PLUGIN_ROOT, abs), content: readFileSync(abs, 'utf8') }));
let manifest = classifyCorpus(entries);
if (write) {
const existing = existsSync(outPath) ? JSON.parse(readFileSync(outPath, 'utf8')) : null;
const enrichedCount = existing ? Object.values(existing).filter(isEnriched).length : 0;
if (enrichedCount > 0 && !merge) {
process.stderr.write(
`refusing --write: existing manifest at ${outPath} carries ${enrichedCount} enriched ` +
'entr(y/ies) (resolvedBy/source/deferred) that a fresh write would clobber. ' +
'Re-run with --merge to preserve the enrichment.\n'
);
process.exit(3);
}
if (existing && merge) {
manifest = mergeManifests(existing, manifest);
}
atomicWriteJson(outPath, manifest);
}
if (json) {
process.stdout.write(JSON.stringify(manifest, null, 2) + '\n');
} else {
const values = Object.values(manifest);
const flagged = values.filter((e) => e.reviewFlag).length;
console.log(`Classified ${values.length} reference files (${flagged} flagged for review)` +
(write ? `${relative(PLUGIN_ROOT, outPath)}` : ''));
}
}
const isMain = (() => {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
})();
if (isMain) main(process.argv);