feat(ms-ai-architect): C3.2 — course-diff (ren) + course-registry load/save (TDD) [skip-docs]

Pure diff/fold + run-mode selector for course detection (C3.2 av GODKJENT C3-spec).

- lib/course-diff.mjs (REN, null imports): normalizeCourse, selectMode,
  isFullEnumDue, diffCourses, updateRegistry, FULL_ENUM_MAX_AGE_DAYS=30.
  Full-enum-pin (§4.2): removed beregnes KUN i full-modus; inkrementell og
  baseline gir removed:[]. Baseline emitterer ingen leads (§8 b).
  updateRegistry ren (muterer aldri): incremental merger, baseline/full bygger
  fra API-sett + dropper retirerte UIDs, stamper last_full_enum.
- registry-io.mjs +loadCourseRegistry/saveCourseRegistry (additivt, speiler
  loadRegistry/saveRegistry; fil course-registry.json).
- test-course-diff.test.mjs (23) + test-courses-invariant (+3: diff-modul ren).

Gate §7 C3.2 møtt. kb-update 237->263, validate PASSED, null regresjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 13:12:49 +02:00
commit 94c7f42128
4 changed files with 504 additions and 1 deletions

View file

@ -0,0 +1,156 @@
// course-diff.mjs — PURE course diff + registry-fold + run-mode selector (C3.2).
// Zero imports: writes NO files, reads NO secrets, never touches the ledger or
// KB. The detector (C3.4) loads/saves course-registry.json via registry-io and
// drives this module — keeping the diff logic pure and unit-testable.
// See c3-course-detection-plan.md §4.1 (registry schema), §4.2 (diff semantics),
// and the §8 decisions: (b) baseline emits no leads, (c) `removed` is computed
// ONLY on a full enumeration.
// A full enumeration is forced when the last one is missing or older than this.
// Only a full enum (no updatedAt.gt filter) can correctly detect retired courses.
export const FULL_ENUM_MAX_AGE_DAYS = 30;
const DAY_MS = 86_400_000;
/**
* Normalize a raw Platform API item into the registry/lead shape. Keeps the diff
* agnostic of the API field names (iduid, updatedAtupdated_at, products[].id).
* @param {{id: string, title?: string, url?: string, updatedAt?: string, products?: Array<{id: string}>}} apiItem
* @param {'module'|'learning-path'} type which endpoint the item came from
* @returns {{uid: string, type: string, title: string, url: string, products: string[], updated_at: string|null}}
*/
export function normalizeCourse(apiItem, type) {
return {
uid: apiItem.id,
type,
title: apiItem.title ?? '',
url: apiItem.url ?? '',
products: (apiItem.products ?? []).map((p) => p.id).filter(Boolean),
updated_at: apiItem.updatedAt ?? null,
};
}
/**
* Is a full enumeration due? True when the registry has never run a full enum
* (last_full_enum missing) or the last one is strictly older than maxAgeDays.
* Fails toward `true` on an unparseable timestamp a full enum is a safe
* superset of an incremental one.
* @param {{last_full_enum?: string|null}} registry
* @param {string} now full ISO datetime
* @param {{maxAgeDays?: number}} [opts]
* @returns {boolean}
*/
export function isFullEnumDue(registry, now, { maxAgeDays = FULL_ENUM_MAX_AGE_DAYS } = {}) {
if (!registry || !registry.last_full_enum) return true;
const last = new Date(registry.last_full_enum).getTime();
const t = new Date(now).getTime();
if (!Number.isFinite(last) || !Number.isFinite(t)) return true;
return t - last > maxAgeDays * DAY_MS;
}
/**
* Pick the run mode from registry state (§4.1's three run types):
* - 'baseline' first ever run (no last_run): establish the registry, emit no leads.
* - 'full' a full enumeration is due: new/updated + removed.
* - 'incremental' normal run: updatedAt.gt fetch, new/updated, removed:[].
* @param {{last_run?: string|null, last_full_enum?: string|null}} registry
* @param {string} now full ISO datetime
* @param {{maxAgeDays?: number}} [opts]
* @returns {'baseline'|'full'|'incremental'}
*/
export function selectMode(registry, now, opts = {}) {
if (!registry || !registry.last_run) return 'baseline';
return isFullEnumDue(registry, now, opts) ? 'full' : 'incremental';
}
// Strictly newer than what we last stored (an equal or older timestamp is a no-op).
function isNewer(apiUpdatedAt, storedUpdatedAt) {
if (!apiUpdatedAt) return false;
if (!storedUpdatedAt) return true;
return new Date(apiUpdatedAt).getTime() > new Date(storedUpdatedAt).getTime();
}
/**
* Diff a set of (already-normalized, already-in-domain) courses against the
* registry and classify leads. Leads are the normalized course objects; the
* detector enriches them with suggested_skill/category downstream.
*
* @param {Array<object>} courses normalized courses from the API for this run
* @param {{courses?: object}} registry
* @param {{mode: 'baseline'|'full'|'incremental'}} options
* @returns {{new: object[], updated: object[], removed: Array<{uid: string, title: string, url: string}>}}
*/
export function diffCourses(courses, registry, { mode } = {}) {
// §8 (b): a baseline run establishes the registry without emitting any leads —
// flagging hundreds of "new" courses on day one trains the operator that the
// tool is noise, not signal.
if (mode === 'baseline') return { new: [], updated: [], removed: [] };
const known = registry?.courses ?? {};
const newLeads = [];
const updatedLeads = [];
for (const c of courses) {
const prev = known[c.uid];
if (!prev) newLeads.push(c);
else if (isNewer(c.updated_at, prev.updated_at)) updatedLeads.push(c);
}
// §4.2 correctness pin: `removed` is computed ONLY on a full enumeration. An
// incremental updatedAt.gt response omits everything unchanged, so "in
// registry, not in response" is true for nearly the whole registry — a naive
// removed-diff would be a false-positive flood. Incremental forces removed:[].
let removed = [];
if (mode === 'full') {
const apiUids = new Set(courses.map((c) => c.uid));
removed = Object.entries(known)
.filter(([uid]) => !apiUids.has(uid))
.map(([uid, e]) => ({ uid, title: e.title ?? '', url: e.url ?? '' }));
}
return { new: newLeads, updated: updatedLeads, removed };
}
// Build a stored entry, preserving first_seen across runs and advancing last_seen.
function foldEntry(prev, c, now) {
return {
type: c.type,
title: c.title,
url: c.url,
products: c.products,
updated_at: c.updated_at,
first_seen: prev?.first_seen ?? now,
last_seen: now,
};
}
/**
* Fold this run's courses into the registry. Pure returns a new registry,
* never mutates the input.
* - incremental: merge (keep untouched UIDs, add/update the ones returned).
* - baseline | full: the API set IS the complete registry rebuild from it,
* dropping UIDs absent from the API (retired courses) so they aren't
* re-flagged as removed on the next full enum. Stamps last_full_enum = now.
* Every run advances last_run = now and seeds created_at when missing.
*
* @param {object} registry
* @param {Array<object>} courses normalized courses from the API for this run
* @param {string} now full ISO datetime
* @param {{mode: 'baseline'|'full'|'incremental'}} options
* @returns {object} new registry
*/
export function updateRegistry(registry, courses, now, { mode } = {}) {
const base = registry ?? { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} };
const prevCourses = base.courses ?? {};
const isFull = mode === 'baseline' || mode === 'full';
// incremental keeps the prior set; baseline/full rebuild from the API set only.
const nextCourses = isFull ? {} : { ...prevCourses };
for (const c of courses) nextCourses[c.uid] = foldEntry(prevCourses[c.uid], c, now);
return {
...base,
version: base.version ?? 1,
created_at: base.created_at ?? now,
last_run: now,
last_full_enum: isFull ? now : base.last_full_enum,
courses: nextCourses,
};
}

