Round 5 (v6.2.0..d37e538, WARN: 11 MAJOR, 2 MINOR). The patching did not converge (the same two places every round), so the operator chose a structural fix on 2026-10-11. Each change behind a test that failed first. - A check runs with no shell. criteria-runner's runCommand starts the runner with the very words the screen checked (spawnSync(words[0], words.slice(1)), without NODE_TEST_CONTEXT); Phase 7 criteria, Regression: (its exec copy is gone, MINOR 0e98d5c7), proof of red, the done line and the test lock all run through it. No quote, glob, tilde or variable can rewrite a checked word any more. The screen stays and says why: a word that means something else in a shell (quote, backslash, $, brace, leading ~) is refused; a glob reaches only node --test, which expands its own patterns (sh read `tests/.?/` as `tests/../`); no path climbs out with .. or starts at /; a word starting with @ is refused (pytest reads @file as more arguments). - The K6 plan-file sweep has v6.2.0's own Files: reader again (516dffa): backticked paths, and plain words only in a field without them. The shared reader from round 3 planned directories a description named and dropped paths v6.2.0 planned. fieldPaths stays for the tests-only check, with a path test on spans (`parse()`, `p.parse()` are no paths), plain descriptions ignored when the field has backticked paths, dotfiles, backslash paths and glued spans read. - Skill frontmatter reads a value as YAML does: trailing space is nothing, and a comment ends a plain value, a flow list or a block indicator (`model: opus # pinned`). A comment that cuts a plain description, or is the whole value, is still a hazard. - release-check reads the diff with --no-renames: a test moved into an allowed place after the review shows as removed. - The example plan's step 2 heading no longer says "must FAIL initially"; steps 1-2 keep the tests-only warning (operator 2026-10-09,73262b6). Open: MINOR c1fef1ad (one shared isMain helper) is not in this round. Measured: 0 of 463 Files: fields with backticked paths (520 fields in 182 plans across the marketplace repos) carry another path-like word. Suite 2185 (2183/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
5.8 KiB
JavaScript
107 lines
5.8 KiB
JavaScript
// lib/verification/done-line.mjs
|
|
// The done line (P9 (4)). A step is "done" only when this script has written
|
|
// its line, and it writes the line only when the test command exits 0:
|
|
//
|
|
// DONE step <N>: `<command>` exit 0 at <HEAD sha> <ISO time>
|
|
//
|
|
// appended to the step's report file. Exit 0 = written, 1 = the command failed
|
|
// (nothing written), 2 = could not run (nothing written; never green): no
|
|
// --step, a command outside the test-runner allowlist, a command that cannot
|
|
// start, a working tree that is not a git repo.
|
|
//
|
|
// `--check --step <N> --report <file> [--commit <sha>]`: exit 0 when the report
|
|
// carries a well-formed line for step N (at exactly that commit, with
|
|
// --commit), 1 when it does not. /trekexecute writes the line right after the
|
|
// step's checkpoint and checks it with --commit, so the line is bound to the
|
|
// commit the test passed on.
|
|
//
|
|
// Adapted from obra/superpowers@8ca22db (MIT, (c) 2025 Jesse Vincent),
|
|
// skills/executing-plans/scripts/task-done: the completion line is appended
|
|
// only when the test command exits 0.
|
|
|
|
import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { allowedCommand, runCommand, screenCommand } from './criteria-runner.mjs';
|
|
import { DEFAULT_TIMEOUT_MS, repoRoot } from './red-proof.mjs';
|
|
|
|
export function doneLineRe(step, commit) {
|
|
const sha = commit ? String(commit).replace(/[^0-9a-f]/g, '') : '[0-9a-f]{40}';
|
|
return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at ${sha} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm');
|
|
}
|
|
|
|
/**
|
|
* With `commit` (the step's checkpoint sha, full 40 hex), the line must name
|
|
* exactly that commit. A regex cannot tell a typed line from a written one; the
|
|
* commit binds the line to the state the test ran on (/trekexecute writes the
|
|
* line right after the checkpoint, so HEAD is the step's own commit).
|
|
*/
|
|
export function hasDoneLine(reportPath, step, commit) {
|
|
if (!existsSync(reportPath)) return false;
|
|
return doneLineRe(step, commit).test(readFileSync(reportPath, 'utf8'));
|
|
}
|
|
|
|
/** @returns {{status: 'written'|'failed'|'not-run', reason: string, line?: string, exit?: number|null}} */
|
|
export function writeDoneLine({ cwd, step, cmd, report, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
if (!/^\d+$/.test(String(step ?? ''))) return { status: 'not-run', reason: '--step must be a step number' };
|
|
if (!report) return { status: 'not-run', reason: '--report is required' };
|
|
// The plan's Verify command is relative to the repo root, as in red-proof.mjs.
|
|
const dir = repoRoot(resolve(cwd ?? process.cwd()));
|
|
if (!dir) return { status: 'not-run', reason: `${cwd ?? process.cwd()} is not inside a git repository` };
|
|
const allowed = allowedCommand(cmd, { cwd: dir });
|
|
if (!allowed.allowed) return { status: 'not-run', reason: `command not run: ${allowed.reason}` };
|
|
const screen = screenCommand(cmd);
|
|
if (!screen.allowed) return { status: 'not-run', reason: `command blocked: ${screen.rule}` };
|
|
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: dir, encoding: 'utf8' });
|
|
if (head.status !== 0) return { status: 'not-run', reason: `no git HEAD in ${dir}` };
|
|
// No shell: the words the screen checked are the words that run.
|
|
const r = runCommand(cmd, { cwd: dir, timeoutMs });
|
|
if (r.error || r.status === null || r.status === 126 || r.status === 127) {
|
|
return { status: 'not-run', reason: `the command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`, exit: r.status };
|
|
}
|
|
if (r.status !== 0) return { status: 'failed', reason: `the test command exited ${r.status}`, exit: r.status };
|
|
const line = `DONE step ${step}: \`${cmd}\` exit 0 at ${head.stdout.trim()} ${new Date().toISOString()}`;
|
|
appendFileSync(report, `${line}\n`);
|
|
return { status: 'written', reason: 'the test command exited 0', line, exit: 0 };
|
|
}
|
|
|
|
const USAGE =
|
|
'usage: done-line.mjs --step <N> --cmd "<test command>" --report <file> [--cwd <dir>] [--json]\n' +
|
|
' done-line.mjs --check --step <N> --report <file> [--commit <sha>]';
|
|
|
|
export function main(argv) {
|
|
const o = {};
|
|
let check = false;
|
|
let json = false;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--step' && argv[i + 1]) o.step = argv[++i];
|
|
else if (a === '--cmd' && argv[i + 1]) o.cmd = argv[++i];
|
|
else if (a === '--report' && argv[i + 1]) o.report = resolve(argv[++i]);
|
|
else if (a === '--cwd' && argv[i + 1]) o.cwd = resolve(argv[++i]);
|
|
else if (a === '--timeout' && argv[i + 1]) o.timeoutMs = Number(argv[++i]);
|
|
else if (a === '--check') check = true;
|
|
else if (a === '--commit' && argv[i + 1]) o.commit = argv[++i];
|
|
else if (a === '--json') json = true;
|
|
else { process.stderr.write(`done-line: unknown argument ${a}\n${USAGE}\n`); return 2; }
|
|
}
|
|
if (check) {
|
|
if (!/^\d+$/.test(String(o.step ?? '')) || !o.report) { process.stderr.write(`done-line: --check needs --step and --report\n${USAGE}\n`); return 2; }
|
|
const ok = hasDoneLine(o.report, o.step, o.commit);
|
|
process.stdout.write(json ? `${JSON.stringify({ step: Number(o.step), done: ok })}\n` : `step ${o.step}: ${ok ? 'DONE line present' : 'no DONE line - not done'}\n`);
|
|
return ok ? 0 : 1;
|
|
}
|
|
let r;
|
|
try { r = writeDoneLine(o); }
|
|
catch (e) { r = { status: 'not-run', reason: `done-line crashed: ${e.message}` }; }
|
|
process.stdout.write(json ? `${JSON.stringify(r)}\n` : `done line: ${r.status.toUpperCase()} - ${r.line ?? r.reason}\n`);
|
|
return r.status === 'written' ? 0 : r.status === 'failed' ? 1 : 2;
|
|
}
|
|
|
|
function isMain() {
|
|
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
|
|
catch { return false; }
|
|
}
|
|
|
|
if (process.argv[1] && isMain()) process.exitCode = main(process.argv.slice(2));
|