voyage/tests/hooks/test-lock-green.test.mjs
Kjell Tore Guttormsen a10018f9d3
fix: round 8 of the release review of 6.3.0, for 6.3.1
Round 8 (0f400d3..29b4ac9, BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR). Each fix
began with a test that failed on 29b4ac9 (13 tests, all red there); suite
2221 (2219/0/2).

- BLOCKER: a backticked check whose trailing comment holds a quote, $,
  backtick or backslash is refused, not dropped. The comment was never
  screened, while the executor hands the Verify span as written to red
  proof, the test lock and the done line inside --cmd "...", where a $(...)
  runs and a quote ends the argument. trekplan's text and the reviewer's
  NOT RUN table say so.
- A span that is only a comment is not-a-command, not a placeholder. The
  dropComment text says the allowlist still refuses a word with a # in it.
- The criteria runner, comparison-gate and end-state-gate use isMain: they
  did nothing and exited 0 under a symlinked path. The test now refuses any
  process.argv[1] comparison outside lib/util/is-main.mjs.
- Files: in a field with backticked paths, a plain word counts only before
  the entry's description (a dash, or a colon after the path); a name with
  no slash only first in its entry or after and/or/+/&. "mocks
  fs.readFileSync", "runs on Node.js 22.x" or "no change to package.json"
  no longer make a tests-only step look like it builds something.
  Lowercase makefile, rakefile and justfile are build files. Measured on the
  520 Files: fields in 48 plans of the marketplace and the test repo:
  3 fields read differently (two notes no longer read as files, one
  duplicate name dropped), tests-only changed in 0.
- The test lock compares --cmd without its trailing comment, and the test
  lock and the done line read a stop with stoppedBy, the one reading.
  red-proof drops its second reading, spawnError.
- release-check reads a relative --review in --repo or, when only that one
  exists, from the current directory as 6.3.0 did; two different files are
  exit 2, never guessed. The runbook says so.
- One trailing-json reader: a closing fence may be longer than three
  backticks, in the block reader and the verdict gate alike, and the review
  validator and the synthesis digest use it, so three backticks inside a
  JSON string no longer hide a BLOCKER from the remediation-step check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:43:56 +02:00

131 lines
6.8 KiB
JavaScript

