The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each fixed behind a test that failed first, or, for the two missing tests, a test shown to fail on a stub of the branch it covers. - Review steps appended by /trekexecute Phase 7.2 are held to the step rules on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a template review step validated and was executed). - The guard refuses where no one can be asked also when its rules will not load; the wave merge path names that refusal instead of MERGE_REFUSED. Tests for the classifier-error refusal and post-approval-log's own check. - release-check: the version files may change only in their version after the review (JSON compared without it), README diff lines are read inside hunks only, an unreadable diff is exit 2, and npm test runs without NODE_TEST_CONTEXT. - What counts as a step of only tests: a note in parentheses is not a path, and a data file under specs/ is not a test. End-to-end: rides on the last step that builds behaviour (validator hint, trekplan, template). - A removed line is cited as <rev>:<path>:<line> (findings-schema hint, trekreview re-ask). - skill-lint counts a trailing newline as no line; regression-check takes commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read exemptions. - /security-review, older than v6.2.0: the criteria runner refuses git --output, which writes a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
22 KiB
JavaScript
406 lines
22 KiB
JavaScript
// tests/commands/trekexecute-coupling.test.mjs
|
|
// Wave 2, order 6 — the executor's coupling to the contracts, run against the
|
|
// real CLIs (the prose pins are in tests/lib/coupling-prose-pins.test.mjs):
|
|
//
|
|
// K7 a stub review.md with `verdict: BLOCK` → the verdict gate exits 1, and
|
|
// Phase 8 keeps the main merge closed on 1 and on 2.
|
|
// K2 a plan edited after approval → the exact Phase 1 command refuses it
|
|
// (a temp repo, the real approval log).
|
|
// K9 the step check's exit 2 → `step_check: "not-measured"`, never green.
|
|
// K10 a failing `Regression:` → regression-check exits 1, so the step does
|
|
// not reach its checkpoint.
|
|
// C15 the runner's `exitCode` must be written as `exit_code`: the progress
|
|
// validator rejects the runner's key in `plan_verification.failed_criteria`.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { validateProgressObject } from '../../lib/validators/progress-validator.mjs';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const CMD = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf8');
|
|
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
|
const LOG = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
|
const STEP_CHECK = join(ROOT, 'lib', 'verification', 'step-check.mjs');
|
|
const REGRESSION = join(ROOT, 'lib', 'verification', 'regression-check.mjs');
|
|
|
|
/** The text of a markdown section: from its heading to the next heading of the same or a higher level. */
|
|
function section(text, heading) {
|
|
const at = text.indexOf(`\n${heading}\n`);
|
|
assert.ok(at >= 0, `trekexecute.md must have the heading "${heading}"`);
|
|
const level = heading.match(/^#+/)[0].length;
|
|
const lines = text.slice(at + heading.length + 2).split('\n');
|
|
const stop = new RegExp(`^#{1,${level}} `);
|
|
let fenced = false;
|
|
const out = [];
|
|
for (const l of lines) {
|
|
if (/^\s*(```|~~~)/.test(l)) fenced = !fenced;
|
|
else if (!fenced && stop.test(l)) break;
|
|
out.push(l);
|
|
}
|
|
return out.join('\n');
|
|
}
|
|
|
|
function tmp(prefix) {
|
|
return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
}
|
|
|
|
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.trim();
|
|
}
|
|
|
|
function repo() {
|
|
const dir = tmp('coupling-repo-');
|
|
git(dir, 'init', '-q', '-b', 'main');
|
|
git(dir, 'config', 'user.email', 't@example.invalid');
|
|
git(dir, 'config', 'user.name', 't');
|
|
git(dir, 'config', 'commit.gpgsign', 'false');
|
|
writeFileSync(join(dir, 'README.md'), 'x\n');
|
|
git(dir, 'add', 'README.md');
|
|
git(dir, 'commit', '-q', '-m', 'init');
|
|
return dir;
|
|
}
|
|
|
|
const node = (args, opts = {}) => spawnSync(process.execPath, args, { encoding: 'utf8', ...opts });
|
|
const json = (r) => JSON.parse(r.stdout.trim().split('\n').pop());
|
|
|
|
// ---- K7 ---------------------------------------------------------------------
|
|
|
|
function reviewStub(verdict) {
|
|
const id = 'b'.repeat(40);
|
|
const severities = verdict === 'BLOCK' ? ['BLOCKER'] : [];
|
|
const findings = severities.map((s) => ({
|
|
id, severity: s, rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.',
|
|
}));
|
|
const counts = { BLOCKER: severities.length, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
|
|
const block = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings }, null, 2);
|
|
return [
|
|
'---', 'type: trekreview', 'review_version: "1.1"', 'task: "t"', 'slug: s',
|
|
'project_dir: .claude/projects/2026-10-08-s/', 'brief_path: .claude/projects/2026-10-08-s/brief.md',
|
|
'scope_sha_end: abc', 'reviewed_files_count: 1', `verdict: ${verdict}`,
|
|
severities.length ? `findings:\n - ${id}` : 'findings: []', '---', '', '# Review: t', '',
|
|
'## Executive Summary', '', 'x', '', '## Coverage', '', '| File | Treatment | Reason |', '|---|---|---|',
|
|
'| a.mjs | summary-only | default |', '', '## Remediation Summary', '', 'x', '', '```json', block, '```', '',
|
|
].join('\n');
|
|
}
|
|
|
|
test('K7: a stub review.md with verdict BLOCK -> the gate exits 1, and Phase 8 keeps the merge closed on 1 and 2', () => {
|
|
const dir = tmp('coupling-review-');
|
|
try {
|
|
const p = join(dir, 'review.md');
|
|
writeFileSync(p, reviewStub('BLOCK'));
|
|
const r = node([GATE, '--json', p]);
|
|
assert.equal(r.status, 1, `BLOCK must exit 1: ${r.stdout}${r.stderr}`);
|
|
assert.equal(json(r).verdict, 'BLOCK');
|
|
writeFileSync(p, reviewStub('ALLOW'));
|
|
assert.equal(node([GATE, p]).status, 0, 'the control: ALLOW exits 0');
|
|
rmSync(p);
|
|
assert.equal(node([GATE, p]).status, 2, 'a review that was never written exits 2');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
|
|
const gate = section(CMD, '### Main-merge gate (MAIN_MERGE_GATE — always pauses)');
|
|
assert.match(gate, /lib\/review\/verdict-gate\.mjs --json "\{review_path\}"/, 'Phase 8 runs the verdict gate on review.md');
|
|
assert.match(gate, /\| 1 \| BLOCK \| closed/, 'exit 1 keeps the merge closed');
|
|
assert.match(gate, /\| 2 \| not measured \| closed/, 'exit 2 keeps the merge closed - never green');
|
|
assert.match(gate, /The merge runs only on exit 0/);
|
|
});
|
|
|
|
// ---- K2 ---------------------------------------------------------------------
|
|
|
|
/** The Phase 1 plan-approval command, as the prose writes it, with the placeholders filled. */
|
|
function approvalCommand(planPath) {
|
|
const m = /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/approval-log\.mjs verify --json --kind plan --subject "\{plan_path\}" --sha256-of "\{plan_path\}"/.exec(
|
|
section(CMD, '### Plan approval gate (K2)'),
|
|
);
|
|
assert.ok(m, 'Phase 1 must run approval-log.mjs verify --kind plan against the plan file');
|
|
return [LOG, 'verify', '--json', '--kind', 'plan', '--subject', planPath, '--sha256-of', planPath];
|
|
}
|
|
|
|
test('K2: a plan edited after approval -> the Phase 1 command refuses it', () => {
|
|
const dir = repo();
|
|
try {
|
|
const plan = join(dir, '.claude', 'projects', '2026-10-08-x', 'plan.md');
|
|
mkdirSync(dirname(plan), { recursive: true });
|
|
writeFileSync(plan, '# Plan\n\n### Step 1: one\n');
|
|
const never = node(approvalCommand(plan), { cwd: dir });
|
|
assert.equal(never.status, 1, 'a plan never approved is refused');
|
|
assert.equal(json(never).status, 'not-logged');
|
|
|
|
const add = node([LOG, 'append', '--kind', 'plan', '--subject', plan, '--sha256-of', plan], { cwd: dir });
|
|
assert.equal(add.status, 0, add.stdout + add.stderr);
|
|
assert.equal(node(approvalCommand(plan), { cwd: dir }).status, 0, 'the approved plan, unchanged, passes');
|
|
|
|
writeFileSync(plan, '# Plan\n\n### Step 1: one\n### Step 2: added after approval\n');
|
|
const edited = node(approvalCommand(plan), { cwd: dir });
|
|
assert.equal(edited.status, 1, 'the edited plan is refused');
|
|
assert.equal(json(edited).status, 'mismatch');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
|
|
const gate = section(CMD, '### Plan approval gate (K2)');
|
|
assert.match(gate, /Exit 1 → \*\*halt\*\*/, 'exit 1 halts');
|
|
assert.match(gate, /Exit 2, or no JSON at all → \*\*halt\*\*/, 'exit 2 halts: a check that could not run is never a pass');
|
|
});
|
|
|
|
// ---- K9 ---------------------------------------------------------------------
|
|
|
|
const stepWith = (extra) => ({
|
|
schema_version: '1', plan: 'plan.md', plan_version: '1.8', mode: 'execute', status: 'in_progress',
|
|
total_steps: 1, current_step: 1, steps: { 1: { status: 'in_progress', attempts: 1, ...extra } },
|
|
});
|
|
const codes = (r) => r.errors.map((e) => e.code);
|
|
|
|
test('K9: exit 2 from the step check -> step_check "not-measured", never green', () => {
|
|
const dir = tmp('coupling-norepo-');
|
|
try {
|
|
const r = node([STEP_CHECK, '--cwd', dir]);
|
|
assert.equal(r.status, 2, `not a repository must be "not measured" (exit 2): ${r.stdout}${r.stderr}`);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
|
|
const d4 = section(CMD, '#### Sub-step D4 — Step check and regression');
|
|
assert.match(d4, /\| 2 \| `"not-measured"` \|/, 'exit 2 is written as not-measured');
|
|
assert.match(d4, /never green/);
|
|
for (const v of ['clean', 'findings', 'not-measured']) {
|
|
assert.deepEqual(codes(validateProgressObject(stepWith({ step_check: v }))), [], `step_check ${v}`);
|
|
}
|
|
for (const bad of ['green', 'pass', 'measured', 2]) {
|
|
assert.deepEqual(codes(validateProgressObject(stepWith({ step_check: bad }))), ['PROGRESS_STEP_CHECK_INVALID'], `step_check ${bad}`);
|
|
}
|
|
});
|
|
|
|
// ---- K10 --------------------------------------------------------------------
|
|
|
|
function regressionRepo(regressionLine) {
|
|
const dir = repo();
|
|
mkdirSync(join(dir, 'tests'));
|
|
writeFileSync(join(dir, 'tests', 'red.test.mjs'), "import { test } from 'node:test';\ntest('red', () => { throw new Error('red'); });\n");
|
|
writeFileSync(join(dir, 'tests', 'green.test.mjs'), "import { test } from 'node:test';\ntest('green', () => {});\n");
|
|
const plan = join(dir, 'plan.md');
|
|
writeFileSync(plan, [
|
|
'---', 'plan_version: "1.8"', '---', '', '## Implementation Plan', '',
|
|
'### Step 1: the step', '- **Files:** `src/a.mjs`', '- **Verify:** `node --test tests/green.test.mjs`',
|
|
...(regressionLine ? [regressionLine] : []), '- **On failure:** revert', '',
|
|
'### Step 2: the next', '- **Files:** `src/b.mjs`', '- **Regression:** `node --test tests/green.test.mjs`', '',
|
|
].join('\n'));
|
|
return { dir, plan };
|
|
}
|
|
|
|
const regression = (dir, plan, step = '1') => node([REGRESSION, '--plan', plan, '--step', step, '--cwd', dir, '--json']);
|
|
|
|
test('K10: a failing Regression: -> exit 1, the step does not reach its checkpoint', () => {
|
|
const red = regressionRepo('- **Regression:** `node --test tests/red.test.mjs`');
|
|
try {
|
|
const r = regression(red.dir, red.plan);
|
|
assert.equal(r.status, 1, `a red regression exits 1: ${r.stdout}${r.stderr}`);
|
|
const out = json(r);
|
|
assert.equal(out.status, 'failed');
|
|
assert.equal(out.exit_code, 1, 'the exit code is written as exit_code (C15)');
|
|
assert.ok(!('exitCode' in out), 'never the runner key');
|
|
assert.equal(regression(red.dir, red.plan, '2').status, 0, 'step 2 has its own, green regression');
|
|
} finally { rmSync(red.dir, { recursive: true, force: true }); }
|
|
|
|
const absent = regressionRepo(null);
|
|
try {
|
|
const r = regression(absent.dir, absent.plan);
|
|
assert.equal(r.status, 0, 'no Regression: field -> nothing new happens');
|
|
assert.equal(json(r).status, 'absent');
|
|
} finally { rmSync(absent.dir, { recursive: true, force: true }); }
|
|
|
|
const piped = regressionRepo('- **Regression:** `npm test | tee x`');
|
|
try {
|
|
const r = regression(piped.dir, piped.plan);
|
|
assert.equal(r.status, 1, 'a command outside the allowlist is never green');
|
|
assert.equal(json(r).status, 'unrunnable');
|
|
} finally { rmSync(piped.dir, { recursive: true, force: true }); }
|
|
|
|
const none = node([REGRESSION, '--plan', join(tmpdir(), 'no-such-plan-coupling.md'), '--step', '1', '--json']);
|
|
assert.equal(none.status, 2, 'a plan that cannot be read is "could not run"');
|
|
|
|
const d4 = section(CMD, '#### Sub-step D4 — Step check and regression');
|
|
assert.match(d4, /lib\/verification\/regression-check\.mjs"? --plan "\{plan_path\}" --step \{N\}/);
|
|
assert.match(d4, /a finding for Sub-step F/, 'a red regression goes into the fix rounds');
|
|
const e = section(CMD, '#### Sub-step E — Control');
|
|
assert.match(e, /`regression` is `"passed"` or `"absent"`/, 'the round passes only on a green or absent regression');
|
|
});
|
|
|
|
// ---- C15 --------------------------------------------------------------------
|
|
|
|
const withVerification = (failed) => ({
|
|
...stepWith({}), plan_verification: { status: 'failed', summary: { total: 1, passed: 0, failed: 1, blocked: 0, unrunnable: 0 }, failed_criteria: failed },
|
|
});
|
|
|
|
test('C15: the progress validator rejects exitCode in failed_criteria and accepts exit_code', () => {
|
|
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exit_code: 1 }]))), []);
|
|
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exit_code: null }]))), [],
|
|
'null: the criterion never ran');
|
|
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exitCode: 1 }]))),
|
|
['PROGRESS_FAILED_CRITERIA_KEY']);
|
|
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test' }]))),
|
|
['PROGRESS_FAILED_CRITERIA_KEY'], 'exit_code is required in each entry');
|
|
assert.match(section(CMD, '### trekplans — run the plan\'s `## Verification`'), /`exitCode` → `exit_code`/,
|
|
'the executor translates the runner key explicitly');
|
|
});
|
|
|
|
// ---- P11: a BLOCK's remediation steps are checked before anyone is asked -------
|
|
|
|
test('P11: a review step in the template form is caught by the plan validator, so the loop stops before it asks', async () => {
|
|
const { buildPlan } = await import('../helpers/plan-v18-builder.mjs');
|
|
const { validatePlanContent } = await import('../../lib/validators/plan-validator.mjs');
|
|
const plan = buildPlan();
|
|
assert.deepEqual(validatePlanContent(plan, { strict: true }).errors, [], 'the control: the plan before the append is valid');
|
|
// The review template's own Step R1, appended the Phase 7.2 way: next number, fields as they are, a Checkpoint.
|
|
const tpl = readFileSync(join(ROOT, 'templates', 'trekreview-template.md'), 'utf8');
|
|
const r1 = /### Step R1:[^\n]*\n[\s\S]*?(?=\n> )/.exec(tpl);
|
|
assert.ok(r1, 'templates/trekreview-template.md carries a ### Step R1');
|
|
const appended = r1[0].replace(/^### Step R1:/, '### Step 3:') +
|
|
'- **Checkpoint:** `git commit -m "fix(review): block-style YAML [01234567]"`\n';
|
|
const edited = plan.replace('\n## Test Strategy', `\n${appended}\n## Test Strategy`);
|
|
const errors = validatePlanContent(edited, { strict: true }).errors.map((e) => e.code);
|
|
assert.ok(errors.length > 0, 'a remediation step the executor cannot run must not validate');
|
|
assert.ok(errors.some((c) => /VERIFY_NOT_RUNNABLE|TEST_FIRST/.test(c)), errors.join(','));
|
|
|
|
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
|
const check = p.indexOf('plan-validator.mjs --strict --json --steps-from {total + 1} "{plan_path}"');
|
|
const ask = p.indexOf('Approve the plan as it is now?');
|
|
assert.ok(check > 0 && ask > check, 'the validator runs before the approval question');
|
|
assert.match(p, /restore the plan from the copy/, 'a stop leaves the plan matching its approval');
|
|
});
|
|
|
|
test('P11: the lock is passed only by the closing run - a wave session keeps its completed', () => {
|
|
const gate = section(CMD, '### Main-merge gate (MAIN_MERGE_GATE — always pauses)');
|
|
assert.match(gate, /Only the closing run passes the lock/);
|
|
assert.match(gate, /`--session N`\s+child, a `--step N` run and a session spec/);
|
|
assert.match(gate, /left as Phase 7\.5 decided it/);
|
|
assert.match(section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)'), /never a `--step N` run, a `--session N`\s+child/);
|
|
});
|
|
|
|
test('every bash block outside Phase 2.6 that calls "$VOYAGE_ROOT" resolves it itself', () => {
|
|
const start = CMD.indexOf('\n## Phase 2.6 —');
|
|
const end = CMD.indexOf('\n## Phase 3 —');
|
|
const outside = CMD.slice(0, start) + CMD.slice(end);
|
|
const fences = [...outside.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
|
|
// A wave-path block (it runs from "$REPO_ROOT") uses the values Phase 2.6 Step 0 records.
|
|
const using = fences.filter((f) => f.includes('"$VOYAGE_ROOT/') && !f.includes('"$REPO_ROOT"'));
|
|
assert.ok(using.length >= 3, `expected the Phase 7, D4 and K5/K6 blocks, found ${using.length}`);
|
|
for (const f of using) {
|
|
assert.match(f, /^VOYAGE_ROOT="\$\{CLAUDE_PLUGIN_ROOT\}"$/m, `a block uses $VOYAGE_ROOT without resolving it:\n${f.slice(0, 300)}`);
|
|
assert.match(f, /\[ ! -f "\$VOYAGE_ROOT\//, 'and guards the unresolved root with exit 2');
|
|
}
|
|
});
|
|
|
|
// ---- P12: the guard is the one question, and it refuses where no one is asked --
|
|
|
|
const GUARD = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
|
const NO_PROMPT_MODE = 'bypassPermissions';
|
|
|
|
function guard(cwd, command, permission_mode) {
|
|
return node([GUARD], {
|
|
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd, permission_mode, session_id: 'coupling', tool_use_id: `t${Math.random()}` }),
|
|
});
|
|
}
|
|
|
|
test('P12: the guard asks before a merge into main; in the mode with no prompts it refuses (operator 2026-10-09)', () => {
|
|
const dir = repo();
|
|
try {
|
|
git(dir, 'branch', 'feature');
|
|
const merge = 'git merge --no-ff feature -m "merge: trekexecute x"';
|
|
for (const mode of ['default', 'auto', 'acceptEdits']) {
|
|
const r = guard(dir, merge, mode);
|
|
assert.equal(r.status, 0, `${mode}: ${r.stderr}`);
|
|
assert.match(r.stdout, /"permissionDecision":"ask"/, `${mode}: the merge into main is asked`);
|
|
}
|
|
const blocked = guard(dir, merge, NO_PROMPT_MODE);
|
|
assert.equal(blocked.status, 2, 'no one to ask: the merge into main is refused');
|
|
assert.match(blocked.stderr, /no one to ask/);
|
|
assert.match(blocked.stderr, /Way forward/);
|
|
assert.equal(guard(dir, 'git push origin feature', NO_PROMPT_MODE).status, 2, 'a push is refused too');
|
|
// Operator 2026-10-09, second decision: the other two classes as well.
|
|
for (const cmd of ['git tag v1.0', 'npm publish', 'git reset --hard HEAD~1', 'git push --force origin feature']) {
|
|
assert.match(guard(dir, cmd, 'default').stdout, /"permissionDecision":"ask"/, `the control: ${cmd} is asked where someone can answer`);
|
|
const r = guard(dir, cmd, NO_PROMPT_MODE);
|
|
assert.equal(r.status, 2, `${cmd}: refused where no one is asked`);
|
|
assert.match(r.stderr, /no one to ask/);
|
|
}
|
|
const plain = guard(dir, 'git status', NO_PROMPT_MODE);
|
|
assert.equal(plain.status, 0, 'an ordinary command still runs');
|
|
assert.equal(plain.stdout, '');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// Release review of 6.3.0 (2026-10-10): the refusal covers the guard's two
|
|
// fallbacks too — rules that will not load, and a command it could not read.
|
|
function guardIn(plugin, cwd, command, permission_mode) {
|
|
return node([join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs')], {
|
|
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd, permission_mode, session_id: 'coupling', tool_use_id: `t${Math.random()}` }),
|
|
});
|
|
}
|
|
|
|
test('P12 / review 630: a guard whose rules will not load refuses where no one is asked', async () => {
|
|
const { pluginCopy } = await import('../hooks/guard-helpers.mjs');
|
|
const plugin = pluginCopy({ 'hooks/lib/git-approval.mjs': 'missing' });
|
|
const dir = repo();
|
|
try {
|
|
const asked = guardIn(plugin, dir, 'git status', 'default');
|
|
assert.equal(asked.status, 0, asked.stderr);
|
|
assert.match(asked.stdout, /"permissionDecision":"ask"/, 'the control: where someone can answer, the broken guard asks');
|
|
const r = guardIn(plugin, dir, 'git push origin main', NO_PROMPT_MODE);
|
|
assert.equal(r.status, 2, `refused, not asked: ${r.stdout}${r.stderr}`);
|
|
assert.match(r.stderr, /no one to ask/);
|
|
assert.match(r.stderr, /could not load/);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
rmSync(plugin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('P12 / review 630: a command the classifier could not read is asked, and refused where no one is asked', async () => {
|
|
const { pluginCopy } = await import('../hooks/guard-helpers.mjs');
|
|
const plugin = pluginCopy();
|
|
const lib = join(plugin, 'hooks', 'lib', 'git-approval.mjs');
|
|
const src = readFileSync(lib, 'utf8');
|
|
assert.ok(src.includes('export function classifyGitApproval('), 'the copy carries the classifier');
|
|
writeFileSync(lib, src.replace('export function classifyGitApproval(',
|
|
'export function classifyGitApproval() { throw new Error(\'classifier broke\'); }\nfunction classifyGitApprovalUnused('));
|
|
const dir = repo();
|
|
try {
|
|
const asked = guardIn(plugin, dir, 'git push origin feature', 'default');
|
|
assert.equal(asked.status, 0, asked.stderr);
|
|
assert.match(asked.stdout, /"permissionDecision":"ask"/, 'where someone can answer it is asked');
|
|
assert.match(asked.stdout, /could not read/);
|
|
const r = guardIn(plugin, dir, 'git push origin feature', NO_PROMPT_MODE);
|
|
assert.equal(r.status, 2, `refused: ${r.stdout}${r.stderr}`);
|
|
assert.match(r.stderr, /could not read/);
|
|
assert.match(r.stderr, /no one to ask/);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
rmSync(plugin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ---- the other new progress fields (Handover 5, additive-optional) -----------
|
|
|
|
test('H5: the wave-2 fields are shape-checked when present and optional when absent', () => {
|
|
assert.deepEqual(codes(validateProgressObject(stepWith({}))), [], 'an older progress file still validates');
|
|
const ok = {
|
|
...stepWith({ regression: 'passed' }),
|
|
plan_approval: 'approved', arch_rules: 'not-measured', simplify: 'applied',
|
|
scope_sweep: { status: 'deviation', outside: ['src/x.mjs'] },
|
|
review: { round: 1, path: '.claude/projects/x/review.md', gate_exit: 1, verdict: 'BLOCK' },
|
|
};
|
|
assert.deepEqual(codes(validateProgressObject(ok)), []);
|
|
const bad = [
|
|
[{ ...stepWith({ regression: 'green' }) }, 'PROGRESS_REGRESSION_INVALID'],
|
|
[{ ...stepWith({}), plan_approval: 'yes' }, 'PROGRESS_PLAN_APPROVAL_INVALID'],
|
|
[{ ...stepWith({}), arch_rules: 'green' }, 'PROGRESS_ARCH_RULES_INVALID'],
|
|
[{ ...stepWith({}), simplify: 'done' }, 'PROGRESS_SIMPLIFY_INVALID'],
|
|
[{ ...stepWith({}), scope_sweep: { status: 'deviation' } }, 'PROGRESS_SCOPE_SWEEP_INVALID'],
|
|
[{ ...stepWith({}), review: { round: 0, gate_exit: 0 } }, 'PROGRESS_REVIEW_INVALID'],
|
|
[{ ...stepWith({}), review: { round: 1, gate_exit: 3 } }, 'PROGRESS_REVIEW_INVALID'],
|
|
];
|
|
for (const [p, code] of bad) assert.deepEqual(codes(validateProgressObject(p)), [code], JSON.stringify(p));
|
|
});
|