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:
parent
0390cc10ca
commit
94c7f42128
4 changed files with 504 additions and 1 deletions
156
scripts/kb-update/lib/course-diff.mjs
Normal file
156
scripts/kb-update/lib/course-diff.mjs
Normal 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 (id→uid, updatedAt→updated_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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
296
tests/kb-update/test-course-diff.test.mjs
Normal file
296
tests/kb-update/test-course-diff.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue