diff --git a/CLAUDE.md b/CLAUDE.md index 35a40a7..37eb44e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ Analyzes and optimizes Claude Code configuration across three pillars: | `/config-audit drift` | Compare current config against saved baseline | | `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence | | `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) | +| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — deterministic stale check (sources older than ~90d) + web candidate poll (CC changelog + Anthropic blog); **human-approved writes only** (Verifiseringsplikt). The "living" half of the knowledge base. Web/judgment-driven, **not byte-stable** | | `/config-audit discover` | Run discovery phase only | | `/config-audit analyze` | Run analysis phase only | | `/config-audit interview` | Gather user preferences (opt-in) | @@ -111,7 +112,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full| node --test 'tests/**/*.test.mjs' ``` -1068 tests across 60 test files (19 lib + 31 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. +1091 tests across 62 test files (20 lib + 32 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. ### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation) @@ -393,6 +394,31 @@ scanner → scanner count stays 15; agents 6→7, commands 18→19, suite 1055 the shared `withHermeticHome` helper — the suite is green on BOTH a real and a clean `HOME` (the OPT section's old «run with clean HOME» caveat is resolved). Snapshot/byte tests were already hermetic. +### knowledge-refresh — the "living" half of the register (v5.7 Fase 1 Chunk 3, commands 19→20) + +Keeps `knowledge/best-practices.json` current so the optimization lens never reads stale rules. +Same hybrid split as Chunk 2b — a deterministic, byte-stable, unit-tested core + a web/judgment shell: + +- **Deterministic core** (`scanners/lib/knowledge-refresh.mjs`, pure, 15 tests): `assessFreshness(register, + {referenceDate, staleAfterDays})` classifies each entry `fresh`/`stale` by the age of its + `source.verified` stamp. `referenceDate` is **injected** (not read from the clock) so the function is + fully deterministic; default threshold `STALE_AFTER_DAYS_DEFAULT = 90` (quarterly re-verify cadence). + An unparseable/missing `verified` → stale with `ageDays: null` (defensive; the schema-validated bundle + never hits this, but the command's hand-built candidates might). «Source changed» detection is a **web + responsibility** (command layer), **not** in this core. +- **CLI** (`scanners/knowledge-refresh-cli.mjs`, `-cli` → **NOT** an orchestrated scanner → scanner count + stays 15, suite byte-stable; 8 tests): read-only — it NEVER writes the register and NEVER hits the + network. `--reference-date` (defaults to today; the **only** place the clock is read) makes it + deterministically testable against the bundled register. `--stale-after N`, `--dry-run` (implicit + only + mode, echoed as `requestedDryRun`). Exit **0** = all fresh, **1** = some stale (advisory), **3** = error. +- **Command** (`commands/knowledge-refresh.md`, opus): orchestrates CLI stale-report → re-verify each stale + entry by re-reading its `source.url` (WebFetch) → poll CC changelog + Anthropic blog for new/changed + practices (WebSearch) → present everything → **apply ONLY human-approved writes**, then re-run the + register schema test before declaring done. **No unverified claim is ever auto-written** (Verifiseringsplikt). + Web/judgment-driven → **deliberately NOT byte-stable** (own command, outside the snapshot suite), exactly + like `/config-audit optimize`. **No new agent** (web poll runs in the command's own context), **no new + orchestrated scanner**. suite 1068→1091. + ## Gotchas - Session directories accumulate — use `/config-audit cleanup` to manage diff --git a/README.md b/README.md index 83fbf6c..248ff75 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ ![Version](https://img.shields.io/badge/version-5.6.0-blue) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Scanners](https://img.shields.io/badge/scanners-15-cyan) -![Commands](https://img.shields.io/badge/commands-19-green) +![Commands](https://img.shields.io/badge/commands-20-green) ![Agents](https://img.shields.io/badge/agents-7-orange) ![Hooks](https://img.shields.io/badge/hooks-4-red) -![Tests](https://img.shields.io/badge/tests-1068+-brightgreen) +![Tests](https://img.shields.io/badge/tests-1091+-brightgreen) ![License](https://img.shields.io/badge/license-MIT-lightgrey) A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 15 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, and output-style detection. Zero external dependencies. @@ -292,6 +292,7 @@ Your team configuration changes over time. Track it: | `/config-audit drift` | Compare current config against a saved baseline | | `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence | | `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) | +| `/config-audit knowledge-refresh` | Keep the best-practices register fresh — flag stale entries (sources older than ~90d) + poll for new/changed Claude Code practices; **human-approved writes only** (Verifiseringsplikt). Deterministic stale core + web candidate poll | | `/config-audit discover` | Run discovery phase only | | `/config-audit analyze` | Run analysis phase only | | `/config-audit interview` | Set preferences for action plan _(optional)_ | diff --git a/commands/knowledge-refresh.md b/commands/knowledge-refresh.md new file mode 100644 index 0000000..0548ae3 --- /dev/null +++ b/commands/knowledge-refresh.md @@ -0,0 +1,134 @@ +--- +name: config-audit:knowledge-refresh +description: Keep the best-practices register fresh — flag stale entries, poll sources for new/changed practices, with human-approved writes only +argument-hint: "[--stale-after N] [--no-candidates]" +allowed-tools: Read, Write, Edit, Bash, WebSearch, WebFetch +model: opus +--- + +# Config-Audit: Knowledge Refresh + +The "living" part of the living knowledge base. The optimization lens (`/config-audit +optimize`) is only as good as the best-practices register it reads — and Claude Code moves +fast. This command keeps `knowledge/best-practices.json` current in two ways: + +- **Stale check (deterministic):** every CONFIRMED entry carries a `source.verified` date. + An entry older than the threshold (default **90 days**) is flagged for re-verification — + its source may have changed since. +- **Candidate poll (web):** scan the CC changelog + the Anthropic "Steering Claude Code" + docs/blog for *new* best-practices the register doesn't yet hold, or *changed* guidance + that contradicts an existing entry. + +**The Iron rule (Verifiseringsplikt): nothing is ever auto-written.** Every change — a +bumped `verified` date, an updated claim, a brand-new entry — is presented to the user and +applied only on explicit approval, and only after the live source has actually been +re-read. No unverified claim enters the register. + +## Implementation + +### Step 1: Parse arguments + +From `$ARGUMENTS`: +- `--stale-after N` → override the staleness threshold (integer days; default 90). +- `--no-candidates` → run the deterministic stale check only; skip the web poll. + +Tell the user what's happening: + +``` +## Knowledge Refresh + +Checking the best-practices register for stale entries (sources that may need +re-verification) and polling for new Claude Code practices... +``` + +### Step 2: Run the stale-check CLI + +```bash +TODAY=$(date +%F) +STALE_AFTER="" +if echo "$ARGUMENTS" | grep -qE -- '--stale-after'; then + STALE_AFTER="--stale-after $(echo "$ARGUMENTS" | sed -nE 's/.*--stale-after[ =]+([0-9]+).*/\1/p')" +fi +node ${CLAUDE_PLUGIN_ROOT}/scanners/knowledge-refresh-cli.mjs \ + --reference-date "$TODAY" $STALE_AFTER \ + --output-file ~/.claude/config-audit/sessions/knowledge-refresh.json 2>/dev/null; echo $? +``` + +Exit code **0** = all fresh, **1** = some stale (advisory, normal), **3** = real error → +"The refresh check couldn't run — the register file may be missing or invalid." + +### Step 3: Read the payload + present stale entries + +Read `~/.claude/config-audit/sessions/knowledge-refresh.json` with the Read tool. It has +`counts {total, stale, fresh}`, a `stale[]` array (each: `id, verified, ageDays, url, +claim`), `referenceDate`, and `staleAfterDays`. + +Present the stale entries as a markdown table (per the UX rules — never show the raw JSON): + +| Entry | Claim (short) | Verified | Age (days) | Source | +|-------|---------------|----------|------------|--------| + +If `counts.stale === 0`, say so plainly: "✓ All N register entries were re-verified within +the last {staleAfterDays} days." Then continue to the candidate poll (unless `--no-candidates`). + +### Step 4: Candidate + source-change poll (web — skip if `--no-candidates`) + +Tell the user this takes a moment ("Polling the changelog + Anthropic docs, ~20-40s..."). + +1. **Re-verify each stale entry.** `WebFetch` the entry's `source.url` and check whether the + claim it backs is **still accurate**. Three outcomes: + - *Still holds* → propose bumping `source.verified` to today (no claim change). + - *Changed* → propose an updated `claim`/`recommendation` quoting the new source text. + - *Cannot verify* (page gone, paywalled, contradicts) → propose **nothing**; flag it + "needs manual review" (Verifiseringsplikt: never bump a date you couldn't confirm). +2. **Look for new practices.** `WebSearch` the CC changelog and the "Steering Claude Code" + blog/docs for steering/config guidance not already represented by an entry's `lensCheck`. + For each genuine new practice, draft a **candidate** entry (next free `BP--NNN` id, + `confidence: "confirmed"` only if a primary source confirms it — otherwise mark `inferred` + and do **not** present it as user-facing). + +### Step 5: Present everything for approval — write nothing yet + +Group the proposals and ask the user to approve per item: + +- **Re-verify (date bump):** "{id} — source re-read, claim still holds → bump verified to {today}?" +- **Update (claim drift):** show the old vs. new claim + the quoted source line. +- **New candidate:** show the drafted entry (id, claim, mechanism, recommendation, source). +- **Needs manual review:** list, with why it couldn't be auto-verified. (No write offered.) + +Be explicit: **"I will not change any file until you approve specific items."** + +### Step 6: Apply approved writes (only the approved ones) + +For each approved item: +1. Edit `knowledge/best-practices.json` — bump `source.verified`, update the `claim`/ + `recommendation`, or append the new entry. Keep the file's 2-space JSON formatting. +2. If a `knowledge/*.md` mirror states the same fact, update it too so the human-readable + mirror doesn't drift from the register. +3. **Validate before declaring done** — re-run the register schema check and confirm zero errors: + ```bash + node --test ${CLAUDE_PLUGIN_ROOT}/tests/lib/best-practices-register.test.mjs 2>&1 | tail -5 + ``` + If validation fails, revert that edit and report it — never leave the register invalid. + +Report exactly what changed (ids + fields), and what was deferred to manual review. + +### Step 7: Next steps + +- `/config-audit optimize` — the lens now reads the refreshed register; re-run it to pick up + any new or changed mechanism-fit rules. +- Re-run `/config-audit knowledge-refresh --no-candidates` anytime for a quick staleness scan + without the web poll. +- Commit the register change (`knowledge/best-practices.json` + any `.md` mirror) with a + `chore(knowledge):` message so the provenance bump is in git history. + +## Notes + +- **Deterministic core, web-driven shell.** The stale classification is byte-stable and + unit-tested (`tests/lib/knowledge-refresh.test.mjs`, `tests/scanners/knowledge-refresh-cli.test.mjs`); + the candidate poll + writes are web/judgment-driven and deliberately **not** byte-stable + (mirrors `/config-audit optimize`). +- **Read-only CLI.** `knowledge-refresh-cli.mjs` never writes the register; all writes happen + here, in the command, after approval. +- The `-cli` suffix keeps it out of the scan-orchestrator, so the scanner count and the + snapshot suite are unaffected. diff --git a/scanners/knowledge-refresh-cli.mjs b/scanners/knowledge-refresh-cli.mjs new file mode 100644 index 0000000..493ad17 --- /dev/null +++ b/scanners/knowledge-refresh-cli.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +/** + * knowledge-refresh CLI — feeds the v5.7 `/config-audit knowledge-refresh` command + * (Chunk 3: the "living" half of the living knowledge base). + * + * This is the DETERMINISTIC half of the hybrid motor: it loads the best-practices + * register and classifies every entry as `fresh` or `stale` by the age of its + * `source.verified` stamp (via the pure `assessFreshness` core). It is READ-ONLY — + * it NEVER writes the register and NEVER touches the network. Candidate discovery + * (polling the CC changelog + Anthropic blog) and the human-approved writes live in + * the command layer (Verifiseringsplikt). `--dry-run` is implicit and the only mode; + * the flag is accepted for explicitness and echoed back. + * + * Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only + * loads scanner modules), so the scanner count is unchanged and the snapshot suite + * stays byte-stable. + * + * Usage: + * node knowledge-refresh-cli.mjs [--output-file ] [--stale-after ] + * [--reference-date ] [--dry-run] + * + * Exit codes: 0 = every entry fresh, 1 = one or more stale (advisory), 3 = error. + */ + +import { resolve } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { loadRegister, REGISTER_PATH } from './lib/best-practices-register.mjs'; +import { assessFreshness, STALE_AFTER_DAYS_DEFAULT } from './lib/knowledge-refresh.mjs'; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function fail(message) { + process.stderr.write(`Error: ${message}\n`); + process.exit(3); +} + +async function main() { + const args = process.argv.slice(2); + let outputFile = null; + let staleAfterDays = STALE_AFTER_DAYS_DEFAULT; + let referenceDate = null; // null → today + let dryRun = false; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--dry-run') dryRun = true; + else if (a === '--output-file' && args[i + 1]) outputFile = args[++i]; + else if (a === '--stale-after' && args[i + 1] !== undefined) { + const n = Number.parseInt(args[++i], 10); + if (!Number.isInteger(n) || n < 0) fail('--stale-after must be a non-negative integer (days)'); + staleAfterDays = n; + } else if (a === '--reference-date' && args[i + 1]) { + referenceDate = args[++i]; + if (!DATE_RE.test(referenceDate)) fail('--reference-date must be YYYY-MM-DD'); + } + } + + // The clock is read here ONLY — the core takes an injected date and stays pure. + const ref = referenceDate || new Date(); + + let register; + try { + register = loadRegister(); + } catch (err) { + fail(`could not load register at ${REGISTER_PATH}: ${err.message}`); + } + + let assessment; + try { + assessment = assessFreshness(register, { referenceDate: ref, staleAfterDays }); + } catch (err) { + fail(err.message); + } + + const payload = { + status: 'ok', + registerPath: REGISTER_PATH, + version: register.version, + dryRun: true, // this CLI never writes; the flag is informational + requestedDryRun: dryRun, + referenceDate: assessment.referenceDate, + staleAfterDays: assessment.staleAfterDays, + counts: assessment.counts, + stale: assessment.stale, + fresh: assessment.fresh, + }; + + const json = JSON.stringify(payload, null, 2); + if (outputFile) await writeFile(outputFile, json, 'utf-8'); + else process.stdout.write(json + '\n'); + + process.exit(assessment.counts.stale > 0 ? 1 : 0); +} + +const isDirectRun = + process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname); +if (isDirectRun) { + main().catch((err) => { + process.stderr.write(`Fatal: ${err.message}\n`); + process.exit(3); + }); +} diff --git a/scanners/lib/knowledge-refresh.mjs b/scanners/lib/knowledge-refresh.mjs new file mode 100644 index 0000000..54dd48d --- /dev/null +++ b/scanners/lib/knowledge-refresh.mjs @@ -0,0 +1,94 @@ +/** + * knowledge-refresh — deterministic freshness core for the best-practices register. + * + * The "living" half of the v5.7 living knowledge base (Chunk 3). This module is the + * PURE, deterministic part of the hybrid `/config-audit knowledge-refresh` motor: given a + * register and an injected reference date, it classifies each entry as `fresh` or `stale` + * by the age of its `source.verified` stamp. It NEVER touches the network and NEVER writes + * — candidate discovery (polling CC changelog + Anthropic blog) and the human-approved + * writes live in the command layer (Verifiseringsplikt: no unverified claim is auto-written). + * + * `referenceDate` is injected (not read from the clock here) so the function is fully + * deterministic and unit-testable; the CLI passes today's date. See + * docs/v5.7-optimization-lens-plan.md. + */ + +/** Default re-verify cadence: a confirmed best-practice older than this needs a re-check. */ +export const STALE_AFTER_DAYS_DEFAULT = 90; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const DAY_MS = 86_400_000; + +/** + * Normalize a reference date (Date or YYYY-MM-DD string) to a UTC-midnight {iso, ms}. + * Throws TypeError on anything else — the reference date is required and must be valid. + */ +function normalizeReferenceDate(value) { + let iso; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) throw new TypeError('referenceDate is an invalid Date'); + iso = value.toISOString().slice(0, 10); + } else if (typeof value === 'string' && DATE_RE.test(value)) { + iso = value; + } else { + throw new TypeError('referenceDate must be a Date or a YYYY-MM-DD string'); + } + const ms = Date.parse(`${iso}T00:00:00Z`); + if (Number.isNaN(ms)) throw new TypeError(`referenceDate is not a real calendar date: ${iso}`); + return { iso, ms }; +} + +/** Parse an entry's `source.verified` to UTC-midnight ms, or null if missing/unparseable. */ +function verifiedMs(entry) { + const v = entry && entry.source && entry.source.verified; + if (typeof v !== 'string' || !DATE_RE.test(v)) return null; + const ms = Date.parse(`${v}T00:00:00Z`); + return Number.isNaN(ms) ? null : ms; +} + +/** + * Classify every register entry as fresh or stale by the age of its source.verified stamp. + * + * @param {{entries:object[]}} register + * @param {{ referenceDate: string|Date, staleAfterDays?: number }} opts + * @returns {{ + * referenceDate: string, + * staleAfterDays: number, + * stale: Array<{id:string, verified:string|undefined, ageDays:number|null, url:string|undefined, claim:string|undefined}>, + * fresh: Array<{id:string, verified:string|undefined, ageDays:number}>, + * counts: { total:number, stale:number, fresh:number } + * }} + */ +export function assessFreshness(register, opts = {}) { + const ref = normalizeReferenceDate(opts.referenceDate); + const staleAfterDays = + typeof opts.staleAfterDays === 'number' ? opts.staleAfterDays : STALE_AFTER_DAYS_DEFAULT; + + const entries = (register && Array.isArray(register.entries)) ? register.entries : []; + const stale = []; + const fresh = []; + + for (const e of entries) { + const verified = e && e.source ? e.source.verified : undefined; + const vms = verifiedMs(e); + if (vms === null) { + // No re-checkable date → needs attention. Stale with ageDays null. + stale.push({ id: e && e.id, verified, ageDays: null, url: e && e.source && e.source.url, claim: e && e.claim }); + continue; + } + const ageDays = Math.floor((ref.ms - vms) / DAY_MS); + if (ageDays > staleAfterDays) { + stale.push({ id: e.id, verified, ageDays, url: e.source && e.source.url, claim: e.claim }); + } else { + fresh.push({ id: e.id, verified, ageDays }); + } + } + + return { + referenceDate: ref.iso, + staleAfterDays, + stale, + fresh, + counts: { total: entries.length, stale: stale.length, fresh: fresh.length }, + }; +} diff --git a/tests/lib/knowledge-refresh.test.mjs b/tests/lib/knowledge-refresh.test.mjs new file mode 100644 index 0000000..8bd6604 --- /dev/null +++ b/tests/lib/knowledge-refresh.test.mjs @@ -0,0 +1,144 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + assessFreshness, + STALE_AFTER_DAYS_DEFAULT, +} from '../../scanners/lib/knowledge-refresh.mjs'; + +// Build a register with one entry verified on `verified`. +const entry = (id, verified, extra = {}) => ({ + id, + claim: `claim for ${id}`, + confidence: 'confirmed', + source: { url: `https://example.com/${id}`, title: id, verified }, + ...extra, +}); +const reg = (entries) => ({ version: 1, entries }); + +const REF = '2026-06-21'; + +describe('STALE_AFTER_DAYS_DEFAULT', () => { + it('is 90 (quarterly re-verify cadence)', () => { + assert.equal(STALE_AFTER_DAYS_DEFAULT, 90); + }); +}); + +describe('assessFreshness — classification', () => { + it('classifies a recently-verified entry as fresh', () => { + const r = assessFreshness(reg([entry('BP-A-001', '2026-06-01')]), { referenceDate: REF }); + assert.equal(r.counts.fresh, 1); + assert.equal(r.counts.stale, 0); + assert.equal(r.fresh[0].id, 'BP-A-001'); + }); + + it('classifies an entry older than the threshold as stale', () => { + // 2026-01-01 → 2026-06-21 is 171 days > 90 + const r = assessFreshness(reg([entry('BP-A-001', '2026-01-01')]), { referenceDate: REF }); + assert.equal(r.counts.stale, 1); + assert.equal(r.counts.fresh, 0); + assert.equal(r.stale[0].id, 'BP-A-001'); + assert.equal(r.stale[0].ageDays, 171); + }); + + it('treats exactly staleAfterDays as still fresh (strictly-greater is stale)', () => { + const r = assessFreshness(reg([entry('BP-A-001', '2026-03-23')]), { + referenceDate: REF, + staleAfterDays: 90, + }); + // 2026-03-23 → 2026-06-21 is exactly 90 days + assert.equal(r.fresh[0].ageDays, 90); + assert.equal(r.counts.fresh, 1); + assert.equal(r.counts.stale, 0); + }); + + it('treats one day past the threshold as stale', () => { + const r = assessFreshness(reg([entry('BP-A-001', '2026-03-22')]), { + referenceDate: REF, + staleAfterDays: 90, + }); + assert.equal(r.stale[0].ageDays, 91); + assert.equal(r.counts.stale, 1); + }); + + it('respects a custom staleAfterDays', () => { + const entries = [entry('BP-A-001', '2026-06-01')]; // 20 days old + assert.equal(assessFreshness(reg(entries), { referenceDate: REF, staleAfterDays: 90 }).counts.stale, 0); + assert.equal(assessFreshness(reg(entries), { referenceDate: REF, staleAfterDays: 10 }).counts.stale, 1); + }); + + it('treats a future verified date as fresh (negative age)', () => { + const r = assessFreshness(reg([entry('BP-A-001', '2026-12-31')]), { referenceDate: REF }); + assert.equal(r.counts.fresh, 1); + assert.ok(r.fresh[0].ageDays < 0); + }); +}); + +describe('assessFreshness — output shape', () => { + it('stale entries carry id, verified, ageDays, url, claim', () => { + const r = assessFreshness(reg([entry('BP-A-001', '2026-01-01')]), { referenceDate: REF }); + const s = r.stale[0]; + assert.equal(s.id, 'BP-A-001'); + assert.equal(s.verified, '2026-01-01'); + assert.equal(typeof s.ageDays, 'number'); + assert.equal(s.url, 'https://example.com/BP-A-001'); + assert.equal(s.claim, 'claim for BP-A-001'); + }); + + it('counts.total equals stale + fresh and matches entry count', () => { + const r = assessFreshness( + reg([entry('BP-A-001', '2026-01-01'), entry('BP-A-002', '2026-06-10'), entry('BP-A-003', '2025-01-01')]), + { referenceDate: REF } + ); + assert.equal(r.counts.total, 3); + assert.equal(r.counts.stale + r.counts.fresh, 3); + }); + + it('normalizes referenceDate + echoes staleAfterDays in the result', () => { + const r = assessFreshness(reg([entry('BP-A-001', '2026-06-01')]), { referenceDate: REF, staleAfterDays: 42 }); + assert.equal(r.referenceDate, '2026-06-21'); + assert.equal(r.staleAfterDays, 42); + }); +}); + +describe('assessFreshness — referenceDate input forms', () => { + it('accepts a Date and a YYYY-MM-DD string identically', () => { + const entries = [entry('BP-A-001', '2026-01-01')]; + const fromString = assessFreshness(reg(entries), { referenceDate: REF }); + const fromDate = assessFreshness(reg(entries), { referenceDate: new Date('2026-06-21T00:00:00Z') }); + assert.deepStrictEqual(fromDate.stale, fromString.stale); + assert.equal(fromDate.referenceDate, '2026-06-21'); + }); + + it('throws a TypeError on a missing or invalid referenceDate', () => { + assert.throws(() => assessFreshness(reg([entry('BP-A-001', '2026-01-01')]), {}), TypeError); + assert.throws( + () => assessFreshness(reg([entry('BP-A-001', '2026-01-01')]), { referenceDate: 'not-a-date' }), + TypeError + ); + }); +}); + +describe('assessFreshness — defensive cases', () => { + it('returns all-zero counts for an empty register', () => { + const r = assessFreshness(reg([]), { referenceDate: REF }); + assert.deepStrictEqual(r.counts, { total: 0, stale: 0, fresh: 0 }); + assert.deepStrictEqual(r.stale, []); + assert.deepStrictEqual(r.fresh, []); + }); + + it('classifies an entry with an unparseable verified date as stale with ageDays null', () => { + const bad = entry('BP-A-001', '2026-06-01'); + bad.source.verified = 'nope'; + const r = assessFreshness(reg([bad]), { referenceDate: REF }); + assert.equal(r.counts.stale, 1); + assert.equal(r.stale[0].ageDays, null); + }); + + it('classifies an entry with a missing source as stale with ageDays null', () => { + const r = assessFreshness(reg([{ id: 'BP-A-001', claim: 'x', confidence: 'confirmed' }]), { + referenceDate: REF, + }); + assert.equal(r.counts.stale, 1); + assert.equal(r.stale[0].ageDays, null); + }); +}); diff --git a/tests/scanners/knowledge-refresh-cli.test.mjs b/tests/scanners/knowledge-refresh-cli.test.mjs new file mode 100644 index 0000000..969ca3c --- /dev/null +++ b/tests/scanners/knowledge-refresh-cli.test.mjs @@ -0,0 +1,96 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const CLI = resolve(__dirname, '../../scanners/knowledge-refresh-cli.mjs'); + +// The bundled register's seed entries are all verified 2026-06-20, so a reference +// date of 2026-06-21 makes every entry exactly 1 day old — deterministic regardless +// of the real clock. We exploit that to exercise both the fresh and stale branches. +const REF = '2026-06-21'; + +function runCli(extraArgs) { + try { + const stdout = execFileSync('node', [CLI, ...extraArgs], { encoding: 'utf-8', timeout: 15000 }); + return { status: 0, stdout }; + } catch (err) { + return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' }; + } +} + +describe('knowledge-refresh-cli — exit codes', () => { + it('exits 0 when every entry is fresh', () => { + const { status, stdout } = runCli(['--reference-date', REF, '--stale-after', '90']); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.equal(out.counts.stale, 0); + }); + + it('exits 1 (advisory) when one or more entries are stale', () => { + // stale-after 0 → anything verified before the reference date is stale. + const { status, stdout } = runCli(['--reference-date', REF, '--stale-after', '0']); + assert.equal(status, 1); + const out = JSON.parse(stdout); + assert.ok(out.counts.stale > 0); + assert.equal(out.counts.fresh, 0); + }); + + it('exits 3 on an invalid --stale-after', () => { + assert.equal(runCli(['--stale-after', 'abc']).status, 3); + assert.equal(runCli(['--stale-after', '-5']).status, 3); + }); + + it('exits 3 on a malformed --reference-date', () => { + assert.equal(runCli(['--reference-date', '20-06-2026']).status, 3); + }); +}); + +describe('knowledge-refresh-cli — payload shape', () => { + it('emits the expected keys + a consistent count triple', () => { + const { stdout } = runCli(['--reference-date', REF, '--stale-after', '90']); + const out = JSON.parse(stdout); + assert.equal(out.status, 'ok'); + assert.match(out.registerPath, /knowledge\/best-practices\.json$/); + assert.equal(out.referenceDate, '2026-06-21'); + assert.equal(out.staleAfterDays, 90); + assert.ok(Array.isArray(out.stale)); + assert.ok(Array.isArray(out.fresh)); + assert.equal(out.counts.total, out.counts.stale + out.counts.fresh); + assert.ok(out.counts.total > 0, 'bundled register is non-empty'); + }); + + it('is always dryRun:true regardless of the flag, and echoes the request', () => { + const without = JSON.parse(runCli(['--reference-date', REF]).stdout); + assert.equal(without.dryRun, true); + assert.equal(without.requestedDryRun, false); + const withFlag = JSON.parse(runCli(['--reference-date', REF, '--dry-run']).stdout); + assert.equal(withFlag.dryRun, true); + assert.equal(withFlag.requestedDryRun, true); + }); + + it('stale entries carry provenance (id, verified, ageDays, url, claim)', () => { + const out = JSON.parse(runCli(['--reference-date', REF, '--stale-after', '0']).stdout); + const s = out.stale[0]; + assert.match(s.id, /^BP-[A-Z]+-\d{3}$/); + assert.match(s.verified, /^\d{4}-\d{2}-\d{2}$/); + assert.equal(s.ageDays, 1); + assert.ok(s.url && s.claim); + }); +}); + +describe('knowledge-refresh-cli — --output-file', () => { + it('writes JSON to the file and not to stdout', () => { + const dir = mkdtempSync(join(tmpdir(), 'kr-cli-')); + const out = join(dir, 'refresh.json'); + const { stdout } = runCli(['--reference-date', REF, '--stale-after', '90', '--output-file', out]); + assert.equal(stdout.trim(), '', 'stdout is silent when --output-file is given'); + const written = JSON.parse(readFileSync(out, 'utf-8')); + assert.equal(written.status, 'ok'); + assert.equal(written.counts.stale, 0); + }); +});