merge: release 6.3.1 (rounds 7-10 of the release review and the release commit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-11 18:02:06 +02:00
commit 6cc6a3f409
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
74 changed files with 1456 additions and 570 deletions

View file

@ -1,7 +1,7 @@
{
"name": "voyage",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, pencil-toggle annotation pattern): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"version": "6.3.0",
"version": "6.3.1",
"author": {
"name": "Kjell Tore Guttormsen"
},

View file

@ -4,6 +4,113 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v6.3.1 — 2026-10-11 — Fixes from the release review of 6.3.0, and two verdict-gate holes closed
Rounds 7 to 10 of the release review of 6.3.0. Round 7 is the WARN 6.3.0 was
released with, round 8 was BLOCK (1 BLOCKER), rounds 9 and 10 were WARN. Every
fix began with a test that failed on the code before it. Suite 2236 (2234
pass, 0 fail, 2 skipped).
Nothing here asks anything of users. Measured against 6.3.0 over the
marketplace and the test repo: the plan validator reports the same errors and
warnings for all 233 plan files, the verdict gate gives the same exit for all
123 review files, and tests-only reads the same for all 1408 plan steps.
### Doors
- two-way: tests-only is decided by the plan's `Files:` alone, and red proof no longer reads the step's diff (operator decision 2026-10-11) — a revert brings the diff check back; plans stay valid.
- two-way: the `Files:` reader keeps a plain file beside a backticked path, reads numbered entries, and keeps a description together across commas and lines — it feeds only the `PLAN_STEP_TESTS_ONLY` warning and red proof's `tests_only`; a revert restores the 6.3.0 reading.
- two-way: a trailing `# comment` is dropped from a backticked check too, so such a check now runs; one whose comment holds a double quote, `$`, a backtick or a backslash is refused out loud — 6.3.0 refused every backticked check with a comment, so nothing that ran stops running.
- two-way: the verdict gate reads the last fenced block once and answers exit 2 unless its info string is exactly `json`, or when it is never closed; a `~~~` fence is a fence — a review written by `/trekreview` reads as before; a revert reopens the two holes under Fixed.
- two-way: stops and timeouts, the release check's `--review`, `isMain` in every CLI, the test lock's command comparison, one fence reader, and cleanups — fixes; a revert restores the old behaviour.
### Fixed
- **Two verdict-gate holes, present in 6.3.0.** A review that ended with a
clean json block and then a block holding a BLOCK verdict, written as
`~~~json` or opened with more text after `json` on its line, passed with
exit 0: the gate did not see the second block. Both are exit 2 now.
- **Tests-only steps.** The plan's `Files:` alone decides, and red proof,
`/trekexecute`, `step-implementer` and the handover contract say so. A change
already in the tree when the step began no longer counts against it, and a
plan outside `.claude/` reads the same.
- **The `Files:` reader** keeps a plain file beside a backticked path
(", Dockerfile", "and Makefile"), reads a numbered entry (`1.` or `1)`) as an
entry, keeps a description together past a comma and onto the next line, and
starts a description at a plain file followed by a colon. Lowercase
`makefile`, `rakefile` and `justfile` are build files.
- **Checks with a comment.** A trailing `# comment` is dropped in a backticked
check and in a command passed as written, so the words screened are the
words run. A comment with a double quote, `$`, a backtick or a backslash is
refused instead, because the executor hands the `Verify:` span to red proof,
the test lock and the done line inside double quotes. Every reader refuses it
out loud (`PLAN_VERIFICATION_NOT_RUNNABLE`, `BRIEF_SC_NOT_RUNNABLE`). A span
that is only a comment is not-a-command.
- **Stops.** Red proof reports a command killed by its timeout as killed after
the timeout, not "could not start". The release check tells a timeout from
output past its buffer and from another signal. One reading of a stop
(`stoppedBy`) serves red proof, the criteria runner, the test lock, the done
line and the release check.
- **Release check.** A relative `--review` is read in `--repo`, or from the
current directory when only that one exists. Two different files are exit 2,
never guessed, and one file reached through a symlinked `--repo` is one file.
- **Every CLI uses one `isMain` helper.** Before, some did nothing and exited 0
when run through a symlink or a path with a space.
- **Test lock.** It compares `--cmd` by its words and without its trailing
comment.
- **One fence reader** for the gate and every reader of a trailing json block.
A fence closes only on a run at least as long as its opener, as in Markdown,
and CRLF and an indented fence read alike everywhere. A fence indented four
spaces or more still counts, on purpose: Markdown's rule made a BLOCKER block
inside a list item invisible.
- **Cleanups.** The 15-minute timeout is defined once, the findings check reads
each cited file once per validation, and the guard writes its no-one-to-ask
refusal once.
### Known issues
Round 11 of the release review (`0f400d3..10ac9c5`) is WARN: 0 BLOCKER, 10
MAJOR, 0 MINOR. All ten come from Claude Code's `/code-review`, which gives no
category, so each enters as MAJOR. Voyage's two reviewers and
`/security-review` found none. The operator chose to release 6.3.1 with it
(2026-10-11). None of the ten lets a gate pass. The regressions below were
checked by running them against 6.3.0. None of them occurs in the 1408 plan
steps measured above.
- The `Files:` reader still guesses paths from prose and misreads in both
directions. Two misreads are regressions from 6.3.0:
- A source file after a described test is dropped
("`tests/a.test.mjs` - new test, src/a.mjs - the fix"). The step that
builds code then reads as tests-only and goes to the plan.
- A path that a description only mentions is read as a file ("covers the
parser in src/parse.mjs"). A step of only tests is then not caught and
goes through fix rounds (at most five, then the operator).
Red proof has no diff check behind the reader. The structural fix is to
require every path in `Files:` to be backticked. It changes the plan
contract and is left for a planned release.
- A json block closed on the same line as its JSON is no longer read (a
regression from 6.3.0). The gate answers exit 2, and a reviewer is asked
again. An opener with a blank before `json` is not json either, as in 6.3.0.
- A trailing comment with a double quote, `$`, a backtick or a backslash is
refused in every backticked check. That includes checks that never reach a
shell: a `## Verification` bullet, `Regression:`, `End-to-end:` and a brief
criterion. 6.3.0 refused these too.
- Cleanups: a dead branch for a span that is only a comment, the comment
dropped up to four times per command, a re-export of the timeout that
nothing imports, and a test helper that copies a PATH lookup.
- An engine run that was stopped and not counted in the verdict reported three
more, each checked by running:
- A backticked bare name such as `LICENSE`, alone on a line after a
description, is no longer read as a file. 6.3.0 read it.
- One unclosed fence earlier in a review hides the review's own json block
from the review validator. 6.3.0 found the block, and the gate answers
exit 2 there.
- An indented json example after a review's own block is read as the last
block, as in 6.3.0.
- The example plan's steps 1-2 keep their tests-only warning (operator
decision 2026-10-09).
## v6.3.0 — 2026-10-11 — The executor is coupled to the review gate, three skills, and a drilled release runbook
Wave 2 of the practice build (five orders, merged 2026-10-09) and the fix round

View file

@ -2,7 +2,7 @@
Contract-driven Claude Code pipeline: brief, research, plan, execute, review. Agent swarms, research triangulation, adversarial review, multi-session resumption.
![Version](https://img.shields.io/badge/version-6.3.0-blue)
![Version](https://img.shields.io/badge/version-6.3.1-blue)
![License](https://img.shields.io/badge/license-MIT-green)
![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple)

View file

@ -111,6 +111,7 @@ column names the reason the runner gave:
| `placeholder` | the text is a template placeholder (`{exact command}`) |
| `not-a-command` | the sentence opened by NAMING a flag or a path (`--verbose`, `tests/`) rather than by invoking something. Running it would have produced exit 2 or exit 126 — a number about the sentence, not about the code |
| `outside the allowlist` | the command is not a known test-runner invocation, or it carries a shell operator. It was never run — say so; do not treat it as a broken criterion |
| `a trailing comment with a quote, $, backtick or backslash` | the backticked command ends in a `#` comment that a shell would still read inside double quotes. It was never run — say so; do not treat it as a broken criterion |
| `the program could not start` | the runner is not installed where the review ran. Nothing was measured — say so; do not treat it as a broken criterion |
In every one of those cases, judge the criterion on delivered code alone and

View file

@ -62,8 +62,9 @@ is one test-runner invocation with no pipe, redirect or `&&`: `npm test`,
`python -m pytest`, `uv run pytest`, `cargo test`, `go test`, `make test`,
`bash tests/<script>.sh` (or `/bin/bash tests/<script>.sh`), or `git status|log|diff|show|ls-files`.
It runs with no shell, as plain words: no quote, `$`, brace, leading `~` or
`@`, no glob outside `node --test` (Node 21+), no `..` or absolute path, and
only the runner's read-only options.
`@` (a git revision such as `@~1` is the one exception), no glob outside
`node --test` (Node 21+), no `..` or absolute path, and only the runner's
read-only options.
Anything else — the product's own binary (`node bin/x.mjs …`), `jq`, `grep`,
`git grep`, a pipe — is reported NOT RUN. Never PROPOSE such a command as a
criterion. When a criterion needs one, propose wrapping it in a script under

View file

@ -43,8 +43,9 @@ and never touch a file the step does not list.
already passes at the start point, so it proves nothing: rewrite the test
until it fails for the reason the step exists, and run the check again.
But when the answer carries `tests_only: true` (it prints that line),
every file in the step's `Files:` is a test: the behaviour already exists
at the start point, and no test can be red here. Do not rewrite the test and do not build; say so in
every file in the step's `Files:` is a test. The plan's
`Files:` alone decides this, not what you have changed: the behaviour
already exists at the start point, and no test can be red here. Do not rewrite the test and do not build; say so in
the report and stop. The executor sends the step up to the plan.
Exit 2 = it could not run (the Verify command is not one test-runner
invocation, or a file is missing): on `plan_version` ≥ 1.8 do not build;

View file

@ -1469,7 +1469,7 @@ already rejects such a step (`PLAN_STEP_VERIFY_NOT_RUNNABLE`).
(the test must FAIL at the start point), locks the test files, and only then
implements. A `tests_only: true` answer stops the helper before it builds
(it does not rewrite the test); the executor's own run below then sends the
step up to the plan.
step up to the plan. The plan's `Files:` alone decides `tests_only`.
2. **The executor proves it both ways after the helper returns:**
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/verification/red-proof.mjs --base {start_sha} --cmd "{Verify command}" --tests {test files, ONE comma-separated argument} --plan "{plan_path}" --step {N} --json
@ -1486,7 +1486,7 @@ already rejects such a step (`PLAN_STEP_VERIFY_NOT_RUNNABLE`).
| Exit | `steps.{N}.red_proof` | Executor |
|------|-----------------------|----------|
| 0 | `"proven"` | continue |
| 1, `not-red-at-base`, `tests_only: true` | `"rejected"` | every file in the step's `Files:` is a test (the plan validator's `PLAN_STEP_TESTS_ONLY`, the same reading), and so is every file the step changed since its start point: the behaviour its tests cover already exists at the start point (an earlier step built it, or it was there before the plan), so the step can never be red and no rewrite of the test can fix that. The plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4), with no fix round — the planner moves the test into the step that builds the behaviour, or makes a test meant to pass from the start the `Regression:` of the step that changes the code |
| 1, `not-red-at-base`, `tests_only: true` | `"rejected"` | every file in the step's `Files:` is a test (the plan validator's `PLAN_STEP_TESTS_ONLY`, the same reading). The plan's `Files:` alone decides: the step's diff is not read, and a path changed outside `Files:` is the controller's scope finding (Sub-step C). The behaviour its tests cover already exists at the start point (an earlier step built it, or it was there before the plan), so the step can never be red and no rewrite of the test can fix that. The plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4), with no fix round — the planner moves the test into the step that builds the behaviour, or makes a test meant to pass from the start the `Regression:` of the step that changes the code |
| 1, `not-red-at-base`, otherwise | `"rejected"` | the test passes without the change, so the TEST is wrong: a finding for Sub-step F, and that fix round is the one exception to the lock — the executor releases the step's test lock for it, the fix helper rewrites the test, passes the `--red-only` gate and locks again. `tests_only: null` (the plan or the step could not be read) counts here too |
| 1, `not-green-at-result` | `"rejected"` | the change does not make the test pass: a finding for Sub-step F |
| 2 | `"not-run"` | never a pass and never proven. **plan_version ≥ 1.8:** when the reason is the command (the `Verify:` is not one test-runner invocation), the plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4); any other reason (a missing test file, an unresolvable start point, a control run that stays red) is a finding for Sub-step F. **Below 1.8:** a step with no `Test first:`, or whose `Verify:` is not a test-runner command, is tolerated as `"not-run"` — counted in the final report among the steps without proof of red, never called proven; any other reason is a finding |

View file

@ -792,11 +792,14 @@ Write the plan following the template structure. The plan MUST include:
`git` subcommand (`status`, `log`, `diff`, `show`, `ls-files` — not `git
grep`) — with no pipe, redirect or `&&`. It runs with no shell: its words
reach the runner as written, so a word with a quote, backslash, `$`,
brace, or a leading `~` or `@` is refused, a glob only reaches
brace, or a leading `~` or `@` is refused (a git revision such as `@~1`
is the one exception), a glob only reaches
`node --test` on Node 21 and later (which expands its own patterns), no
path climbs out with `..` or starts at `/` (git included), each runner
takes a short list of read-only options, and nothing may follow
`make test`. A trailing `# comment` on a line of a shell fence is dropped. Anything else is reported NOT
`make test`. A trailing `# comment` is dropped, on a line of a shell fence and in a backticked command alike;
in a backticked command it may not hold a quote, `$`, backtick or backslash (the executor passes the
span on inside double quotes), and such a comment is refused, not dropped. Anything else is reported NOT
RUN, never executed and never failed. A check that needs more than that is
wrapped in a script under `tests/` and declared as `bash tests/<script>.sh`.
9. **Estimated Scope** — File counts and complexity rating.

View file

