diff --git a/CLAUDE.md b/CLAUDE.md index 8b270d0..5e40915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full| node --test 'tests/**/*.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`. +1127 tests across 64 test files (21 lib + 33 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) @@ -435,6 +435,18 @@ command surface (Blocks 3b/3c/4). **Internal plumbing, byte-stable until consume function scan` + lives in `lib/` → scanner count stays 15, no orchestrator wiring, SC-5 unchanged. 28 tests, suite 1091→1119. +### campaign-cli — read-only ledger reporter (v5.7 Fase 2, Block 3b) + +`scanners/campaign-cli.mjs` (`-cli` → NOT an orchestrated scanner → scanner count stays 15, suite +byte-stable; 8 tests): the DETERMINISTIC, READ-ONLY half of the campaign motor, mirroring +`knowledge-refresh-cli`. It `loadLedger`s the durable ledger, `validateLedger`s it, and emits +`{status, initialized, ledgerPath, schemaVersion, createdDate, updatedDate, repos, rollUp}` as JSON. +It NEVER writes — a missing ledger is reported gracefully (`initialized:false`, all-zero roll-up), +**never created**; init + every status transition belong to the Block 3c command layer (human-approved +writes, Verifiseringsplikt). `--ledger-file` overrides the default path (deterministic testing); +`--output-file` mirrors the sibling. Exit codes: **0** = initialized & valid, **1** = not initialized +yet (advisory), **3** = error (parse/corrupt/invalid). suite 1119→1127. + ## Gotchas - Session directories accumulate — use `/config-audit cleanup` to manage diff --git a/scanners/campaign-cli.mjs b/scanners/campaign-cli.mjs new file mode 100644 index 0000000..fc3846b --- /dev/null +++ b/scanners/campaign-cli.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +/** + * campaign-cli — read-only reporter for the durable machine-wide campaign ledger + * (v5.7 Fase 2, Block 3b). + * + * Mirrors the knowledge-refresh-cli precedent: it is the DETERMINISTIC, READ-ONLY half + * of the hybrid motor. It loads the campaign ledger (the durable file that sits ABOVE + * individual config-audit sessions), validates it, and emits the repo list + a + * machine-wide roll-up as JSON. It NEVER writes the ledger — initialization and every + * status transition belong to the command layer (Block 3c `/config-audit campaign`), + * which calls the lib's pure transforms + saveLedger only on explicit, human-approved + * action. A missing ledger file is reported gracefully (initialized:false), NEVER created. + * + * 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 campaign-cli.mjs [--ledger-file ] [--output-file ] + * + * Exit codes: 0 = initialized & valid, 1 = not initialized yet (advisory), 3 = error. + */ + +import { resolve } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { + loadLedger, + validateLedger, + rollUp, + defaultLedgerPath, +} from './lib/campaign-ledger.mjs'; + +function fail(message) { + process.stderr.write(`Error: ${message}\n`); + process.exit(3); +} + +async function main() { + const args = process.argv.slice(2); + let ledgerFile = null; + let outputFile = null; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--ledger-file' && args[i + 1]) ledgerFile = args[++i]; + else if (a === '--output-file' && args[i + 1]) outputFile = args[++i]; + } + + const ledgerPath = resolve(ledgerFile || defaultLedgerPath()); + + let ledger; + try { + // loadLedger returns null on ENOENT (graceful first run) and throws on parse error. + ledger = await loadLedger(ledgerPath); + } catch (err) { + fail(`could not read ledger at ${ledgerPath}: ${err.message}`); + } + + let payload; + let exitCode; + + if (ledger === null) { + // Graceful first run — the ledger does not exist yet. We DO NOT create it; that is + // the command layer's job (Block 3c), on explicit human-approved action. + payload = { + status: 'ok', + initialized: false, + ledgerPath, + schemaVersion: null, + createdDate: null, + updatedDate: null, + repos: [], + rollUp: rollUp({ repos: [] }), + }; + exitCode = 1; // advisory: there is no campaign to report yet + } else { + const { valid, errors } = validateLedger(ledger); + if (!valid) { + fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`); + } + payload = { + status: 'ok', + initialized: true, + ledgerPath, + schemaVersion: ledger.schemaVersion, + createdDate: ledger.createdDate, + updatedDate: ledger.updatedDate, + repos: ledger.repos, + rollUp: rollUp(ledger), + }; + exitCode = 0; + } + + const json = JSON.stringify(payload, null, 2); + if (outputFile) await writeFile(outputFile, json, 'utf-8'); + else process.stdout.write(json + '\n'); + + process.exit(exitCode); +} + +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/tests/scanners/campaign-cli.test.mjs b/tests/scanners/campaign-cli.test.mjs new file mode 100644 index 0000000..dea642b --- /dev/null +++ b/tests/scanners/campaign-cli.test.mjs @@ -0,0 +1,140 @@ +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, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { + createLedger, + addRepo, + setRepoStatus, + rollUp, + saveLedger, +} from '../../scanners/lib/campaign-ledger.mjs'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const CLI = resolve(__dirname, '../../scanners/campaign-cli.mjs'); + +const NOW = '2026-06-22'; + +// Every test passes an explicit --ledger-file in a temp dir, so the CLI never touches +// the real default path under HOME — the suite is hermetic by construction. +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 || '' }; + } +} + +// Round-trip a populated ledger through the real lib + saveLedger so the CLI reads +// exactly what the command layer would persist. +async function seedLedger(dir) { + let l = createLedger({ now: NOW }); + l = addRepo(l, { path: '/r/a', name: 'a' }, { now: NOW }); + l = addRepo(l, { path: '/r/b', name: 'b' }, { now: NOW }); + l = addRepo(l, { path: '/r/c', name: 'c' }, { now: NOW }); + l = setRepoStatus(l, '/r/a', 'audited', { + now: NOW, + findingsBySeverity: { critical: 1, high: 2, medium: 0, low: 4 }, + sessionId: '20260622_120000', + }); + l = setRepoStatus(l, '/r/b', 'implemented', { + now: NOW, + findingsBySeverity: { critical: 0, high: 1, medium: 3, low: 0 }, + }); + // c stays pending, no findings + const file = join(dir, 'campaign-ledger.json'); + await saveLedger(file, l); + return { file, ledger: l }; +} + +const EMPTY_ROLLUP = { + totalRepos: 0, + byStatus: { pending: 0, audited: 0, planned: 0, implemented: 0 }, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + reposWithFindings: 0, +}; + +describe('campaign-cli — exit codes', () => { + it('exits 0 when the ledger exists and is valid', async () => { + const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); + const { file } = await seedLedger(dir); + const { status, stdout } = runCli(['--ledger-file', file]); + assert.equal(status, 0); + assert.equal(JSON.parse(stdout).initialized, true); + }); + + it('exits 1 (advisory) when no ledger file exists yet', () => { + const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); + const { status, stdout } = runCli(['--ledger-file', join(dir, 'nope.json')]); + assert.equal(status, 1); + const out = JSON.parse(stdout); + assert.equal(out.initialized, false); + assert.deepEqual(out.repos, []); + }); + + it('exits 3 on a malformed (non-JSON) ledger file', () => { + const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); + const file = join(dir, 'campaign-ledger.json'); + writeFileSync(file, '{ not valid json'); + assert.equal(runCli(['--ledger-file', file]).status, 3); + }); + + it('exits 3 on a schema-invalid ledger', () => { + const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); + const file = join(dir, 'campaign-ledger.json'); + writeFileSync(file, JSON.stringify({ schemaVersion: 99, repos: 'nope' })); + assert.equal(runCli(['--ledger-file', file]).status, 3); + }); +}); + +describe('campaign-cli — payload shape', () => { + it('emits the expected keys and a roll-up matching the lib', async () => { + const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); + const { file, ledger } = await seedLedger(dir); + const out = JSON.parse(runCli(['--ledger-file', file]).stdout); + assert.equal(out.status, 'ok'); + assert.equal(out.initialized, true); + assert.equal(out.ledgerPath, resolve(file)); + assert.equal(out.schemaVersion, 1); + assert.equal(out.createdDate, NOW); + assert.equal(out.updatedDate, NOW); + assert.equal(out.repos.length, 3); + assert.deepEqual(out.rollUp, rollUp(ledger)); + }); + + it('repos carry per-repo provenance (status, findingsBySeverity, sessionId)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); + const { file } = await seedLedger(dir); + const out = JSON.parse(runCli(['--ledger-file', file]).stdout); + const a = out.repos.find((r) => r.name === 'a'); + assert.equal(a.status, 'audited'); + assert.deepEqual(a.findingsBySeverity, { critical: 1, high: 2, medium: 0, low: 4 }); + assert.equal(a.sessionId, '20260622_120000'); + }); + + it('uninitialized payload has empty repos and an all-zero roll-up', () => { + const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); + const out = JSON.parse(runCli(['--ledger-file', join(dir, 'nope.json')]).stdout); + assert.equal(out.initialized, false); + assert.equal(out.schemaVersion, null); + assert.deepEqual(out.repos, []); + assert.deepEqual(out.rollUp, EMPTY_ROLLUP); + }); +}); + +describe('campaign-cli — --output-file', () => { + it('writes JSON to the file and stays silent on stdout', async () => { + const dir = mkdtempSync(join(tmpdir(), 'camp-cli-')); + const { file } = await seedLedger(dir); + const out = join(dir, 'report.json'); + const { stdout } = runCli(['--ledger-file', file, '--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.repos.length, 3); + }); +});