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>
72 lines
2.8 KiB
JavaScript
72 lines
2.8 KiB
JavaScript
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);
|
|
});
|
|
});
|