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:
parent
ba66f1fc17
commit
0f9c091a14
7 changed files with 601 additions and 3 deletions
144
tests/lib/knowledge-refresh.test.mjs
Normal file
144
tests/lib/knowledge-refresh.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
96
tests/scanners/knowledge-refresh-cli.test.mjs
Normal file
96
tests/scanners/knowledge-refresh-cli.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue