Round 9 (0f400d3..a10018f, WARN: 0 BLOCKER, 12 MAJOR): seven findings and three brief notes, by operator choice. Each finding began with a test that failed ona10018fon its assertion; suite 2230 (2228/0/2). - A check refused for its comment (a quote, $, backtick or backslash) keeps its command and its reason, and every reader refuses it out loud: a nested ## Verification bullet stays a criterion (PLAN_VERIFICATION_NOT_RUNNABLE) and a brief SC warns (BRIEF_SC_NOT_RUNNABLE). It was folded into a sub-point or read as prose, a regression froma10018f. Readers that took a command as runnable (the end-to-end lists) skip a refused one. - The verdict gate reads the last fenced block's info string and body from one reading, and exits 2 unless the info string is exactly json: a last block opened as ```json {x} held a BLOCKER the gate passed with exit 0 (also in 6.3.0). A last block never closed is exit 2 too. - One fence reader (findings-schema fencedBlocks) for the gate and every trailing-json reader: a fence closes only on a run at least as long as its opener, as in Markdown, so a four-backtick example of a json block is one block; CRLF and an indented fence read alike in the gate and the readers. - Files: a description runs on past a comma and onto the next line until a new entry starts (a list item on a new line, or a backticked path), and a plain path followed by a colon starts its description. Measured over all 939 Files: fields in the .md files under the marketplace and the test repo (560 of them in the 48 plan.md files outside worktrees): 0 read differently, tests-only changed in 0. - The test lock compares --cmd by its words, so other blanks between the same words are the step's command. - release-check decides "two files" on real paths: one review.md reached through a symlinked --repo is one file. - Brief notes: red proof's stopped control run has its own test (green at once, red with the branch deleted); the two tests that assumed jest is not installed run with a PATH that finds only git (red ona10018fwith a jest on PATH, green now); the Files: reader's doc states the measured counts (520 fields in 48 plan files at rounds 7-8, not "182 plans"; 560 now). Not in this round (operator 2026-10-11): a144e9ce, 1dc2e785, dc3a467e, 2fa88853, 26873fa5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
10 KiB
JavaScript
231 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// lib/util/test-lock.mjs
|
|
// K8 -- the test lock. The executor (wave-1 order 1) locks a step's test files
|
|
// once the test is written and seen failing, and unlocks them when the test
|
|
// passes. Two guards read the lock:
|
|
// - pre-write-executor.mjs refuses Write/Edit/NotebookEdit on a locked file;
|
|
// - stop-test-lock.mjs refuses "done" while the session that took the lock
|
|
// still holds it.
|
|
// The lock lives in the repository's git directory (<git dir>/voyage/
|
|
// test-lock.json), so it can never be committed, and each worktree has its own.
|
|
//
|
|
// Usage:
|
|
// node lib/util/test-lock.mjs lock --files <a,b> --step <N> [--cmd "<test command>"] [--cwd <dir>]
|
|
// node lib/util/test-lock.mjs unlock [--cwd <dir>]
|
|
// node lib/util/test-lock.mjs status [--cwd <dir>] (JSON on stdout)
|
|
// node lib/util/test-lock.mjs green [--cmd "<test command>"] [--cwd <dir>]
|
|
// node lib/util/test-lock.mjs pending [--cwd <dir>]
|
|
// Exit: 0 = done, 1 = refused (another step holds the lock; for green: no
|
|
// lock, or the test command did not exit 0), 2 = could not run (not a
|
|
// repository, bad usage, unreadable lock, a command outside the test-runner
|
|
// allowlist). 2 is never "unlocked" and never green.
|
|
//
|
|
// The green mark (operator decision 2026-10-08). The stop guard blocked Stop
|
|
// three times while the executor waited for its controller, with the step's
|
|
// test already green (voyage-lekerepo trial, 2026-10-06). `green` RUNS the
|
|
// step's test command (the same allowlist and denylist as done-line.mjs) and
|
|
// records `green_at` only on exit 0. The command is the one stored at `lock
|
|
// --cmd` time, never one chosen at mark time: an independent review showed
|
|
// `green --cmd "git status"` marking a red step green. A lock without a
|
|
// stored command is never marked (exit 2); a --cmd that differs is refused; the stop guard then lets the session
|
|
// stop, while the write guard keeps refusing the locked files. `pending`
|
|
// clears the mark when a fix round starts; a new `lock` starts without one.
|
|
// Paths in --files are relative to the --cwd (default: the current directory).
|
|
// The session is taken from CLAUDE_CODE_SESSION_ID, which Claude Code sets in
|
|
// Bash tool subprocesses (code.claude.com/docs/en/env-vars).
|
|
//
|
|
// Credit: locking tests during a fix is Claude Code's own hook pattern
|
|
// (hooks-guide "Block edits to protected files").
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync } from 'node:fs';
|
|
import { join, resolve, dirname } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { tmpdir } from 'node:os';
|
|
import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs';
|
|
import { DEFAULT_TIMEOUT_MS, allowedCommand, commandWords, runCommand, screenCommand, stoppedBy } from '../verification/criteria-runner.mjs';
|
|
import { isMain } from './is-main.mjs';
|
|
|
|
export const LOCK_VERSION = 1;
|
|
|
|
export function lockPathFor(gitDir) {
|
|
return join(gitDir, 'voyage', 'test-lock.json');
|
|
}
|
|
|
|
/**
|
|
* Read the lock for the repo that `start` (a file or directory) belongs to.
|
|
* @returns {{ state: 'no-repo'|'none'|'locked'|'corrupt', lock?: object, path?: string, error?: string }}
|
|
*/
|
|
export function readLock(start) {
|
|
const repo = resolveRepo(start);
|
|
if (!repo) return { state: 'no-repo' };
|
|
const path = lockPathFor(repo.gitDir);
|
|
if (!existsSync(path)) return { state: 'none', path, repo };
|
|
try {
|
|
const lock = JSON.parse(readFileSync(path, 'utf8'));
|
|
if (!lock || !Array.isArray(lock.files) || !Number.isInteger(lock.step)) {
|
|
return { state: 'corrupt', path, repo, error: 'lock has no files/step' };
|
|
}
|
|
return { state: 'locked', lock, path, repo };
|
|
} catch (e) {
|
|
return { state: 'corrupt', path, repo, error: e.message };
|
|
}
|
|
}
|
|
|
|
// A pointer from the session that took a lock to the lock file, so the stop
|
|
// guard finds it even after the session has moved to another directory.
|
|
const SESSION_DIR = join(tmpdir(), `voyage-test-locks-${process.getuid ? process.getuid() : 'u'}`);
|
|
const sessionPointer = (sessionId) =>
|
|
join(SESSION_DIR, `${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 32)}.json`);
|
|
|
|
// Pointers whose lock is gone are dropped on the next lock.
|
|
function sweepPointers() {
|
|
try {
|
|
for (const f of readdirSync(SESSION_DIR)) {
|
|
const p = join(SESSION_DIR, f);
|
|
try {
|
|
if (!existsSync(JSON.parse(readFileSync(p, 'utf8')).lock_path)) rmSync(p, { force: true });
|
|
} catch { rmSync(p, { force: true }); }
|
|
}
|
|
} catch { /* no dir yet */ }
|
|
}
|
|
|
|
function writeSessionPointer(sessionId, lockPath) {
|
|
if (!sessionId) return;
|
|
try {
|
|
mkdirSync(SESSION_DIR, { recursive: true });
|
|
sweepPointers();
|
|
writeFileSync(sessionPointer(sessionId), JSON.stringify({ lock_path: lockPath }));
|
|
} catch { /* the cwd lookup still works */ }
|
|
}
|
|
|
|
function removeSessionPointer(sessionId) {
|
|
if (sessionId) rmSync(sessionPointer(sessionId), { force: true });
|
|
}
|
|
|
|
/**
|
|
* The lock this session holds, wherever it lives: { state: 'locked', lock, path }
|
|
* or { state: 'none' }. A pointer to a lock that is gone, or that another
|
|
* session now holds, is not this session's lock.
|
|
*/
|
|
export function lockForSession(sessionId) {
|
|
if (!sessionId) return { state: 'none' };
|
|
try {
|
|
const { lock_path } = JSON.parse(readFileSync(sessionPointer(sessionId), 'utf8'));
|
|
const lock = JSON.parse(readFileSync(lock_path, 'utf8'));
|
|
if (lock?.session === sessionId && Array.isArray(lock.files) && Number.isInteger(lock.step)) {
|
|
return { state: 'locked', lock, path: lock_path };
|
|
}
|
|
} catch { /* no pointer, or the lock is gone */ }
|
|
return { state: 'none' };
|
|
}
|
|
|
|
// Is `file` one of the locked files? Compared as canonical real paths.
|
|
export function isLockedFile(lock, file) {
|
|
const c = canonicalPath(file);
|
|
return lock.files.includes(c);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { _: [] };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const a = argv[i];
|
|
if (a === '--files' || a === '--step' || a === '--cwd' || a === '--cmd') { out[a.slice(2)] = argv[i + 1]; i += 1; }
|
|
else out._.push(a);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function fail(code, msg) {
|
|
process.stderr.write(`[voyage] test-lock: ${msg}\n`);
|
|
process.exit(code);
|
|
}
|
|
|
|
function main(argv) {
|
|
const args = parseArgs(argv);
|
|
const cmd = args._[0];
|
|
if (!['lock', 'unlock', 'status', 'green', 'pending'].includes(cmd)) {
|
|
fail(2, 'usage: test-lock.mjs lock --files <a,b> --step <N> | unlock | status | green --cmd "<test command>" | pending [--cwd <dir>]');
|
|
}
|
|
const cwd = resolve(args.cwd || process.cwd());
|
|
const cur = readLock(cwd);
|
|
if (cur.state === 'no-repo') fail(2, `not a git repository: ${cwd}`);
|
|
|
|
if (cmd === 'status') {
|
|
if (cur.state === 'corrupt') {
|
|
process.stdout.write(JSON.stringify({ locked: null, state: 'corrupt', lock_path: cur.path, error: cur.error }, null, 2) + '\n');
|
|
process.exit(2);
|
|
}
|
|
const l = cur.lock || {};
|
|
process.stdout.write(JSON.stringify({
|
|
locked: cur.state === 'locked', step: l.step ?? null, files: l.files ?? [], session: l.session ?? null,
|
|
locked_at: l.locked_at ?? null, cmd: l.cmd ?? null, green_at: l.green_at ?? null, lock_path: cur.path,
|
|
}, null, 2) + '\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
if (cmd === 'green' || cmd === 'pending') {
|
|
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error})`);
|
|
if (cur.state !== 'locked') fail(1, 'no lock to mark');
|
|
const lock = { ...cur.lock };
|
|
if (cmd === 'pending') {
|
|
delete lock.green_at;
|
|
} else {
|
|
const testCmd = typeof lock.cmd === 'string' && lock.cmd.trim() !== '' ? lock.cmd : null;
|
|
if (!testCmd) fail(2, 'the lock names no test command (lock with --cmd "<Verify>"); never marked green');
|
|
// One reading of a command passed as written: its words, as runCommand
|
|
// runs them, so a trailing comment (round 8) and the blanks between the
|
|
// words (round 9) are no part of it.
|
|
const words = (c) => commandWords(c).join(' ');
|
|
if (args.cmd !== undefined && words(args.cmd) !== words(testCmd)) fail(2, `--cmd is not the step's test command stored in the lock (${testCmd})`);
|
|
const allowed = allowedCommand(testCmd, { cwd: cur.repo.root });
|
|
if (!allowed.allowed) fail(2, `command not run: ${allowed.reason}`);
|
|
const screen = screenCommand(testCmd);
|
|
if (!screen.allowed) fail(2, `command blocked: ${screen.rule}`);
|
|
// No shell: the words the screen checked are the words that run.
|
|
const r = runCommand(testCmd, { cwd: cur.repo.root, timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
// With no shell, 126/127 is the test program's own exit: a red, not "could not run".
|
|
// A stop is read once, by stoppedBy (round 8).
|
|
const stopped = stoppedBy(r, { timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
if (stopped) fail(2, `the test command ${stopped.why}`);
|
|
if (r.status !== 0) fail(1, `the test command exited ${r.status}; the lock stays unmarked`);
|
|
lock.green_at = new Date().toISOString();
|
|
}
|
|
writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
if (cmd === 'unlock') {
|
|
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error}); inspect it before removing it`);
|
|
removeSessionPointer(cur.lock?.session);
|
|
rmSync(cur.path, { force: true });
|
|
process.exit(0);
|
|
}
|
|
|
|
// lock
|
|
if (!args.files) fail(2, 'lock needs --files <a,b>');
|
|
if (args.step === undefined || !/^\d+$/.test(String(args.step))) fail(2, 'lock needs --step <N> (a whole number)');
|
|
const step = Number(args.step);
|
|
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error})`);
|
|
if (cur.state === 'locked' && cur.lock.step !== step) {
|
|
fail(1, `step ${cur.lock.step} holds the lock; unlock it first`);
|
|
}
|
|
const files = String(args.files).split(',').map((f) => f.trim()).filter(Boolean).map((f) => canonicalPath(resolve(cwd, f)));
|
|
if (files.length === 0) fail(2, 'lock needs at least one file');
|
|
const lock = {
|
|
version: LOCK_VERSION,
|
|
step,
|
|
files: [...new Set(files)],
|
|
session: process.env.CLAUDE_CODE_SESSION_ID || null,
|
|
locked_at: new Date().toISOString(),
|
|
};
|
|
if (args.cmd !== undefined) {
|
|
if (String(args.cmd).trim() === '') fail(2, '--cmd is empty');
|
|
lock.cmd = String(args.cmd);
|
|
}
|
|
mkdirSync(dirname(cur.path), { recursive: true });
|
|
writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n');
|
|
writeSessionPointer(lock.session, cur.path);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (isMain(import.meta.url)) main(process.argv.slice(2));
|