1. A step of only tests can never be proven red. One reading, stepTestsOnly (lib/parsers/plan-schema.mjs, test paths from the new lib/util/test-path.mjs): the plan validator warns PLAN_STEP_TESTS_ONLY, red-proof --plan/--step adds tests_only to a not-red-at-base, and /trekexecute and the step helper send such a step straight up to the plan instead of into fix rounds that cannot succeed. /trekplan, the plan template and plan-critic (dimension 8, still 12) say the test belongs in the step that builds the behaviour. 2. Timing tests under load: the hook helper's kill limit is 30 s and per call overridable; SC #14 passes on the fastest of up to three runs against the same 800 ms limit (tests/helpers/timing.mjs). 3. The example plan's On failure lines open with a K11 word. 4. /trekreview saves the reviewer's whole answer for the findings check and names FINDING_PROSE_LINE_OUT_OF_RANGE (error: re-ask; warning: report). 5. The criteria runner's commands run without NODE_TEST_CONTEXT (commandEnv, moved to lib/util/command-env.mjs). 6. The resolver returns effort only when the brief's phase signal sets one; the four commands said it always did. Also: the K2 hook tests used fixed session ids in a per-user temp dir, so two suites running at once took each other's approval records; the ids are now unique per run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
6.1 KiB
JavaScript
139 lines
6.1 KiB
JavaScript
// tests/hooks/approval-log.test.mjs
|
|
// P12 + P3 — an approval a person gave through Claude Code's prompt is written
|
|
// to the approval log (contract K2, lib/util/approval-log.mjs, owned by wave-1
|
|
// order 2). The log CLI is a STUB here until order 2 is merged: the stub
|
|
// records its argv. When the CLI is missing, the guard says "not logged" —
|
|
// never green, never silent.
|
|
//
|
|
// Operator decision 06.10: merge/push to main is logged as `merge`, tag and
|
|
// publish as `release`; push of other branches and destructive git are asked
|
|
// about but not logged. P12 approvals carry an expiry (`--expires`).
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { createHash } from 'node:crypto';
|
|
import { join } from 'node:path';
|
|
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
|
import { scriptsFor, pluginCopy, makeRepo, gitIn, tmp, readStubLog, parseJsonOut } from './guard-helpers.mjs';
|
|
|
|
const K2 = 'lib/util/approval-log.mjs';
|
|
const STUBBED = pluginCopy({ [K2]: 'stub' });
|
|
const MISSING = pluginCopy({ [K2]: 'missing' });
|
|
const hookIn = (root) => join(root, 'hooks', 'scripts', 'post-approval-log.mjs');
|
|
|
|
const REPO = makeRepo('main');
|
|
gitIn(REPO, 'checkout', '-q', '-b', 'feat');
|
|
|
|
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
|
|
let callNo = 0;
|
|
// Approval records live in one temp dir per user (hooks/lib/approval-record.mjs),
|
|
// keyed by session_id + tool_use_id, so two suites running at once (two
|
|
// worktrees) took each other's records with fixed ids. Unique per run.
|
|
const RUN = `${process.pid}-${Date.now()}`;
|
|
|
|
// A Bash call goes through the same two hooks as in a session: the guard asks
|
|
// (and records what it asked), the call runs, then the approval is logged.
|
|
async function post(root, input, env = {}) {
|
|
const logFile = join(tmp(), 'stub.jsonl');
|
|
const ids = { session_id: `sess-log-${RUN}`, tool_use_id: `toolu_log_${(callNo += 1)}` };
|
|
if (input.tool_name === 'Bash') {
|
|
await runHookWithEnv(join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs'),
|
|
{ hook_event_name: 'PreToolUse', cwd: REPO, ...ids, tool_name: 'Bash', tool_input: input.tool_input }, env);
|
|
}
|
|
const r = await runHookWithEnv(hookIn(root), { hook_event_name: 'PostToolUse', cwd: REPO, ...ids, ...input }, { STUB_LOG: logFile, ...env });
|
|
return { ...r, calls: readStubLog(logFile), out: parseJsonOut(r.stdout) };
|
|
}
|
|
|
|
const bash = (command) => ({ tool_name: 'Bash', tool_input: { command }, tool_response: { stdout: '', stderr: '' } });
|
|
|
|
const VOYAGE_PLAN = '---\nplan_version: 1.7\n---\n\n# Plan\n\n### Step 1: x\n';
|
|
const OTHER_PLAN = '# Refactor auth\n\n1. Extract the helper\n';
|
|
const exitPlan = (plan, filePath = '/tmp/plans/p.md') => ({
|
|
tool_name: 'ExitPlanMode',
|
|
tool_input: { plan, planFilePath: filePath },
|
|
tool_response: { plan, filePath },
|
|
});
|
|
|
|
function arg(call, flag) {
|
|
const i = call.argv.indexOf(flag);
|
|
return i === -1 ? undefined : call.argv[i + 1];
|
|
}
|
|
|
|
test('K2: push to main is logged as merge, with subject, sha256 and an expiry', async () => {
|
|
const r = await post(STUBBED, bash('git push origin main'));
|
|
assert.equal(r.code, 0, r.stderr);
|
|
assert.equal(r.calls.length, 1, `one append expected; got ${JSON.stringify(r.calls)}`);
|
|
const c = r.calls[0];
|
|
assert.equal(c.argv[0], 'append');
|
|
assert.equal(arg(c, '--kind'), 'merge');
|
|
assert.ok(arg(c, '--subject'), 'subject (a path) is given');
|
|
assert.match(arg(c, '--sha256'), /^[0-9a-f]{64}$/);
|
|
const exp = Date.parse(arg(c, '--expires'));
|
|
assert.ok(Number.isFinite(exp) && exp > Date.now(), 'expires is a future ISO time');
|
|
assert.match(r.out.hookSpecificOutput.additionalContext, /approval logged/i);
|
|
});
|
|
|
|
test('K2: a tag is logged as release', async () => {
|
|
const r = await post(STUBBED, bash('git tag v9.9.9'));
|
|
assert.equal(r.calls.length, 1);
|
|
assert.equal(arg(r.calls[0], '--kind'), 'release');
|
|
});
|
|
|
|
test('K2: push of a feature branch is asked about but NOT logged', async () => {
|
|
const r = await post(STUBBED, bash('git push origin feat'));
|
|
assert.equal(r.code, 0);
|
|
assert.equal(r.calls.length, 0);
|
|
});
|
|
|
|
test('K2: destructive git is asked about but NOT logged', async () => {
|
|
const r = await post(STUBBED, bash('git reset --hard HEAD~1'));
|
|
assert.equal(r.code, 0, r.stderr);
|
|
assert.equal(r.calls.length, 0);
|
|
});
|
|
|
|
test('K2: an ordinary command logs nothing and says nothing', async () => {
|
|
const r = await post(STUBBED, bash('npm test'));
|
|
assert.equal(r.code, 0, r.stderr);
|
|
assert.equal(r.calls.length, 0);
|
|
assert.equal(r.out, null);
|
|
});
|
|
|
|
test('K2 missing: the approval is reported "not logged", never green', async () => {
|
|
const r = await post(MISSING, bash('git push origin main'));
|
|
assert.equal(r.code, 0);
|
|
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
|
});
|
|
|
|
test('K2 refuses (exit 1): reported "not logged"', async () => {
|
|
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '1' });
|
|
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
|
});
|
|
|
|
test('K2 could not run (exit 2): reported "not logged"', async () => {
|
|
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '2' });
|
|
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
|
|
});
|
|
|
|
test('P3: an approved Voyage plan is logged as plan, bound to the sha256 of the approved text', async () => {
|
|
const r = await post(STUBBED, exitPlan(VOYAGE_PLAN, '/tmp/plans/voyage-plan.md'));
|
|
assert.equal(r.calls.length, 1, JSON.stringify(r.calls));
|
|
const c = r.calls[0];
|
|
assert.equal(arg(c, '--kind'), 'plan');
|
|
assert.equal(arg(c, '--subject'), '/tmp/plans/voyage-plan.md');
|
|
assert.equal(arg(c, '--sha256'), sha(VOYAGE_PLAN));
|
|
});
|
|
|
|
test('P3: an approved plan without plan_version is not Voyage\'s and is not logged', async () => {
|
|
const r = await post(STUBBED, exitPlan(OTHER_PLAN));
|
|
assert.equal(r.code, 0, r.stderr);
|
|
assert.equal(r.calls.length, 0);
|
|
assert.equal(r.out, null);
|
|
});
|
|
|
|
test('wiring: hooks.json runs post-approval-log on PostToolUse for Bash and ExitPlanMode', () => {
|
|
for (const tool of ['Bash', 'ExitPlanMode']) {
|
|
const s = scriptsFor('PostToolUse', tool);
|
|
assert.ok(s.some((p) => p.endsWith('post-approval-log.mjs')), `${tool}: ${JSON.stringify(s)}`);
|
|
}
|
|
});
|