config-audit/tests/scanners/knowledge-refresh-cli.test.mjs
Kjell Tore Guttormsen e9921d3c9d feat(optimize): add --subtract, the subtraction axis, behind a deterministic floor
Every command so far asked an addition question — what to add, what to move,
what it costs. Nothing asked what is no longer earning its always-loaded rent.
This adds that axis as a fourth lensCheck on the existing hybrid motor rather
than a new scanner or a 22nd command: the measured payoff (~18% of one file)
justifies a mode, not machinery.

It is the only lens that proposes REMOVING config, so it carries a guarantee
the others don't need: a load-bearing block is never a candidate. Precision is
asymmetric — a missed dead line costs a few tokens per turn, a deleted one
costs a wrong remote or a broken script — so the floor is decided in code
(lib/floor-exclusion.mjs) before the opus judge sees anything, never in prose.

Granularity is the leaf block, with two structural exceptions: a paragraph
ending in ':' merges with the list it introduces, and an ordered list is a
contract whose steps inherit floor from any sibling. Unordered lists
deliberately do not inherit — a load-bearing bullet and a disposable one
routinely share a list, and container-reasoning is the error the hand-built
ground truth exists to catch.

Verified against that ground truth (built before any classifier existed), with
the comparison machine-checked rather than read by eye: zero load-bearing
blocks proposed, 11/18 deletable groups surfaced, ~756 tok ~ 18% of a ~4300
token file — inside the pre-registered band. The first run found five floor
violations the synthesized fixture missed; each got a structural rule and a
fixture shape so it cannot regress.

Three real bugs the dogfood run exposed, all now covered:
- JS \b is ASCII-only, so /\bunngå\b/ never matches — every Norwegian keyword
  ending in æ/ø/å was silently dead.
- A bare word/word is not a path; "pros/cons" vetoed the largest deletable
  block until PATH_RE was tightened to rooted paths and globs.
- "Mid-sentence" must key on a preceding lowercase letter; the loose version
  read **bold labels:** and quoted openers as entities, costing 4 of 11 groups.

BP-SUB-001 is grounded entirely in the Anthropic steering blog already cited by
BP-MECH-001..004 and asserts nothing from the talk that motivated the feature —
no "80%", no ablation figure.

Suite 1365 -> 1382/0. Frozen v5.0.0 snapshots untouched; plain optimize output
byte-identical on identical input (--subtract adds keys only when passed).
knowledge-refresh-cli's reference date moved to 2026-08-01: its premise that
every seed entry was verified 2026-06-20 expired when BP-SUB-001 got a genuine
verification date, and backdating the entry to fit the test would have been a
lie about when its source was checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RW2haJXbxZpKivKHseSXNh
2026-07-31 16:24:51 +02:00

109 lines
4.9 KiB
JavaScript

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 reference date must sit AFTER the newest entry's `verified` date, so every
// entry is at least one day old and both the fresh and stale branches are
// reachable deterministically, regardless of the real clock.
//
// It used to be 2026-06-21, on the premise that every seed entry was verified
// 2026-06-20. That premise expired when BP-SUB-001 was added with a genuine
// 2026-07-31 verification date — an entry verified *after* the reference date has
// a negative age and counts as fresh, so `--stale-after 0` no longer emptied the
// fresh bucket. Backdating the entry to fit the test would have been a lie about
// when its source was checked; moving the reference date is the honest fix.
// Bump this again when a newer entry lands.
const REF = '2026-08-01';
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, REF);
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}$/);
// Was `=== 1`, which only held while every entry shared one verified date.
// Assert the invariant that actually matters: ageDays is a positive integer
// and agrees with this entry's own `verified` date against the reference.
const expectedAge = Math.round((Date.parse(REF) - Date.parse(s.verified)) / 86400000);
assert.equal(s.ageDays, expectedAge);
assert.ok(s.ageDays >= 1, 'a stale entry is at least a day old');
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);
});
});