@ -269,7 +269,7 @@ prose form (relaxed in S26 so the template's prose emission parses).
7. **Every plan_version, no version bump** (a plan without the field validates unchanged; voyage-lekerepo trial 2, 2026-10-08):
- **K10 — `Regression:`** (optional step field): ONE backticked command the criteria runner's allowlist runs, with no pipe and no `&&` (first backticked span, as for `Verify:`). The executor runs it green before the step's checkpoint; a step without it runs nothing new. `Verify:` stays the step's own red-first test. One that can never run is `PLAN_STEP_REGRESSION_NOT_RUNNABLE` (an error; a warning under `--soft`).
- **K11 — `On failure:`** opens with one of `revert`, `retry`, `skip`, `escalate` (case-insensitive; a leading `**`, `_`, backtick or `-` is ignored, other leading characters are not; `onFailureAction` in `lib/parsers/plan-schema.mjs`), the words `/trekexecute` reads; any other opening is the warning `PLAN_STEP_ON_FAILURE_UNKNOWN`. A missing `On failure:` is unchanged (the executor defaults to `escalate`).
- **A step of only tests** (order 10, voyage-lekerepo trial 3, 2026-10-09): when every file in a step's `Files:` is a test (its `Test first:` file, or a path `lib/util/test-path.mjs` reads as a test: under `tests/`, `test/`, `__tests__/`, `spec/`, `specs/`, or named `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`, `test_*.py`, but never a document such as `docs/specs/api.md`; `stepTestsOnly` in `lib/parsers/plan-schema.mjs`), the warning is `PLAN_STEP_TESTS_ONLY`: the behaviour its tests cover exists at its start point, so it can never be proven red. A step without `Files:` is not judged. At run time `red-proof.mjs --plan <plan.md> --step <N>` puts the same reading on a `not-red-at-base` as `tests_only`, true only when every file the step changed since its start point is a test too, and `/trekexecute` sends a `tests_only: true` step straight up to the plan.
- **A step of only tests** (order 10, voyage-lekerepo trial 3, 2026-10-09): when every file in a step's `Files:` is a test (its `Test first:` file, or a path `lib/util/test-path.mjs` reads as a test: under `tests/`, `test/`, `__tests__/`, `spec/`, `specs/`, or named `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`, `test_*.py`, but never a document such as `docs/specs/api.md`; `stepTestsOnly` in `lib/parsers/plan-schema.mjs`), the warning is `PLAN_STEP_TESTS_ONLY`: the behaviour its tests cover exists at its start point, so it can never be proven red. A step without `Files:` is not judged. At run time `red-proof.mjs --plan <plan.md> --step <N>` puts the same reading on a `not-red-at-base` as `tests_only`: the plan's `Files:` alone decides, and the step's diff is not read (operator 2026-10-11), and `/trekexecute` sends a `tests_only: true` step straight up to the plan.
**Validation strategy:**

View file

@ -78,6 +78,11 @@ do. `scripts/release-check.mjs` refuses a release whose section has no
node scripts/release-check.mjs --review <review.md>
```
From another directory, add `--repo <dir>`: a relative `--review` is then
read in that directory, like every other path the check reads, or from
where you stand when only that file exists. When both exist and differ,
the check does not guess: exit 2, pass an absolute path.
It checks, in order: the working tree is clean (otherwise the suite would
not measure HEAD), `npm test` exits 0 at HEAD, the review verdict is not
BLOCK, the review covers HEAD (its `scope_sha_end` is HEAD or an ancestor,

View file

@ -218,20 +218,27 @@ if (!command || typeof command !== 'string') {
}
// In the permission mode where Claude Code shows no prompts, an "ask" reaches
// no one and the call runs unattended (see the refusal below).
// no one and the call runs unattended (see the refusals below).
const NO_PROMPT_MODE = 'bypassPermissions';
const noOneToAsk = input.permission_mode === NO_PROMPT_MODE;
// The one refusal text for "no one to ask", with the way forward (release
// review of 6.3.0, round 7: it was written out twice).
function refuseUnattended(what, commandLine, way) {
process.stderr.write(
`[voyage] BLOCKED: ${what} with no one to ask\n` + commandLine +
' This session runs in the permission mode where Claude Code asks nothing, so the guard cannot ask a person. ' +
`Way forward: ${way}\n`
);
process.exit(2);
}
if (loadError) {
// A guard that cannot read its rules asks about every command; where no one
// can be asked it refuses instead (release review of 6.3.0, 2026-10-10).
if (noOneToAsk) {
process.stderr.write(
`[voyage] BLOCKED: Voyage's Bash guard could not load its rules (${loadError.message}) and there is no one to ask\n` +
' This session runs in the permission mode where Claude Code asks nothing, so the guard cannot ask a person. ' +
'Way forward: reinstall Voyage to restore the guard, or run the command yourself.\n'
);
process.exit(2);
refuseUnattended(`Voyage's Bash guard could not load its rules (${loadError.message})`, '',
'reinstall Voyage to restore the guard, or run the command yourself.');
}
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
@ -295,15 +302,10 @@ try {
// decisions 2026-10-09: first merge and push, then the other two classes).
// /trekexecute relies on this: its main-merge gate is a lock, and this guard's
// question is the one question.
const unattended = noOneToAsk ? approvals : [];
if (unattended.length > 0) {
process.stderr.write(
`[voyage] BLOCKED: ${unattended.map((m) => m.name).join(', ')} with no one to ask\n` +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
' This session runs in the permission mode where Claude Code asks nothing, so the approval guard ' +
'cannot ask a person. Way forward: run the command yourself, or switch to a mode that asks, then retry.\n'
);
process.exit(2);
if (noOneToAsk && approvals.length > 0) {
refuseUnattended(approvals.map((m) => m.name).join(', '),
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n`,
'run the command yourself, or switch to a mode that asks, then retry.');
}
if (approvals.length > 0) {

View file

@ -4,6 +4,8 @@
// Each command has its own valid-flag set; passing flags from another command
// produces an `unknown_flags` array but does not error — the caller decides.
import { isMain } from '../util/is-main.mjs';
const FLAG_SCHEMA = {
trekbrief: {
boolean: ['--quick', '--fg'],
@ -140,7 +142,7 @@ const REQUIRED = {
// CLI: node lib/parsers/arg-parser.mjs --command <command> -- $ARGUMENTS
// stdout: the parse result as JSON. exit 0, or exit 1 with each error as
// "Error: <message>" plus the usage line on stderr.
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const argv = process.argv.slice(2);
const ci = argv.indexOf('--command');
const command = ci >= 0 ? argv[ci + 1] : undefined;

View file

@ -196,12 +196,15 @@ export function ceremonyFromBrief(briefText) {
* Tracks fenced code blocks the CommonMark way: a fence closes only on the same
* character (` or ~), at least as long as the opener, with nothing after it.
* `feed(line)` returns true when the line is inside a fence or is a fence line.
* A fence line is indented up to three spaces; with `anyIndent` any indent
* counts (the trailing-json readers, findings-schema `fencedBlocks`).
*/
export function fenceTracker() {
export function fenceTracker({ anyIndent = false } = {}) {
const fenceLine = anyIndent ? /^[ \t]*(`{3,}|~{3,})(.*)$/ : /^\s{0,3}(`{3,}|~{3,})(.*)$/;
let open = null; // { ch, len }
return {
feed(line) {
const m = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
const m = line.match(fenceLine);
if (!open) {
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
open = { ch: m[1][0], len: m[1].length };
@ -288,24 +291,47 @@ export function onFailureAction(text) {
* The paths a step's `Files:` field names, in order, for the tests-only check
* below (the K6 sweep keeps v6.2.0's own reader: plan-scope-sweep.mjs,
* operator 2026-10-11). The convention in the plan template is a backticked
* path, and in every plan measured (520 Files: fields in 182 plans, 11.10)
* a field with backticked paths had no other path-like word beside them:
* path. Measured on the plan.md files outside worktrees under the marketplace
* and the test repo: 520 Files: fields in 48 plan files at rounds 7-8 (round 7
* wrote "182 plans" here; round 8's count of the files is 48), and 560 fields
* in 48 plan files at round 9 (2026-10-11; the same at round 10), 548 of
* them with a backticked path. In 3 of those a plain path-like word stands
* beside it, each inside a description, and none of them is read as a file:
*
* - A field with a backticked path names its backticked spans that pass the
* path test (a `.`, `/` or `\`; no URL, option or call like `parse()`),
* wherever they sit (`a.mjs`/`b.mjs`, inside a note), and a backticked name
* with no extension that stands alone in its entry (`Makefile`). Its descriptions are
* not read: "covers pass/fail", "JSON, YAML" or a directory named in passing
* is no path. An entry of its own with no backticks (", .gitignore") counts
* by its first word, and only when that is clearly a file or a directory.
* wherever they sit (`a.mjs`/`b.mjs`, inside a note), a backticked build
* file (`Makefile`, `Dockerfile`), and a backticked capitalised name with no
* extension that stands alone in its entry before any description. Beside
* them a plain word counts only when it is clearly a file or a directory,
* and only before the entry's description starts (a dash, or a colon after
* a path, backticked or plain, wherever it stands: "src/cli.mjs: wires
* parse", "the entry cli.mjs: wires parse"; a colon after a span that is no
* path, `x()`:, starts none): a path with a slash that ends in a file or a
* `/` anywhere there ("the CLI entry src/cli.mjs"), and a name with an
* extension, a dotfile or a build file only where an entry or a list starts:
* first in its entry, or after "and", "or", "+" or "&" (", Dockerfile", "and
* Makefile", ", package.json"). A description runs on past a comma and onto
* the next line until a new entry starts: a list item on a new line (`-`,
* `1.` or `1)`), a backticked path, or a new line that opens with a plain
* file with a slash, or with one alone on the line or followed by a colon or
* a dash ("src/cli.mjs: the fix", "package.json", "Makefile — a target").
* Round 7: such files were dropped, and a step that builds something read as
* tests-only. Rounds 8-9: "— mocks fs.readFileSync", "runs on Node.js 22.x",
* "no change to package.json", "— mocks fs, child_process.spawnSync" or
* "src/cli.mjs: wires parse and README.md" read as files, and a step that
* holds only its test did not. Round 10: a plain file on the next line, a
* numbered item, a `Parser` inside a description, `x()`: and a mid-entry
* "cli.mjs:" were each misread. "covers pass/fail", "JSON, YAML", "v6.3.0
* format" or a next line "index.js and the CLI" is no path.
* - A field with no backticked path names every path-like word: a name with an
* extension, a dotfile, a path with a slash, a directory (`docs/`) or a glob
* (`src/*.mjs`); a parenthesised note is not read. A word with no slash and
* no extension counts only alone in its entry and capitalised (`Makefile`);
* "format" or "v6.3.0" in a description is no path.
* extension, a dotfile, a path with a slash, a directory (`docs/`), a glob
* (`src/*.mjs`) or a build file; a parenthesised note is not read. Another
* word with no slash and no extension counts only alone in its entry and
* capitalised; "format" or "v6.3.0" in a description is no path.
*
* A backslash is read as a slash, a leading `./` is dropped. Release review of
* 6.3.0, rounds 1-5.
* 6.3.0, rounds 1-10.
*/
const SEG = '[\\w@.*?()-]';
const PATH_WORD = new RegExp(`^(?:${SEG}+\\/)+${SEG}*$|^[\\w@*?-][\\w@.*?-]*\\.[A-Za-z*][A-Za-z0-9*]*$|^\\.[\\w-][\\w.-]*$`);
@ -314,38 +340,87 @@ const NOT_A_PATH = /^(?:e\.g|i\.e|n\.b|cf|etc|vs)$/i;
const BARE_NAME = /^[A-Z][\w.-]*$/;
// The last segment is a dotfile or a name with an extension, or the path is a directory.
const FILE_OR_DIR = /(?:^|\/)(?:\.[\w-][\w.-]*|[^/]*\.[A-Za-z*][A-Za-z0-9*]*)$|\/$/;
// A build file named without an extension: a capitalised name that ends in
// `file` (Makefile, Dockerfile, Gemfile, Jenkinsfile, ...), not the word Profile,
// and the lowercase names make, rake and just also read (round 8).
const BUILD_FILE = /^(?!Profile$)(?:[A-Z][a-z]+|GNUmake)file$|^(?:make|rake|just)file$/;
// Where an entry's description starts, and the words that start a list item.
const DESCRIPTION_DASH = /^(?:\u2014|\u2013|-|--)$/;
const LIST_JOINER = /^(?:and|or|\+|&)$/i;
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
const lastSegment = (p) => p.replace(/\/$/, '').split('/').pop();
const norm = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '');
// A backticked span that is a path, not a URL, an option or a call (`parse()`, `p.parse()`).
const tickedPath = (s) => /[./\\]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && !/^[\w$.]+\(.*\)$/.test(s);
// A plain word without the brackets and punctuation around it, `\` read as `/`.
const plainWord = (w) => w.replace(/^[(\[]+/, '').replace(/[.,;:)\]]+$/, '').replace(/\\/g, '/');
const isPathWord = (w) => PATH_WORD.test(w) && !NOT_A_PATH.test(w);
// Clearly a file or a directory: what a plain word beside backticked paths must be.
const isFileWord = (w) => BUILD_FILE.test(w) || (isPathWord(w) && (FILE_OR_DIR.test(w) || BUILD_FILE.test(lastSegment(w))));
// A line inside a description that opens a new entry: its first word is a
// plain file with a slash, or a plain file alone on the line or followed by a
// colon or a dash ("src/cli.mjs", "package.json", "Makefile — a target"), not
// "child_process.spawnSync, path.join" or "Node.js 22 only" (round 10).
const opensEntry = (tokens) => {
const w = plainWord(tokens[0]);
return isFileWord(w) && (w.includes('/') || tokens.length === 1 || tokens[0].endsWith(':') || DESCRIPTION_DASH.test(tokens[1]));
};
export function fieldPaths(text) {
const spans = [];
const masked = String(text ?? '')
.replace(/`([^`\n]+)`/g, (_, p) => `\u0000${spans.push(p.trim()) - 1}\u0000`)
.replace(/(^|\s)\(([^()\n]*)\)/g, (m, pre, inner) => (inner.includes('\u0000') ? m : `${pre} `));
const isTicked = spans.map(tickedPath);
const isTicked = spans.map((p) => tickedPath(p) || BUILD_FILE.test(p));
const tickedField = isTicked.some(Boolean);
const out = [];
for (const raw of masked.split(/[,\n]/)) {
const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue;
const inEntry = [...entry.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1]));
const ticked = inEntry.filter((i) => isTicked[i]);
if (ticked.length) { out.push(...ticked.map((i) => spans[i])); continue; }
// A backticked `Makefile` alone in its entry is a file (round 6).
if (inEntry.length === 1 && /^\u0000\d+\u0000$/.test(entry) && BARE_NAME.test(spans[inEntry[0]])) { out.push(spans[inEntry[0]]); continue; }
const words = entry.split(/\s+/).filter((w) => !w.includes('\u0000')).map(plainWord).filter(Boolean);
if (tickedField) {
const first = plainWord(entry.split(/\s+/)[0]);
if (!first.includes('\u0000') && isPathWord(first) && FILE_OR_DIR.test(first)) out.push(first);
continue;
// A description runs on past a comma and onto the next line until a new
// entry starts: a list item on a new line (`-`, `1.`, `1)`), a backticked
// path, or a line that opens with a plain file (rounds 9-10).
let described = false;
for (const line of masked.split('\n')) {
const item = LIST_ITEM.exec(line);
const body = item ? line.slice(item[0].length) : line;
if (item || (described && body.trim() !== '' && opensEntry(body.trim().split(/\s+/)))) described = false;
for (const raw of body.split(',')) {
const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue;
const tokens = entry.split(/\s+/);
const first = /^\u0000(\d+)\u0000/.exec(entry);
if (first && isTicked[Number(first[1])]) described = false;
// A backticked capitalised name alone in its entry, before any
// description, is a file (rounds 6, 10).
if (first && first[0] === entry && !described && BARE_NAME.test(spans[Number(first[1])])) {
out.push(spans[Number(first[1])]);
continue;
}
if (tickedField) {
// In the order they stand: the backticked paths, and before the
// description a plain word that is clearly a file where it may stand
// (rounds 7-8). A path followed by a colon starts its description,
// backticked or plain, wherever it stands; a span that is no path
// does not (rounds 9-10).
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t.includes('\u0000')) {
const ticked = [...t.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1])).filter((n) => isTicked[n]);
out.push(...ticked.map((n) => spans[n]));
if (ticked.length > 0 && t.endsWith(':')) described = true;
continue;
}
if (DESCRIPTION_DASH.test(t)) described = true;
const w = plainWord(t);
if (described || !isFileWord(w)) continue;
if (w.includes('/') || i === 0 || LIST_JOINER.test(tokens[i - 1])) out.push(w);
if (t.endsWith(':')) described = true;
}
continue;
}
const words = tokens.filter((w) => !w.includes('\u0000')).map(plainWord).filter(Boolean);
const found = words.filter((w) => isPathWord(w) || BUILD_FILE.test(w));
if (found.length === 0 && words.length === 1 && tokens.length === 1 && BARE_NAME.test(words[0])) found.push(words[0]);
out.push(...found);
}
const found = words.filter(isPathWord);
if (found.length === 0 && words.length === 1 && entry.split(/\s+/).length === 1 && BARE_NAME.test(words[0])) found.push(words[0]);
out.push(...found);
}
return out.filter(Boolean).map(norm);
}
@ -377,10 +452,10 @@ export function stepTestsOnly(body) {
/**
* Whether a path counts as a test for this step: its own `Test first:` file, or
* a path that is clearly a test. red-proof.mjs uses it on the step's diff.
* a path that is clearly a test.
* @returns {(path: string) => boolean}
*/
export function stepTestFile(f) {
function stepTestFile(f) {
const testFirst = f.has('Test first')
? [...f.get('Test first').text.matchAll(/\bFile:\**[ \t]*`([^`\n]+)`/gi)].map((m) => norm(m[1].trim()))
: [];

View file

@ -29,6 +29,8 @@
import { readFileSync, existsSync } from 'node:fs';
import { issue, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
import { extractFindingsBlock } from '../review/findings-schema.mjs';
// Origin tag for every synthesised finding (Phase 7 rule 7: codebase vs research).
export const ORIGIN_VALUES = Object.freeze(['codebase', 'research']);
@ -54,23 +56,15 @@ const ARRAY_FIELDS = Object.freeze([
['sources', 'DIGEST_SOURCES_NOT_ARRAY'],
]);
// Last fenced ```json … ``` block, so prose above it never confuses the parser.
const JSON_FENCE_GLOBAL = /```json[ \t]*\r?\n([\s\S]*?)```/gi;
/**
* Extract the inner body of the LAST fenced `json` block in `text`.
* Extract the inner body of the LAST fenced `json` block in `text`, with the
* one block reader (lib/review/findings-schema.mjs): three backticks inside a
* JSON string do not end the block (release review of 6.3.0, round 8).
* @param {string} text
* @returns {string|null} the JSON source, or null if no json fence is present.
*/
export function extractDigestBlock(text) {
if (typeof text !== 'string') return null;
JSON_FENCE_GLOBAL.lastIndex = 0;
let last = null;
let m;
while ((m = JSON_FENCE_GLOBAL.exec(text)) !== null) {
last = m[1];
}
return last;
return extractFindingsBlock(text);
}
function isNonEmptyString(v) {
@ -160,7 +154,7 @@ export function validateAgentOutput(rawText) {
// ---- CLI shim ----------------------------------------------------------------
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const filePath = args.find((a) => !a.startsWith('--'));
if (!filePath) {

View file

@ -13,6 +13,7 @@ import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { PHASE_SIGNAL_PHASES, EFFORT_LEVELS } from '../validators/brief-validator.mjs';
import { BASE_ALLOWED_MODELS } from '../validators/profile-validator.mjs';
import { isMain } from '../util/is-main.mjs';
/**
* Resolve a brief's phase_signal entry for one phase.
@ -72,7 +73,7 @@ export function resolvePhaseSignalFromFile(briefPath, phase) {
// CLI (`resolver.mjs --resolve-phase-model`, brief > profile > default) is the
// single resolution source for {effort, model}. Do not re-wire commands/*.md
// Bash blocks back to this shim.
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const getArg = (name) => {
const i = args.indexOf(name);

View file

@ -42,6 +42,7 @@ import { homedir } from 'node:os';
import { parseDocument } from '../util/frontmatter.mjs';
import { validateProfile } from '../validators/profile-validator.mjs';
import { resolvePhaseSignal } from './phase-signal-resolver.mjs';
import { isMain } from '../util/is-main.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BUILTIN_PROFILES_DIR = __dirname; // lib/profiles/
@ -297,7 +298,7 @@ export function resolvePhaseModel(phase, briefPath, argv, env = process.env) {
// CLI shim — invoked by commands/trek*.md via Bash.
// Usage: node lib/profiles/resolver.mjs --resolve-phase-model --phase plan --brief-path foo.md --json
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
if (args.includes('--resolve-phase-model')) {
const getArg = (name) => {

View file

@ -34,8 +34,7 @@
import { SEVERITY_VALUES, RULE_KEYS, getRule } from './rule-catalogue.mjs';
import { computeFindingId } from '../parsers/finding-id.mjs';
import { validateFindings } from './findings-schema.mjs';
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isMain } from '../util/is-main.mjs';
export const JUDGE_TITLE_MAX = 100;
export const JUDGE_DETAIL_MAX = 800;
@ -460,15 +459,7 @@ export function runContract(reviewerPayloads, options = {}) {
// ---- CLI shim ----------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const filePath = args.find((a) => !a.startsWith('--'));
if (!filePath) {

View file

@ -43,11 +43,11 @@
// CLI: node lib/review/engine-findings.mjs [--scope <file-list.txt>] [--repo-root <dir>] <engine-findings.json>
// exit 0 = payload on stdout; 1 = input invalid (errors on stderr); 2 = could not run.
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync } from 'node:fs';
import { computeFindingId } from '../parsers/finding-id.mjs';
import { RULE_KEYS, getRule, SEVERITY_VALUES } from './rule-catalogue.mjs';
import { validateFindings } from './findings-schema.mjs';
import { isMain } from '../util/is-main.mjs';
export const ENGINES = Object.freeze({
'code-review': Object.freeze({
@ -146,15 +146,7 @@ export function toReviewerPayload(input, opts = {}) {
return { ok: true, errors: [], payload, out_of_scope };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const valueOf = (name) => { const i = args.indexOf(name); return i === -1 ? null : args[i + 1]; };
const scopePath = valueOf('--scope');

View file

@ -16,6 +16,7 @@ import { parseDocument } from '../util/frontmatter.mjs';
import { jaccardSimilarity } from '../parsers/jaccard.mjs';
import { computeFindingId } from '../parsers/finding-id.mjs';
import { extractFindingsBlock } from './findings-schema.mjs';
import { isMain } from '../util/is-main.mjs';
export const DEFAULT_JACCARD_TOLERANCE = 0.7;
@ -152,7 +153,7 @@ export function fidelityDiffStructured(armA, armB, opts = {}) {
// ---- CLI shim ----------------------------------------------------------------
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const { readFileSync } = await import('node:fs');
const args = process.argv.slice(2);
const files = args.filter((x) => !x.startsWith('--'));

View file

@ -18,10 +18,10 @@
// 1 = unresolved placeholder / wrong id (errors on stderr, file untouched);
// 2 = could not run (unreadable file, no JSON block).
import { readFileSync, writeFileSync, renameSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import { computeFindingId } from '../parsers/finding-id.mjs';
import { extractFindingsBlock } from './findings-schema.mjs';
import { isMain } from '../util/is-main.mjs';
const PLACEHOLDER = /@@ID_(\d+)@@/g;
const HEX40 = /^[0-9a-f]{40}$/;
@ -66,15 +66,7 @@ export function fillFindingIds(text) {
return { ok: true, text: out, errors: [] };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const path = process.argv[2];
if (!path) {
process.stderr.write('Usage: fill-finding-ids.mjs <review.md>\n');

View file

@ -21,11 +21,12 @@
//
// 3-layer pattern (Content → Raw-text → CLI shim) mirroring the other validators.
import { readFileSync, existsSync, realpathSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import { issue, fail } from '../util/result.mjs';
import { RULE_KEYS, SEVERITY_VALUES } from './rule-catalogue.mjs';
import { fenceTracker } from '../parsers/plan-schema.mjs';
import { isMain } from '../util/is-main.mjs';
// The fields main + the coordinator depend on. Descriptive fields are not here
// on purpose: a missing recommended_action should not trigger a re-ask.
@ -36,24 +37,71 @@ export const FINDING_REQUIRED_FIELDS = Object.freeze([
'line',
]);
// Last fenced ```json … ``` block in a reviewer's output. The contract pins the
// JSON block as the LAST fence so prose above it never confuses the parser.
const JSON_FENCE_GLOBAL = /```json[ \t]*\r?\n([\s\S]*?)```/gi;
// The fenced blocks of a Markdown text, in order, read as Markdown reads a
// fence (plan-schema `fenceTracker`, the one fence rule): an opening line of
// three or more backticks or tildes, closed only by a line of the same
// character at least as long as the opening run (round 9: any three closed any
// opener, so a four-backtick example of a json block split there). A backtick
// opener's info string holds no backtick. Inside a ~~~ block a ```json line
// opens nothing (round 10: it swallowed the real block, and the review
// validator lost every BLOCKER id). A block never closed runs to the end of the
// text (`closed: false`). Indented or not, a fence counts: a fence inside a
// list item can sit four spaces in, and a gate that skipped it would pass a
// BLOCKER written there; the cost is that a ```json line in a code block
// indented four spaces fails the gate closed (exit 2), never open. A `\r`
// before a line break is no part of the line, so a CRLF file reads as its LF
// twin. A JSON string cannot hold a line break, so three backticks inside one
// ("a fenced ```bash block") never end a block, and three backticks after text
// on the same line are no fence (release review of 6.3.0, rounds 7-10).
// `info` is the info string as written, trailing blanks dropped; `body` is the
// text between the fence lines, as written.
// One reader for the verdict gate and every trailing-json reader: the review
// validator, the synthesis digest, fill-finding-ids, recurrence, fidelity-diff.
const FENCE_INFO = /^[ \t]*(?:`{3,}|~{3,})(.*)$/;
/**
* Extract the inner body of the LAST fenced `json` block in `text`.
* @param {string} text
* @returns {Array<{info: string, body: string, closed: boolean}>}
*/
export function fencedBlocks(text) {
const blocks = [];
if (typeof text !== 'string') return blocks;
const fence = fenceTracker({ anyIndent: true });
let open = null;
for (let at = 0; at <= text.length;) {
const nl = text.indexOf('\n', at);
const end = nl === -1 ? text.length : nl;
const line = text.slice(at, end).replace(/\r$/, '');
const wasInside = fence.inside;
fence.feed(line);
if (!wasInside && fence.inside) {
open = { info: FENCE_INFO.exec(line)[1].replace(/[ \t]+$/, ''), start: end + 1 };
} else if (wasInside && !fence.inside) {
blocks.push({ info: open.info, body: text.slice(open.start, at), closed: true });
open = null;
}
if (nl === -1) break;
at = nl + 1;
}
if (open !== null) blocks.push({ info: open.info, body: text.slice(open.start), closed: false });
return blocks;
}
/** A `json` block: its info string is exactly `json` (any case). */
export function isJsonBlock(block) {
return block.info.toLowerCase() === 'json';
}
/**
* Extract the inner body of the LAST closed `json` block in `text`. The
* contract pins the JSON block as the LAST fence so prose above it never
* confuses the parser.
* @param {string} text
* @returns {string|null} the JSON source, or null if no json fence is present.
*/
export function extractFindingsBlock(text) {
if (typeof text !== 'string') return null;
JSON_FENCE_GLOBAL.lastIndex = 0;
let last = null;
let m;
while ((m = JSON_FENCE_GLOBAL.exec(text)) !== null) {
last = m[1];
}
return last;
const json = fencedBlocks(text).filter((b) => b.closed && isJsonBlock(b));
return json.length > 0 ? json[json.length - 1].body : null;
}
function validateFinding(finding, index, errors) {
@ -109,8 +157,7 @@ function validateFinding(finding, index, errors) {
// and the claim itself was right. With a repo root, a line past the end of a
// file that exists is an error, so Phase 5's bounded re-ask fixes it before the
// coordinator ever sees it. A file absent from the tree is not judged here.
function lineCount(repoRoot, file) {
const root = resolve(repoRoot);
function readLineCount(root, file) {
const abs = resolve(root, file);
if (abs !== root && !abs.startsWith(root + sep)) return null;
try {
@ -123,9 +170,24 @@ function lineCount(repoRoot, file) {
}
}
function checkLineRange(finding, index, repoRoot, errors) {
/**
* Line counts for one validation, each file read once however often it is
* cited: a reviewer that cited the 3,000-line commands/trekexecute.md 40
* times read it 40 times, again on every re-ask (release review of 6.3.0,
* round 7). The map lives for one call, so an edited file is read afresh.
*/
function lineCounter(repoRoot) {
const root = resolve(repoRoot);
const counts = new Map();
return (file) => {
if (!counts.has(file)) counts.set(file, readLineCount(root, file));
return counts.get(file);
};
}
function checkLineRange(finding, index, lineCount, errors) {
if (!finding || typeof finding.file !== 'string' || !Number.isInteger(finding.line) || finding.line <= 0) return;
const n = lineCount(repoRoot, finding.file);
const n = lineCount(finding.file);
if (n === null || finding.line <= n) return;
errors.push(issue(
'FINDING_LINE_OUT_OF_RANGE',
@ -147,14 +209,14 @@ function checkLineRange(finding, index, repoRoot, errors) {
const PROSE_CITE = /(?<![\w./\\:-])((?:\.{0,2}\/)?[\w.-]+(?:\/[\w.-]+)*\.[A-Za-z0-9]+):(\d+)(?:[-–—](\d+))?(?![\w])/g;
const PROSE_FIELDS = Object.freeze(['title', 'detail', 'recommended_action', 'brief_ref']);
function checkProseCitations(text, where, repoRoot, errors, seen) {
function checkProseCitations(text, where, lineCount, errors, seen) {
if (typeof text !== 'string' || text === '') return;
PROSE_CITE.lastIndex = 0;
let m;
while ((m = PROSE_CITE.exec(text)) !== null) {
const line = Math.max(Number(m[2]), m[3] === undefined ? 0 : Number(m[3]));
if (line <= 0) continue;
const n = lineCount(repoRoot, m[1]);
const n = lineCount(m[1]);
if (n === null || line <= n) continue;
const key = `${where}\u0000${m[0]}`;
if (seen.has(key)) continue;
@ -169,10 +231,10 @@ function checkProseCitations(text, where, repoRoot, errors, seen) {
}
}
function checkFindingProse(finding, index, repoRoot, errors, seen) {
function checkFindingProse(finding, index, lineCount, errors, seen) {
if (!finding || typeof finding !== 'object') return;
for (const field of PROSE_FIELDS) {
checkProseCitations(finding[field], `findings[${index}].${field}`, repoRoot, errors, seen);
checkProseCitations(finding[field], `findings[${index}].${field}`, lineCount, errors, seen);
}
}
@ -182,6 +244,11 @@ function checkFindingProse(finding, index, repoRoot, errors, seen) {
* each finding's `file:line`, and every `path:line` in its prose fields
*/
export function validateFindings(payload, opts = {}) {
return validatePayload(payload, opts.repoRoot ? lineCounter(opts.repoRoot) : null);
}
// One line counter for one validation: the JSON block and the prose around it share it.
function validatePayload(payload, lineCount) {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
return fail(issue('FINDINGS_NOT_OBJECT', `Reviewer output must be a JSON object, got ${Array.isArray(payload) ? 'array' : typeof payload}`));
}
@ -201,9 +268,9 @@ export function validateFindings(payload, opts = {}) {
const seen = new Set();
for (let i = 0; i < payload.findings.length; i++) {
validateFinding(payload.findings[i], i, errors);
if (opts.repoRoot) {
checkLineRange(payload.findings[i], i, opts.repoRoot, errors);
checkFindingProse(payload.findings[i], i, opts.repoRoot, errors, seen);
if (lineCount) {
checkLineRange(payload.findings[i], i, lineCount, errors);
checkFindingProse(payload.findings[i], i, lineCount, errors, seen);
}
}
@ -232,8 +299,9 @@ export function validateReviewerOutput(rawText, opts = {}) {
} catch (e) {
return fail(issue('FINDINGS_PARSE_ERROR', `Reviewer JSON block did not parse: ${e.message}`));
}
const r = validateFindings(parsed, opts);
if (!opts.repoRoot) return r;
const lineCount = opts.repoRoot ? lineCounter(opts.repoRoot) : null;
const r = validatePayload(parsed, lineCount);
if (!lineCount) return r;
// The prose around the block (the text outside the LAST json fence; the
// block itself was checked field by field above) is a WARNING, not an error:
// the bounded re-ask asks for the JSON block only, which cannot correct that
@ -241,7 +309,7 @@ export function validateReviewerOutput(rawText, opts = {}) {
// review over text no one uses (independent review, 2026-10-08).
const at = rawText.lastIndexOf(block);
const found = [];
checkProseCitations(rawText.slice(0, at) + rawText.slice(at + block.length), 'reviewer prose', opts.repoRoot, found, new Set());
checkProseCitations(rawText.slice(0, at) + rawText.slice(at + block.length), 'reviewer prose', lineCount, found, new Set());
return found.length === 0 ? r : { ...r, warnings: [...r.warnings, ...found] };
}
@ -251,15 +319,7 @@ function isDir(p) {
try { return statSync(p).isDirectory(); } catch { return false; }
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const rootIdx = args.indexOf('--repo-root');
const repoRoot = rootIdx >= 0 ? args[rootIdx + 1] : undefined;

View file

@ -34,10 +34,10 @@
// blocks here. Malformed stdin exits NON-ZERO — a broken hand-off must surface
// loudly, never collapse into a silent empty merge.
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { jaccardSimilarity, meetsThreshold } from '../parsers/jaccard.mjs';
import { computeFindingId } from '../parsers/finding-id.mjs';
import { isMain } from '../util/is-main.mjs';
export const DEFAULT_THRESHOLD = 0.7;
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor']);
@ -282,17 +282,7 @@ function readStdinSourcesOrExit() {
];
}
// realpath on both sides: `file://${argv[1]}` silently no-ops under a symlink
// or a path with a space.
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
const sources = args.stdin
? readStdinSourcesOrExit()

View file

@ -41,10 +41,10 @@ import {
} from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { validateReviewContent } from '../validators/review-validator.mjs';
import { extractFindingsBlock } from './findings-schema.mjs';
import { getRule } from './rule-catalogue.mjs';
import { isMain } from '../util/is-main.mjs';
export const LESSONS_DIR = join('.claude', 'rules');
export const LESSONS_FILE_NAME = 'voyage-review-lessons.md';
@ -347,20 +347,12 @@ export function applyLesson(repo, ruleKey, opts = {}) {
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
function flag(args, name) {
const i = args.indexOf(name);
return i === -1 ? undefined : args[i + 1];
}
if (isMain()) {
if (isMain(import.meta.url)) {
const [cmd, ...args] = process.argv.slice(2);
const repo = flag(args, '--repo');
const out = (o) => process.stdout.write(JSON.stringify(o, null, 2) + '\n');

View file

@ -26,10 +26,10 @@
// `templates/checklist-template.md:8-9`, MIT). The blocking itself is Voyage's
// own design — neither source blocks on its own.
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync } from 'node:fs';
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
import { extractFindingsBlock } from './findings-schema.mjs';
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
import { isMain } from '../util/is-main.mjs';
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
@ -67,17 +67,23 @@ export function decideVerdict(text) {
if ((fmText.match(/^verdict\s*:/gm) ?? []).length > 1) {
return undecided('frontmatter carries more than one verdict line', verdict);
}
const lastFence = lastFenceLanguage(body);
if (lastFence === null) {
// The last fenced block, its info string and its body from one reading
// (findings-schema `fencedBlocks`): a gate that took the language from one
// reader and the body from another passed a BLOCKER in a last block opened
// as ```json {x} (release review of 6.3.0, round 9).
const last = fencedBlocks(body).at(-1);
if (last === undefined) {
return undecided(`verdict ${verdict} has no trailing JSON block to back it`, verdict);
}
if (lastFence !== 'json') {
return undecided(`the last fenced block is "${lastFence || '(no language)'}", not the trailing \`json\` block`, verdict);
if (!last.closed) {
return undecided(`the last fenced block ("${last.info || '(no language)'}") is never closed, so it runs to the end of the file`, verdict);
}
if (!isJsonBlock(last)) {
return undecided(`the last fenced block is "${last.info || '(no language)'}", not the trailing \`json\` block`, verdict);
}
const block = extractFindingsBlock(body);
let payload;
try {
payload = JSON.parse(block);
payload = JSON.parse(last.body);
} catch (e) {
return undecided(`trailing JSON block does not parse: ${e.message}`, verdict);
}
@ -118,17 +124,6 @@ function atLeastVersion(v, major, minor) {
return Boolean(m) && (Number(m[1]) > major || (Number(m[1]) === major && Number(m[2]) >= minor));
}
const FENCE_RE = /^```[ \t]*([A-Za-z0-9_+-]*)[^\n]*\n[\s\S]*?^```[ \t]*$/gm;
/** Language tag of the LAST fenced block in `body` (lower-cased), '' for none, null when no fence. */
function lastFenceLanguage(body) {
let last = null;
let m;
FENCE_RE.lastIndex = 0;
while ((m = FENCE_RE.exec(body)) !== null) last = m[1].toLowerCase();
return last;
}
/** The JSON block shape the gate needs; returns an error string or null. */
function checkPayloadShape(p) {
if (p === null || typeof p !== 'object' || Array.isArray(p)) return 'not an object';
@ -168,15 +163,7 @@ export function decideVerdictFile(filePath) {
return decideVerdict(text);
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const files = args.filter((a) => !a.startsWith('--'));
const filePath = files[0];

View file

@ -34,10 +34,11 @@
// Osmani. Reimplemented, no code copied; the reader here takes multi-line
// (`description: |`) descriptions, which the original reads as "|".
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { descriptionOf, readFrontmatterFields } from './skill-frontmatter.mjs';
import { isMain } from '../util/is-main.mjs';
export const THRESHOLDS = Object.freeze({
COLLISION_ERROR: 0.75,
@ -280,12 +281,7 @@ export function checkRouting(root, t = THRESHOLDS) {
// ---- CLI ----
function isMain() {
if (!process.argv[1]) return false;
try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } catch { return false; }
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const at = args.indexOf('--root');
const root = resolve(at >= 0 ? args[at + 1] || '' : join(dirname(fileURLToPath(import.meta.url)), '..', '..'));

View file

@ -40,10 +40,11 @@
// docs/skill-anatomy.md - MIT, Copyright (c) 2025 Addy Osmani. Reimplemented,
// no code copied.
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFrontmatterFields, splitFile } from './skill-frontmatter.mjs';
import { isMain } from '../util/is-main.mjs';
/** Claude Code's skill frontmatter fields (skills.md, Frontmatter reference). */
export const KNOWN_FIELDS = new Set([
@ -227,12 +228,7 @@ export function lintSkillsRoot(pluginRoot) {
// ---- CLI ----
function isMain() {
if (!process.argv[1]) return false;
try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } catch { return false; }
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const at = args.indexOf('--root');
const root = resolve(at >= 0 ? args[at + 1] || '' : join(dirname(fileURLToPath(import.meta.url)), '..', '..'));

View file

@ -9,6 +9,7 @@
// Zero npm dependencies. Node stdlib only.
import { readFileSync, existsSync } from 'node:fs';
import { isMain } from '../util/is-main.mjs';
function usage() {
return `cache-analyzer.mjs — summarize trekexecute-stats.jsonl
@ -123,7 +124,7 @@ export function summarizeFile(path) {
return summarize(text.split('\n'));
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const jsonIdx = args.indexOf('--json');
if (jsonIdx === -1 || !args[jsonIdx + 1]) {

View file

@ -30,10 +30,10 @@
// Import direction: event-emit imports stats-append; stats-append must never
// import event-emit (that would be a cycle).
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveStatsDir } from './stats-append.mjs';
import { isMain } from '../util/is-main.mjs';
export const KNOWN_EVENTS = Object.freeze(new Set([
'brief-approved',
@ -110,17 +110,7 @@ function parseArgs(argv) {
return out;
}
// Run as a CLI? Compared as real paths: `file://${argv[1]}` misses a path with a
// space (URL-encoded) and a symlink (import.meta.url is the realpath).
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
let result;
if (!args.event) {

View file

@ -23,11 +23,11 @@
// JSON
// → prints {"written":true,"path":...} exit 0 | {"written":false,"reason":...} exit 1
import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { attributeOrder } from './claimed-order.mjs';
import { isMain } from '../util/is-main.mjs';
export const STATS_KINDS = Object.freeze([
'trekbrief', 'trekresearch', 'trekplan', 'trekexecute', 'trekreview', 'trekcontinue',
@ -68,17 +68,7 @@ export function appendStatsRecord(kind, record, env = process.env, { cwd = proce
return { written: true, path };
}
// Run as a CLI? Compared as real paths: `file://${argv[1]}` misses a path with a
// space (URL-encoded) and a symlink (import.meta.url is the realpath).
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const kind = process.argv[2];
let record = null;
let reason = null;

View file

@ -45,7 +45,7 @@ import {
appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmdirSync, statSync,
} from 'node:fs';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isMain } from './is-main.mjs';
export const APPROVAL_KINDS = Object.freeze(['intent', 'plan', 'review', 'merge', 'release']);
export const LOG_RELPATH = '.claude/voyage/approvals.jsonl';
@ -278,15 +278,7 @@ function shaFromArgs(args) {
return { sha: raw === undefined ? undefined : normalizeHex(raw), raw };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'answer') {
let text = opt(args, '--text');

View file

@ -21,8 +21,8 @@
import { spawnSync } from 'node:child_process';
import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { LOG_RELPATH, logPath, normalizeSubject } from './approval-log.mjs';
import { isMain } from './is-main.mjs';
export const ARTIFACT_STORES = Object.freeze(['git', 'local']);
const DEFAULT_MESSAGE = 'docs(voyage): record approval';
@ -85,15 +85,7 @@ export function commitArtifacts({ repo, store, artifacts = [], message = DEFAULT
return { ...res, exit: 0, committed: true, commit: git(repo, ['rev-parse', 'HEAD']).stdout.trim() };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
const artifacts = args.flatMap((a, i) => (a === '--artifact' && args[i + 1] !== undefined ? [args[i + 1]] : []));

View file

@ -22,6 +22,8 @@
//
// Pure data; no I/O. Re-entry to `completed` is idempotent.
import { isMain } from './is-main.mjs';
export const STATES = Object.freeze({
IDLE: 'idle',
GATES_ON: 'gates_on',
@ -114,7 +116,7 @@ function parseArgs(argv) {
return out;
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
if (!args.state || !args.event) {
process.stdout.write(JSON.stringify({

26
lib/util/is-main.mjs Normal file
View file

@ -0,0 +1,26 @@
// lib/util/is-main.mjs
// Whether a module is the script node was started with, so a module can be both
// a library and a CLI: `if (isMain(import.meta.url)) { ... }`.
//
// Real paths on both sides. The older guard, import.meta.url === `file://${argv[1]}`,
// is false under a symlinked path (import.meta.url is the realpath) and under a
// path with a space (URL-encoded), and the CLI then exits 0 having done nothing
// (measured 2026-10-04). One helper instead of a copy per CLI (release review of
// 6.3.0, round 7: 31 copies of the fixed guard and 20 of the old one).
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
/**
* @param {string} url - the calling module's import.meta.url
* @param {string|undefined} [argv1] - the started script (process.argv[1])
* @returns {boolean}
*/
export function isMain(url, argv1 = process.argv[1]) {
if (!argv1) return false;
try {
return realpathSync(argv1) === realpathSync(fileURLToPath(url));
} catch {
return false;
}
}

View file

@ -42,6 +42,7 @@
import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { isMain } from './is-main.mjs';
export const MAX_CONV_TURNS = 3;
export const MAX_TOTAL_DIMENSIONS = 8; // settings.json:16 maxDimensions — whole list, not discovered-only
@ -325,7 +326,7 @@ function parseArgs(argv) {
return out;
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
// The dimension ceiling is a Phase 4.5 concern, and Phase 4.5 never calls the

View file

@ -29,6 +29,7 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, dirname, basename, extname, isAbsolute } from 'node:path';
import { isMain } from './is-main.mjs';
function sectionBody(text, heading) {
const lines = text.split('\n');
@ -301,7 +302,7 @@ const NEEDS = {
'record-merged': ['plan', 'wave'], 'next-wave': ['plan'], clear: [],
};
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
const usage = 'usage: session-orders.mjs write|status|archive|record-merged --plan <repo-relative plan> --wave <W> --session-dir <dir> [--branch <b>]\n'
+ ' session-orders.mjs next-wave --plan <repo-relative plan> --session-dir <dir>\n'

View file

@ -37,14 +37,13 @@
// 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, realpathSync, readdirSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs';
import { allowedCommand, runCommand, screenCommand } from '../verification/criteria-runner.mjs';
import { DEFAULT_TIMEOUT_MS } from '../verification/red-proof.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;
@ -173,7 +172,11 @@ function main(argv) {
} 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');
if (args.cmd !== undefined && args.cmd !== testCmd) fail(2, `--cmd is not the step's test command stored in the lock (${testCmd})`);
// 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);
@ -181,9 +184,9 @@ function main(argv) {
// 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".
if (r.error || r.status === null) {
fail(2, `the test command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`);
}
// 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();
}
@ -225,12 +228,4 @@ function main(argv) {
process.exit(0);
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) main(process.argv.slice(2));
if (isMain(import.meta.url)) main(process.argv.slice(2));

View file

@ -10,6 +10,7 @@
import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { issue } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
const CANONICAL_OVERVIEW = 'overview.md';
const CANONICAL_GAPS = 'gaps.md';
@ -76,7 +77,7 @@ export function discoverArchitecture(projectDir) {
return result;
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const projectDir = process.argv[2];
const wantJson = process.argv.includes('--json');
if (!projectDir) {

View file

@ -29,8 +29,8 @@
// exit 0 = current (skip the review); 1 = stale or absent (review); 2 = usage / unreadable
import { createHash } from 'node:crypto';
import { readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, renameSync, writeFileSync } from 'node:fs';
import { isMain } from '../util/is-main.mjs';
export const REVIEWED_HASH_FIELD = 'brief_reviewed_hash';
export const REVIEWED_AT_FIELD = 'brief_reviewed_at';
@ -83,15 +83,7 @@ export function checkBriefReview(text) {
return { state: stamped === actual ? 'current' : 'stale', stamped, actual };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null;
const path = args.find((a) => !a.startsWith('--'));

View file

@ -6,12 +6,12 @@
// optional fields (jf. the source_findings precedent on trekreview) can be
// added without a brief_version bump.
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { BASE_ALLOWED_MODELS } from './profile-validator.mjs';
import { parseSuccessCriteria, runnableCheck } from '../verification/criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
export const BRIEF_REQUIRED_FRONTMATTER = ['type', 'brief_version', 'task', 'slug', 'research_topics', 'research_status'];
export const REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER = ['type', 'task', 'slug', 'project_dir', 'findings'];
@ -328,15 +328,7 @@ export function validateBrief(filePath, opts = {}) {
return { ...r, parsed: { ...r.parsed, filePath } };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const minIdx = args.indexOf('--min-version');

View file

@ -39,13 +39,13 @@
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue } from '../util/result.mjs';
import { emit } from '../stats/event-emit.mjs';
import { resolveStatsDir } from '../stats/stats-append.mjs';
import { isMain } from '../util/is-main.mjs';
export const INTENT_HASH_FIELD = 'intent_approved_hash';
export const INTENT_APPROVED_AT_FIELD = 'intent_approved_at';
@ -234,18 +234,7 @@ export function resolveApproveTarget(arg) {
return { brief_path: briefPath, project_dir: projectDir, plan_command: planCommand };
}
// Main-module guard on real paths: comparing against `file://` + argv[1] fails
// when the plugin sits behind a symlink or in a path with a space; the CLI then
// exits 0 having checked nothing.
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--'));
if (args.includes('--resolve')) {

View file

@ -11,6 +11,7 @@
import { readFileSync, existsSync } from 'node:fs';
import { issue, fail } from '../util/result.mjs';
import { splitFrontmatter, parseFrontmatter } from '../util/frontmatter.mjs';
import { isMain } from '../util/is-main.mjs';
export const NEXT_SESSION_PROMPT_REQUIRED_FIELDS = Object.freeze(['produced_by', 'produced_at']);
@ -150,7 +151,7 @@ export function validateNextSessionPromptConsistency(a, b, opts = {}) {
return { valid: errors.length === 0, errors, warnings, parsed: { a: aFm || null, b: bFm || null } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const positionals = args.filter(a => !a.startsWith('--'));
const wantJson = args.includes('--json');

View file

@ -54,12 +54,12 @@
// The verdict is a trace of a process, not a signature: anything that can
// write the plan can write the block.
import { readFileSync, existsSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
import { readFileSync, existsSync, writeFileSync, renameSync, rmSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { validatePlanContent } from './plan-validator.mjs';
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
import { isMain } from '../util/is-main.mjs';
export const MAX_ROUNDS = 3;
export const VERDICT_SCHEMA = 'voyage.plan-verdict/1';
@ -343,19 +343,11 @@ export function gatePlan(rawText) {
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
function out(obj) {
process.stdout.write(JSON.stringify(obj) + '\n');
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
const known = new Set(['--json', '--hash', '--record', '--reviewed', '--extra-round', '--accept-open']);

View file

@ -34,8 +34,7 @@
// warning PLAN_STEP_TESTS_ONLY: its tests pass at its start point, so it can
// never be proven red (voyage-lekerepo trial 3, 2026-10-09).
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync } from 'node:fs';
import {
sliceSteps, validatePlanHeadings, extractPlanVersion, extractPlanCeremony,
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
@ -45,7 +44,8 @@ import {
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, fail } from '../util/result.mjs';
import { parsePlanVerification, firstCommand, runnableCheck } from '../verification/criteria-runner.mjs';
import { parsePlanVerification, firstCommand, runnableCheck, refusal } from '../verification/criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
export const OPTIONAL_STEP_FIELDS = Object.freeze(['Interface in', 'Interface out', 'Review focus']);
export const DESIGN_SUBSECTIONS = Object.freeze([
@ -109,7 +109,7 @@ function checkRunnable(text, sections, opts) {
`Phase 7 runs every criterion and fells the run on one it cannot run. ${WAY_IN}`));
}
}
const verified = new Set(criteria.filter(c => c.command).map(c => c.command.trim().replace(/\s+/g, ' ')));
const verified = new Set(criteria.filter(c => !refusal(c)).map(c => c.command.trim().replace(/\s+/g, ' ')));
for (const s of sections) {
const f = parseStepFields(s.body);
if (f.has('Verify')) {
@ -371,17 +371,7 @@ export function validatePlan(filePath, opts = {}) {
return { ...r, parsed: { ...r.parsed, filePath } };
}
// realpath on both sides: `file://${argv[1]}` silently no-ops under a symlink
// or a path with a space (the CLI then exits 0 having checked nothing).
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const at = args.indexOf('--steps-from');

View file

@ -28,6 +28,7 @@
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
export const PROFILE_REQUIRED_FIELDS = Object.freeze([
'name',
@ -175,7 +176,7 @@ export function validateProfile(filePath, opts = {}) {
return { ...r, parsed: { ...(r.parsed || {}), filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {

View file

@ -13,9 +13,9 @@
// simplify, review) and the key of plan_verification.failed_criteria: the runner
// says `exitCode`, progress says `exit_code`, and only `exit_code` is accepted (C15).
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync } from 'node:fs';
import { issue, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
export const PROGRESS_REQUIRED_TOP = ['schema_version', 'plan', 'plan_version', 'mode', 'status', 'total_steps', 'current_step', 'steps'];
export const PROGRESS_VALID_STATUSES = ['pending', 'in_progress', 'completed', 'failed', 'partial', 'stopped'];
@ -212,17 +212,7 @@ export function validateProgress(filePath, opts = {}) {
return validateProgressContent(text, opts);
}
// The real paths on both sides: a `file://` comparison fails when the path holds a
// space or runs through a symlink, and the CLI then exits 0 having checked nothing.
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {

View file

@ -19,6 +19,7 @@
// → JSON {valid, errors, warnings}; exit 0 valid, 1 invalid.
import { issue } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
// WARN tier — absolute filesystem paths (leaks local directory layout).
export const ABSOLUTE_PATH_PATTERNS = Object.freeze([
@ -105,7 +106,7 @@ export function validateOutboundQuery(text, opts = {}) {
// ---- CLI shim ----------------------------------------------------------------
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const text = args.find(a => !a.startsWith('--'));

View file

@ -5,6 +5,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
export const RESEARCH_REQUIRED_FRONTMATTER = ['type', 'created', 'question'];
export const RESEARCH_BODY_SECTIONS = ['Executive Summary', 'Dimensions'];
@ -77,7 +78,7 @@ export function validateResearchDir(dirPath, opts = {}) {
return { valid: errors.length === 0, errors, warnings, parsed: { files: results } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const dirIdx = args.indexOf('--dir');

View file

@ -13,10 +13,11 @@
// gate that decides on the verdict is lib/review/verdict-gate.mjs, which never
// reads a missing verdict as ALLOW.
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
import { extractFindingsBlock } from '../review/findings-schema.mjs';
export const REVIEW_REQUIRED_FRONTMATTER = [
'type',
@ -33,14 +34,13 @@ export const REVIEW_BODY_SECTIONS = ['Executive Summary', 'Coverage', 'Remediati
export const VERDICT_VALUES = Object.freeze(['ALLOW', 'WARN', 'BLOCK']);
const HEX_ID_RE = /^[0-9a-f]{40}$/;
const JSON_FENCE_GLOBAL = /```json[ \t]*\r?\n([\s\S]*?)```/gi;
/** IDs of BLOCKER findings in the trailing JSON block ([] when absent/unparseable). */
/**
* IDs of BLOCKER findings in the trailing JSON block ([] when absent/unparseable).
* Read with the one block reader, so three backticks inside a JSON string do
* not end the block early and hide a BLOCKER (release review of 6.3.0, round 8).
*/
function blockerIdsFromBody(body) {
let last = null;
let m;
JSON_FENCE_GLOBAL.lastIndex = 0;
while ((m = JSON_FENCE_GLOBAL.exec(body)) !== null) last = m[1];
const last = extractFindingsBlock(body);
if (last === null) return [];
try {
const payload = JSON.parse(last);
@ -180,15 +180,7 @@ export function validateReview(filePath, opts = {}) {
return { ...r, parsed: { ...r.parsed, filePath } };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const filePath = args.find(a => !a.startsWith('--'));

View file

@ -4,6 +4,7 @@
import { readFileSync, existsSync } from 'node:fs';
import { issue, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
export const SESSION_STATE_REQUIRED_TOP = [
'schema_version',
@ -98,7 +99,7 @@ export function validateSessionState(filePath, opts = {}) {
return validateSessionStateContent(text, opts);
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {

View file

@ -46,7 +46,7 @@ import {
} from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, resolve, dirname, basename, relative, isAbsolute, delimiter, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isMain } from '../util/is-main.mjs';
const TIMEOUT_MS = 180_000;
const DEPCRUISE_CONFIGS = ['.dependency-cruiser.cjs', '.dependency-cruiser.js', '.dependency-cruiser.mjs', '.dependency-cruiser.json'];
@ -255,14 +255,6 @@ export function proveRule({ repo, targets = [], plants, rule }) {
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
function parseArgs(argv) {
const o = { targets: [], plants: [], prove: false, repo: null, rule: null, bad: null };
const value = (i, flag) => {
@ -287,7 +279,7 @@ function parseArgs(argv) {
return o;
}
if (isMain()) {
if (isMain(import.meta.url)) {
const o = parseArgs(process.argv.slice(2));
let result;
if (o.bad || !o.repo) {

View file

@ -50,14 +50,19 @@ import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { commandEnv } from '../util/command-env.mjs';
import { isMain } from '../util/is-main.mjs';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const PLAN_HEADING = '## Verification';
export const BRIEF_HEADING = '## Success Criteria';
export const DEFAULT_HOOK = join(REPO_ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
// The one timeout every check Voyage runs is stopped by (15 minutes): red
// proof, the done line, the test lock, Phase 7 criteria, regression-check and
// the release check's suite (release review of 6.3.0, round 7).
export const DEFAULT_TIMEOUT_MS = 900_000;
export const DEFAULT_MAX_OUTPUT = 4000;
const MAX_BUFFER = 64 * 1024 * 1024;
// --- parsing ----------------------------------------------------------------
@ -145,30 +150,64 @@ export function looksLikeCommand(span) {
return true;
}
const COMMENT_NOT_PLAIN = 'a trailing comment with a quote, $, backtick or backslash (the span reaches a shell inside double quotes)';
// The first backtick-delimited span on the line is the command by convention —
// both templates put it first and a second span holds the expected output.
// A plan step's `Verify:` and `End-to-end:` fields follow the same convention.
// A `reason` says why the span cannot run. It comes with `command: null` when
// the span names nothing to run, and beside the command when only its comment
// is refused: that span does carry a command, so it stays a criterion of its
// own and is refused out loud (`refusal`), never read as prose or as a
// sub-point (release review of 6.3.0, round 9).
export function firstCommand(text) {
const m = text.match(/`([^`]+)`/);
if (!m) return { command: null, reason: 'no-command' };
const raw = m[1].trim();
const span = m[1].trim();
const raw = dropComment(span);
// Template placeholders (`{exact command}`) are not commands.
if (raw === '' || /^\{.*\}$/.test(raw)) return { command: null, reason: 'placeholder' };
if (!looksLikeCommand(raw)) return { command: null, reason: 'not-a-command' };
if (span === '' || /^\{.*\}$/.test(raw)) return { command: null, reason: 'placeholder' };
// A span that is only a comment names nothing to run (round 8).
if (raw === '' || !looksLikeCommand(raw)) return { command: null, reason: 'not-a-command' };
// The comment is never screened, but the executor hands the span as written
// to red proof, the test lock and the done line inside double quotes
// (`--cmd "{Verify command}"`): there a `"` ends the argument and a `$`, a
// backtick or a backslash still means something (release review of 6.3.0,
// round 8). A comment with one of them is refused, never dropped.
if (/["$`\\]/.test(span.slice(raw.length))) return { command: raw, reason: COMMENT_NOT_PLAIN };
return { command: raw, reason: '' };
}
/** Why a criterion cannot run (no command, or a refused one), or '' when it may be screened. */
export function refusal(criterion) {
if (!criterion?.command) return criterion?.reason || 'no command';
return criterion.reason || '';
}
/**
* A command without its trailing comment (`npm test # all green`): a shell
* drops it, and with no shell (runCommand) its words would reach the screen
* and make an honest check NOT RUN (release review of 6.3.0, round 6). One
* reading for every place a command comes from: a fence line, a backticked
* span (`Verify:`, `Regression:`, `End-to-end:`, a `## Verification` bullet)
* and a command passed as written (red proof, the done line, the test lock),
* so the words screened are the words run (round 7). Only a `#` that opens a
* word on the same line is a comment: `tests/a#b.test.mjs` is kept whole here,
* and the allowlist then refuses the word, since a `#` is not a plain word
* (round 8).
*/
export function dropComment(command) {
return String(command ?? '').replace(/[ \t]+#[^\n]*$/, '').trim();
}
// A command line inside a shell-tagged fence. A console block may prefix the
// line with a `$` or `>` prompt; blank and comment-only lines declare nothing.
// A `#` root prompt is NOT stripped: it is indistinguishable from a comment,
// and running a comment is the worse of the two mistakes. A trailing comment
// (`npm test # all green`) is not part of the command: a shell dropped it,
// and with no shell (runCommand) its words would reach the screen and make an
// honest check NOT RUN (release review of 6.3.0, round 6).
// and running a comment is the worse of the two mistakes.
function fencedCommand(text) {
const line = text.trim();
if (line === '' || line.startsWith('#')) return null;
return line.replace(/^[$>]\s+/, '').replace(/\s+#.*$/, '');
return dropComment(line.replace(/^[$>]\s+/, ''));
}
function parseSection(markdown, heading, prefix) {
@ -233,7 +272,7 @@ export function parseSuccessCriteria(markdown) {
export function screenCommand(command, opts = {}) {
const hookPath = opts.hookPath ?? DEFAULT_HOOK;
const r = spawnSync(process.execPath, [hookPath], {
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command } }),
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command: dropComment(command) } }),
encoding: 'utf8',
timeout: 30_000,
});
@ -449,15 +488,16 @@ export function allowedCommand(command, opts = {}) {
* what adds it. Returns `{runnable, reason}`.
*/
export function runnableCheck(criterion, opts = {}) {
if (!criterion?.command) return { runnable: false, reason: criterion?.reason || 'no command' };
const refused = refusal(criterion);
if (refused) return { runnable: false, reason: refused };
const r = screenShape(criterion.command, opts, true);
return { runnable: r.allowed, reason: r.reason };
}
function screenShape(command, opts, writeTime) {
const cwd = resolve(opts.cwd ?? process.cwd());
const text = String(command ?? '');
if (text.trim() === '') return deny('the command is empty');
const text = dropComment(command);
if (text === '') return deny('the command is empty');
if (/\n/.test(text)) return deny('a newline: one criterion declares one command');
const operator = text.match(SHELL_OPERATORS);
if (operator) return deny(`a shell operator (${operator[0]}) — one criterion declares one command, unpiped`);
@ -492,7 +532,7 @@ function screenShape(command, opts, writeTime) {
/** The words of a command: what the screen checks is exactly what runs. */
export function commandWords(command) {
return String(command ?? '').trim().split(/\s+/);
return dropComment(command).split(/\s+/);
}
/**
@ -505,7 +545,29 @@ export function commandWords(command) {
*/
export function runCommand(command, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const [program, ...args] = commandWords(command);
return spawnSync(program, args, { cwd, env: commandEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
return spawnSync(program, args, { cwd, env: commandEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer: MAX_BUFFER });
}
/**
* Why a spawnSync run has no exit code of its own, or null when it has one.
* spawnSync sets `error` AND `signal` together when it kills the program
* itself: ETIMEDOUT for the timeout, ENOBUFS for output past `maxBuffer`. So
* the error code is read first, and a bare `error` with no signal is a program
* that never started (not installed). Red proof called a timeout "could not
* start" and the release check called an overflow a timeout (release review of
* 6.3.0, round 7). `started` is false only for a program that never ran.
* @returns {{started: boolean, why: string}|null}
*/
export function stoppedBy(r, { timeoutMs = DEFAULT_TIMEOUT_MS, maxBuffer = MAX_BUFFER } = {}) {
const signal = r.signal ?? 'SIGTERM';
if (r.error?.code === 'ETIMEDOUT') return { started: true, why: `was killed (${signal}) after the ${Math.round(timeoutMs / 1000)} s timeout` };
if (r.error?.code === 'ENOBUFS') {
const size = maxBuffer >= 1024 * 1024 ? `${Math.round(maxBuffer / (1024 * 1024))} MB` : `${maxBuffer} bytes`;
return { started: true, why: `was killed (${signal}): its output passed the ${size} that is read from it` };
}
if (r.signal) return { started: true, why: `was killed (${r.signal})` };
if (r.error || r.status === null) return { started: false, why: `could not start: ${r.error?.message ?? 'no exit code'}` };
return null;
}
// --- running ----------------------------------------------------------------
@ -515,8 +577,9 @@ export function runCommand(command, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS } = {}
// One killed by the timeout did start, and a hang is a red (`failed`).
function defaultExec(command, { cwd, timeoutMs }) {
const r = runCommand(command, { cwd, timeoutMs });
if (r.status === null && r.signal === null && r.error) return { status: null, stdout: '', stderr: '', startError: r.error.message };
const why = r.signal ? `killed (${r.signal})\n` : '';
const stopped = stoppedBy(r, { timeoutMs });
if (stopped && !stopped.started) return { status: null, stdout: '', stderr: '', startError: r.error?.message ?? stopped.why };
const why = stopped ? `${stopped.why}\n` : '';
return { status: r.status, stdout: r.stdout ?? '', stderr: `${r.stderr ?? ''}${why}` };
}
@ -545,8 +608,9 @@ export function runCriteria(criteria, opts = {}) {
return criteria.map((c) => {
const base = { label: c.label, text: c.text, command: c.command };
if (!c.command) {
return { ...base, status: 'unrunnable', exitCode: null, output: `not runnable: ${c.reason || 'no command'}` };
const refused = refusal(c);
if (refused) {
return { ...base, status: 'unrunnable', exitCode: null, output: `not runnable: ${refused}` };
}
const permission = allow(c.command, { cwd });
if (!permission.allowed) {
@ -598,13 +662,14 @@ export function summarize(results, opts = {}) {
return summary;
}
/** The commands a plan's steps declare in `End-to-end:` (first backticked span each). */
/** The commands a plan's steps declare in `End-to-end:` (first backticked span each; a refused one is none). */
export function endToEndCommands(markdown) {
return sliceSteps(String(markdown ?? '').replace(/\r\n?/g, '\n'))
.map((s) => parseStepFields(s.body).get('End-to-end'))
.filter(Boolean)
.map((f) => firstCommand(f.text).command)
.filter(Boolean);
.map((f) => firstCommand(f.text))
.filter((c) => !refusal(c))
.map((c) => c.command);
}
const normalizeCommand = (c) => String(c).trim().replace(/\s+/g, ' ');
@ -849,6 +914,6 @@ export function main(argv) {
return rep.summary.ok ? 0 : 1;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (isMain(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}

View file

@ -20,12 +20,12 @@
// 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 { appendFileSync, existsSync, readFileSync } 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';
import { DEFAULT_TIMEOUT_MS, allowedCommand, runCommand, screenCommand, stoppedBy } from './criteria-runner.mjs';
import { repoRoot } from './red-proof.mjs';
import { isMain } from '../util/is-main.mjs';
export function doneLineRe(step, commit) {
const sha = commit ? String(commit).replace(/[^0-9a-f]/g, '') : '[0-9a-f]{40}';
@ -58,9 +58,9 @@ export function writeDoneLine({ cwd, step, cmd, report, timeoutMs = DEFAULT_TIME
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) {
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 };
}
// A stop is read once, by stoppedBy (round 8).
const stopped = stoppedBy(r, { timeoutMs });
if (stopped) return { status: 'not-run', reason: `the command ${stopped.why}`, 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`);
@ -100,9 +100,4 @@ export function main(argv) {
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));
if (isMain(import.meta.url)) process.exitCode = main(process.argv.slice(2));

View file

@ -32,9 +32,9 @@
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve, relative, dirname, isAbsolute, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { parseManifest } from '../parsers/manifest-yaml.mjs';
import { isMain } from '../util/is-main.mjs';
// Files Voyage's own process writes while planning and executing.
export const PROCESS_FILES = Object.freeze([
@ -174,15 +174,7 @@ export function sweep({ plan, base, repo = process.cwd(), process: extra = [] })
// ---- CLI --------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const argv = process.argv.slice(2);
const o = { process: [] };
let bad = null;

View file

@ -15,9 +15,9 @@
// run, and only the full run is authoritative.
//
// `--plan <plan.md> --step <N>` adds `tests_only` to a `not-red-at-base`: true
// when every file in the step's `Files:` is a test and so is every file the
// step changed since its start point, so the step can never be red and goes
// up to the plan (see `testsOnly` below).
// when every file in the step's `Files:` is a test, so the step can never be
// red and goes up to the plan. The plan's `Files:` alone decides (see
// `testsOnly` below).
//
// Exit 0 = proven, 1 = rejected (`not-red-at-base` or `not-green-at-result`),
// 2 = could not run (`not-run`: no test files, an unknown start point, a command
@ -55,16 +55,17 @@
// Borrowed: GitHub Spec Kit (MIT, github/spec-kit@2e941fe) - its test-first
// rule is an instruction there; here a command proves it.
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { allowedCommand, runCommand, screenCommand } from './criteria-runner.mjs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { DEFAULT_TIMEOUT_MS, allowedCommand, runCommand, screenCommand, stoppedBy } from './criteria-runner.mjs';
import { commandEnv } from '../util/command-env.mjs';
import { parseStepFields, sliceSteps, stepTestFile, stepTestsOnlyFromFields } from '../parsers/plan-schema.mjs';
import { parseStepFields, sliceSteps, stepTestsOnlyFromFields } from '../parsers/plan-schema.mjs';
import { isMain } from '../util/is-main.mjs';
export const DEFAULT_TIMEOUT_MS = 900_000;
// The one timeout, defined in criteria-runner.mjs (release review of 6.3.0, round 7).
export { DEFAULT_TIMEOUT_MS };
const TAIL = 2000;
function tap(output) {
@ -96,7 +97,8 @@ const LOAD_ERROR = /^\s*SyntaxError: |ERR_MODULE_NOT_FOUND|does not provide an e
function run(cmd, cwd, timeoutMs) {
const r = runCommand(cmd, { cwd, timeoutMs });
const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
return { exit: r.status, signal: r.signal ?? null, tap: tap(output), tail: output.slice(-TAIL), spawnError: r.error ? r.error.message : null, loadError: LOAD_ERROR.test(output) };
const stopped = stoppedBy(r, { timeoutMs });
return { exit: r.status, signal: r.signal ?? null, tap: tap(output), tail: output.slice(-TAIL), stopped: stopped?.why ?? null, loadError: LOAD_ERROR.test(output) };
}
/** What the red at the start point was: the test file failing to load, or a test failing. */
@ -112,42 +114,31 @@ const notRun = (reason, extra = {}) => ({ status: 'not-run', reason, ...extra })
// (voyage-lekerepo trial 3, 2026-10-09). `null` = the plan or the step could
// not be read; without `--plan` the field is absent and nothing new happens.
//
// The plan's `Files:` is prose, read by a reader that can misread it; the
// step's own diff is a fact. So a step the plan calls tests-only is so only
// when every file it changed since the start point is a test too (release
// review of 6.3.0, round 6). Before building (`--red-only`) the diff holds the
// step's test alone, so there the plan decides. The plan file itself and
// Voyage's own files under .claude/ (progress, approvals) are not the step's change.
function testsOnly(o, cwd, baseSha) {
// The plan's `Files:` alone decides (operator 2026-10-11): the step's diff is
// not read. In the documented flow the helper writes only the test before this
// gate, so the diff could never overrule the plan, and a change already in the
// tree when the step began (a dirty tree is allowed, trekexecute Sub-step 0)
// made a true tests-only step look like it built something (release review of
// 6.3.0, round 7). A path the step changed outside `Files:` is the
// controller's scope finding (trekexecute Sub-step C, Hard rule 2), not this check's.
function testsOnly(o) {
if (!o.plan) return {};
let text;
try { text = readFileSync(o.plan, 'utf8'); }
catch (e) { return { tests_only: null, tests_only_note: `cannot read the plan: ${e.message}` }; }
const step = sliceSteps(text).find((s) => Number(s.n) === Number(o.step));
if (!step) return { tests_only: null, tests_only_note: `no "### Step ${o.step}:" in the plan` };
const fields = parseStepFields(step.body);
if (!stepTestsOnlyFromFields(fields).testsOnly) return { tests_only: false };
const isTest = stepTestFile(fields);
const given = new Set(o.tests ?? []);
const changed = [
...lines(git(cwd, ['diff', '--name-only', '--no-renames', baseSha]).stdout),
...lines(git(cwd, ['ls-files', '--others', '--exclude-standard']).stdout),
];
const planRel = relative(cwd, realpathSync(o.plan)).split(sep).join('/');
const other = [...new Set(changed)].filter((p) => p !== planRel && !p.startsWith('.claude/') && !given.has(p) && !isTest(p));
if (other.length === 0) return { tests_only: true };
return { tests_only: false, tests_only_note: `the plan's Files: names only tests, but the step's diff also changes ${other.slice(0, 5).join(', ')}` };
return { tests_only: stepTestsOnlyFromFields(parseStepFields(step.body)).testsOnly };
}
// A program that could not start (a spawn error: not installed) or a signal
// (the timeout) is not a test verdict at all. With no shell, an exit 126/127
// A program that could not start (a spawn error: not installed), or one this
// run stopped (the timeout, output past the buffer, a signal), is not a test
// verdict at all (criteria-runner `stoppedBy`). With no shell, an exit 126/127
// is the test program's own (a script whose tool the step has not built yet):
// a red like any other, and the control run tells an environment fault apart
// (release review of 6.3.0, round 6).
function unrunnable(r) {
if (r.spawnError) return `the command could not start: ${r.spawnError}`;
if (r.exit === null) return `the command was killed (${r.signal ?? 'timeout'})`;
return null;
return r.stopped ? `the command ${r.stopped}` : null;
}
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
@ -241,7 +232,7 @@ export function proveRed(o) {
const baseBroken = unrunnable(base);
if (baseBroken) return notRun(`at the start point: ${baseBroken}`, { base, result, linked });
const out = { base_sha: baseSha, linked, base, result };
if (base.exit === 0) return { status: 'rejected', reason: 'not-red-at-base', ...testsOnly(o, cwd, baseSha), ...out };
if (base.exit === 0) return { status: 'rejected', reason: 'not-red-at-base', ...testsOnly(o), ...out };
if (o.redOnly) return { status: 'proven', reason: 'red at the start point (--red-only: the result was not run)', red_kind: redKind(base), base_sha: baseSha, linked, base };
if (result.exit !== 0) return { status: 'rejected', reason: 'not-green-at-result', ...out };
@ -249,6 +240,8 @@ export function proveRed(o) {
// copy itself cannot run the test green, so its red proved nothing.
applyResult(cwd, tree, baseSha);
const control = run(o.cmd, tree, timeoutMs);
const controlBroken = unrunnable(control);
if (controlBroken) return notRun(`in the control run: ${controlBroken}`, { ...out, control });
if (control.exit !== 0) {
return notRun('the control run (start-point copy + the change) stayed red: the start point failed for an environment reason, not for the missing change', { ...out, control });
}
@ -302,9 +295,4 @@ export function main(argv) {
return r.status === 'proven' ? 0 : r.status === 'rejected' ? 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));
if (isMain(import.meta.url)) process.exitCode = main(process.argv.slice(2));

View file

@ -34,11 +34,11 @@
// left K10 regressions running differently from Phase 7 criteria (release
// review of 6.3.0, round 5).
import { readFileSync, realpathSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { firstCommand, runCriteria } from './criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
export const REGRESSION_FIELD = 'Regression';
const EXIT = Object.freeze({ passed: 0, absent: 0, failed: 1, blocked: 1, unrunnable: 1, error: 2 });
@ -99,12 +99,4 @@ export function main(argv) {
return say(checkRegression({ planText, step, cwd }));
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) process.exitCode = main(process.argv.slice(2));
if (isMain(import.meta.url)) process.exitCode = main(process.argv.slice(2));

View file

@ -28,7 +28,7 @@
import { existsSync, readFileSync, statSync, realpathSync, accessSync, constants } from 'node:fs';
import { dirname, join, resolve, isAbsolute, extname, delimiter } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { isMain } from '../util/is-main.mjs';
// ---------------------------------------------------------------------------
// The repository
@ -248,15 +248,7 @@ export function runTool(tool, files, { cwd, timeout = 120_000, env = process.env
// CLI
// ---------------------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const i = process.argv.indexOf('--repo');
const start = i !== -1 ? process.argv[i + 1] : null;
if (!start) {

View file

@ -23,13 +23,13 @@
// Credit: the tiers and "not measured" are agent-skills' constraint-driven
// development (MIT, `9d0c60d`); the stop at step end is Cherny's and Kiro's.
import { existsSync, realpathSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { join, extname } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { resolveRepo, detectTools, runTool } from './repo-tools.mjs';
import { latestPostEditRecords } from '../../hooks/lib/post-edit.mjs';
import { runContract } from '../../hooks/lib/contract-cli.mjs';
import { isMain } from '../util/is-main.mjs';
// NUL-separated and unquoted, so a name with spaces or non-ASCII letters
// comes back as it is on disk (git quotes such names by default).
@ -105,15 +105,7 @@ export function stepCheck({ cwd, full = false }) {
return { status, root: repo.root, full, files, not_covered: notCovered, checks };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
const argv = process.argv.slice(2);
const i = argv.indexOf('--cwd');
const cwd = i !== -1 ? argv[i + 1] : null;

View file

@ -23,11 +23,11 @@
// constraint-driven development - mechanized here as a diff reader so a
// command, not the model's word, says whether a test was weakened.
import { readFileSync, realpathSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isTestPath } from '../util/test-path.mjs';
import { isMain } from '../util/is-main.mjs';
export { isTestPath };
@ -275,9 +275,4 @@ function out(r, json) {
return r.status === 'clean' ? 0 : r.status === 'findings' ? 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));
if (isMain(import.meta.url)) process.exitCode = main(process.argv.slice(2));

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "voyage",
"version": "6.3.0",
"version": "6.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voyage",
"version": "6.3.0",
"version": "6.3.1",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -1,6 +1,6 @@
{
"name": "voyage",
"version": "6.3.0",
"version": "6.3.1",
"private": true,
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, pencil-toggle annotation pattern): select text or click any heading/paragraph/list-item, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"type": "module",

View file

@ -26,6 +26,7 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
import { isMain } from '../lib/util/is-main.mjs';
function escapeHtml(s) {
return String(s)
@ -935,7 +936,7 @@ function render(inputPath, outputPath) {
return out;
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.input) {
process.stdout.write(

View file

@ -26,6 +26,7 @@
import { readFileSync, existsSync } from 'node:fs';
import { validateReviewerOutput } from '../lib/review/findings-schema.mjs';
import { isMain } from '../lib/util/is-main.mjs';
// Triplet dedup — byte-for-byte the same key + first-wins policy as
// scripts/trekreview-armB.workflow.mjs `dedupByTriplet`, so neither arm gets a
@ -72,7 +73,7 @@ export function mergeArmA(reviewerOutputs) {
// ---- CLI shim ----------------------------------------------------------------
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const argv = process.argv.slice(2);
const asJson = argv.includes('--json');
const files = argv.filter((a) => !a.startsWith('--'));

View file

@ -20,6 +20,7 @@
import { readFileSync } from 'node:fs';
import { fidelityDiffStructured } from '../lib/review/fidelity-diff.mjs';
import { isMain } from '../lib/util/is-main.mjs';
function median(nums) {
if (nums.length === 0) return null;
@ -89,7 +90,7 @@ export function analyze(armA, armB) {
// ---- CLI shim ----------------------------------------------------------------
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
const argv = process.argv.slice(2);
const asJson = argv.includes('--json');
function collect(flag) {

View file

@ -30,6 +30,7 @@ import { homedir } from 'node:os';
import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { isMain } from '../lib/util/is-main.mjs';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@ -336,6 +337,6 @@ export function main(argv) {
return result.green ? 0 : 1;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (isMain(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}

View file

@ -28,6 +28,7 @@ import { join, resolve, dirname, relative, sep, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { isMain } from '../lib/util/is-main.mjs';
export const REGISTRY_PATH = 'scripts/end-state-registry.json';
export const FROZEN_PATH = 'tests/fixtures/end-state-frozen.json';
@ -579,6 +580,6 @@ export function main(argv) {
return result.green ? 0 : 1;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (isMain(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}

View file

@ -5,6 +5,8 @@
// code, never a summary:
//
// node scripts/release-check.mjs --review <review.md> [--repo <dir>] [--version X.Y.Z] [--json]
// (a relative --review is read in --repo, as every other path, or from the
// current directory when only that one exists; two different files: exit 2)
// 0 = ready
// 1 = not ready — every failed check is listed
// 2 = could not run: not a git repo, no version to release, the suite could
@ -35,10 +37,12 @@
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, join, relative, resolve } from 'node:path';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { decideVerdict } from '../lib/review/verdict-gate.mjs';
import { commandEnv } from '../lib/util/command-env.mjs';
import { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs';
import { isMain } from '../lib/util/is-main.mjs';
const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@ -157,16 +161,18 @@ function checkTree(repo) {
}
// A suite that never ends is stopped, as every other check runner here stops
// its command (15 minutes), and read as not measured (release review of
// 6.3.0, round 6): the operator always gets an answer.
export const SUITE_TIMEOUT_MS = 900_000;
// its command (the one 15-minute timeout of criteria-runner.mjs), and read as
// not measured (release review of 6.3.0, round 6): the operator always gets
// an answer. What stopped it is said as it was: the timeout, output past the
// buffer, another signal, or a runner that never started (round 7).
const SUITE_MAX_BUFFER = 256 * 1024 * 1024;
function checkSuite(repo, treeClean, timeoutMs = SUITE_TIMEOUT_MS) {
function checkSuite(repo, treeClean, timeoutMs = DEFAULT_TIMEOUT_MS, maxBuffer = SUITE_MAX_BUFFER) {
if (!treeClean) return { id: 'suite', status: 'fail', detail: 'suite not run: the tree has uncommitted changes, so it would not measure HEAD' };
// commandEnv: under a parent `node --test` the suite would report to it and exit 0 whatever it found.
const r = spawnSync('npm', ['test'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, env: commandEnv(), timeout: timeoutMs });
if (r.signal) return { id: 'suite', status: 'error', detail: `npm test was killed (${r.signal}) after the ${Math.round(timeoutMs / 1000)} s timeout — not measured` };
if (r.error) return { id: 'suite', status: 'error', detail: `npm test could not start: ${r.error.message} — not measured` };
const r = spawnSync('npm', ['test'], { cwd: repo, encoding: 'utf8', maxBuffer, env: commandEnv(), timeout: timeoutMs });
const stopped = stoppedBy(r, { timeoutMs, maxBuffer });
if (stopped) return { id: 'suite', status: 'error', detail: `npm test ${stopped.why} — not measured` };
const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
const summary = out.split('\n').filter((l) => /^ℹ (tests|pass|fail|skipped) /.test(l)).map((l) => l.slice(2));
const tail = summary.length ? ` (${summary.join(', ')})` : '';
@ -174,6 +180,26 @@ function checkSuite(repo, treeClean, timeoutMs = SUITE_TIMEOUT_MS) {
return { id: 'suite', status: 'pass', detail: `npm test exited 0 at HEAD${tail}` };
}
/**
* Where a relative --review is: in --repo, like every other path here (round 7),
* or from the caller's directory, as 6.3.0 read it (round 8: the round-7 change
* broke a command that worked). The one that exists is taken; two different
* files are never guessed between. One file is one file by its real path: a
* --repo given through a symlink, run from the directory it points at, names
* the same review.md by two path strings (round 9).
* @returns {{path: string|null, error?: string}}
*/
function reviewPath(root, review) {
if (!review) return { path: null };
if (isAbsolute(review)) return { path: review };
const inRepo = resolve(root, review);
const fromHere = resolve(review);
if (inRepo === fromHere || !existsSync(fromHere)) return { path: inRepo };
if (!existsSync(inRepo)) return { path: fromHere };
if (realpathSync(inRepo) === realpathSync(fromHere)) return { path: inRepo };
return { path: null, error: `--review ${review} names two files: ${inRepo} (in --repo) and ${fromHere} (from here); pass an absolute path` };
}
function checkReview(reviewPath) {
if (!reviewPath) return { check: { id: 'review', status: 'error', detail: 'no --review given — the release has no review verdict to read (K7)' }, text: null };
if (!existsSync(reviewPath)) return { check: { id: 'review', status: 'error', detail: `${reviewPath} does not exist — no review, no release` }, text: null };
@ -269,7 +295,7 @@ function resolveVersion(repo, explicit) {
* Run every check. Pure of process exit; the CLI below prints and exits.
* @returns {{ exit: 0|1|2, version: string|null, repo: string, checks: object[], reason?: string }}
*/
export function releaseCheck({ repo, review, version: explicitVersion, suiteTimeoutMs }) {
export function releaseCheck({ repo, review, version: explicitVersion, suiteTimeoutMs, suiteMaxBuffer }) {
const top = git(repo, ['rev-parse', '--show-toplevel']);
if (top.error || top.status !== 0) return { exit: EXIT.UNDECIDED, version: null, repo, checks: [], reason: `${repo} is not a git repository` };
// The plugin's own directory, not the git top level: a plugin may sit in a
@ -281,13 +307,16 @@ export function releaseCheck({ repo, review, version: explicitVersion, suiteTime
// The cheap checks first: when one of them cannot be measured the answer is
// already exit 2, and the suite (minutes) is not run for nothing.
const tree = checkTree(root);
const { check: rev, text } = checkReview(review ? resolve(review) : null);
const where = reviewPath(root, review);
const { check: rev, text } = where.error
? { check: { id: 'review', status: 'error', detail: where.error }, text: null }
: checkReview(where.path);
const coverage = checkCoverage(root, text);
const door = checkDoor(root, version);
const undecided = [tree, rev, coverage, door].filter((c) => c.status === 'error');
const suite = undecided.length > 0
? { id: 'suite', status: 'skip', detail: `not run: ${undecided.map((c) => c.id).join(', ')} could not be measured, so the answer is exit 2 already` }
: checkSuite(root, tree.status === 'pass', suiteTimeoutMs);
: checkSuite(root, tree.status === 'pass', suiteTimeoutMs, suiteMaxBuffer);
const checks = [tree, suite, rev, coverage, door];
let exit = EXIT.READY;
@ -337,13 +366,7 @@ function parseArgs(argv) {
return out;
}
function isMain() {
try {
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch { return false; }
}
if (isMain()) {
if (isMain(import.meta.url)) {
// Anything that throws is "could not run" (2), never "not ready" (1): exit 1
// promises a list of failed checks, and none ran.
let result;

View file

@ -25,6 +25,7 @@
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { isMain } from '../lib/util/is-main.mjs';
// Pre-registered in docs/storm-measurement.md. Do not tune to fit a result.
export const ADOPT_THRESHOLD = 0.30;
@ -337,6 +338,6 @@ function mainCli() {
process.exit(0);
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
mainCli();
}

View file

@ -30,6 +30,7 @@
import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isMain } from '../lib/util/is-main.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
@ -292,6 +293,6 @@ function mainCli() {
process.exit(0);
}
if (import.meta.url === `file://${process.argv[1]}`) {
if (isMain(import.meta.url)) {
mainCli();
}

View file

@ -52,6 +52,7 @@ import { join, resolve, dirname, relative, sep, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { evaluateCheck, loadRegistry, TEST_TIMEOUT_MS } from './end-state-gate.mjs';
import { isMain } from '../lib/util/is-main.mjs';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@ -551,15 +552,6 @@ export function main(argv) {
return result.green ? 0 : 1;
}
// Run as a CLI? Compared as real paths, so a symlink or a space in the path still runs it.
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
if (isMain(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}

View file

@ -0,0 +1,36 @@
// tests/helpers/bin-path.mjs
// A PATH on which only the named programs are found: one directory of small
// wrappers, each starting the program from where the current PATH finds it.
// A test that needs a test runner to be absent ("could not start") then holds
// on every machine, also one where that runner is installed (release review
// of 6.3.0, round 9: two tests assumed `jest` is not on PATH).
import { accessSync, constants, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
function onPath(name) {
for (const dir of String(process.env.PATH ?? '').split(delimiter).filter(Boolean)) {
const p = join(dir, name);
try {
accessSync(p, constants.X_OK);
if (statSync(p).isFile()) return p;
} catch { /* not here */ }
}
return null;
}
/**
* @param {string[]} names - the programs the PATH finds (git, for the repo work)
* @returns {{path: string, cleanup: () => void}}
*/
export function pathWith(names) {
const dir = mkdtempSync(join(tmpdir(), 'voyage-bin-'));
for (const name of names) {
const found = onPath(name);
if (!found) throw new Error(`${name} is not on PATH`);
if (found.includes("'")) throw new Error(`${found}: a quote in the path`);
writeFileSync(join(dir, name), `#!/bin/sh\nexec '${found}' "$@"\n`, { mode: 0o755 });
}
return { path: dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
}

View file

@ -17,6 +17,7 @@ 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';
import { pathWith } from '../helpers/bin-path.mjs';
const CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
@ -24,8 +25,8 @@ 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 } });
function cli(repo, args, env = {}) {
const r = spawnSync(process.execPath, [CLI, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS, ...env } });
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}
@ -94,3 +95,57 @@ test('trekexecute: D1 marks the lock green through the command, a fix round mark
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; the PATH finds only git, so it cannot start on
// any machine, one with jest installed too (round 9).
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3', '--cmd', 'jest tests/step.test.mjs']).code, 0);
const bin = pathWith(['git']);
try {
const g = cli(repo, ['green', '--cmd', 'jest tests/step.test.mjs'], { PATH: bin.path });
assert.equal(g.code, 2, g.stderr);
assert.match(g.stderr, /could not start/, g.stderr);
} finally { bin.cleanup(); }
});
test('green (release review of 6.3.0, round 9 / e36a533e): the same words with other blanks between them are the step\'s command', () => {
// runCommand splits a command on any blank, so a double space or a tab from
// the plan span runs the very same words; the --cmd check compared strings.
for (const greenCmd of ['node --test tests/step.test.mjs', 'node\t--test tests/step.test.mjs', ` ${CMD}\t`]) {
const repo = lockedRepo('assert.equal(1, 1);');
const g = cli(repo, ['green', '--cmd', greenCmd]);
assert.equal(g.code, 0, `green ${JSON.stringify(greenCmd)}: ${g.stderr}`);
}
// The control: other words are still refused.
const repo = lockedRepo('assert.equal(1, 1);');
const g = cli(repo, ['green', '--cmd', 'node --test tests/other.test.mjs']);
assert.equal(g.code, 2, g.stderr);
assert.match(g.stderr, /not the step's test command/, g.stderr);
});

View file

@ -209,11 +209,12 @@ test('HANDOVER-CONTRACTS.md contains Handover 7 section (session-state)', () =>
});
test('review-validator has CLI shim', () => {
// Ordre 5: the realpath form (no-op-free under a symlink or a space); the run
// through a symlink is proven in tests/validators/review-validator.test.mjs.
// Ordre 5: the realpath form (no-op-free under a symlink or a space), shared
// since 6.3.1 (lib/util/is-main.mjs); the run through a symlink is proven in
// tests/validators/review-validator.test.mjs.
const text = read('lib/validators/review-validator.mjs');
assert.ok(
text.includes('realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))'),
text.includes('if (isMain(import.meta.url))'),
'lib/validators/review-validator.mjs should expose the realpath CLI shim so commands can call it from Bash',
);
});
@ -221,18 +222,18 @@ test('review-validator has CLI shim', () => {
test('session-state-validator has CLI shim', () => {
const text = read('lib/validators/session-state-validator.mjs');
assert.ok(
text.includes('import.meta.url === '),
text.includes('if (isMain(import.meta.url))'),
'lib/validators/session-state-validator.mjs should expose the standard CLI shim ' +
'(if (import.meta.url === `file://${process.argv[1]}`)) so /trekcontinue can call it from Bash',
'(if (isMain(import.meta.url))) so /trekcontinue can call it from Bash',
);
});
test('next-session-prompt-validator has CLI shim', () => {
const text = read('lib/validators/next-session-prompt-validator.mjs');
assert.ok(
text.includes('import.meta.url === '),
text.includes('if (isMain(import.meta.url))'),
'lib/validators/next-session-prompt-validator.mjs should expose the standard CLI shim ' +
'(if (import.meta.url === `file://${process.argv[1]}`)) so /trekcontinue Phase 1.5 can call it from Bash',
'(if (isMain(import.meta.url))) so /trekcontinue Phase 1.5 can call it from Bash',
);
});

View file

@ -4,14 +4,17 @@
// live next to their files (tests/commands/trekexecute-coupling.test.mjs,
// tests/hooks/guards-review-regressions.test.mjs, tests/scripts/release-check.test.mjs).
import { test } from 'node:test';
import { test, mock } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import fs from 'node:fs';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
import { pathWith } from '../helpers/bin-path.mjs';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import { stepTestsOnly } from '../../lib/parsers/plan-schema.mjs';
import { validateFindings } from '../../lib/review/findings-schema.mjs';
@ -640,7 +643,10 @@ test('review 630 r6 / plan-schema.mjs:318: a backticked file name with no extens
assert.deepEqual(stepTestsOnly(step('`tests/a.test.mjs` — covers `parse` and `JSON`')).files, ['tests/a.test.mjs'], 'a span inside a description is no path');
});
test('review 630 r6 / plan-schema.mjs:329 (altitude): red proof checks tests-only against the step\'s own diff', async () => {
// Round 6 had red proof overrule the plan with the step's diff; the operator
// reversed that on 2026-10-11 (round 7): the plan's Files: alone decides, and a
// plain Makefile beside a backticked test is now read from Files: itself.
test('review 630 r6 / plan-schema.mjs:329 (reversed in r7): a Makefile beside a backticked test is read from Files:, not from the diff', async () => {
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
const work = mkdtempSync(join(tmpdir(), 'r630-diff-'));
const repo = gitRepo('r630-diff-repo-');
@ -650,16 +656,14 @@ test('review 630 r6 / plan-schema.mjs:329 (altitude): red proof checks tests-onl
mkdirSync(join(repo, 'tests'));
writeFileSync(join(repo, 'tests', 'a.test.mjs'), "import { test } from 'node:test';\ntest('a', () => {});\n");
writeFileSync(join(repo, 'Makefile'), 'test:\n\tnode --test tests/\n');
// The prose reads only the test (a plain Makefile beside backticked paths is a description word).
const plan = join(work, 'plan.md');
writeFileSync(plan, '## Implementation Plan\n\n### Step 1: x\n\n- **Files:** `tests/a.test.mjs`, Makefile\n- **Changes:** x\n');
const o = { cwd: repo, base, cmd: 'node --test tests/a.test.mjs', tests: ['tests/a.test.mjs'], plan, step: 1 };
const r = proveRed(o);
assert.equal(r.reason, 'not-red-at-base', JSON.stringify(r));
assert.equal(r.tests_only, false, `the step's diff changes Makefile: ${JSON.stringify(r.tests_only_note ?? null)}`);
assert.match(r.tests_only_note ?? '', /Makefile/);
assert.equal(r.tests_only, false, 'Files: names Makefile');
rmSync(join(repo, 'Makefile'));
assert.equal(proveRed(o).tests_only, true, 'the control: a step whose diff holds only its test is tests-only');
assert.equal(proveRed(o).tests_only, false, 'the diff is not read: without Makefile in the tree the plan still names it');
} finally { rmSync(work, { recursive: true, force: true }); rmSync(repo, { recursive: true, force: true }); }
});
@ -704,3 +708,578 @@ test('review 630 r6 / hooks are ASCII: the lines this release added to pre-bash-
assert.ok(block.includes('Everything this guard would ask about'));
assert.doesNotMatch(block, /[^\x00-\x7f]/, 'a hook script is ASCII (~/.claude/CLAUDE.md, Miljø)');
});
// ==== round 7 of the release review (2026-10-11), fixed in 6.3.1; each red on 0f400d3 ====
test('review 630 r7 / 48939bcb d20c5619 (operator 2026-10-11): the plan\'s Files: alone decides tests-only, whatever else is in the tree', async () => {
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
const repo = gitRepo('r630-r7-files-');
try {
writeFileSync(join(repo, 'README.md'), 'x\n');
const base = commitAll(repo, 'base');
// Work the plan does not know about, there before the step began (trekexecute
// Sub-step 0 lets a step start on a dirty tree and logs it as a decision).
writeFileSync(join(repo, 'README.md'), 'edited before the step\n');
writeFileSync(join(repo, 'notes.txt'), 'untracked, not ignored\n');
// A plan placed outside .claude/, with the executor's files beside it (brief note 2).
mkdirSync(join(repo, 'plans', 'steps'), { recursive: true });
const plan = join(repo, 'plans', 'plan.md');
writeFileSync(plan, '## Implementation Plan\n\n### Step 1: x\n\n- **Files:** `tests/a.test.mjs`\n- **Changes:** x\n\n' +
'### Step 2: y\n\n- **Files:** `tests/a.test.mjs`, `src/a.mjs`\n- **Changes:** y\n');
writeFileSync(join(repo, 'plans', 'progress.json'), '{}\n');
writeFileSync(join(repo, 'plans', 'steps', 'step-1.report.md'), 'report\n');
// The helper wrote the step's test alone (step-implementer, mode build, step 1); it passes at the start point.
mkdirSync(join(repo, 'tests'));
writeFileSync(join(repo, 'tests', 'a.test.mjs'), "import { test } from 'node:test';\ntest('a', () => {});\n");
const o = { cwd: repo, base, cmd: 'node --test tests/a.test.mjs', tests: ['tests/a.test.mjs'], plan };
for (const redOnly of [true, false]) {
const one = proveRed({ ...o, step: 1, redOnly });
assert.equal(one.reason, 'not-red-at-base', JSON.stringify(one));
assert.equal(one.tests_only, true, `step 1's Files: names only its test (redOnly ${redOnly}): ${JSON.stringify(one.tests_only_note ?? null)}`);
const two = proveRed({ ...o, step: 2, redOnly });
assert.equal(two.reason, 'not-red-at-base', JSON.stringify(two));
assert.equal(two.tests_only, false, `step 2's Files: names src/a.mjs, though the tree holds only its test (redOnly ${redOnly})`);
}
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('review 630 r7 / 48939bcb (operator 2026-10-11): the text says plainly that the plan\'s Files: alone decides', () => {
for (const rel of ['commands/trekexecute.md', 'agents/step-implementer.md', 'lib/verification/red-proof.mjs', 'docs/HANDOVER-CONTRACTS.md']) {
const text = read(rel);
assert.match(text, /`Files:` alone decides/, `${rel} says that the plan's Files: alone decides`);
assert.doesNotMatch(text, /every file (?:the step|it) changed since (?:its|the) start point/, `${rel} no longer reads the step's diff for tests-only`);
}
const row = read('commands/trekexecute.md').split('\n').find((l) => l.startsWith('| 1, `not-red-at-base`, `tests_only: true`'));
assert.match(row, /`Files:` alone decides/, 'the routing row itself says it');
});
test('review 630 r7 / 0180a979: a file named without backticks beside a backticked path is read', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const [files, paths] of [
['`tests/x.test.mjs`, Dockerfile', ['tests/x.test.mjs', 'Dockerfile']],
['`tests/x.test.mjs`, the CLI entry src/cli.mjs', ['tests/x.test.mjs', 'src/cli.mjs']],
['`tests/x.test.mjs` and Makefile', ['tests/x.test.mjs', 'Makefile']],
['`tests/x.test.mjs` and `Makefile`', ['tests/x.test.mjs', 'Makefile']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
// The control: a description beside a backticked path is still no path.
for (const files of ['`tests/a.test.mjs` — covers `parse` and `JSON`', '`tests/a.test.mjs` — covers pass/fail, JSON, YAML',
'`tests/a.test.mjs` (v6.3.0 format)', '`tests/a.test.mjs`: Covers the Parser, e.g. its Profile']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs'] }, files);
}
});
test('review 630 r7 / 633546914: a command its timeout killed is reported as killed by the timeout, not as "could not start"', async () => {
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
const repo = gitRepo('r630-r7-hang-');
try {
writeFileSync(join(repo, 'README.md'), 'x\n');
const base = commitAll(repo, 'base');
mkdirSync(join(repo, 'tests'));
writeFileSync(join(repo, 'tests', 'hang.test.mjs'), "import { test } from 'node:test';\ntest('hangs', () => new Promise((r) => setTimeout(r, 6000)));\n");
const r = proveRed({ cwd: repo, base, cmd: 'node --test tests/hang.test.mjs', tests: ['tests/hang.test.mjs'], redOnly: true, timeoutMs: 2000 });
assert.equal(r.status, 'not-run', JSON.stringify(r));
assert.doesNotMatch(r.reason, /could not start/, r.reason);
assert.match(r.reason, /killed .*after the 2 s timeout/, r.reason);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('review 630 r7 / 5b2063e2: a trailing # comment is no part of a backticked check either, and never reaches the runner', async () => {
const { firstCommand, allowedCommand, runCommand } = await import('../../lib/verification/criteria-runner.mjs');
assert.equal(firstCommand('`npm test # full suite`').command, 'npm test');
assert.equal(firstCommand('`node --test tests/a.test.mjs #1` — expected: pass').command, 'node --test tests/a.test.mjs');
const plan = '## Implementation Plan\n\n### Step 1: x\n\n- **Files:** `src/a.mjs`\n- **Changes:** x\n' +
'- **Regression:** `npm test # full suite`\n';
const r = validatePlanContent(plan, { strict: false });
const codes = [...r.errors, ...r.warnings].map((i) => i.code);
assert.ok(!codes.includes('PLAN_STEP_REGRESSION_NOT_RUNNABLE'), JSON.stringify(codes));
// The executor passes the Verify span to red proof, the done line and the test lock as written.
assert.equal(allowedCommand('npm test # full suite', { cwd: ROOT }).allowed, true);
assert.equal(runCommand('node -e process.exit(process.argv.length-1) # two words', { cwd: ROOT }).status, 0, 'the comment\'s words reach no program');
assert.equal(firstCommand('`node --test tests/a#b.test.mjs`').command, 'node --test tests/a#b.test.mjs', 'the control: a # inside a word is no comment');
});
test('review 630 r7 / aa08f47f: the 15-minute timeout is defined once', async () => {
const files = spawnSync('git', ['ls-files', 'lib', 'scripts', 'hooks'], { cwd: ROOT, encoding: 'utf8' }).stdout.split('\n').filter((p) => p.endsWith('.mjs'));
assert.ok(files.length > 50, `listed ${files.length} files`);
assert.deepEqual(files.filter((p) => /\b900_?000\b/.test(read(p))), ['lib/verification/criteria-runner.mjs']);
const rp = await import('../../lib/verification/red-proof.mjs');
const cr = await import('../../lib/verification/criteria-runner.mjs');
assert.equal(rp.DEFAULT_TIMEOUT_MS, cr.DEFAULT_TIMEOUT_MS);
});
test('review 630 r7 / efa7de84: a file cited many times is read once per validation', () => {
const cite = 'commands/trekexecute.md:1';
const findings = Array.from({ length: 40 }, (_, i) => ({
severity: 'MINOR', rule_key: 'MISSING_TEST', file: 'commands/trekexecute.md', line: 1 + i,
title: `see ${cite} and ${cite}0`, detail: `${cite}2`, recommended_action: 'Add a test.',
}));
let reads = 0;
const real = fs.readFileSync;
mock.method(fs, 'readFileSync', function (p, ...rest) {
if (String(p).endsWith(`${'/'}commands/trekexecute.md`)) reads++;
return real.call(this, p, ...rest);
});
syncBuiltinESMExports();
let r;
try { r = validateFindings({ reviewer: 'r', findings }, { repoRoot: ROOT }); }
finally { mock.restoreAll(); syncBuiltinESMExports(); }
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(reads, 1, `commands/trekexecute.md was read ${reads} times for one validation`);
});
test('review 630 r7 / d5ab7698: one isMain helper, and a CLI started through a symlinked path with a space does its work', () => {
const files = spawnSync('git', ['ls-files', 'lib', 'scripts', 'hooks'], { cwd: ROOT, encoding: 'utf8' }).stdout.split('\n').filter((p) => p.endsWith('.mjs'));
const local = files.filter((p) => p !== 'lib/util/is-main.mjs' &&
/function isMain\b|import\.meta\.url === `file:\/\/\$\{process\.argv\[1\]\}`/.test(read(p)));
assert.deepEqual(local, [], 'every CLI uses lib/util/is-main.mjs');
const base = mkdtempSync(join(tmpdir(), 'r630 r7 main '));
const link = join(base, 'voyage link');
try {
symlinkSync(ROOT, link);
// The first had the file:// guard (a silent exit 0 here), the second the realpath copy.
for (const cli of ['lib/validators/session-state-validator.mjs', 'lib/validators/plan-validator.mjs']) {
const r = spawnSync(process.execPath, [join(link, cli)], { encoding: 'utf8', input: '', timeout: 30_000 });
assert.equal(r.status, 2, `${cli} through "${link}": ${r.stdout}${r.stderr}`);
assert.match(r.stderr, /usage/i);
}
} finally { fs.rmSync(link, { force: true }); rmSync(base, { recursive: true, force: true }); }
});
test('review 630 r7 / 4f8a69e7: the guard writes its no-one-to-ask refusal once, on a direct condition', () => {
const src = read('hooks/scripts/pre-bash-executor.mjs');
assert.doesNotMatch(src, /const unattended = /);
assert.equal(src.split('permission mode where Claude Code asks nothing').length - 1, 1, 'one refusal text for both cases');
});
test('review 630 r7 / findings-schema.mjs:41: a triple backtick inside a JSON string does not end the block', async () => {
const { extractFindingsBlock } = await import('../../lib/review/findings-schema.mjs');
const { fillFindingIds } = await import('../../lib/review/fill-finding-ids.mjs');
const payload = { verdict: 'WARN', findings: [{ id: '@@ID_1@@', severity: 'MAJOR', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1,
title: 'x', detail: 'write the check as ```bash\nnpm test\n``` and run it', recommended_action: 'Add a test.' }] };
const text = `# Review\n\nProse with a \`\`\`json fence quoted in it.\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\`\n`;
const block = extractFindingsBlock(text);
assert.match(block, /"recommended_action": "Add a test\."\s*\}\s*\]\s*\}\s*$/, `the block runs to its own closing fence, not to the backticks in a string: …${block.slice(-60)}`);
assert.deepEqual(JSON.parse(block), payload);
const r = fillFindingIds(text);
assert.equal(r.ok, true, JSON.stringify(r.errors));
assert.doesNotMatch(r.text, /@@ID_1@@/);
});
test('review 630 r7 / brief note 3: the prose says a git revision may start with @, as the screen allows', async () => {
const { allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
assert.equal(allowedCommand('git show @~1 --stat', { cwd: ROOT }).allowed, true);
assert.equal(allowedCommand('pytest @args.txt', { cwd: ROOT }).allowed, false, 'the control: a runner word with a leading @');
for (const rel of ['agents/brief-reviewer.md', 'commands/trekplan.md']) {
assert.match(read(rel), /`@~1`/, `${rel} names the git revision as the exception`);
}
});
// ==== round 8 of the release review of 6.3.0 (0f400d3..29b4ac9, 2026-10-11), fixed in 6.3.1 ====
test('review 630 r8 / BLOCKER criteria-runner.mjs:158: a Verify comment that a shell would read inside double quotes is refused', () => {
// The executor and the helper hand the Verify span to red proof, the test lock
// and the done line as `--cmd "{Verify command}"`: inside double quotes a `$`,
// a backtick or a backslash still means something, and a `"` ends the argument.
const verify = (span) => buildPlan().replace('`node --test tests/mod1.test.mjs` → expected: pass', () => `\`${span}\` → expected: pass`);
const codes = (span) => {
const r = validatePlanContent(verify(span), { strict: false });
return [...r.errors, ...r.warnings].filter((i) => i.code === 'PLAN_STEP_VERIFY_NOT_RUNNABLE').map((i) => i.message);
};
for (const span of ['node --test tests/mod1.test.mjs # $(touch x)', 'node --test tests/mod1.test.mjs # expect "fail 1"',
'node --test tests/mod1.test.mjs # a back\\slash', 'node --test tests/mod1.test.mjs # ${HOME}']) {
assert.equal(codes(span).length, 1, `${span} is refused: ${JSON.stringify(codes(span))}`);
}
// The control: a plain comment is still dropped, and the check stays runnable.
assert.deepEqual(codes('node --test tests/mod1.test.mjs # all green'), []);
assert.deepEqual(codes("node --test tests/mod1.test.mjs # it's green"), [], 'a single quote means nothing inside double quotes');
});
test('review 630 r8 / criteria-runner.mjs:158: a span that is only a comment is not-a-command, not a placeholder', async () => {
const { firstCommand } = await import('../../lib/verification/criteria-runner.mjs');
assert.equal(firstCommand('` # fill in`').reason, 'not-a-command');
assert.equal(firstCommand('`{exact command}`').reason, 'placeholder', 'the control');
});
test('review 630 r8 / criteria-runner.mjs:175: a # inside a word is kept by dropComment and refused by the allowlist, and the text says so', async () => {
const { firstCommand, allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
assert.equal(firstCommand('`node --test tests/a#b.test.mjs`').command, 'node --test tests/a#b.test.mjs');
assert.equal(allowedCommand('node --test tests/a#b.test.mjs', { cwd: ROOT }).allowed, false);
const doc = /\/\*\*\n((?: \*[^\n]*\n)+) \*\/\nexport function dropComment/.exec(read('lib/verification/criteria-runner.mjs'));
assert.ok(doc, 'dropComment has its doc comment');
assert.match(doc[1].replace(/\n \* ?/g, ' '), /a#b\.test\.mjs`?[^.]*\bkept\b[^.]*\ballowlist\b[^.]*\brefuses\b/,
'the doc says the allowlist still refuses the word');
});
test('review 630 r8 / criteria-runner.mjs:890: no CLI compares process.argv[1] itself, and the three that did work through a symlinked path with a space', () => {
const files = spawnSync('git', ['ls-files', 'lib', 'scripts', 'hooks'], { cwd: ROOT, encoding: 'utf8' }).stdout.split('\n').filter((p) => p.endsWith('.mjs'));
assert.deepEqual(files.filter((p) => p !== 'lib/util/is-main.mjs' && /process\.argv\[1\]/.test(read(p))), [],
'only lib/util/is-main.mjs reads process.argv[1]');
const base = mkdtempSync(join(tmpdir(), 'r630 r8 main '));
const link = join(base, 'voyage link');
try {
symlinkSync(ROOT, link);
for (const cli of ['lib/verification/criteria-runner.mjs', 'scripts/comparison-gate.mjs', 'scripts/end-state-gate.mjs']) {
const r = spawnSync(process.execPath, [join(link, cli), '--no-such-flag'], { encoding: 'utf8', input: '', timeout: 30_000 });
assert.equal(r.status, 2, `${cli} through "${link}": ${r.stdout}${r.stderr}`);
assert.match(r.stderr, /unknown argument --no-such-flag/);
}
} finally { fs.rmSync(link, { force: true }); rmSync(base, { recursive: true, force: true }); }
});
test('review 630 r8 / plan-schema.mjs:353 + :322: a description beside a backticked path names no file; a build file in its own entry does', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
// A description after a dash, or a word inside a sentence, is no path (code-review, code-correctness).
for (const files of ['`tests/lib/x.test.mjs` — mocks fs.readFileSync', '`tests/foo.test.mjs` — covers lib/foo.mjs',
'`tests/a.test.mjs`, runs on Node.js 22.x', '`tests/a.test.mjs` — exercises the parser in index.js',
'`tests/a.test.mjs`, no change to package.json', '`tests/a.test.mjs` - asserts JSON.parse behaviour',
'`tests/a.test.mjs`: covers src/a.mjs and Makefile']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs', 'tests/foo.test.mjs', 'tests/lib/x.test.mjs'].filter((t) => files.startsWith(`\`${t}\``)) }, files);
}
// A file in its own entry, after "and", or a path with a slash before any description, still counts (round 7).
for (const [files, paths] of [
['`tests/a.test.mjs`, makefile', ['tests/a.test.mjs', 'makefile']],
['`tests/a.test.mjs`, justfile', ['tests/a.test.mjs', 'justfile']],
['`tests/a.test.mjs`, package.json', ['tests/a.test.mjs', 'package.json']],
['`tests/a.test.mjs` and package.json', ['tests/a.test.mjs', 'package.json']],
['`tests/a.test.mjs`, the CLI entry src/cli.mjs — reads argv', ['tests/a.test.mjs', 'src/cli.mjs']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
});
test('review 630 r8 / done-line.mjs:61 + test-lock.mjs:183: a stopped command is read by stoppedBy, the one reading', async () => {
const { writeDoneLine } = await import('../../lib/verification/done-line.mjs');
const repo = gitRepo('r630-r8-done-');
try {
writeFileSync(join(repo, 'README.md'), 'x\n');
commitAll(repo, 'base');
mkdirSync(join(repo, 'tests'));
writeFileSync(join(repo, 'tests', 'hang.test.mjs'), "import { test } from 'node:test';\ntest('hangs', () => new Promise((r) => setTimeout(r, 6000)));\n");
const r = writeDoneLine({ cwd: repo, step: 1, cmd: 'node --test tests/hang.test.mjs', report: join(repo, 'report.md'), timeoutMs: 2000 });
assert.equal(r.status, 'not-run', JSON.stringify(r));
assert.match(r.reason, /killed .*after the 2 s timeout/, r.reason);
} finally { rmSync(repo, { recursive: true, force: true }); }
for (const rel of ['lib/verification/done-line.mjs', 'lib/util/test-lock.mjs', 'lib/verification/red-proof.mjs']) {
const src = read(rel);
assert.match(src, /\bstoppedBy\(/, `${rel} reads a stop with stoppedBy`);
assert.doesNotMatch(src, /r\.error \|\| r\.status === null/, `${rel} does not read the spawn result by hand`);
}
});
test('review 630 r8 / red-proof.mjs:101: a run reports one reading of a stop, not a second spawnError beside it', () => {
const repo = gitRepo('r630-r8-spawn-');
// `jest` is on the allowlist; the PATH finds only git, so it cannot start on
// any machine, one with jest installed too (round 9, brief note 4).
const bin = pathWith(['git']);
try {
writeFileSync(join(repo, 'README.md'), 'x\n');
const base = commitAll(repo, 'base');
mkdirSync(join(repo, 'tests'));
writeFileSync(join(repo, 'tests', 'a.test.mjs'), 'x\n');
const out = spawnSync(process.execPath, [join(ROOT, 'lib', 'verification', 'red-proof.mjs'), '--cwd', repo, '--base', base,
'--cmd', 'jest tests/a.test.mjs', '--tests', 'tests/a.test.mjs', '--red-only', '--json'],
{ encoding: 'utf8', env: { ...process.env, PATH: bin.path }, timeout: 60_000 });
const r = JSON.parse(out.stdout);
assert.equal(r.status, 'not-run', JSON.stringify(r));
assert.match(r.reason, /could not start/, r.reason);
const runs = [r.base, r.result].filter(Boolean);
assert.ok(runs.length > 0, JSON.stringify(r));
for (const run of runs) assert.ok(!('spawnError' in run), `no spawnError beside stopped: ${JSON.stringify(run)}`);
} finally { bin.cleanup(); rmSync(repo, { recursive: true, force: true }); }
});
test('review 630 r8 / findings-schema.mjs:46: the block reader and the verdict gate agree on what a fence is', async () => {
const { extractFindingsBlock } = await import('../../lib/review/findings-schema.mjs');
const json = '{\n "findings": []\n}';
// Markdown: a closing fence may be longer than the opening one.
assert.equal(extractFindingsBlock(`x\n\n\`\`\`json\n${json}\n\`\`\`\`\n`), `${json}\n`, 'a ```` closer ends the block');
// Not a fence in Markdown, and not in the verdict gate either: three backticks
// after text on the same line.
assert.equal(extractFindingsBlock(`\`\`\`json\n${json}\`\`\`\n`), null, 'a closer after JSON on its line');
assert.equal(extractFindingsBlock(`see \`\`\`json\n${json}\n\`\`\`\n`), null, 'an opener after prose on its line');
const { decideVerdict } = await import('../../lib/review/verdict-gate.mjs');
const fm = ['---', 'type: trekreview', 'review_version: "1.1"', 'created: 2026-10-11', 'task: "t"', 'slug: s',
'project_dir: p/', 'brief_path: p/brief.md', `scope_sha_start: ${'b'.repeat(40)}`, `scope_sha_end: ${'c'.repeat(40)}`,
'reviewed_files_count: 1', 'verdict: WARN', 'findings: []', '---', ''].join('\n');
const payload = JSON.stringify({ verdict: 'WARN', counts: { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }, allow_blocked_by: [], findings: [] }, null, 2);
for (const closer of ['```', '````']) {
const v = decideVerdict(`${fm}\n# R\n\n## Executive Summary\n\nx\n\n## Coverage\n\nx\n\n## Declined to Judge\n\nNone.\n\nTrimmed: 0\n\n` +
`## Remediation Summary\n\nx\n\n\`\`\`json\n${payload}\n${closer}\n`);
assert.equal(v.exit, 0, `closer ${closer}: ${v.reason}`);
}
});
test('review 630 r8 / review-validator.mjs:36 + synthesis-digest-schema.mjs:59: three backticks in a JSON string do not end the block there either', async () => {
const { validateReviewContent } = await import('../../lib/validators/review-validator.mjs');
const { extractDigestBlock } = await import('../../lib/plan/synthesis-digest-schema.mjs');
const id = 'a'.repeat(40);
const payload = { verdict: 'BLOCK', counts: { BLOCKER: 1, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }, allow_blocked_by: [],
findings: [{ id, severity: 'BLOCKER', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 'x',
detail: 'write the check as ```bash\nnpm test\n``` and run it', recommended_action: 'Add a test.' }] };
const review = ['---', 'type: trekreview', 'review_version: "1.1"', 'created: 2026-10-11', 'task: "t"', 'slug: s',
'project_dir: p/', 'brief_path: p/brief.md', 'scope_sha_start: ' + 'b'.repeat(40), 'scope_sha_end: ' + 'c'.repeat(40),
'reviewed_files_count: 1', 'verdict: BLOCK', 'findings:', ` - ${id}`, '---', '', '# Review', '',
'## Executive Summary', '', 'x', '', '## Coverage', '', 'x', '', '## Declined to Judge', '', 'None.', '', 'Trimmed: 0', '',
'## Remediation Summary', '', 'x', '', '```json', JSON.stringify(payload, null, 2), '```', ''].join('\n');
const r = validateReviewContent(review);
assert.ok([...r.errors, ...r.warnings].some((i) => i.code === 'REVIEW_MISSING_REMEDIATION_STEP'),
`a BLOCKER with no ### Step R<n> is found although its detail holds a fence: ${JSON.stringify(r.errors.map((e) => e.code))}`);
const digest = '```json\n{"note": "a ```bash fence"}\n```\n';
assert.equal(extractDigestBlock(digest), '{"note": "a ```bash fence"}\n');
});
// ==== round 9 of the release review of 6.3.0 (0f400d3..a10018f, 2026-10-11), fixed in 6.3.1 ====
test('review 630 r9 / f8087ca4 criteria-runner.mjs:172: a check refused for its comment stays a command, refused out loud', async () => {
const { parsePlanVerification, runCriteria } = await import('../../lib/verification/criteria-runner.mjs');
const { validateBriefContent } = await import('../../lib/validators/brief-validator.mjs');
const nestedPlan = (comment) => buildPlan().replace('- [ ] `npm test` → expected: exit 0', () =>
`- [ ] \`npm test\` → expected: exit 0\n - [ ] \`node --test tests/e2e.test.mjs # ${comment}\` → expected: exit 0`);
// (1) A nested ## Verification bullet under a parent with its own command is
// a criterion of its own, never a sub-point, and it is NOT RUN with the reason.
const plan = nestedPlan('expect "3 pass"');
const nested = parsePlanVerification(plan).find((c) => c.text.includes('tests/e2e.test.mjs'));
assert.ok(nested, `the nested check is a criterion: ${JSON.stringify(parsePlanVerification(plan))}`);
const ran = [];
const [r] = runCriteria([nested], {
exec: (c) => { ran.push(c); return { status: 0, stdout: '', stderr: '' }; },
screen: () => ({ allowed: true, rule: '' }),
});
assert.equal(r.status, 'unrunnable', JSON.stringify(r));
assert.match(r.output, /trailing comment/, r.output);
assert.deepEqual(ran, [], 'a refused check never runs, its comment dropped or not');
const issues = (p) => { const v = validatePlanContent(p, { strict: false }); return [...v.errors, ...v.warnings]; };
assert.ok(issues(plan).some((i) => i.code === 'PLAN_VERIFICATION_NOT_RUNNABLE' && /tests\/e2e\.test\.mjs/.test(i.message)),
JSON.stringify(issues(plan).map((i) => i.code)));
// The control: a plain comment is still dropped, and the nested check still runs.
assert.ok(!issues(nestedPlan('all green')).some((i) => i.code === 'PLAN_VERIFICATION_NOT_RUNNABLE'));
// (2) A brief SC with such a comment is BRIEF_SC_NOT_RUNNABLE, not prose-only.
const brief = (sc) => [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
'## Success Criteria', '', sc, '',
].join('\n');
const notRunnable = (sc) => validateBriefContent(brief(sc)).warnings.filter((w) => w.code === 'BRIEF_SC_NOT_RUNNABLE');
assert.equal(notRunnable('- `npm test # prints "ok"` exits 0.').length, 1);
assert.equal(notRunnable('- `npm test # prints ok` exits 0.').length, 0, 'the control');
});
// One review.md: a clean WARN block, then `tail` (what the last block is).
const B3 = '`'.repeat(3);
const B4 = '`'.repeat(4);
function r9Review(tail, { eol = '\n' } = {}) {
const fm = ['---', 'type: trekreview', 'review_version: "1.1"', 'created: 2026-10-11', 'task: "t"', 'slug: s',
'project_dir: p/', 'brief_path: p/brief.md', `scope_sha_start: ${'b'.repeat(40)}`, `scope_sha_end: ${'c'.repeat(40)}`,
'reviewed_files_count: 1', 'verdict: WARN', 'findings: []', '---', ''].join('\n');
const body = '# R\n\n## Executive Summary\n\nx\n\n## Coverage\n\nx\n\n## Declined to Judge\n\nNone.\n\nTrimmed: 0\n\n## Remediation Summary\n\nx\n\n';
return `${fm}${body}${tail}`.replace(/\n/g, eol);
}
const R9_WARN = JSON.stringify({ verdict: 'WARN', counts: { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }, allow_blocked_by: [], findings: [] }, null, 2);
const R9_BLOCK = JSON.stringify({ verdict: 'BLOCK', counts: { BLOCKER: 1, MAJOR: 0, MINOR: 0, SUGGESTION: 0 }, allow_blocked_by: [],
findings: [{ id: 'a'.repeat(40), severity: 'BLOCKER', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1 }] }, null, 2);
const r9Clean = `${B3}json\n${R9_WARN}\n${B3}\n`;
test('review 630 r9 / 5fb0d492 verdict-gate.mjs:123: the last fence\'s language and body come from one reading', async () => {
const { decideVerdict } = await import('../../lib/review/verdict-gate.mjs');
// The controls: a clean review passes, LF and CRLF, indented or not.
for (const [name, text] of [['LF', r9Review(r9Clean)], ['CRLF', r9Review(r9Clean, { eol: '\r\n' })],
['indented', r9Review(` ${B3}json\n ${R9_WARN.replace(/\n/g, '\n ')}\n ${B3}\n`)]]) {
assert.equal(decideVerdict(text).exit, 0, `${name}: ${decideVerdict(text).reason}`);
}
// A last block whose info string is not exactly `json` held a BLOCKER the
// block reader skipped: the gate read the earlier clean block and exited 0.
for (const opener of [`${B3}json {x}`, `${B3} json`, `${B3}json\t{"x": 1}`]) {
for (const eol of ['\n', '\r\n']) {
for (const indent of ['', ' ']) {
const tail = `${r9Clean}\n${indent}${opener}\n${R9_BLOCK}\n${indent}${B3}\n`;
const v = decideVerdict(r9Review(tail, { eol }));
assert.equal(v.exit, 2, `${JSON.stringify(indent + opener)} (${JSON.stringify(eol)}): ${v.reason}`);
assert.match(v.reason, /last fenced block/, v.reason);
}
}
}
// An indented last block is read as the last block: its BLOCKER is seen.
const indented = `${r9Clean}\n ${B3}json\n ${R9_BLOCK.replace(/\n/g, '\n ')}\n ${B3}\n`;
assert.equal(decideVerdict(r9Review(indented)).exit, 2, decideVerdict(r9Review(indented)).reason);
// A last block that is never closed runs to the end of the file, as in Markdown.
const unclosed = decideVerdict(r9Review(`${r9Clean}\n${B3}json\n${R9_BLOCK}\n`));
assert.equal(unclosed.exit, 2, unclosed.reason);
assert.match(unclosed.reason, /never closed/, unclosed.reason);
});
test('review 630 r9 / 2eb4b921 verdict-gate.mjs:123: a fence is closed only by a run at least as long as its opener', async () => {
const { decideVerdict } = await import('../../lib/review/verdict-gate.mjs');
const { extractFindingsBlock } = await import('../../lib/review/findings-schema.mjs');
const example = `${B4}markdown\n${B3}json\n{"example": true}\n${B3}\n${B4}\n`;
// A four-backtick example of a json block, then the review's own block: valid.
const before = r9Review(`${example}\n${r9Clean}`);
assert.equal(decideVerdict(before).exit, 0, decideVerdict(before).reason);
assert.equal(extractFindingsBlock(before), `${R9_WARN}\n`);
// The same example after it: the last block is the markdown block, and the
// block reader does not take the example inside it.
const after = r9Review(`${r9Clean}\n${example}`);
const v = decideVerdict(after);
assert.equal(v.exit, 2, v.reason);
assert.match(v.reason, /"markdown"/, v.reason);
assert.equal(extractFindingsBlock(after), `${R9_WARN}\n`, 'the review\'s own block, not the example');
// A four-backtick json block is one block; three backticks inside do not end it.
assert.equal(extractFindingsBlock(`${B4}json\n{"a": 1}\n${B3}\n${B4}\n`), `{"a": 1}\n${B3}\n`);
});
test('review 630 r9 / c0465ecd plan-schema.mjs:350: a description runs on past a comma and onto the next line', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const files of ['`tests/a.test.mjs` — mocks fs, child_process.spawnSync and path.join',
'`tests/a.test.mjs` — covers parse, Node.js 22 only',
'`tests/a.test.mjs` — mocks fs and\n child_process.spawnSync, path.join']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs'] }, files);
}
// A new entry starts with a backticked path, or on a new list line.
for (const [files, paths] of [
['`tests/a.test.mjs` — new test, `src/a.mjs` — the fix', ['tests/a.test.mjs', 'src/a.mjs']],
['\n - `tests/a.test.mjs` — new test, mocks fs\n - src/a.mjs — the fix', ['tests/a.test.mjs', 'src/a.mjs']],
['\n - `tests/a.test.mjs` — new test\n - package.json', ['tests/a.test.mjs', 'package.json']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
});
test('review 630 r9 / 7b3750e9 plan-schema.mjs:372: a plain path that ends with a colon starts its description', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const [files, paths] of [
['`tests/a.test.mjs`, src/cli.mjs: wires parse and README.md', ['tests/a.test.mjs', 'src/cli.mjs']],
['`tests/a.test.mjs`, Makefile: a target and package.json', ['tests/a.test.mjs', 'Makefile']],
]) {
assert.deepEqual(stepTestsOnly(step(files)).files, paths, files);
}
// The control: a word that is no path, with a colon, is a label, not a description.
assert.deepEqual(stepTestsOnly(step('`tests/a.test.mjs`, note: Makefile and package.json')).files, ['tests/a.test.mjs', 'package.json']);
});
test('review 630 r9 / brief note 3: red proof reports a control run it stopped as not-run, in the control run', async () => {
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
const repo = gitRepo('r630-r9-control-');
try {
writeFileSync(join(repo, 'README.md'), 'x\n');
const base = commitAll(repo, 'base');
mkdirSync(join(repo, 'tests'));
mkdirSync(join(repo, 'src'));
// Red at the start point (no src/impl.mjs), green in the working tree, and
// it hangs only in the control: the start-point copy with the change laid on.
writeFileSync(join(repo, 'tests', 'a.test.mjs'), [
"import { test } from 'node:test';",
"import { existsSync } from 'node:fs';",
"test('impl', async () => {",
" if (!existsSync('src/impl.mjs')) throw new Error('no impl');",
" if (process.cwd().includes('voyage-red-proof-')) await new Promise((r) => setTimeout(r, 6000));",
'});', ''].join('\n'));
writeFileSync(join(repo, 'src', 'impl.mjs'), 'export const x = 1;\n');
const r = proveRed({ cwd: repo, base, cmd: 'node --test tests/a.test.mjs', tests: ['tests/a.test.mjs'], timeoutMs: 2000 });
assert.equal(r.status, 'not-run', JSON.stringify(r));
assert.match(r.reason, /^in the control run: the command was killed .*after the 2 s timeout/, r.reason);
assert.equal(r.base.exit, 1, 'red at the start point');
assert.equal(r.result.exit, 0, 'green at the result');
assert.ok(r.control, 'the control run is reported');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('review 630 r9 / brief note 8: the Files: reader states one count of plan files, the one measured', () => {
const doc = /\/\*\*\n((?: \*[^\n]*\n)+) \*\/\nconst SEG = /.exec(read('lib/parsers/plan-schema.mjs'));
assert.ok(doc, 'fieldPaths has its doc comment');
const text = doc[1].replace(/\n \* ?/g, ' ');
assert.match(text, /520 Files: fields in 48 plan files at rounds 7-8/);
assert.match(text, /560 fields in 48 plan files at round 9/);
assert.doesNotMatch(text.replace(/round 7 wrote "182 plans" here/, ''), /182/, 'the round-7 count is named only as the one replaced');
});
// ==== round 10 of the release review of 6.3.0 (0f400d3..a1e0e50, 2026-10-11), fixed in 6.3.1 ====
test('review 630 r10 / ecd9da6f plan-schema.mjs:363: a new line that opens with a plain file starts a new entry', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const [files, paths] of [
['`tests/a.test.mjs`: the test\n src/cli.mjs', ['tests/a.test.mjs', 'src/cli.mjs']],
['`tests/a.test.mjs` — the test\n src/cli.mjs: the fix', ['tests/a.test.mjs', 'src/cli.mjs']],
['`tests/a.test.mjs` — the test\n src/a.mjs, src/b.mjs', ['tests/a.test.mjs', 'src/a.mjs', 'src/b.mjs']],
['`tests/a.test.mjs` — the test\n package.json', ['tests/a.test.mjs', 'package.json']],
['`tests/a.test.mjs` — the test\n Makefile — a target', ['tests/a.test.mjs', 'Makefile']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
// The control: a description that runs onto the next line names no file
// there (round 9), unless the line opens with one in an entry's own shape.
for (const files of ['`tests/a.test.mjs` — mocks fs and\n child_process.spawnSync, path.join',
'`tests/a.test.mjs` — runs on\n Node.js 22 only', '`tests/a.test.mjs` — covers\n index.js and the CLI']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs'] }, files);
}
});
test('review 630 r10 / b4215783 plan-schema.mjs:365: a numbered list item is an entry, `1.` or `1)`', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const [files, paths] of [
['`tests/a.test.mjs`\n 1. package.json\n 2. src/b.mjs', ['tests/a.test.mjs', 'package.json', 'src/b.mjs']],
['`tests/a.test.mjs`\n 1) package.json', ['tests/a.test.mjs', 'package.json']],
['`tests/a.test.mjs` — the test, mocks fs\n 2) Makefile', ['tests/a.test.mjs', 'Makefile']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
});
test('review 630 r10 / 39a57fee plan-schema.mjs:371: a backticked capitalised name inside a description is no file', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const files of ['`tests/a.test.mjs` — covers `parse`, `Parser`, `ParseError`',
'`tests/a.test.mjs` — mocks fs, `Date`, and child_process.spawnSync']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs'] }, files);
}
// The control: alone in its own entry, before any description, it is a file (round 6).
assert.deepEqual(stepTestsOnly(step('`tests/a.test.mjs`, `Parser`')), { testsOnly: false, files: ['tests/a.test.mjs', 'Parser'] });
});
test('review 630 r10 / 882cc82a plan-schema.mjs:385: a backticked span that is no path starts no description with a colon', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const [files, paths] of [
['`tests/a.test.mjs` adds `x()`: y, package.json', ['tests/a.test.mjs', 'package.json']],
['`tests/a.test.mjs` adds `parse()`: y and Makefile', ['tests/a.test.mjs', 'Makefile']],
]) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: false, files: paths }, files);
}
// The control: a backticked path with a colon still starts its description.
assert.deepEqual(stepTestsOnly(step('`tests/a.test.mjs`: y and package.json')), { testsOnly: true, files: ['tests/a.test.mjs'] });
});
test('review 630 r10 / a6bd9c8c plan-schema.mjs:391: a plain file with a colon starts its description wherever it stands', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
for (const files of ['`tests/a.test.mjs`, the entry cli.mjs: wires parse and README.md',
'`tests/a.test.mjs`, exercises cli.mjs: argv and package.json',
'`tests/a.test.mjs`, then index.js: reads argv and .env']) {
assert.deepEqual(stepTestsOnly(step(files)), { testsOnly: true, files: ['tests/a.test.mjs'] }, files);
}
});
test('review 630 r10 / 0d94c5fe findings-schema.mjs:53: a ~~~ fence is a fence; a fence four spaces in still counts, so the gate fails closed', async () => {
const { extractFindingsBlock, fencedBlocks } = await import('../../lib/review/findings-schema.mjs');
const { decideVerdict } = await import('../../lib/review/verdict-gate.mjs');
const { validateReviewContent } = await import('../../lib/validators/review-validator.mjs');
const asBlock = (text) => text.replace('verdict: WARN\nfindings: []', `verdict: BLOCK\nfindings:\n - ${'a'.repeat(40)}`);
// A ~~~ example that holds a ```json line, then the review's own block: the
// ```json line opens nothing, so the block, the gate and the BLOCKER ids are read.
const tilde = `~~~\n${B3}json\n~~~\n\n`;
assert.equal(extractFindingsBlock(`${tilde}${B3}json\n{"findings": []}\n${B3}\n`), '{"findings": []}\n');
const v = decideVerdict(r9Review(`${tilde}${r9Clean}`));
assert.equal(v.exit, 0, v.reason);
const r = validateReviewContent(asBlock(r9Review(`${tilde}${B3}json\n${R9_BLOCK}\n${B3}\n`)));
assert.ok([...r.errors, ...r.warnings].some((i) => i.code === 'REVIEW_MISSING_REMEDIATION_STEP'),
`a BLOCKER with no ### Step R<n> is found: ${JSON.stringify([...r.errors, ...r.warnings].map((i) => i.code))}`);
// A ~~~ block is one block, closed only by ~~~; a ~~~json last block is read.
assert.deepEqual(fencedBlocks(`~~~md\n${B3}json\n{}\n~~~\n`).map((b) => [b.info, b.body, b.closed]), [['md', `${B3}json\n{}\n`, true]]);
const tildeJson = decideVerdict(r9Review(`${r9Clean}\n~~~json\n${R9_BLOCK}\n~~~\n`));
assert.notEqual(tildeJson.exit, 0, `a ~~~json block after the clean one is the last block: ${tildeJson.reason}`);
// Indented or not, a fence counts. A BLOCKER block inside a list item, four
// spaces in, after the clean block is the last block and is never passed...
const listItem = `${r9Clean}\n1. The payload:\n\n${`${B3}json\n${R9_BLOCK}\n${B3}`.replace(/^/gm, ' ')}\n`;
assert.notEqual(decideVerdict(r9Review(listItem)).exit, 0, decideVerdict(r9Review(listItem)).reason);
// ...and a ```json line in a code block four spaces in fails the gate closed, never open.
const coded = decideVerdict(r9Review(`x\n\n ${B3}json\n\nx\n\n${r9Clean}`));
assert.equal(coded.exit, 2, coded.reason);
});

View file

@ -13,7 +13,7 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -462,3 +462,70 @@ test('release-check r6 — a suite that never ends is stopped and read as not me
assert.equal(r.exit, 2);
} finally { fx.cleanup(); }
});
// ==== round 7 of the release review of 6.3.0 (2026-10-11), fixed in 6.3.1 ====
test('release-check r7 — a suite killed for too much output is not called a timeout → 2', () => {
const fx = fixture({ testScript: "node -e \"process.stdout.write('x'.repeat(200000))\"" });
try {
const r = mod.releaseCheck({ repo: fx.repo, review: fx.review, suiteMaxBuffer: 4096 });
const suite = r.checks.find((c) => c.id === 'suite');
assert.equal(suite.status, 'error', JSON.stringify(suite));
assert.doesNotMatch(suite.detail, /timeout/i, suite.detail);
assert.match(suite.detail, /output/i, suite.detail);
assert.equal(r.exit, 2);
} finally { fx.cleanup(); }
});
test('release-check r7 — a relative --review is read in --repo, like every other path → 0', () => {
const fx = fixture();
try {
mkdirSync(join(fx.repo, '.claude', 'projects', 'x'), { recursive: true });
writeFileSync(join(fx.repo, '.claude', 'projects', 'x', 'review.md'), readFileSync(fx.review, 'utf8'));
writeFileSync(join(fx.repo, '.git', 'info', 'exclude'), '.claude/\n');
// Run from the directory above the repo, as `--repo ../voyage` from a sibling would.
const r = spawnSync(process.execPath, [CHECK, '--repo', 'repo', '--review', '.claude/projects/x/review.md'], { cwd: fx.base, encoding: 'utf8' });
assert.doesNotMatch(r.stdout, /does not exist/, r.stdout);
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
// ==== round 8 of the release review of 6.3.0 (2026-10-11), fixed in 6.3.1 ====
test('release-check r8 — a relative --review written from the caller\'s directory, as 6.3.0 read it, still works → 0', () => {
const fx = fixture();
try {
// The review sits beside the repo (fx.base/review.md); run from fx.base.
const r = spawnSync(process.execPath, [CHECK, '--repo', 'repo', '--review', 'review.md'], { cwd: fx.base, encoding: 'utf8' });
assert.doesNotMatch(r.stdout, /does not exist/, r.stdout);
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('release-check r8 — a relative --review that names two different files is not guessed → 2', () => {
const fx = fixture();
try {
writeFileSync(join(fx.repo, 'review.md'), readFileSync(fx.review, 'utf8'));
writeFileSync(join(fx.repo, '.git', 'info', 'exclude'), 'review.md\n');
const r = spawnSync(process.execPath, [CHECK, '--repo', 'repo', '--review', 'review.md'], { cwd: fx.base, encoding: 'utf8' });
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(r.stdout, /two files/, r.stdout);
} finally { fx.cleanup(); }
});
// ==== round 9 of the release review of 6.3.0 (2026-10-11), fixed in 6.3.1 ====
test('release-check r9 / bdd46799 — one review.md reached through a symlinked --repo is one file, not two → 0', () => {
const fx = fixture();
const link = join(fx.base, 'repo link');
try {
writeFileSync(join(fx.repo, 'review.md'), readFileSync(fx.review, 'utf8'));
writeFileSync(join(fx.repo, '.git', 'info', 'exclude'), 'review.md\n');
symlinkSync(fx.repo, link);
// --repo through the symlink, run from the repo itself: process.cwd() is the
// physical path, so the two path strings differ while the file is one.
const r = spawnSync(process.execPath, [CHECK, '--repo', link, '--review', 'review.md'], { cwd: fx.repo, encoding: 'utf8' });
assert.doesNotMatch(r.stdout, /two files/, r.stdout);
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { rmSync(link, { force: true }); fx.cleanup(); }
});