R8 of cleanup R-a (plan .claude/projects/2026-11-07-cleanup-r-a, appendix A). The live tests of the round files move into the test named after their source; each moved test was seen red under a mutation of its source. New: tests/lib/lib-imports.test.mjs, tests/lib/timing.test.mjs (from timing-under-load). The [#1] --gates rule and shim-line pins move into tests/lib/autonomy-gate.test.mjs. Removed: the round test files (retterunde, retterunde-prose-pins, trial-mechanical-fixes, trial2-fixes, trial2-minor, trial2-minor-prose-pins, fix-round-4-5a-prose, fix-round-4-5b2-b-prose, timing-under-load), the 8 R8 e2e scripts, and seven round test files on the R-a removal list that pinned them (fix-round-4-5b-prose, fix-round-4-5b-part-2/3/4-scripts, fix-round-4-5b-part-4-prose, fix-round-4-5b2-b/c-scripts). The diff-mode e2e pin (test 24) and the red-script round-wrapper cases go too. tests/synthetic/plan-run-C.md becomes tests/fixtures/plan-run-c.md, regenerated from buildPlan({ version: '1.9' }). The red judge, reporter and judge test headers now name the shared red script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
14 KiB
JavaScript
296 lines
14 KiB
JavaScript
// tests/lib/approval-log.test.mjs
|
|
// K2 — the approval log: append-only, hash-chained, bound to the content's sha256.
|
|
// Contract (docs/bolge1 § 4, K2): `append --kind <intent|plan|review|merge|release>
|
|
// --subject <path> --sha256 <hex> [--expires <ISO>]` and `verify`; exit 0 = ok,
|
|
// 1 = broken chain / refused, 2 = could not run (never green).
|
|
//
|
|
// The module is imported dynamically so a missing module fails each test on its own.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, '..', '..');
|
|
const CLI = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
|
const load = () => import(CLI);
|
|
|
|
const H1 = 'a'.repeat(64);
|
|
const H2 = 'b'.repeat(64);
|
|
|
|
function tmp() { return mkdtempSync(join(tmpdir(), 'approval-log-')); }
|
|
|
|
function cli(args, cwd) {
|
|
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8' });
|
|
}
|
|
|
|
function git(cwd, ...args) {
|
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
|
return r.stdout;
|
|
}
|
|
|
|
function gitRepo() {
|
|
const d = tmp();
|
|
git(d, 'init', '-q');
|
|
git(d, 'config', 'user.name', 'Test');
|
|
git(d, 'config', 'user.email', 'test@example.invalid');
|
|
git(d, 'config', 'commit.gpgsign', 'false');
|
|
mkdirSync(join(d, '.hooks-none'));
|
|
git(d, 'config', 'core.hooksPath', '.hooks-none');
|
|
return d;
|
|
}
|
|
|
|
function logFile(repo) { return join(repo, '.claude', 'voyage', 'approvals.jsonl'); }
|
|
|
|
// ---- the test that fails first (order 2) ----
|
|
|
|
test('two appends, then one byte changed in the FIRST record → verify exits 1', () => {
|
|
const d = tmp();
|
|
try {
|
|
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 0);
|
|
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d).status, 0);
|
|
assert.equal(cli(['verify'], d).status, 0, 'an untouched chain verifies');
|
|
const p = logFile(d);
|
|
const text = readFileSync(p, 'utf8');
|
|
const i = text.indexOf('brief.md');
|
|
writeFileSync(p, text.slice(0, i) + 'B' + text.slice(i + 1)); // one byte, first record
|
|
const r = cli(['verify', '--json'], d);
|
|
assert.equal(r.status, 1, r.stdout + r.stderr);
|
|
assert.equal(JSON.parse(r.stdout).status, 'broken');
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('a changed byte inside the stored hash field also breaks the chain', () => {
|
|
const d = tmp();
|
|
try {
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
|
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
|
|
const p = logFile(d);
|
|
const lines = readFileSync(p, 'utf8').split('\n');
|
|
const rec = JSON.parse(lines[0]);
|
|
rec.sha256 = H2; // re-bind the first approval to other content
|
|
lines[0] = JSON.stringify(rec);
|
|
writeFileSync(p, lines.join('\n'));
|
|
assert.equal(cli(['verify'], d).status, 1);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('deleting the first record breaks the chain (prev no longer links)', () => {
|
|
const d = tmp();
|
|
try {
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
|
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
|
|
const p = logFile(d);
|
|
const lines = readFileSync(p, 'utf8').split('\n');
|
|
writeFileSync(p, lines.slice(1).join('\n'));
|
|
assert.equal(cli(['verify'], d).status, 1);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('a record carries kind, subject, sha256, ts, prev and hash; prev links the chain', async () => {
|
|
const { readLog } = await load();
|
|
const d = tmp();
|
|
try {
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
|
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2, '--expires', '2099-01-01T00:00:00Z'], d);
|
|
const recs = readLog(d).records;
|
|
assert.equal(recs.length, 2);
|
|
assert.equal(recs[0].kind, 'intent');
|
|
assert.equal(recs[0].subject, 'brief.md');
|
|
assert.equal(recs[0].sha256, H1);
|
|
assert.equal(recs[0].prev, '0'.repeat(64));
|
|
assert.equal(recs[1].prev, recs[0].hash);
|
|
assert.equal(recs[1].expires, '2099-01-01T00:00:00.000Z');
|
|
assert.match(recs[1].ts, /^\d{4}-\d\d-\d\dT/);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('append refuses a bad kind, a bad hash and a bad expiry (exit 1), and writes nothing', () => {
|
|
const d = tmp();
|
|
try {
|
|
const bad = cli(['append', '--kind', 'vibes', '--subject', 'x', '--sha256', H1], d);
|
|
assert.equal(bad.status, 1);
|
|
assert.equal(JSON.parse(bad.stdout).appended, false, 'a refusal is reported, not a crash');
|
|
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'x', '--sha256', 'nothex'], d).status, 1);
|
|
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'x', '--sha256', H1, '--expires', 'tomorrow'], d).status, 1);
|
|
assert.equal(cli(['append', '--kind', 'intent', '--sha256', H1], d).status, 1, 'subject is required');
|
|
assert.equal(existsSync(logFile(d)), false);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('append accepts the "sha256:<hex>" form the intent stamp prints', () => {
|
|
const d = tmp();
|
|
try {
|
|
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', `sha256:${H1}`], d).status, 0);
|
|
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 0);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('append refuses to extend a broken chain (exit 1)', () => {
|
|
const d = tmp();
|
|
try {
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
|
const p = logFile(d);
|
|
writeFileSync(p, readFileSync(p, 'utf8').replace('brief.md', 'brjef.md'));
|
|
const before = readFileSync(p, 'utf8');
|
|
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d).status, 1);
|
|
assert.equal(readFileSync(p, 'utf8'), before, 'nothing appended onto a broken chain');
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// ---- verify for one subject: the gate /trekplan runs ----
|
|
|
|
test('verify --subject: the current approval passes; other content, no record, or expiry is exit 1', () => {
|
|
const d = tmp();
|
|
try {
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
|
const ok = cli(['verify', '--json', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
|
assert.equal(ok.status, 0, ok.stdout);
|
|
assert.equal(JSON.parse(ok.stdout).status, 'approved');
|
|
const other = cli(['verify', '--json', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
|
|
assert.equal(other.status, 1);
|
|
assert.equal(JSON.parse(other.stdout).status, 'mismatch');
|
|
const none = cli(['verify', '--json', '--kind', 'intent', '--subject', 'other.md', '--sha256', H1], d);
|
|
assert.equal(none.status, 1);
|
|
assert.equal(JSON.parse(none.stdout).status, 'not-logged');
|
|
cli(['append', '--kind', 'merge', '--subject', 'main', '--sha256', H2, '--expires', '2000-01-01T00:00:00Z'], d);
|
|
const old = cli(['verify', '--json', '--kind', 'merge', '--subject', 'main', '--sha256', H2], d);
|
|
assert.equal(old.status, 1);
|
|
assert.equal(JSON.parse(old.stdout).status, 'expired');
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('verify --subject: the LATEST record for the subject decides (re-approval supersedes)', () => {
|
|
const d = tmp();
|
|
try {
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
|
|
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d).status, 0);
|
|
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 1);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('the subject is stored relative to the repo, so an absolute and a relative path match', () => {
|
|
const d = tmp();
|
|
try {
|
|
mkdirSync(join(d, 'docs'));
|
|
writeFileSync(join(d, 'docs', 'b.md'), 'x');
|
|
cli(['append', '--kind', 'intent', '--subject', join(d, 'docs', 'b.md'), '--sha256', H1], d);
|
|
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'docs/b.md', '--sha256', H1], d).status, 0);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('verify with no log: the empty chain is intact (0); a subject check is not-logged (1)', () => {
|
|
const d = tmp();
|
|
try {
|
|
assert.equal(cli(['verify'], d).status, 0);
|
|
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 1);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('verify that cannot run is exit 2, never green (bad args, log is a directory)', () => {
|
|
const d = tmp();
|
|
try {
|
|
assert.equal(cli(['verify', '--kind', 'intent'], d).status, 2, 'a partial subject filter is a usage error');
|
|
assert.equal(cli(['frobnicate'], d).status, 2);
|
|
mkdirSync(logFile(d), { recursive: true });
|
|
assert.equal(cli(['verify'], d).status, 2);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('--sha256-of <file> binds to the file bytes; digest prints the same hex', () => {
|
|
const d = tmp();
|
|
try {
|
|
writeFileSync(join(d, 'plan.md'), '# Plan\n');
|
|
const hex = cli(['digest', join(d, 'plan.md')], d).stdout.trim();
|
|
assert.match(hex, /^[0-9a-f]{64}$/);
|
|
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256-of', 'plan.md'], d).status, 0);
|
|
assert.equal(cli(['verify', '--kind', 'plan', '--subject', 'plan.md', '--sha256', hex], d).status, 0);
|
|
writeFileSync(join(d, 'plan.md'), '# Plan, edited after approval\n');
|
|
assert.equal(cli(['verify', '--kind', 'plan', '--subject', 'plan.md', '--sha256-of', 'plan.md'], d).status, 1);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// ---- committed log: a rewrite of committed records shows; uncommitted records are listed ----
|
|
|
|
test('in git: records not yet committed are reported; a committed record removed from the file is exit 1', () => {
|
|
const d = gitRepo();
|
|
try {
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
|
|
git(d, 'add', '.claude/voyage/approvals.jsonl');
|
|
git(d, 'commit', '-q', '-m', 'log');
|
|
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
|
|
const r = cli(['verify', '--json'], d);
|
|
assert.equal(r.status, 0, r.stdout);
|
|
assert.equal(JSON.parse(r.stdout).uncommitted, 1, 'the second approval is visible as not committed');
|
|
// A session that rewrites the log from scratch makes a valid chain that does not
|
|
// start with what was committed: the chain alone cannot see that, the commit can.
|
|
rmSync(logFile(d));
|
|
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
|
|
const w = cli(['verify', '--json'], d);
|
|
assert.equal(w.status, 1, w.stdout);
|
|
assert.equal(JSON.parse(w.stdout).status, 'diverged');
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// ---- answers that are not approval (agent-skills interview-me) ----
|
|
|
|
test('«høres bra ut», «gjør som du vil» and an empty answer are not approval; "Approve" is', async () => {
|
|
const { isExplicitApproval } = await load();
|
|
for (const a of ['høres bra ut', 'Høres bra ut!', 'gjør som du vil', 'sounds good', 'Whatever you think', 'looks good', 'ok', '', ' ']) {
|
|
assert.equal(isExplicitApproval(a).approved, false, `«${a}» must not count as approval`);
|
|
}
|
|
for (const a of ['Approve', 'approve', 'Godkjenn', 'godkjent']) {
|
|
assert.equal(isExplicitApproval(a).approved, true, `«${a}» is an explicit approval`);
|
|
}
|
|
assert.equal(isExplicitApproval('').reason, 'empty');
|
|
assert.equal(isExplicitApproval('høres bra ut').reason, 'hollow');
|
|
});
|
|
|
|
test('«Approve (Recommended)», the label AskUserQuestion recommends, is the explicit approval it labels', async () => {
|
|
const { isExplicitApproval } = await load();
|
|
for (const a of ['Approve (Recommended)', 'Approve (recommended)', 'Godkjenn (anbefalt)', 'Approve (Recommended).']) {
|
|
assert.equal(isExplicitApproval(a).approved, true, a);
|
|
}
|
|
});
|
|
|
|
test('the (Recommended) suffix does not turn a hollow answer into a yes', async () => {
|
|
const { isExplicitApproval } = await load();
|
|
for (const a of ['Sounds good (Recommended)', 'Høres bra ut (anbefalt)', '(Recommended)', 'Revise (Recommended)']) {
|
|
assert.equal(isExplicitApproval(a).approved, false, a);
|
|
}
|
|
});
|
|
|
|
test('the answer CLI: exit 0 only for an explicit approval', () => {
|
|
const d = tmp();
|
|
try {
|
|
assert.equal(cli(['answer', '--text', 'Approve'], d).status, 0);
|
|
assert.equal(cli(['answer', '--text', 'høres bra ut'], d).status, 1);
|
|
assert.equal(cli(['answer', '--text', 'gjør som du vil'], d).status, 1);
|
|
assert.equal(cli(['answer', '--text', ''], d).status, 1);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// ---- honesty and the main-module guard ----
|
|
|
|
test('the module states its trust limits (a trace, not a signature) and credits ai-native-sdlc', () => {
|
|
const src = readFileSync(CLI, 'utf8');
|
|
assert.match(src, /NOT a signature/);
|
|
assert.match(src, /same user, same machine/i);
|
|
assert.match(src, /bashebr\/ai-native-sdlc@11fb8d7 \(MIT/);
|
|
});
|
|
|
|
test('the CLI runs through a symlinked path with a space in it (realpath main-module guard)', () => {
|
|
const d = tmp();
|
|
try {
|
|
const link = join(d, 'with space');
|
|
spawnSync('ln', ['-s', join(ROOT, 'lib'), link]);
|
|
const r = spawnSync(process.execPath, [join(link, 'util', 'approval-log.mjs'), 'verify'], { cwd: d, encoding: 'utf8' });
|
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
} finally { rmSync(d, { recursive: true, force: true }); }
|
|
});
|