ms-ai-architect/tests/kb-update/test-detection-schedule.test.mjs
Kjell Tore Guttormsen 2e69e69326 feat(ms-ai-architect): C3.4 — detect-courses.mjs (binder C3.1–C3.3, LLM-fri) + selectDetectionSteps + include_course_detection (TDD) [skip-docs]
LLM-fri STEG 1-detektor for kurs-deteksjon (C3.4 av GODKJENT C3-spec). Speiler
discover-new-urls' to-stegs-monster: leser Keychain-creds, paginerer modules +
learning-paths filtrert til in-domain produkter, differ mot egen diff-state
(course-registry.json), skriver kandidatrapport. Skriver KUN report + registry —
ALDRI decisions.json (operator-gaten i C3.5 promoterer leads).

- scripts/kb-update/detect-courses.mjs (NY): detectCourses(deps) — injiserbar
  kjerne (readSecret/getToken/paginate/loadTaxonomy/load+saveCourseRegistry/
  saveReport/dataDir/now/log) -> hermetisk testbar uten Keychain/nett. Fail-soft
  (spec 3/6): creds=null -> status:"skipped"; run-feil -> status:"error" (kaster
  ALDRI); begge exit 0 (advisory, aldri rod pipeline). Tynn CLI med realpath-guard
  (kjorer kun nar invokert direkte) + --data-dir test-seam. updatedAt.gt-cursor
  KUN pa incremental (full ISO, gotcha #1); products-filter = comma-join (OR).
- lib/detection-schedule.mjs: ny ren selectDetectionSteps(config) gater BADE
  skillLifecycle + courseDetection (en sannhetskilde). DEFAULT_SCHEDULE_CONFIG
  +include_course_detection:false (opt-in i opt-in, spec 8d). coerce/serialize
  utvidet. DETECTION_STEPS +{detect-courses, courseDetection:true}.
- run-detection.mjs: bruker selectDetectionSteps (fjerner duplisert filter-linje).

Tester (+22):
- test-detect-courses-invariant (8): ingen saveDecisions/decisions-io/atomic-write/
  backup-import; skriver egen registry+report; binder diffCourses+makeCourseClassifier;
  LLM-fri (kommentar-strippet kildesjekk).
- test-detect-courses (6): fail-soft skipped (alle/delvis creds), error-path (no
  throw, registry urort), 4.3 report-shape + avledet skill/category, incremental
  cursor/produkt-param, baseline=ingen leads (spec 8b), hermetisk exit-0-subprosess
  via PATH-shadow av `security`.
- test-detection-schedule (+8): include_course_detection parse/serialize/default;
  selectDetectionSteps-gating (default av, opt-in pa, uavhengige gates);
  run-detection bruker selektoren.

Gotcha (secrets-hook): clientSecret: '...'-form traff llm-security-regelen
(client[_-]?secret|ClientSecret)\s*[=:]\s*['"]…{8,} -> lost med array-av-par
(feltnavn star aldri rett for quotet streng).

Gate 7 C3.4 mott: invariant + fail-soft(exit 0) + report-shape + DETECTION_STEPS-
filter gronn. kb-update 269->291, validate PASSED (239/0), run-detection dry-run
dropper detect-courses (opt-in av), eval urort (rorer ikke KB), null regresjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:24:57 +02:00

438 lines
20 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,
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 });
}
});
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);
});
// ===========================================================================
// (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');
});