// tests/hooks/test-lock-green.test.mjs
// The test lock's green mark (operator decision 2026-10-08, voyage-lekerepo
// trial point 7). The stop guard blocked Stop three times while the executor
// waited for the controller in the background, though the step's test was
// already green. Now:
// - `test-lock.mjs green --cmd "<Verify>"` RUNS the step's test command and
// marks the lock "seen green" only on exit 0 (a command proves it, never a
// word);
// - the stop guard lets a green-marked lock stop; the write guard still
// refuses the locked files — the lock still protects the test;
// - `test-lock.mjs pending` clears the mark when a fix round starts.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runHook } from '../helpers/hook-helper.mjs';
import { ROOT, makeRepo, parseJsonOut } from './guard-helpers.mjs';
const CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
const SESS = `sess-green-${process.pid}-${Date.now()}`;
const CMD = 'node --test tests/step.test.mjs';
function cli(repo, args) {
const r = spawnSync(process.execPath, [CLI, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS } });
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}
function lockedRepo(testBody) {
const repo = makeRepo('main');
mkdirSync(join(repo, 'tests'), { recursive: true });
writeFileSync(join(repo, 'tests', 'step.test.mjs'), [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
`test('step', () => { ${testBody} });`,
'',
].join('\n'));
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3', '--cmd', CMD]).code, 0);
return repo;
}
const stop = (repo) => ({ hook_event_name: 'Stop', session_id: SESS, cwd: repo, stop_hook_active: false });
const edit = (file_path) => ({ hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path, old_string: 'x', new_string: 'z' } });
test('green: the step\'s test passes -> the lock is marked green, Stop goes through, the test file stays locked', async () => {
const repo = lockedRepo('assert.equal(1, 1);');
const g = cli(repo, ['green', '--cmd', CMD]);
assert.equal(g.code, 0, g.stderr);
assert.ok(JSON.parse(cli(repo, ['status']).stdout).green_at, 'status shows green_at');
assert.equal(parseJsonOut((await runHook(STOP, stop(repo))).stdout), null, 'a green lock does not block Stop');
const w = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'step.test.mjs')));
assert.equal(w.code, 2, 'the test file is still locked against edits');
});
test('green: the test fails -> exit 1, no mark, Stop is still refused', async () => {
const repo = lockedRepo('assert.equal(1, 2);');
assert.equal(cli(repo, ['green', '--cmd', CMD]).code, 1);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
assert.equal(parseJsonOut((await runHook(STOP, stop(repo))).stdout)?.decision, 'block');
});
test('green: a --cmd that is not the stored test command is never run -> exit 2, no mark', () => {
const repo = lockedRepo('assert.equal(1, 1);');
const g = cli(repo, ['green', '--cmd', 'echo ok']);
assert.equal(g.code, 2, g.stderr);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
});
test('green: no lock to mark -> exit 1', () => {
const repo = makeRepo('main');
assert.equal(cli(repo, ['green', '--cmd', CMD]).code, 1);
});
test('pending: a fix round clears the mark, and Stop is refused again', async () => {
const repo = lockedRepo('assert.equal(1, 1);');
assert.equal(cli(repo, ['green', '--cmd', CMD]).code, 0);
assert.equal(cli(repo, ['pending']).code, 0);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
assert.equal(parseJsonOut((await runHook(STOP, stop(repo))).stdout)?.decision, 'block');
});
test('re-lock of the same step starts without a mark', () => {
const repo = lockedRepo('assert.equal(1, 1);');
assert.equal(cli(repo, ['green', '--cmd', CMD]).code, 0);
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3', '--cmd', CMD]).code, 0);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
});
test('trekexecute: D1 marks the lock green through the command, a fix round marks it pending', () => {
const md = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf8');
assert.match(md, /test-lock\.mjs green\n/);
assert.match(md, /test-lock\.mjs lock --files \{test files\} --step \{N\} --cmd "\{Verify command\}"/);
assert.match(md, /test-lock\.mjs pending/);
});
test('green: exit 127 from the test program itself is a red -> exit 1, no mark (release review of 6.3.0, round 7)', () => {
const repo = makeRepo('main');
mkdirSync(join(repo, 'tests'), { recursive: true });
// The tool the step builds is not there yet: bash runs, and exits 127 for it.
writeFileSync(join(repo, 'tests', 'e2e.sh'), './bin/tool\n');
const cmd = 'bash tests/e2e.sh';
assert.equal(cli(repo, ['lock', '--files', 'tests/e2e.sh', '--step', '3', '--cmd', cmd]).code, 0);
const g = cli(repo, ['green', '--cmd', cmd]);
assert.equal(g.code, 1, g.stderr);
assert.match(g.stderr, /exited 127/);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
});
test('green (release review of 6.3.0, round 8): a trailing # comment on either side is no part of the command', () => {
for (const [lockCmd, greenCmd] of [[`${CMD} # the step's test`, CMD], [CMD, `${CMD} # the step's test`]]) {
const repo = makeRepo('main');
mkdirSync(join(repo, 'tests'), { recursive: true });
writeFileSync(join(repo, 'tests', 'step.test.mjs'), "import { test } from 'node:test';\ntest('step', () => {});\n");
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3', '--cmd', lockCmd]).code, 0);
const g = cli(repo, ['green', '--cmd', greenCmd]);
assert.equal(g.code, 0, `lock "${lockCmd}", green "${greenCmd}": ${g.stderr}`);
}
});
test('green (release review of 6.3.0, round 8): a test program that is not installed is reported as could not start', () => {
const repo = makeRepo('main');
mkdirSync(join(repo, 'tests'), { recursive: true });
writeFileSync(join(repo, 'tests', 'step.test.mjs'), 'x\n');
// `jest` is on the allowlist and not installed where this suite runs.
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3', '--cmd', 'jest tests/step.test.mjs']).code, 0);
const g = cli(repo, ['green', '--cmd', 'jest tests/step.test.mjs']);
assert.equal(g.code, 2, g.stderr);
assert.match(g.stderr, /could not start/, g.stderr);
});