feat(knowledge): knowledge-refresh — living register, deterministic stale core + web poll (v5.7 Chunk 3)

The 'living' half of the v5.7 living knowledge base. Same hybrid split as the
optimization lens (Chunk 2b): a deterministic, byte-stable, unit-tested core +
a web/judgment command shell.

- scanners/lib/knowledge-refresh.mjs: pure assessFreshness(register,
  {referenceDate, staleAfterDays=90}) — age-based fresh/stale classification of
  source.verified; referenceDate injected (never reads the clock) → fully
  deterministic. 15 tests.
- scanners/knowledge-refresh-cli.mjs: -cli (NOT an orchestrated scanner →
  scanner count stays 15, suite byte-stable). Read-only — never writes the
  register, never hits the network. --reference-date/--stale-after/--dry-run,
  exit 0/1/3. 8 tests.
- commands/knowledge-refresh.md (opus): CLI stale-report → re-verify each stale
  entry by re-reading its source.url → poll CC changelog + Anthropic blog →
  apply ONLY human-approved writes, then re-validate the register. No unverified
  claim is ever auto-written (Verifiseringsplikt). Web-driven → not byte-stable.

No new agent, no new orchestrated scanner. Docs/badges: commands 19→20,
tests 1068→1091 (20 lib + 32 scanner test files). self-audit A/A, readmeCheck passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-21 19:22:57 +02:00
commit 0f9c091a14
7 changed files with 601 additions and 3 deletions

View file

@ -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

View file

@ -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)_ |

View file

@ -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-<TOPIC>-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.

View file

@ -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 <path>] [--stale-after <N>]
* [--reference-date <YYYY-MM-DD>] [--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);
});
}

View file

@ -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 },
};
}

View file

@ -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);
});
});

View file

@ -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);
});
});