feat(ms-ai-architect): Spor 1 — klassifiserer-CLI + registry-merge (389 klassifisert, union reconciled) [skip-docs]
This commit is contained in:
parent
6b8e1b3767
commit
9e4bf2a4f9
2 changed files with 252 additions and 0 deletions
135
scripts/kb-update/classify-ref-type.mjs
Normal file
135
scripts/kb-update/classify-ref-type.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
#!/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);
|
||||
|
|
@ -6,12 +6,20 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
classifyRefType,
|
||||
classifyCorpus,
|
||||
VALID_TYPES,
|
||||
} from '../../scripts/kb-update/lib/classify-ref-type.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = join(__dirname, '..', '..', 'scripts', 'kb-update', 'classify-ref-type.mjs');
|
||||
|
||||
const MS_CITING =
|
||||
'# Azure AI Search\n\n**Category:** rag\n\n---\n\n## A\n\n' +
|
||||
'Se https://learn.microsoft.com/azure/search/hybrid-search-overview for detaljer.\n';
|
||||
|
|
@ -103,3 +111,112 @@ test('classifyCorpus — relpath-keyed, key-sorted, deterministic across runs',
|
|||
assert.equal(typeof entry.mscite, 'boolean');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3 — the IO-shell CLI: full-corpus classification, --write persistence
|
||||
// and the manifest re-entrance guard (plan v1.8: --write refuses exit 3 on an
|
||||
// enriched manifest unless --merge; --merge preserves enrichment verbatim).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A real corpus file (stable since 2026-06) used as the enriched-entry key.
|
||||
const REAL_RELPATH = 'skills/ms-ai-engineering/references/mlops-genaiops/mlops-fundamentals-overview.md';
|
||||
const GONE_RELPATH = 'skills/ms-ai-engineering/references/zzz-does-not-exist.md';
|
||||
|
||||
// Deliberately different from what fresh classification would produce, so
|
||||
// verbatim preservation is distinguishable from re-classification.
|
||||
const ENRICHED_ENTRY = {
|
||||
type: 'methodology',
|
||||
signal: 'test-signal',
|
||||
mscite: true,
|
||||
reviewFlag: false,
|
||||
resolvedBy: 'human-test',
|
||||
source: 'https://learn.microsoft.com/test-authority',
|
||||
};
|
||||
|
||||
test('CLI --json — exactly 389 relpath-keyed entries, every type valid, zero unclassified', () => {
|
||||
const out = execFileSync('node', [CLI, '--json'], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
||||
const manifest = JSON.parse(out);
|
||||
const keys = Object.keys(manifest);
|
||||
assert.equal(keys.length, 389);
|
||||
assert.deepEqual(keys, [...keys].sort());
|
||||
for (const [path, entry] of Object.entries(manifest)) {
|
||||
assert.ok(path.startsWith('skills/'), path);
|
||||
assert.ok(VALID_TYPES.includes(entry.type), `${path}: ${entry.type}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI — without --write no manifest file is created', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'clsrt-'));
|
||||
const out = join(tmp, 'manifest.json');
|
||||
try {
|
||||
execFileSync('node', [CLI, '--out', out], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
||||
assert.equal(existsSync(out), false);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI --write — persists a parseable, key-sorted manifest to --out', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'clsrt-'));
|
||||
const out = join(tmp, 'manifest.json');
|
||||
try {
|
||||
execFileSync('node', [CLI, '--write', '--out', out], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
||||
const manifest = JSON.parse(readFileSync(out, 'utf8'));
|
||||
assert.equal(Object.keys(manifest).length, 389);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI --write — refuses (exit 3, file untouched) when the existing manifest is enriched', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'clsrt-'));
|
||||
const out = join(tmp, 'manifest.json');
|
||||
try {
|
||||
const existing = { [REAL_RELPATH]: ENRICHED_ENTRY };
|
||||
writeFileSync(out, JSON.stringify(existing, null, 2) + '\n');
|
||||
const before = readFileSync(out, 'utf8');
|
||||
let status = 0;
|
||||
try {
|
||||
execFileSync('node', [CLI, '--write', '--out', out], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
||||
} catch (err) {
|
||||
status = err.status;
|
||||
}
|
||||
assert.equal(status, 3);
|
||||
assert.equal(readFileSync(out, 'utf8'), before);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI --write --merge — preserves enrichment verbatim, adds disk-new, drops deleted', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'clsrt-'));
|
||||
const out = join(tmp, 'manifest.json');
|
||||
try {
|
||||
const existing = {
|
||||
[REAL_RELPATH]: ENRICHED_ENTRY,
|
||||
[GONE_RELPATH]: { type: 'reference', signal: 'ms-citation', mscite: true, reviewFlag: false },
|
||||
};
|
||||
writeFileSync(out, JSON.stringify(existing, null, 2) + '\n');
|
||||
execFileSync('node', [CLI, '--write', '--merge', '--out', out], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
||||
const manifest = JSON.parse(readFileSync(out, 'utf8'));
|
||||
// deleted-on-disk entry dropped; disk-new files added → the live 389
|
||||
assert.equal(manifest[GONE_RELPATH], undefined);
|
||||
assert.equal(Object.keys(manifest).length, 389);
|
||||
// enriched entry preserved verbatim — type/reviewFlag/resolvedBy/source/signal intact
|
||||
assert.deepEqual(manifest[REAL_RELPATH], ENRICHED_ENTRY);
|
||||
const keys = Object.keys(manifest);
|
||||
assert.deepEqual(keys, [...keys].sort());
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI — unknown flag → usage error exit 2', () => {
|
||||
let status = 0;
|
||||
try {
|
||||
execFileSync('node', [CLI, '--bogus'], { encoding: 'utf8' });
|
||||
} catch (err) {
|
||||
status = err.status;
|
||||
}
|
||||
assert.equal(status, 2);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue