From 9e4bf2a4f93e20bdeaf36a97a44a3113c72e43f8 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 3 Jul 2026 00:55:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(ms-ai-architect):=20Spor=201=20=E2=80=94?= =?UTF-8?q?=20klassifiserer-CLI=20+=20registry-merge=20(389=20klassifisert?= =?UTF-8?q?,=20union=20reconciled)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/kb-update/classify-ref-type.mjs | 135 ++++++++++++++++++ .../kb-update/test-classify-ref-type.test.mjs | 117 +++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 scripts/kb-update/classify-ref-type.mjs diff --git a/scripts/kb-update/classify-ref-type.mjs b/scripts/kb-update/classify-ref-type.mjs new file mode 100644 index 0000000..4c97d3c --- /dev/null +++ b/scripts/kb-update/classify-ref-type.mjs @@ -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 ] +// +// 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 ]\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 ]\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); diff --git a/tests/kb-update/test-classify-ref-type.test.mjs b/tests/kb-update/test-classify-ref-type.test.mjs index 72bfc9d..4031b34 100644 --- a/tests/kb-update/test-classify-ref-type.test.mjs +++ b/tests/kb-update/test-classify-ref-type.test.mjs @@ -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); +});