feat(campaign): plan export + execution-by-reuse (v5.7 Fase 2 Block 4c)
Completes Block 4 (4b backlog + 4c export/execution). Asymmetric: plan
export is new testable code; execution is pure reuse of the existing
per-repo implement/rollback (no new execution machinery), per the plan's
"reuse existing backup/rollback".
Plan export ("planer følger arbeidsstedet"):
- scanners/lib/campaign-export.mjs (pure, now injected, 8 tests):
planExportPath(repo,sessionId) -> <repo>/docs/config-audit-plan-<sessionId>.md
(sessionId-keyed so same-day re-audits never collide);
buildPlanExportDocument({...,now}) -> provenance header + verbatim plan.
- scanners/campaign-export-cli.mjs (-cli, read-only by default, 10 tests):
--repo resolves the repo's linked session, reads its action-plan.md,
assembles the doc, emits {exportable,problems,targetPath,document}. Two
gates -> exit 1 advisory: no-session-linked / no-action-plan. Writes the
file ONLY under opt-in --write (byte-faithful copy; the LLM never re-types
a 200-line plan). --sessions-dir override for hermetic tests; exit 0/1/3.
Command: commands/campaign.md gains an `export <path>` mode (Step 6:
preview -> approve -> --write), then routes the user to the existing
/config-audit implement (backup + verify) + rollback + set-status
implemented. Nothing auto-written (Verifiseringsplikt).
Byte-stable: lib + -cli + command-doc only -> scanner count stays 15,
agents 7, commands 21 (export is a mode, not a new command), SC-5 +
backcompat suite untouched. suite 1150->1168. Block 4a (migrateLedger)
still deferred to the first breaking schema change.
Docs: CLAUDE.md section + badge 1150->1168/65->67 files; README badge +
campaign row + Testing prose (fixed stale 1055/59 -> true 1168/67).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49833aded8
commit
319e5541c9
7 changed files with 575 additions and 22 deletions
72
tests/lib/campaign-export.test.mjs
Normal file
72
tests/lib/campaign-export.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planExportPath, buildPlanExportDocument } from '../../scanners/lib/campaign-export.mjs';
|
||||
|
||||
const NOW = '2026-06-23';
|
||||
const SESSION = '20260623_101500';
|
||||
|
||||
describe('planExportPath', () => {
|
||||
it('targets <repo>/docs/config-audit-plan-<sessionId>.md', () => {
|
||||
assert.equal(
|
||||
planExportPath('/Users/ktg/repos/foo', SESSION),
|
||||
`/Users/ktg/repos/foo/docs/config-audit-plan-${SESSION}.md`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keys on sessionId (not date) so same-day audits do not collide', () => {
|
||||
const a = planExportPath('/r/x', '20260623_090000');
|
||||
const b = planExportPath('/r/x', '20260623_180000');
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
it('throws on a missing/blank repoPath or sessionId (programmer error)', () => {
|
||||
assert.throws(() => planExportPath('', SESSION), TypeError);
|
||||
assert.throws(() => planExportPath('/r/x', ' '), TypeError);
|
||||
assert.throws(() => planExportPath('/r/x'), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPlanExportDocument', () => {
|
||||
const base = {
|
||||
repoName: 'foo',
|
||||
repoPath: '/Users/ktg/repos/foo',
|
||||
sessionId: SESSION,
|
||||
planMarkdown: '## Action 1\nDo the thing.\n',
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
it('prepends a provenance header, then the verbatim plan body', () => {
|
||||
const doc = buildPlanExportDocument(base);
|
||||
assert.match(doc, /^# Config-Audit Action Plan — foo\n/);
|
||||
assert.ok(doc.includes(`on ${NOW}.`));
|
||||
assert.ok(doc.includes('`/Users/ktg/repos/foo`'));
|
||||
assert.ok(doc.includes(`\`${SESSION}\``));
|
||||
// body is present verbatim, after the --- separator
|
||||
const [, body] = doc.split('\n---\n');
|
||||
assert.ok(body.includes('## Action 1\nDo the thing.'));
|
||||
});
|
||||
|
||||
it('points at the existing execute/undo/record machinery (reuse, not reinvent)', () => {
|
||||
const doc = buildPlanExportDocument(base);
|
||||
assert.ok(doc.includes('/config-audit implement'));
|
||||
assert.ok(doc.includes('/config-audit rollback'));
|
||||
assert.ok(doc.includes(`/config-audit campaign set-status ${base.repoPath} implemented`));
|
||||
});
|
||||
|
||||
it('is deterministic — same inputs produce identical bytes', () => {
|
||||
assert.equal(buildPlanExportDocument(base), buildPlanExportDocument({ ...base }));
|
||||
});
|
||||
|
||||
it('trims trailing whitespace on the body and ends with exactly one newline', () => {
|
||||
const doc = buildPlanExportDocument({ ...base, planMarkdown: 'body\n\n\n' });
|
||||
assert.ok(doc.endsWith('body\n'));
|
||||
assert.ok(!doc.endsWith('body\n\n'));
|
||||
});
|
||||
|
||||
it('throws when any required field is missing or blank', () => {
|
||||
for (const k of ['repoName', 'repoPath', 'sessionId', 'planMarkdown', 'now']) {
|
||||
assert.throws(() => buildPlanExportDocument({ ...base, [k]: '' }), TypeError, `blank ${k}`);
|
||||
}
|
||||
assert.throws(() => buildPlanExportDocument(), TypeError);
|
||||
});
|
||||
});
|
||||
154
tests/scanners/campaign-export-cli.test.mjs
Normal file
154
tests/scanners/campaign-export-cli.test.mjs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
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 { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createLedger, addRepo, setRepoStatus, saveLedger } from '../../scanners/lib/campaign-ledger.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const CLI = resolve(__dirname, '../../scanners/campaign-export-cli.mjs');
|
||||
|
||||
const NOW = '2026-06-23';
|
||||
const SESSION = '20260623_101500';
|
||||
const PLAN_BODY = '## Executive summary\n\n2 actions, 1 auto-fixable.\n\n## Action 1\nFix the thing.\n';
|
||||
|
||||
// Every test points --ledger-file + --sessions-dir at temp dirs, so the CLI never touches the
|
||||
// real ~/.claude/ — 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 || '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a hermetic world: a temp dir holding the ledger + a sessions/ store + a repo/ dir.
|
||||
* `opts.withSession` links a sessionId; `opts.withPlan` writes that session's action-plan.md.
|
||||
*/
|
||||
function world({ withSession = true, withPlan = true } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'camp-exp-'));
|
||||
const sessionsDir = join(root, 'sessions');
|
||||
const repoDir = join(root, 'repo');
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
|
||||
let l = createLedger({ now: NOW });
|
||||
l = addRepo(l, { path: repoDir, name: 'repo' }, { now: NOW });
|
||||
l = setRepoStatus(l, repoDir, 'planned', {
|
||||
now: NOW,
|
||||
findingsBySeverity: { critical: 0, high: 1, medium: 0, low: 2 },
|
||||
sessionId: withSession ? SESSION : undefined,
|
||||
});
|
||||
const ledgerFile = join(root, 'campaign-ledger.json');
|
||||
// saveLedger is async via the lib; call synchronously through writeFileSync to keep tests simple.
|
||||
writeFileSync(ledgerFile, `${JSON.stringify(l, null, 2)}\n`);
|
||||
|
||||
if (withSession && withPlan) {
|
||||
mkdirSync(join(sessionsDir, SESSION), { recursive: true });
|
||||
writeFileSync(join(sessionsDir, SESSION, 'action-plan.md'), PLAN_BODY);
|
||||
}
|
||||
return { root, sessionsDir, repoDir, ledgerFile };
|
||||
}
|
||||
|
||||
const baseArgs = (w) => ['--ledger-file', w.ledgerFile, '--sessions-dir', w.sessionsDir, '--reference-date', NOW];
|
||||
|
||||
describe('campaign-export-cli — preview (read-only default)', () => {
|
||||
it('exits 0 and returns an exportable preview without writing', () => {
|
||||
const w = world();
|
||||
const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]);
|
||||
assert.equal(status, 0);
|
||||
const out = JSON.parse(stdout);
|
||||
assert.equal(out.exportable, true);
|
||||
assert.equal(out.written, false);
|
||||
assert.deepEqual(out.problems, []);
|
||||
assert.equal(out.targetPath, join(w.repoDir, 'docs', `config-audit-plan-${SESSION}.md`));
|
||||
assert.ok(out.document.startsWith('# Config-Audit Action Plan — repo'));
|
||||
assert.ok(out.document.includes('## Action 1\nFix the thing.'));
|
||||
// preview must NOT have created the file
|
||||
assert.equal(existsSync(out.targetPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campaign-export-cli — --write', () => {
|
||||
it('writes a byte-exact copy of the assembled document into the repo docs/', () => {
|
||||
const w = world();
|
||||
const preview = JSON.parse(runCli([...baseArgs(w), '--repo', w.repoDir]).stdout);
|
||||
const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--write']);
|
||||
assert.equal(status, 0);
|
||||
const out = JSON.parse(stdout);
|
||||
assert.equal(out.written, true);
|
||||
assert.ok(existsSync(out.targetPath));
|
||||
// file on disk equals the document the preview reported (no drift)
|
||||
assert.equal(readFileSync(out.targetPath, 'utf-8'), preview.document);
|
||||
});
|
||||
|
||||
it('creates the docs/ dir if missing', () => {
|
||||
const w = world();
|
||||
const out = JSON.parse(runCli([...baseArgs(w), '--repo', w.repoDir, '--write']).stdout);
|
||||
assert.ok(existsSync(join(w.repoDir, 'docs')));
|
||||
assert.ok(existsSync(out.targetPath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('campaign-export-cli — gates (advisory exit 1)', () => {
|
||||
it('exits 1 with no-session-linked when the repo has no sessionId', () => {
|
||||
const w = world({ withSession: false });
|
||||
const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]);
|
||||
assert.equal(status, 1);
|
||||
const out = JSON.parse(stdout);
|
||||
assert.equal(out.exportable, false);
|
||||
assert.deepEqual(out.problems, ['no-session-linked']);
|
||||
assert.equal(out.written, false);
|
||||
});
|
||||
|
||||
it('exits 1 with no-action-plan when the linked session has no action-plan.md', () => {
|
||||
const w = world({ withPlan: false });
|
||||
const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]);
|
||||
assert.equal(status, 1);
|
||||
const out = JSON.parse(stdout);
|
||||
assert.equal(out.exportable, false);
|
||||
assert.deepEqual(out.problems, ['no-action-plan']);
|
||||
});
|
||||
|
||||
it('--write writes nothing when the repo is not exportable', () => {
|
||||
const w = world({ withPlan: false });
|
||||
const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--write']);
|
||||
assert.equal(status, 1);
|
||||
assert.equal(JSON.parse(stdout).written, false);
|
||||
assert.equal(existsSync(join(w.repoDir, 'docs')), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campaign-export-cli — errors (exit 3)', () => {
|
||||
it('exits 3 when --repo is missing', () => {
|
||||
const w = world();
|
||||
assert.equal(runCli(baseArgs(w)).status, 3);
|
||||
});
|
||||
|
||||
it('exits 3 when the repo is not tracked in the ledger', () => {
|
||||
const w = world();
|
||||
assert.equal(runCli([...baseArgs(w), '--repo', join(w.root, 'other')]).status, 3);
|
||||
});
|
||||
|
||||
it('exits 3 when no ledger file exists', () => {
|
||||
const w = world();
|
||||
const status = runCli(['--ledger-file', join(w.root, 'nope.json'), '--sessions-dir', w.sessionsDir, '--repo', w.repoDir]).status;
|
||||
assert.equal(status, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campaign-export-cli — --output-file', () => {
|
||||
it('writes the payload JSON to the file and stays silent on stdout', () => {
|
||||
const w = world();
|
||||
const out = join(w.root, 'report.json');
|
||||
const { stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--output-file', out]);
|
||||
assert.equal(stdout.trim(), '');
|
||||
const written = JSON.parse(readFileSync(out, 'utf-8'));
|
||||
assert.equal(written.exportable, true);
|
||||
assert.equal(written.targetPath, join(w.repoDir, 'docs', `config-audit-plan-${SESSION}.md`));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue