Det rapporterende gulvet (§4 Port 3, P3b): SessionStart-hooken surfacer en KB-tillit-énlinjer fra cachet verified-staleness-report.json. Et GULV, ikke en gate (§4c — unngå alarm-tretthet på 84 %-signal). summarizeTrustFreshness (detection-schedule.mjs, ren, speiler summarizeSkillQuality): surfacer KUN drift (stale-since-verified) + kontraktbrudd (unverified) — det handlingsbare «bekreft mot kilde»-settet. unmigrated (Spor-1-backlog, 306 i dag) ekskluderes bevisst: ved det volumet ville det fyre hver økt og bli støy; migrerings-censusen hører i CT5 (P3c). Tolererer manglende/malformed cache → null (gitignored, fraværende i fersk klon, krasjer aldri hooken). session-start-context.mjs: leser cachen read-only (samme mønster som skill-quality-blokken), dytter linjen når den finnes. Ende-til-ende verifisert: injisert stale-rapport → «KB-tillit: 3 filer driftet siden verifisering, 1 uverifisert — bekreft mot kilde (aldri auto-fiks)»; ekte korpus (306 unmigrated) → taus (ingen falsk alarm). TDD (Iron Law): 8 nye tester. Suite 612/612. Plugin-validering PASS.
690 lines
32 KiB
JavaScript
690 lines
32 KiB
JavaScript
// tests/kb-update/test-detection-schedule.test.mjs
|
|
// Spor C / C1 (Sesjon 21): opt-in session-anchored detection.
|
|
//
|
|
// The detection layer (poll/report/discover/skill-lifecycle) is PURE node and
|
|
// never contacts Anthropic, so it is OUTSIDE the ToS surface (Consumer Terms
|
|
// §3.7 targets automated access to "our Services"). This suite proves:
|
|
// (A) the opt-in config defaults OFF and parses safely,
|
|
// (B) shouldRunDetection gates the hook's background spawn (disabled => never),
|
|
// (C) the skill-lifecycle one-liner surfaced at session start,
|
|
// (D) the STRUCTURAL ToS guarantee: run-detection.mjs is Claude-free — it
|
|
// only ever spawns `node` on the known detection scripts, never `claude`.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
DEFAULT_SCHEDULE_CONFIG,
|
|
DETECTION_STEPS,
|
|
parseScheduleConfig,
|
|
serializeScheduleConfig,
|
|
loadScheduleConfig,
|
|
shouldRunDetection,
|
|
selectDetectionSteps,
|
|
summarizeSkillLifecycle,
|
|
summarizeCourses,
|
|
summarizeSkillQuality,
|
|
summarizeTrustFreshness,
|
|
daysSinceLastPoll,
|
|
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
|
|
import { writeScheduleConfig } from '../../scripts/kb-update/write-schedule-config.mjs';
|
|
import { resolveConfigPath, resolveUserDataDir } from '../../scripts/kb-update/lib/user-data.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
|
|
|
// ===========================================================================
|
|
// (A) parseScheduleConfig / loadScheduleConfig — opt-in, default OFF
|
|
// ===========================================================================
|
|
|
|
test('DEFAULT_SCHEDULE_CONFIG — opt-in: disabled by default', () => {
|
|
assert.equal(DEFAULT_SCHEDULE_CONFIG.enabled, false, 'detection is OFF until the user opts in');
|
|
assert.equal(DEFAULT_SCHEDULE_CONFIG.interval_days, 7);
|
|
assert.equal(DEFAULT_SCHEDULE_CONFIG.include_skill_lifecycle, true);
|
|
});
|
|
|
|
// --- C3.4: include_course_detection (opt-in inside opt-in; default OFF) ---
|
|
|
|
test('DEFAULT_SCHEDULE_CONFIG — include_course_detection defaults to false (§8 d)', () => {
|
|
assert.equal(DEFAULT_SCHEDULE_CONFIG.include_course_detection, false,
|
|
'course detection needs Keychain creds almost nobody has => opt-in, default OFF');
|
|
});
|
|
|
|
test('parseScheduleConfig — reads include_course_detection: true', () => {
|
|
const cfg = parseScheduleConfig('scheduled_detection:\n include_course_detection: true\n');
|
|
assert.equal(cfg.include_course_detection, true);
|
|
});
|
|
|
|
test('parseScheduleConfig — a non-boolean include_course_detection stays OFF', () => {
|
|
const cfg = parseScheduleConfig('scheduled_detection:\n include_course_detection: maybe\n');
|
|
assert.equal(cfg.include_course_detection, false);
|
|
});
|
|
|
|
test('serializeScheduleConfig — emits + round-trips include_course_detection', () => {
|
|
assert.match(serializeScheduleConfig({}), /\n {2}include_course_detection: false/,
|
|
'serialized at its OFF default');
|
|
const cfg = parseScheduleConfig(serializeScheduleConfig({ include_course_detection: true }));
|
|
assert.equal(cfg.include_course_detection, true);
|
|
});
|
|
|
|
test('parseScheduleConfig — empty/absent text returns the OFF default', () => {
|
|
assert.deepEqual(parseScheduleConfig(''), DEFAULT_SCHEDULE_CONFIG);
|
|
assert.deepEqual(parseScheduleConfig('# just some markdown, no frontmatter'), DEFAULT_SCHEDULE_CONFIG);
|
|
});
|
|
|
|
test('parseScheduleConfig — reads an enabled block with overrides', () => {
|
|
const text = [
|
|
'---',
|
|
'scheduled_detection:',
|
|
' enabled: true',
|
|
' interval_days: 3',
|
|
' include_skill_lifecycle: false',
|
|
'---',
|
|
'',
|
|
'# notes',
|
|
].join('\n');
|
|
const cfg = parseScheduleConfig(text);
|
|
assert.equal(cfg.enabled, true);
|
|
assert.equal(cfg.interval_days, 3);
|
|
assert.equal(cfg.include_skill_lifecycle, false);
|
|
});
|
|
|
|
test('parseScheduleConfig — partial block keeps defaults for missing keys', () => {
|
|
const cfg = parseScheduleConfig('scheduled_detection:\n enabled: true\n');
|
|
assert.equal(cfg.enabled, true);
|
|
assert.equal(cfg.interval_days, 7, 'unspecified key falls back to default');
|
|
assert.equal(cfg.include_skill_lifecycle, true);
|
|
});
|
|
|
|
test('parseScheduleConfig — malformed input never throws, falls back to OFF', () => {
|
|
assert.equal(parseScheduleConfig('scheduled_detection: : : nonsense').enabled, false);
|
|
assert.equal(parseScheduleConfig('scheduled_detection:\n enabled: maybe\n').enabled, false,
|
|
'a non-boolean enabled is treated as OFF');
|
|
});
|
|
|
|
// --- Tier 2 (launchd) cadence: os_scheduler_cadence (Sesjon 24) ---
|
|
|
|
test('DEFAULT_SCHEDULE_CONFIG — os_scheduler_cadence defaults to daily', () => {
|
|
assert.equal(DEFAULT_SCHEDULE_CONFIG.os_scheduler_cadence, 'daily');
|
|
});
|
|
|
|
test('parseScheduleConfig — reads os_scheduler_cadence: interval', () => {
|
|
const cfg = parseScheduleConfig('scheduled_detection:\n os_scheduler_cadence: interval\n');
|
|
assert.equal(cfg.os_scheduler_cadence, 'interval');
|
|
});
|
|
|
|
test('parseScheduleConfig — unknown/garbage cadence falls back to daily', () => {
|
|
assert.equal(parseScheduleConfig('scheduled_detection:\n os_scheduler_cadence: weekly\n').os_scheduler_cadence, 'daily');
|
|
assert.equal(parseScheduleConfig('scheduled_detection:\n enabled: true\n').os_scheduler_cadence, 'daily',
|
|
'unspecified cadence => daily default');
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (A2) C2.3 — serializeScheduleConfig + writeScheduleConfig (onboarding writes
|
|
// the opt-in config; parseScheduleConfig round-trips it — acceptance K5)
|
|
// ===========================================================================
|
|
|
|
test('serializeScheduleConfig — produces a parseable scheduled_detection: block', () => {
|
|
const text = serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'interval' });
|
|
assert.match(text, /^---/, 'frontmatter-fenced config file');
|
|
assert.match(text, /scheduled_detection:/);
|
|
assert.match(text, /\n {2}enabled: true/);
|
|
assert.match(text, /\n {2}os_scheduler_cadence: interval/);
|
|
});
|
|
|
|
test('serializeScheduleConfig — round-trips through parseScheduleConfig (daily)', () => {
|
|
const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'daily' }));
|
|
assert.equal(cfg.enabled, true);
|
|
assert.equal(cfg.os_scheduler_cadence, 'daily');
|
|
assert.equal(cfg.interval_days, 7, 'unspecified key serialized at its default');
|
|
assert.equal(cfg.include_skill_lifecycle, true);
|
|
});
|
|
|
|
test('serializeScheduleConfig — round-trips interval cadence + disabled', () => {
|
|
const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: false, os_scheduler_cadence: 'interval' }));
|
|
assert.equal(cfg.enabled, false);
|
|
assert.equal(cfg.os_scheduler_cadence, 'interval');
|
|
});
|
|
|
|
test('serializeScheduleConfig — preserves a non-default interval_days through the round-trip', () => {
|
|
const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, interval_days: 14, os_scheduler_cadence: 'daily' }));
|
|
assert.equal(cfg.interval_days, 14);
|
|
});
|
|
|
|
test('serializeScheduleConfig — garbage cadence is normalized to daily', () => {
|
|
const cfg = parseScheduleConfig(serializeScheduleConfig({ enabled: true, os_scheduler_cadence: 'weekly' }));
|
|
assert.equal(cfg.os_scheduler_cadence, 'daily');
|
|
});
|
|
|
|
test('writeScheduleConfig — writes user config that parseScheduleConfig reads back (K5)', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
const { configPath } = writeScheduleConfig({ enabled: true, cadence: 'interval', home });
|
|
assert.equal(configPath, resolveConfigPath(home), 'writes to the user-owned config path');
|
|
const cfg = parseScheduleConfig(readFileSync(configPath, 'utf8'));
|
|
assert.equal(cfg.enabled, true);
|
|
assert.equal(cfg.os_scheduler_cadence, 'interval');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('writeScheduleConfig — coerces string flags (CLI args arrive as strings)', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
writeScheduleConfig({ enabled: 'true', cadence: 'daily', home });
|
|
const cfg = parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8'));
|
|
assert.equal(cfg.enabled, true, '"true" string => boolean true');
|
|
assert.equal(cfg.os_scheduler_cadence, 'daily');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('writeScheduleConfig — merge preserves pre-existing keys (interval_days) it does not own', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
mkdirSync(resolveUserDataDir(home), { recursive: true });
|
|
writeFileSync(
|
|
resolveConfigPath(home),
|
|
'scheduled_detection:\n enabled: true\n interval_days: 21\n include_skill_lifecycle: false\n',
|
|
);
|
|
writeScheduleConfig({ enabled: false, cadence: 'interval', home });
|
|
const cfg = parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8'));
|
|
assert.equal(cfg.enabled, false, 'onboarding-owned key updated');
|
|
assert.equal(cfg.os_scheduler_cadence, 'interval', 'onboarding-owned key updated');
|
|
assert.equal(cfg.interval_days, 21, 'non-owned key preserved across the rewrite');
|
|
assert.equal(cfg.include_skill_lifecycle, false, 'non-owned key preserved across the rewrite');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// --- C3 (3): --courses flag wires include_course_detection from the CLI ---
|
|
test('writeScheduleConfig — --courses true sets include_course_detection (opt-in inside opt-in)', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
writeScheduleConfig({ enabled: true, cadence: 'daily', courses: true, home });
|
|
const cfg = parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8'));
|
|
assert.equal(cfg.include_course_detection, true);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('writeScheduleConfig — coerces the --courses string flag (CLI args arrive as strings)', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
writeScheduleConfig({ enabled: true, cadence: 'daily', courses: 'true', home });
|
|
assert.equal(parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8')).include_course_detection, true);
|
|
writeScheduleConfig({ enabled: true, cadence: 'daily', courses: 'false', home });
|
|
assert.equal(parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8')).include_course_detection, false);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('writeScheduleConfig — omitting --courses preserves a pre-existing include_course_detection', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
mkdirSync(resolveUserDataDir(home), { recursive: true });
|
|
writeFileSync(
|
|
resolveConfigPath(home),
|
|
'scheduled_detection:\n enabled: true\n include_course_detection: true\n',
|
|
);
|
|
writeScheduleConfig({ enabled: false, cadence: 'interval', home }); // no `courses` arg
|
|
const cfg = parseScheduleConfig(readFileSync(resolveConfigPath(home), 'utf8'));
|
|
assert.equal(cfg.include_course_detection, true, 'non-owned key preserved when --courses is omitted');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('writeScheduleConfig — backs up an existing config before overwriting it', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
writeScheduleConfig({ enabled: true, cadence: 'daily', home }); // first write: nothing to back up
|
|
const backupRoot = join(resolveUserDataDir(home), '.backups');
|
|
assert.equal(existsSync(backupRoot), false, 'no backup created on first write (no prior file)');
|
|
writeScheduleConfig({ enabled: false, cadence: 'interval', home }); // second write: prior file exists
|
|
assert.equal(existsSync(backupRoot), true, 'prior config backed up before the second write');
|
|
const stamps = readdirSync(backupRoot);
|
|
assert.equal(stamps.length, 1, 'exactly one backup generation');
|
|
const backedUp = parseScheduleConfig(
|
|
readFileSync(join(backupRoot, stamps[0], 'ms-ai-architect.local.md'), 'utf8'),
|
|
);
|
|
assert.equal(backedUp.enabled, true, 'backup holds the PRIOR (pre-overwrite) config');
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('writeScheduleConfig — loadScheduleConfig picks up what onboarding wrote (end-to-end)', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sched-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
writeScheduleConfig({ enabled: true, cadence: 'interval', home });
|
|
const cfg = loadScheduleConfig(root, home);
|
|
assert.equal(cfg.enabled, true, 'the scheduler loads the onboarding-written user config');
|
|
assert.equal(cfg.os_scheduler_cadence, 'interval');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('daysSinceLastPoll — Infinity when report missing or last_poll absent/invalid', () => {
|
|
const now = Date.UTC(2026, 5, 21);
|
|
assert.equal(daysSinceLastPoll(null, now), Infinity);
|
|
assert.equal(daysSinceLastPoll({}, now), Infinity);
|
|
assert.equal(daysSinceLastPoll({ last_poll: 'not-a-date' }, now), Infinity);
|
|
});
|
|
|
|
test('daysSinceLastPoll — computes whole days since last_poll', () => {
|
|
const now = Date.UTC(2026, 5, 21, 0, 0, 0);
|
|
const tenDaysAgo = new Date(Date.UTC(2026, 5, 11, 0, 0, 0)).toISOString();
|
|
assert.equal(daysSinceLastPoll({ last_poll: tenDaysAgo }, now), 10);
|
|
});
|
|
|
|
test('loadScheduleConfig — no config (neither user nor plugin-root) => OFF default', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sched-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'home-')); // empty home: no user-owned config
|
|
try {
|
|
assert.deepEqual(loadScheduleConfig(root, home), DEFAULT_SCHEDULE_CONFIG);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('loadScheduleConfig — reads plugin-root ms-ai-architect.local.md when no user config (fallback)', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sched-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
writeFileSync(
|
|
join(root, 'ms-ai-architect.local.md'),
|
|
'---\nscheduled_detection:\n enabled: true\n interval_days: 14\n---\n',
|
|
);
|
|
const cfg = loadScheduleConfig(root, home);
|
|
assert.equal(cfg.enabled, true);
|
|
assert.equal(cfg.interval_days, 14);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// --- C2.1: user-owned config wins; plugin-root is the backward-compat fallback ---
|
|
|
|
function writeUserConfig(home, text) {
|
|
mkdirSync(resolveUserDataDir(home), { recursive: true });
|
|
writeFileSync(resolveConfigPath(home), text);
|
|
}
|
|
|
|
test('loadScheduleConfig — user-owned config is read even when pluginRoot has none (K2 survival)', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sched-')); // fresh reinstall: plugin root has no config
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
writeUserConfig(home, '---\nscheduled_detection:\n enabled: true\n interval_days: 5\n---\n');
|
|
const cfg = loadScheduleConfig(root, home);
|
|
assert.equal(cfg.enabled, true, 'config survives in the user dir, independent of pluginRoot');
|
|
assert.equal(cfg.interval_days, 5);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('loadScheduleConfig — user config wins over a plugin-root config', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'sched-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'home-'));
|
|
try {
|
|
writeFileSync(join(root, 'ms-ai-architect.local.md'), 'scheduled_detection:\n enabled: true\n interval_days: 99\n');
|
|
writeUserConfig(home, 'scheduled_detection:\n enabled: true\n interval_days: 3\n');
|
|
const cfg = loadScheduleConfig(root, home);
|
|
assert.equal(cfg.interval_days, 3, 'user path takes precedence over plugin-root');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (B) shouldRunDetection — the hook's background-spawn gate
|
|
// ===========================================================================
|
|
|
|
test('shouldRunDetection — DISABLED (default) never runs, even when stale', () => {
|
|
const r = shouldRunDetection(DEFAULT_SCHEDULE_CONFIG, 999);
|
|
assert.equal(r.run, false);
|
|
assert.match(r.reason, /opt-in|disabled/i);
|
|
});
|
|
|
|
test('shouldRunDetection — enabled + stale (days >= interval) runs', () => {
|
|
const cfg = { enabled: true, interval_days: 7, include_skill_lifecycle: true };
|
|
assert.equal(shouldRunDetection(cfg, 7).run, true, 'boundary: exactly interval => run');
|
|
assert.equal(shouldRunDetection(cfg, 30).run, true);
|
|
});
|
|
|
|
test('shouldRunDetection — enabled + fresh (days < interval) does NOT run', () => {
|
|
const cfg = { enabled: true, interval_days: 7, include_skill_lifecycle: true };
|
|
const r = shouldRunDetection(cfg, 3);
|
|
assert.equal(r.run, false);
|
|
assert.match(r.reason, /fresh|fersk/i);
|
|
});
|
|
|
|
test('shouldRunDetection — force overrides a disabled config', () => {
|
|
assert.equal(shouldRunDetection(DEFAULT_SCHEDULE_CONFIG, 0, { force: true }).run, true);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (C) summarizeSkillLifecycle — one-liner surfaced at session start
|
|
// ===========================================================================
|
|
|
|
const REPORT_WITH_SIGNALS = {
|
|
overlap: { pairs: [{ pair: ['ms-ai-engineering', 'ms-ai-infrastructure'], combined: 7.4167 }, { pair: ['a', 'b'], combined: 2.1 }] },
|
|
coverage: { summary: { gaps: 1, thin: 2 }, gaps: [{ category: 'x' }] },
|
|
bloat: { summary: { bloatCandidates: [], staleCandidates: ['ms-ai-governance'] } },
|
|
};
|
|
|
|
test('summarizeSkillLifecycle — surfaces overlap-over-threshold, gaps, thin, stale', () => {
|
|
const s = summarizeSkillLifecycle(REPORT_WITH_SIGNALS, { threshold: 7.0 });
|
|
assert.match(s, /Skill-signaler:/);
|
|
assert.match(s, /1 overlapp-par/, 'only the combined>=7 pair counts');
|
|
assert.match(s, /1 (gap|dekningshull)/);
|
|
assert.match(s, /2 tynne/);
|
|
assert.match(s, /1 stale/);
|
|
});
|
|
|
|
test('summarizeSkillLifecycle — no signals returns null (nothing to surface)', () => {
|
|
const empty = {
|
|
overlap: { pairs: [{ pair: ['a', 'b'], combined: 1.0 }] },
|
|
coverage: { summary: { gaps: 0, thin: 0 } },
|
|
bloat: { summary: { bloatCandidates: [], staleCandidates: [] } },
|
|
};
|
|
assert.equal(summarizeSkillLifecycle(empty, { threshold: 7.0 }), null);
|
|
});
|
|
|
|
test('summarizeSkillLifecycle — tolerates a missing/partial report shape', () => {
|
|
assert.equal(summarizeSkillLifecycle({}, { threshold: 7.0 }), null);
|
|
assert.equal(summarizeSkillLifecycle(null, { threshold: 7.0 }), null);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (C2) summarizeCourses — one-liner surfaced at session start (Spor C / C3.6)
|
|
// ===========================================================================
|
|
// Mirrors summarizeSkillLifecycle: a pure one-liner over a parsed
|
|
// course-detection-report.json, surfacing NEW + UPDATED course leads in
|
|
// covered products. `removed` is an informational rapport-signal (spec §4.2),
|
|
// NOT a surfaced lead, so it must never make this return non-null on its own.
|
|
|
|
const COURSE_REPORT_OK = {
|
|
generated_at: '2026-06-23T12:00:00Z',
|
|
status: 'ok',
|
|
skipped_reason: null,
|
|
new: [{ uid: 'm1' }, { uid: 'm2' }],
|
|
updated: [{ uid: 'm3' }],
|
|
removed: [],
|
|
counts: { new: 2, updated: 1, removed: 0 },
|
|
};
|
|
|
|
test('summarizeCourses — surfaces new + updated leads in the canonical one-liner', () => {
|
|
const s = summarizeCourses(COURSE_REPORT_OK);
|
|
assert.match(s, /Kurs-signaler:/);
|
|
assert.match(s, /2 nye/);
|
|
assert.match(s, /1 endrede/);
|
|
assert.match(s, /kurs i dekkede produkter/);
|
|
});
|
|
|
|
test('summarizeCourses — only-new and only-updated each render alone', () => {
|
|
const onlyNew = summarizeCourses({ status: 'ok', counts: { new: 3, updated: 0, removed: 0 } });
|
|
assert.match(onlyNew, /3 nye kurs i dekkede produkter/);
|
|
assert.doesNotMatch(onlyNew, /endrede/);
|
|
const onlyUpdated = summarizeCourses({ status: 'ok', counts: { new: 0, updated: 4, removed: 0 } });
|
|
assert.match(onlyUpdated, /4 endrede kurs i dekkede produkter/);
|
|
assert.doesNotMatch(onlyUpdated, /nye/);
|
|
});
|
|
|
|
test('summarizeCourses — zero new + zero updated returns null (nothing to surface)', () => {
|
|
assert.equal(summarizeCourses({ status: 'ok', counts: { new: 0, updated: 0, removed: 0 } }), null);
|
|
});
|
|
|
|
test('summarizeCourses — removed-only is informational, not a surfaced lead (spec §4.2)', () => {
|
|
assert.equal(
|
|
summarizeCourses({ status: 'ok', counts: { new: 0, updated: 0, removed: 5 } }),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('summarizeCourses — skipped/error reports surface nothing', () => {
|
|
const skipped = { status: 'skipped', skipped_reason: 'creds', counts: { new: 0, updated: 0, removed: 0 } };
|
|
const errored = { status: 'error', skipped_reason: 'network', counts: { new: 0, updated: 0, removed: 0 } };
|
|
assert.equal(summarizeCourses(skipped), null);
|
|
assert.equal(summarizeCourses(errored), null);
|
|
});
|
|
|
|
test('summarizeCourses — tolerates a missing/partial report shape', () => {
|
|
assert.equal(summarizeCourses({}, {}), null);
|
|
assert.equal(summarizeCourses(null), null);
|
|
assert.equal(summarizeCourses({ status: 'ok' }), null); // no counts
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (C3) summarizeSkillQuality — skill-quality one-liner at session start (Spor D / Steg C)
|
|
// ===========================================================================
|
|
// Reads the CACHED skill-score-report.json (produced by `score-skill.mjs
|
|
// --write`), it NEVER scores live — buildReport reads ~400 ref-files, far too
|
|
// expensive for a SessionStart hook. The cache is gitignored => ABSENT in a
|
|
// fresh clone, so this MUST tolerate a missing/malformed cache → null and never
|
|
// crash the hook. Mirrors summarizeCourses / summarizeSkillLifecycle: a pure
|
|
// one-liner, null when there is nothing worth surfacing (all skills ≥ target).
|
|
|
|
const SCORE_CACHE_ALL_PASS = {
|
|
generatedAt: '2026-06-23',
|
|
target: 90,
|
|
scored: [
|
|
{ name: 'ms-ai-advisor', score: 91, meetsTarget: true },
|
|
{ name: 'ms-ai-security', score: 96, meetsTarget: true },
|
|
],
|
|
below: [],
|
|
};
|
|
|
|
const SCORE_CACHE_TWO_BELOW = {
|
|
generatedAt: '2026-06-23',
|
|
target: 90,
|
|
scored: [
|
|
{ name: 'ms-ai-advisor', score: 88, meetsTarget: false },
|
|
{ name: 'ms-ai-engineering', score: 72, meetsTarget: false },
|
|
{ name: 'ms-ai-security', score: 96, meetsTarget: true },
|
|
],
|
|
below: ['ms-ai-advisor', 'ms-ai-engineering'],
|
|
};
|
|
|
|
test('summarizeSkillQuality — surfaces the count + names of skills below target', () => {
|
|
const s = summarizeSkillQuality(SCORE_CACHE_TWO_BELOW);
|
|
assert.match(s, /Skill-kvalitet:/);
|
|
assert.match(s, /2 skills < 90 %/, 'canonical one-liner form (spec §3 komponent 4)');
|
|
assert.match(s, /ms-ai-advisor/);
|
|
assert.match(s, /ms-ai-engineering/);
|
|
});
|
|
|
|
test('summarizeSkillQuality — singular noun for exactly one skill below target', () => {
|
|
const s = summarizeSkillQuality({
|
|
target: 90,
|
|
below: ['ms-ai-advisor'],
|
|
scored: [{ name: 'ms-ai-advisor', meetsTarget: false }],
|
|
});
|
|
assert.match(s, /1 skill < 90 %/);
|
|
assert.doesNotMatch(s, /1 skills/, 'no plural "skills" for a single skill');
|
|
});
|
|
|
|
test('summarizeSkillQuality — all skills meet target returns null (nothing to surface)', () => {
|
|
assert.equal(summarizeSkillQuality(SCORE_CACHE_ALL_PASS), null);
|
|
});
|
|
|
|
test('summarizeSkillQuality — missing cache (fresh clone, gitignored) returns null', () => {
|
|
assert.equal(summarizeSkillQuality(null), null);
|
|
assert.equal(summarizeSkillQuality(undefined), null);
|
|
});
|
|
|
|
test('summarizeSkillQuality — malformed cache returns null (never crashes the hook)', () => {
|
|
assert.equal(summarizeSkillQuality({}), null, 'no below[] and no scored[] => unusable');
|
|
assert.equal(summarizeSkillQuality('nonsense'), null, 'non-object');
|
|
assert.equal(summarizeSkillQuality({ target: 90 }), null, 'target only, no skill data');
|
|
});
|
|
|
|
test('summarizeSkillQuality — uses the cache target in the message (not a hardcoded 90)', () => {
|
|
const s = summarizeSkillQuality({
|
|
target: 85,
|
|
below: ['ms-ai-advisor'],
|
|
scored: [{ name: 'ms-ai-advisor', meetsTarget: false }],
|
|
});
|
|
assert.match(s, /< 85 %/);
|
|
});
|
|
|
|
test('summarizeSkillQuality — derives below from scored when below[] is absent (shape tolerance)', () => {
|
|
const s = summarizeSkillQuality({
|
|
target: 90,
|
|
scored: [
|
|
{ name: 'ms-ai-advisor', meetsTarget: false },
|
|
{ name: 'ms-ai-security', meetsTarget: true },
|
|
],
|
|
});
|
|
assert.match(s, /1 skill < 90 %/);
|
|
assert.match(s, /ms-ai-advisor/);
|
|
assert.doesNotMatch(s, /ms-ai-security/, 'only below-target skills are named');
|
|
});
|
|
|
|
// ===========================================================================
|
|
// (D) STRUCTURAL ToS GUARANTEE — detection is Claude-free
|
|
// ===========================================================================
|
|
|
|
const KNOWN_DETECTION_SCRIPTS = new Set([
|
|
'poll-sitemaps.mjs',
|
|
'report-changes.mjs',
|
|
'discover-new-urls.mjs',
|
|
'detect-skill-lifecycle.mjs',
|
|
'detect-courses.mjs',
|
|
'build-registry.mjs',
|
|
]);
|
|
|
|
test('DETECTION_STEPS — only the known pure-node detection scripts, never claude', () => {
|
|
assert.ok(DETECTION_STEPS.length >= 4);
|
|
for (const step of DETECTION_STEPS) {
|
|
assert.ok(step.script.endsWith('.mjs'), `${step.script} must be a node script`);
|
|
assert.ok(KNOWN_DETECTION_SCRIPTS.has(step.script), `${step.script} must be an allow-listed detection script`);
|
|
assert.ok(['kb-update', 'kb-eval'].includes(step.dir), `${step.dir} unexpected`);
|
|
}
|
|
// skill-lifecycle detection must be present and flagged so config can exclude it
|
|
assert.ok(DETECTION_STEPS.some((s) => s.script === 'detect-skill-lifecycle.mjs' && s.skillLifecycle === true));
|
|
// C3.4: course detection present and flagged so config can exclude it (opt-in)
|
|
assert.ok(DETECTION_STEPS.some((s) => s.script === 'detect-courses.mjs' && s.courseDetection === true));
|
|
});
|
|
|
|
// --- C3.4: selectDetectionSteps — the pure gate run-detection.mjs applies ---
|
|
|
|
test('selectDetectionSteps — drops detect-courses when include_course_detection is OFF (default)', () => {
|
|
const steps = selectDetectionSteps(DEFAULT_SCHEDULE_CONFIG);
|
|
assert.ok(!steps.some((s) => s.courseDetection), 'course detection excluded by default');
|
|
// the always-on steps survive
|
|
assert.ok(steps.some((s) => s.script === 'poll-sitemaps.mjs'));
|
|
});
|
|
|
|
test('selectDetectionSteps — includes detect-courses when opted in', () => {
|
|
const steps = selectDetectionSteps({ ...DEFAULT_SCHEDULE_CONFIG, include_course_detection: true });
|
|
assert.ok(steps.some((s) => s.script === 'detect-courses.mjs' && s.courseDetection === true));
|
|
});
|
|
|
|
test('selectDetectionSteps — the two opt-in gates are independent', () => {
|
|
const onlyCourses = selectDetectionSteps({
|
|
...DEFAULT_SCHEDULE_CONFIG,
|
|
include_skill_lifecycle: false,
|
|
include_course_detection: true,
|
|
});
|
|
assert.ok(!onlyCourses.some((s) => s.skillLifecycle), 'skill-lifecycle gated OFF');
|
|
assert.ok(onlyCourses.some((s) => s.courseDetection), 'course detection gated ON');
|
|
});
|
|
|
|
test('run-detection.mjs source — gates detection via the shared selector (no duplicated filter)', () => {
|
|
const src = readFileSync(join(PLUGIN_ROOT, 'scripts', 'kb-update', 'run-detection.mjs'), 'utf8');
|
|
assert.match(src, /selectDetectionSteps\(/, 'run-detection uses the pure step selector');
|
|
});
|
|
|
|
test('run-detection.mjs source — spawns node, NEVER claude (the ToS boundary made structural)', () => {
|
|
const src = readFileSync(join(PLUGIN_ROOT, 'scripts', 'kb-update', 'run-detection.mjs'), 'utf8');
|
|
// It must run node on local scripts...
|
|
assert.match(src, /execFileSync\(\s*'node'|spawn\(\s*'node'/, 'detection runs `node`');
|
|
// ...and must NEVER invoke the claude binary. Strip comments first so the
|
|
// Claude-free CONTRACT can be documented in prose without tripping the guard:
|
|
// the guarantee is "no claude INVOCATION", not "the word never appears".
|
|
const code = src.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
assert.doesNotMatch(code, /(execFileSync|spawn|exec|execSync)\(\s*['"`]claude['"`]/i, 'never spawns claude');
|
|
assert.doesNotMatch(code, /['"`]claude['"`]/i, 'no claude binary referenced in code');
|
|
assert.doesNotMatch(code, /anthropic/i, 'code must not call Anthropic');
|
|
});
|
|
|
|
// ===========================================================================
|
|
// summarizeTrustFreshness — KB-trust reporting floor at session start (Spor 3 P3b)
|
|
// ===========================================================================
|
|
// Reads the CACHED verified-staleness-report.json (produced by
|
|
// report-verified-staleness.mjs on the KB-refresh cadence) — it NEVER judges
|
|
// live. That cache is gitignored => ABSENT in a fresh clone, so this MUST
|
|
// tolerate a missing/malformed report → null and never crash the hook. Mirrors
|
|
// summarizeSkillQuality: a pure one-liner, null when there is nothing worth
|
|
// surfacing. A REPORTING FLOOR, not a gate (§4c — avoid alarm fatigue): it
|
|
// surfaces only drift (stale-since-verified) + contract breaches (unverified).
|
|
// `unmigrated` (the Spor-1 backlog, 306 today) is DELIBERATELY excluded — at
|
|
// that volume it would fire every session and become pure noise; the migration
|
|
// census belongs to CT5 (P3c), not this per-session line.
|
|
|
|
const STALE_REPORT = {
|
|
generated_at: '2026-06-29',
|
|
counts: { total: 10, fresh: 4, stale: 3, unverified: 1, unmigrated: 2, outOfScope: 0 },
|
|
flagged: [],
|
|
};
|
|
|
|
test('summarizeTrustFreshness — surfaces drift + contract breaches', () => {
|
|
const s = summarizeTrustFreshness(STALE_REPORT);
|
|
assert.match(s, /KB-tillit:/);
|
|
assert.match(s, /3 .*driftet/, 'stale count surfaced');
|
|
assert.match(s, /1 uverifisert/, 'unverified count surfaced');
|
|
});
|
|
|
|
test('summarizeTrustFreshness — stale only renders without the unverified clause', () => {
|
|
const s = summarizeTrustFreshness({ counts: { stale: 2, unverified: 0, unmigrated: 50 } });
|
|
assert.match(s, /2 filer driftet/);
|
|
assert.doesNotMatch(s, /uverifisert/);
|
|
});
|
|
|
|
test('summarizeTrustFreshness — unverified only renders without the drift clause', () => {
|
|
const s = summarizeTrustFreshness({ counts: { stale: 0, unverified: 1, unmigrated: 50 } });
|
|
assert.match(s, /1 uverifisert/);
|
|
assert.doesNotMatch(s, /driftet/);
|
|
});
|
|
|
|
test('summarizeTrustFreshness — singular "fil" for exactly one drifted file', () => {
|
|
const s = summarizeTrustFreshness({ counts: { stale: 1, unverified: 0 } });
|
|
assert.match(s, /1 fil driftet/);
|
|
assert.doesNotMatch(s, /1 filer/, 'no plural for a single file');
|
|
});
|
|
|
|
test('summarizeTrustFreshness — unmigrated-only is silent (Spor-1 backlog, not a drift signal)', () => {
|
|
assert.equal(summarizeTrustFreshness({ counts: { stale: 0, unverified: 0, unmigrated: 306 } }), null);
|
|
});
|
|
|
|
test('summarizeTrustFreshness — clean corpus (no stale, no unverified) returns null', () => {
|
|
assert.equal(summarizeTrustFreshness({ counts: { total: 5, fresh: 5, stale: 0, unverified: 0 } }), null);
|
|
});
|
|
|
|
test('summarizeTrustFreshness — missing report (fresh clone, gitignored) returns null', () => {
|
|
assert.equal(summarizeTrustFreshness(null), null);
|
|
assert.equal(summarizeTrustFreshness(undefined), null);
|
|
});
|
|
|
|
test('summarizeTrustFreshness — malformed report returns null (never crashes the hook)', () => {
|
|
assert.equal(summarizeTrustFreshness({}), null, 'no counts => unusable');
|
|
assert.equal(summarizeTrustFreshness('nonsense'), null, 'non-object');
|
|
assert.equal(summarizeTrustFreshness({ counts: 'bad' }), null, 'counts not an object');
|
|
});
|