Completes the THIN machine-wide campaign surface (ledger + roll-up + status). The write half of the campaign motor, mirroring how knowledge-refresh gates register writes: - scanners/campaign-write-cli.mjs (-cli → NOT a scanner; 11 tests): init/add/ set-status, each a thin wrapper over the invariant-enforcing lib transforms (createLedger/addRepo/setRepoStatus) + saveLedger — path-normalization/dedup, idempotent add, status-lifecycle guard and updatedDate bump never hand-rolled. init refuses to clobber an existing/corrupt ledger (exit 1, file untouched); add auto-inits + reports added vs skipped; set-status takes --findings/--session. Deterministic: --reference-date is the only clock read, injected as `now`. Exit 0=write, 1=advisory no-op, 3=error. - commands/campaign.md (opus, no Web): thin orchestrator — always reports first (read-only campaign-cli), then proposes init/add/set-status and invokes ONE write-CLI subcommand only on explicit human approval (Verifiseringsplikt; never hand-edits the JSON). add --discover finds git repos under a root to pick from. Not byte-stable (own command, outside snapshot suite) like /config-audit optimize + knowledge-refresh. Both CLIs are -cli → scanner count stays 15, snapshot suite untouched. commands 20→21, suite 1127→1138 (+11), test files 64→65. Docs: CLAUDE.md §campaign-write-cli + command table + Testing badge; README commands badge 20→21 + table row; help.md + router wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
211 lines
8.1 KiB
JavaScript
211 lines
8.1 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, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import {
|
|
loadLedger,
|
|
validateLedger,
|
|
rollUp,
|
|
} from '../../scanners/lib/campaign-ledger.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const CLI = resolve(__dirname, '../../scanners/campaign-write-cli.mjs');
|
|
|
|
const NOW = '2026-06-22';
|
|
|
|
// Every test passes an explicit --ledger-file in a temp dir + an injected --reference-date,
|
|
// so the write-CLI never touches the real default path under HOME and never reads the clock.
|
|
// The suite is hermetic + deterministic by construction.
|
|
function runWrite(args) {
|
|
try {
|
|
const stdout = execFileSync('node', [CLI, ...args], { encoding: 'utf-8', timeout: 15000 });
|
|
return { status: 0, stdout };
|
|
} catch (err) {
|
|
return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' };
|
|
}
|
|
}
|
|
|
|
function newDir() {
|
|
return mkdtempSync(join(tmpdir(), 'camp-write-'));
|
|
}
|
|
|
|
// Read the persisted ledger back through the real lib so a test proves the on-disk file
|
|
// is exactly what the command layer would later load.
|
|
async function readLedger(file) {
|
|
const ledger = await loadLedger(file);
|
|
return { ledger, validation: validateLedger(ledger) };
|
|
}
|
|
|
|
describe('campaign-write-cli — init', () => {
|
|
it('creates a valid, empty, versioned ledger (exit 0)', async () => {
|
|
const dir = newDir();
|
|
const file = join(dir, 'ledger.json');
|
|
const { status, stdout } = runWrite(['init', '--ledger-file', file, '--reference-date', NOW]);
|
|
assert.equal(status, 0);
|
|
const out = JSON.parse(stdout);
|
|
assert.equal(out.action, 'init');
|
|
assert.equal(out.written, true);
|
|
assert.equal(out.alreadyInitialized, false);
|
|
assert.equal(out.ledgerPath, resolve(file));
|
|
|
|
const { ledger, validation } = await readLedger(file);
|
|
assert.ok(validation.valid, validation.errors.join('; '));
|
|
assert.equal(ledger.schemaVersion, 1);
|
|
assert.equal(ledger.createdDate, NOW);
|
|
assert.equal(ledger.updatedDate, NOW);
|
|
assert.deepEqual(ledger.repos, []);
|
|
});
|
|
|
|
it('refuses to clobber an existing ledger (exit 1, file untouched)', async () => {
|
|
const dir = newDir();
|
|
const file = join(dir, 'ledger.json');
|
|
// First init, then add a repo so we can prove a second init does NOT wipe it.
|
|
runWrite(['init', '--ledger-file', file, '--reference-date', NOW]);
|
|
runWrite(['add', '/r/a', '--ledger-file', file, '--reference-date', NOW]);
|
|
|
|
const { status, stdout } = runWrite(['init', '--ledger-file', file, '--reference-date', NOW]);
|
|
assert.equal(status, 1, 'advisory: already initialized');
|
|
const out = JSON.parse(stdout);
|
|
assert.equal(out.written, false);
|
|
assert.equal(out.alreadyInitialized, true);
|
|
|
|
const { ledger } = await readLedger(file);
|
|
assert.equal(ledger.repos.length, 1, 'existing repo preserved — not clobbered');
|
|
});
|
|
});
|
|
|
|
describe('campaign-write-cli — add', () => {
|
|
it('auto-initializes when no ledger exists, then adds repos (exit 0)', async () => {
|
|
const dir = newDir();
|
|
const file = join(dir, 'ledger.json');
|
|
assert.ok(!existsSync(file));
|
|
const { status, stdout } = runWrite([
|
|
'add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW,
|
|
]);
|
|
assert.equal(status, 0);
|
|
const out = JSON.parse(stdout);
|
|
assert.equal(out.action, 'add');
|
|
assert.equal(out.autoInitialized, true);
|
|
assert.deepEqual(out.added.sort(), [resolve('/r/a'), resolve('/r/b')].sort());
|
|
|
|
const { ledger, validation } = await readLedger(file);
|
|
assert.ok(validation.valid, validation.errors.join('; '));
|
|
assert.equal(ledger.repos.length, 2);
|
|
assert.ok(ledger.repos.every((r) => r.status === 'pending'));
|
|
});
|
|
|
|
it('appends to an existing ledger and is idempotent on re-add (added vs skipped)', async () => {
|
|
const dir = newDir();
|
|
const file = join(dir, 'ledger.json');
|
|
runWrite(['add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW]);
|
|
|
|
const { status, stdout } = runWrite([
|
|
'add', '/r/a', '/r/c', '--ledger-file', file, '--reference-date', NOW,
|
|
]);
|
|
assert.equal(status, 0);
|
|
const out = JSON.parse(stdout);
|
|
assert.equal(out.autoInitialized, false);
|
|
assert.deepEqual(out.added, [resolve('/r/c')]);
|
|
assert.deepEqual(out.skipped, [resolve('/r/a')]);
|
|
|
|
const { ledger } = await readLedger(file);
|
|
assert.equal(ledger.repos.length, 3);
|
|
});
|
|
|
|
it('uses --name for a single path; derives basenames for multiple', async () => {
|
|
const dir = newDir();
|
|
const solo = join(dir, 'solo.json');
|
|
runWrite(['add', '/r/x', '--name', 'custom', '--ledger-file', solo, '--reference-date', NOW]);
|
|
const { ledger: l1 } = await readLedger(solo);
|
|
assert.equal(l1.repos[0].name, 'custom');
|
|
|
|
const multi = join(dir, 'multi.json');
|
|
runWrite(['add', '/r/alpha', '/r/beta', '--ledger-file', multi, '--reference-date', NOW]);
|
|
const { ledger: l2 } = await readLedger(multi);
|
|
assert.deepEqual(l2.repos.map((r) => r.name).sort(), ['alpha', 'beta']);
|
|
});
|
|
});
|
|
|
|
describe('campaign-write-cli — set-status', () => {
|
|
async function seeded() {
|
|
const dir = newDir();
|
|
const file = join(dir, 'ledger.json');
|
|
runWrite(['add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW]);
|
|
return file;
|
|
}
|
|
|
|
it('transitions a tracked repo and attaches findings + session (exit 0)', async () => {
|
|
const file = await seeded();
|
|
const findings = { critical: 1, high: 2, medium: 0, low: 4 };
|
|
const { status, stdout } = runWrite([
|
|
'set-status', '/r/a', 'audited',
|
|
'--findings', JSON.stringify(findings),
|
|
'--session', '20260622_120000',
|
|
'--ledger-file', file, '--reference-date', NOW,
|
|
]);
|
|
assert.equal(status, 0);
|
|
const out = JSON.parse(stdout);
|
|
assert.equal(out.action, 'set-status');
|
|
assert.equal(out.repo.status, 'audited');
|
|
assert.deepEqual(out.repo.findingsBySeverity, findings);
|
|
assert.equal(out.repo.sessionId, '20260622_120000');
|
|
|
|
const { ledger } = await readLedger(file);
|
|
assert.deepEqual(rollUp(ledger).bySeverity, findings);
|
|
assert.deepEqual(out.rollUp, rollUp(ledger));
|
|
});
|
|
|
|
it('exits 3 on an invalid status', async () => {
|
|
const file = await seeded();
|
|
const { status } = runWrite([
|
|
'set-status', '/r/a', 'nonsense', '--ledger-file', file, '--reference-date', NOW,
|
|
]);
|
|
assert.equal(status, 3);
|
|
});
|
|
|
|
it('exits 3 when the repo is not tracked', async () => {
|
|
const file = await seeded();
|
|
const { status } = runWrite([
|
|
'set-status', '/r/ghost', 'audited', '--ledger-file', file, '--reference-date', NOW,
|
|
]);
|
|
assert.equal(status, 3);
|
|
});
|
|
|
|
it('exits 3 when no ledger exists yet', () => {
|
|
const dir = newDir();
|
|
const { status } = runWrite([
|
|
'set-status', '/r/a', 'audited',
|
|
'--ledger-file', join(dir, 'nope.json'), '--reference-date', NOW,
|
|
]);
|
|
assert.equal(status, 3);
|
|
});
|
|
});
|
|
|
|
describe('campaign-write-cli — determinism + --output-file', () => {
|
|
it('stamps updatedDate from --reference-date and writes the payload to --output-file', async () => {
|
|
const dir = newDir();
|
|
const file = join(dir, 'ledger.json');
|
|
runWrite(['init', '--ledger-file', file, '--reference-date', '2026-01-01']);
|
|
const report = join(dir, 'report.json');
|
|
const { stdout } = runWrite([
|
|
'add', '/r/a', '--ledger-file', file, '--reference-date', '2026-03-15',
|
|
'--output-file', report,
|
|
]);
|
|
assert.equal(stdout.trim(), '', 'stdout is silent when --output-file is given');
|
|
const written = JSON.parse(readFileSync(report, 'utf-8'));
|
|
assert.equal(written.action, 'add');
|
|
|
|
const { ledger } = await readLedger(file);
|
|
assert.equal(ledger.createdDate, '2026-01-01', 'created stamp preserved');
|
|
assert.equal(ledger.updatedDate, '2026-03-15', 'updated stamp from the later --reference-date');
|
|
});
|
|
|
|
it('exits 3 on an unknown subcommand', () => {
|
|
const dir = newDir();
|
|
const { status } = runWrite(['frobnicate', '--ledger-file', join(dir, 'x.json')]);
|
|
assert.equal(status, 3);
|
|
});
|
|
});
|