View file

@ -40,6 +40,40 @@ export function saveRegistry(registry, dataDir = DEFAULT_DATA_DIR) {
renameSync(tmp, path);
}
/**
* Load the course registry (C3 detector's private diff-state mirrors the URL
* registry but UID-keyed). Absent file empty scaffold with the full-ISO
* cursors (last_run + last_full_enum) the diff/mode logic relies on.
* @param {string} [dataDir] defaults to ../data/ relative to lib/
* @returns {object} parsed course registry or empty scaffold
*/
export function loadCourseRegistry(dataDir = DEFAULT_DATA_DIR) {
const path = join(dataDir, 'course-registry.json');
if (!existsSync(path)) {
return {
version: 1,
created_at: null,
last_run: null,
last_full_enum: null,
courses: {},
};
}
return JSON.parse(readFileSync(path, 'utf8'));
}
/**
* Save the course registry atomically (write to .tmp, then rename).
* @param {object} registry
* @param {string} [dataDir]
*/
export function saveCourseRegistry(registry, dataDir = DEFAULT_DATA_DIR) {
ensureDir(dataDir);
const path = join(dataDir, 'course-registry.json');
const tmp = path + '.tmp';
writeFileSync(tmp, JSON.stringify(registry, null, 2) + '\n', 'utf8');
renameSync(tmp, path);
}
/**
* Load a JSON report file (change-report.json or discovery-report.json).
* @param {string} name filename without path (e.g. 'change-report.json')

View file

@ -0,0 +1,296 @@
// tests/kb-update/test-course-diff.test.mjs
// Unit tests for scripts/kb-update/lib/course-diff.mjs (C3.2) — the PURE course
// diff + registry-fold + run-mode selector, plus the course-registry load/save
// round-trip (registry-io). Mirrors the diff semantics in c3-course-detection-plan.md
// §4.1/§4.2 and the §8 decisions:
// (b) a baseline run emits NO leads (registry-establishing only),
// (c) `removed` is computed ONLY on a full enumeration — never incrementally,
// where an updatedAt.gt response omits everything unchanged and a naive
// removed-diff would flag nearly the whole registry (false-positive flood).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
diffCourses,
updateRegistry,
selectMode,
isFullEnumDue,
normalizeCourse,
FULL_ENUM_MAX_AGE_DAYS,
} from '../../scripts/kb-update/lib/course-diff.mjs';
import { loadCourseRegistry, saveCourseRegistry } from '../../scripts/kb-update/lib/registry-io.mjs';
const NOW = '2026-06-23T12:00:00Z';
// A normalized course (the shape diffCourses/updateRegistry operate on).
function course(uid, updated_at, over = {}) {
return {
uid,
type: 'module',
title: `t-${uid}`,
url: `https://learn.microsoft.com/training/modules/${uid}`,
products: ['azure-openai'],
updated_at,
...over,
};
}
// A stored registry entry.
function entry(updated_at, over = {}) {
return {
type: 'module',
title: 'stored',
url: 'stored-url',
products: [],
updated_at,
first_seen: '2026-01-01T00:00:00Z',
last_seen: '2026-06-20T00:00:00Z',
...over,
};
}
function registryWith(courses = {}, meta = {}) {
return {
version: 1,
created_at: '2026-01-01T00:00:00Z',
last_run: '2026-06-20T00:00:00Z',
last_full_enum: '2026-06-20T00:00:00Z',
courses,
...meta,
};
}
// --- normalizeCourse -------------------------------------------------------
test('normalizeCourse — maps API fields to registry shape (products flattened)', () => {
const api = {
id: 'learn.az.x',
title: 'X',
url: 'https://learn.microsoft.com/training/modules/x',
updatedAt: '2026-06-01T00:00:00Z',
products: [{ id: 'azure-openai' }, { id: 'ai-builder' }],
};
const c = normalizeCourse(api, 'module');
assert.equal(c.uid, 'learn.az.x');
assert.equal(c.type, 'module');
assert.equal(c.title, 'X');
assert.equal(c.url, 'https://learn.microsoft.com/training/modules/x');
assert.equal(c.updated_at, '2026-06-01T00:00:00Z');
assert.deepEqual(c.products, ['azure-openai', 'ai-builder']);
});
test('normalizeCourse — missing fields degrade safely', () => {
const c = normalizeCourse({ id: 'u1' }, 'learning-path');
assert.equal(c.uid, 'u1');
assert.equal(c.type, 'learning-path');
assert.equal(c.title, '');
assert.equal(c.url, '');
assert.deepEqual(c.products, []);
assert.equal(c.updated_at, null);
});
// --- diffCourses: new / updated / no-op ------------------------------------
test('diffCourses — new UID (in API, not in registry) → new lead', () => {
const out = diffCourses([course('u1', '2026-06-22T00:00:00Z')], registryWith({}), { mode: 'incremental' });
assert.equal(out.new.length, 1);
assert.equal(out.new[0].uid, 'u1');
assert.equal(out.updated.length, 0);
assert.deepEqual(out.removed, []);
});
test('diffCourses — newer updatedAt on a known UID → updated lead', () => {
const reg = registryWith({ u1: entry('2026-06-01T00:00:00Z') });
const out = diffCourses([course('u1', '2026-06-22T00:00:00Z')], reg, { mode: 'incremental' });
assert.equal(out.updated.length, 1);
assert.equal(out.updated[0].uid, 'u1');
assert.equal(out.updated[0].updated_at, '2026-06-22T00:00:00Z');
assert.equal(out.new.length, 0);
});
test('diffCourses — identical updatedAt → no-op (no lead)', () => {
const reg = registryWith({ u1: entry('2026-06-01T00:00:00Z') });
const out = diffCourses([course('u1', '2026-06-01T00:00:00Z')], reg, { mode: 'incremental' });
assert.equal(out.new.length, 0);
assert.equal(out.updated.length, 0);
});
test('diffCourses — older updatedAt → no-op (never a downgrade lead)', () => {
const reg = registryWith({ u1: entry('2026-06-10T00:00:00Z') });
const out = diffCourses([course('u1', '2026-06-01T00:00:00Z')], reg, { mode: 'incremental' });
assert.equal(out.new.length, 0);
assert.equal(out.updated.length, 0);
});
// --- diffCourses: baseline emits no leads (§8 (b)) -------------------------
test('diffCourses — baseline (empty registry) emits NO leads (§8 (b) tom-baseline)', () => {
const empty = { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} };
const out = diffCourses(
[course('u1', '2026-06-01T00:00:00Z'), course('u2', '2026-06-02T00:00:00Z')],
empty,
{ mode: 'baseline' },
);
assert.deepEqual(out, { new: [], updated: [], removed: [] });
});
test('diffCourses — baseline emits no leads even against a non-empty registry', () => {
const reg = registryWith({ u1: entry('2026-01-01T00:00:00Z') });
const out = diffCourses([course('u1', '2026-06-22T00:00:00Z'), course('u2', '2026-06-22T00:00:00Z')], reg, { mode: 'baseline' });
assert.deepEqual(out, { new: [], updated: [], removed: [] });
});
// --- full-enum-pin: `removed` only on full enumeration (§4.2) --------------
test('diffCourses — removed is emitted ONLY in full mode (§4.2 correctness pin)', () => {
const reg = registryWith({
gone: entry('2026-01-01T00:00:00Z', { title: 'Gone', url: 'gone-url' }),
keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', url: 'keep-url' }),
});
const apiSet = [course('keep', '2026-01-01T00:00:00Z')]; // 'gone' absent from the full API set
const out = diffCourses(apiSet, reg, { mode: 'full' });
assert.deepEqual(out.removed.map((r) => r.uid), ['gone']);
assert.equal(out.removed[0].title, 'Gone');
assert.equal(out.removed[0].url, 'gone-url');
});
test('diffCourses — incremental NEVER emits removed, even when every registry UID is absent from the filtered response', () => {
const reg = registryWith({ a: entry('2026-01-01T00:00:00Z'), b: entry('2026-01-01T00:00:00Z') });
// An updatedAt.gt response with nothing changed returns []: all registry UIDs
// are "missing" — a naive removed-diff would flag the whole registry.
const out = diffCourses([], reg, { mode: 'incremental' });
assert.deepEqual(out.removed, []);
});
test('diffCourses — baseline never emits removed', () => {
const reg = registryWith({ a: entry('2026-01-01T00:00:00Z') });
const out = diffCourses([], reg, { mode: 'baseline' });
assert.deepEqual(out.removed, []);
});
// --- selectMode / isFullEnumDue (full-enum kadens) -------------------------
test('selectMode — no last_run → baseline', () => {
assert.equal(selectMode({ last_run: null, last_full_enum: null, courses: {} }, NOW), 'baseline');
});
test('selectMode — recent full enum → incremental', () => {
const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: '2026-06-20T00:00:00Z' });
assert.equal(selectMode(reg, NOW), 'incremental');
});
test('selectMode — last_full_enum missing but has run → full', () => {
const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: null });
assert.equal(selectMode(reg, NOW), 'full');
});
test('selectMode — last_full_enum older than 30 days → full', () => {
const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: '2026-05-01T00:00:00Z' });
assert.equal(selectMode(reg, NOW), 'full');
});
test('isFullEnumDue — exactly 30 days is NOT due; one second over IS due', () => {
// NOW = 2026-06-23T12:00:00Z; exactly 30 days earlier = 2026-05-24T12:00:00Z.
assert.equal(isFullEnumDue({ last_full_enum: '2026-05-24T12:00:00Z' }, NOW), false);
assert.equal(isFullEnumDue({ last_full_enum: '2026-05-24T11:59:59Z' }, NOW), true);
});
test('FULL_ENUM_MAX_AGE_DAYS — pinned at 30', () => {
assert.equal(FULL_ENUM_MAX_AGE_DAYS, 30);
});
// --- updateRegistry (pure fold) --------------------------------------------
test('updateRegistry — incremental merges (keep untouched, update changed, add new) and leaves last_full_enum', () => {
const reg = registryWith(
{
keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z' }),
chg: entry('2026-01-01T00:00:00Z', { title: 'Old', first_seen: '2026-02-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z' }),
},
{ last_full_enum: '2026-06-20T00:00:00Z' },
);
const next = updateRegistry(
reg,
[course('chg', '2026-06-22T00:00:00Z', { title: 'New' }), course('add', '2026-06-21T00:00:00Z')],
NOW,
{ mode: 'incremental' },
);
// untouched survives, last_seen NOT advanced
assert.ok(next.courses.keep);
assert.equal(next.courses.keep.last_seen, '2026-06-20T00:00:00Z');
// changed: updated_at refreshed, first_seen preserved, last_seen advanced
assert.equal(next.courses.chg.title, 'New');
assert.equal(next.courses.chg.updated_at, '2026-06-22T00:00:00Z');
assert.equal(next.courses.chg.first_seen, '2026-02-01T00:00:00Z');
assert.equal(next.courses.chg.last_seen, NOW);
// new: first_seen = now
assert.equal(next.courses.add.first_seen, NOW);
assert.equal(next.courses.add.last_seen, NOW);
// metadata
assert.equal(next.last_run, NOW);
assert.equal(next.last_full_enum, '2026-06-20T00:00:00Z');
});
test('updateRegistry — full rebuilds from the API set (drops retired UIDs) and stamps last_full_enum', () => {
const reg = registryWith(
{
gone: entry('2026-01-01T00:00:00Z', { title: 'Gone', first_seen: '2026-01-01T00:00:00Z' }),
keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', first_seen: '2026-03-01T00:00:00Z' }),
},
{ last_full_enum: '2026-05-01T00:00:00Z' },
);
const next = updateRegistry(reg, [course('keep', '2026-01-01T00:00:00Z')], NOW, { mode: 'full' });
assert.deepEqual(Object.keys(next.courses), ['keep']); // 'gone' dropped
assert.equal(next.courses.keep.first_seen, '2026-03-01T00:00:00Z'); // preserved
assert.equal(next.courses.keep.last_seen, NOW);
assert.equal(next.last_full_enum, NOW);
assert.equal(next.last_run, NOW);
});
test('updateRegistry — baseline on empty registry seeds created_at + last_full_enum', () => {
const empty = { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} };
const next = updateRegistry(empty, [course('u1', '2026-06-01T00:00:00Z')], NOW, { mode: 'baseline' });
assert.equal(next.created_at, NOW);
assert.equal(next.last_run, NOW);
assert.equal(next.last_full_enum, NOW);
assert.ok(next.courses.u1);
assert.equal(next.courses.u1.first_seen, NOW);
});
test('updateRegistry — does not mutate the input registry', () => {
const reg = registryWith({ u1: entry('2026-01-01T00:00:00Z') });
const before = JSON.stringify(reg);
updateRegistry(reg, [course('u1', '2026-06-22T00:00:00Z')], NOW, { mode: 'incremental' });
assert.equal(JSON.stringify(reg), before);
});
// --- course-registry load/save round-trip (registry-io) --------------------
test('loadCourseRegistry — absent file returns the empty scaffold (schema incl. last_full_enum)', () => {
const dir = mkdtempSync(join(tmpdir(), 'course-reg-'));
try {
assert.deepEqual(loadCourseRegistry(dir), {
version: 1,
created_at: null,
last_run: null,
last_full_enum: null,
courses: {},
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('saveCourseRegistry → loadCourseRegistry round-trips deeply', () => {
const dir = mkdtempSync(join(tmpdir(), 'course-reg-'));
try {
const reg = updateRegistry(loadCourseRegistry(dir), [course('u1', '2026-06-01T00:00:00Z')], NOW, { mode: 'baseline' });
saveCourseRegistry(reg, dir);
assert.deepEqual(loadCourseRegistry(dir), reg);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

View file

@ -1,8 +1,9 @@
// tests/kb-update/test-courses-invariant.test.mjs
// Architecture-invariant guard for the C3 course-detection libs (spec §2, §6).
// The network/secret layers are Claude-free and write NO files:
// The network/secret/diff layers are Claude-free and write NO files:
// - learn-api.mjs is a pure network client (writes nothing, no KB/ledger).
// - keychain.mjs reads secrets via the `security` CLI only.
// - course-diff.mjs is PURE diff/fold logic (zero imports; no fs, no IO).
// As with test-discover-invariant, the checks are import/source-specific: a
// comment that merely *describes* the invariant must not be able to satisfy it.
@ -16,6 +17,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const LIB = join(__dirname, '..', '..', 'scripts', 'kb-update', 'lib');
const learnApiSrc = readFileSync(join(LIB, 'learn-api.mjs'), 'utf8');
const keychainSrc = readFileSync(join(LIB, 'keychain.mjs'), 'utf8');
const courseDiffSrc = readFileSync(join(LIB, 'course-diff.mjs'), 'utf8');
const importLines = (src) => src.split('\n').filter((l) => /^\s*import\b/.test(l)).join('\n');
@ -43,3 +45,18 @@ test('keychain imports only child_process (reads via the security CLI)', () => {
assert.match(imports, /node:child_process/);
assert.doesNotMatch(imports, /node:fs|atomic-write|decisions-io|registry-io/);
});
test('course-diff is Claude-free (no claude/anthropic references)', () => {
assert.doesNotMatch(courseDiffSrc, /claude|anthropic/i);
});
test('course-diff is PURE — imports NO write-utils (fs / atomic-write / registry-io / decisions-io / save*)', () => {
const imports = importLines(courseDiffSrc);
assert.doesNotMatch(imports, /node:fs/);
assert.doesNotMatch(imports, /atomic-write|registry-io|decisions-io|backup/);
assert.doesNotMatch(imports, /\bsaveRegistry\b|\bsaveCourseRegistry\b|\bsaveDecisions\b|\bsaveReport\b/);
});
test('course-diff also does not call fs write functions inline', () => {
assert.doesNotMatch(courseDiffSrc, /\bwriteFileSync\b|\brenameSync\b|\bmkdirSync\b/);
});