fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.
- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
text says so (red-proof, trekexecute Sub-step B, step-implementer,
HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
already in the tree when the step began no longer counts against it, and a
plan placed outside .claude/ reads the same. The round-6 test that had the
diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
(", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed 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
(criteria-runner stoppedBy) for red proof, the criteria runner and the
release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
the realpath guard and the 20 modules still on the file:// guard, which
exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
passed at once; it fails when 127 is read as could-not-run); the prose now
names the git revision `@~1` as the one leading-@ exception.
Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0f400d3461
commit
29b4ac91b3
65 changed files with 582 additions and 485 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -792,11 +792,12 @@ 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. 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.
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ 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.
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -293,19 +293,22 @@ export function onFailureAction(text) {
|
|||
*
|
||||
* - 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. Beside them a plain word counts
|
||||
* only when it is clearly a file or a directory: a name with an extension, a
|
||||
* dotfile, a path with a slash that ends in a file or a `/`, or a build file
|
||||
* (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"; round 7: they
|
||||
* were dropped, and a step that builds something read as tests-only).
|
||||
* "covers pass/fail", "JSON, YAML" or "v6.3.0 format" 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-7.
|
||||
*/
|
||||
const SEG = '[\\w@.*?()-]';
|
||||
const PATH_WORD = new RegExp(`^(?:${SEG}+\\/)+${SEG}*$|^[\\w@*?-][\\w@.*?-]*\\.[A-Za-z*][A-Za-z0-9*]*$|^\\.[\\w-][\\w.-]*$`);
|
||||
|
|
@ -314,37 +317,46 @@ 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.
|
||||
const BUILD_FILE = /^(?!Profile$)(?:[A-Z][a-z]+|GNUmake)file$/;
|
||||
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))));
|
||||
|
||||
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);
|
||||
const tokens = entry.split(/\s+/);
|
||||
// A backticked capitalised name alone in its entry is a file (round 6).
|
||||
const alone = /^\u0000(\d+)\u0000$/.exec(entry);
|
||||
if (alone && BARE_NAME.test(spans[Number(alone[1])])) { out.push(spans[Number(alone[1])]); continue; }
|
||||
if (tickedField) {
|
||||
const first = plainWord(entry.split(/\s+/)[0]);
|
||||
if (!first.includes('\u0000') && isPathWord(first) && FILE_OR_DIR.test(first)) out.push(first);
|
||||
// In the order they stand: the backticked paths, and a plain word only
|
||||
// when it is clearly a file or a directory (round 7).
|
||||
for (const t of tokens) {
|
||||
if (t.includes('\u0000')) out.push(...[...t.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1])).filter((i) => isTicked[i]).map((i) => spans[i]));
|
||||
else if (isFileWord(plainWord(t))) out.push(plainWord(t));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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]);
|
||||
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);
|
||||
}
|
||||
return out.filter(Boolean).map(norm);
|
||||
|
|
@ -377,10 +389,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()))
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { issue, fail } from '../util/result.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
// Origin tag for every synthesised finding (Phase 7 rule 7: codebase vs research).
|
||||
export const ORIGIN_VALUES = Object.freeze(['codebase', 'research']);
|
||||
|
|
@ -160,7 +161,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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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('--'));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@
|
|||
//
|
||||
// 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 { 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.
|
||||
|
|
@ -38,7 +38,12 @@ export const FINDING_REQUIRED_FIELDS = Object.freeze([
|
|||
|
||||
// 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;
|
||||
// Both fences stand on a line of their own, as in Markdown and in the verdict
|
||||
// gate's reader: a JSON string cannot hold a line break, so three backticks
|
||||
// inside one ("a fenced ```bash block") never end the block. Ending it at the
|
||||
// first triple backtick anywhere made fill-finding-ids exit 2 (release review
|
||||
// of 6.3.0, found while writing round 7's review.md; present in 6.2.0).
|
||||
const JSON_FENCE_GLOBAL = /^[ \t]*```json[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*\r?$/gim;
|
||||
|
||||
/**
|
||||
* Extract the inner body of the LAST fenced `json` block in `text`.
|
||||
|
|
@ -109,8 +114,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 +127,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 +166,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 +188,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 +201,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 +225,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 +256,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 +266,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 +276,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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
|
||||
|
||||
|
|
@ -168,15 +168,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];
|
||||
|
|
|
|||
|
|
@ -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)), '..', '..'));
|
||||
|
|
|
|||
|
|
@ -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)), '..', '..'));
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]] : []));
|
||||
|
|
|
|||
|
|
@ -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
26
lib/util/is-main.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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, runCommand, screenCommand } from '../verification/criteria-runner.mjs';
|
||||
import { isMain } from './is-main.mjs';
|
||||
|
||||
export const LOCK_VERSION = 1;
|
||||
|
||||
|
|
@ -225,12 +224,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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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('--'));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -46,6 +45,7 @@ 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 { 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([
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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('--'));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
// 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';
|
||||
|
||||
export const REVIEW_REQUIRED_FRONTMATTER = [
|
||||
'type',
|
||||
|
|
@ -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('--'));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -56,8 +56,12 @@ 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 ----------------------------------------------------------------
|
||||
|
||||
|
|
@ -151,24 +155,35 @@ export function looksLikeCommand(span) {
|
|||
export function firstCommand(text) {
|
||||
const m = text.match(/`([^`]+)`/);
|
||||
if (!m) return { command: null, reason: 'no-command' };
|
||||
const raw = m[1].trim();
|
||||
const raw = dropComment(m[1]);
|
||||
// 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' };
|
||||
return { command: raw, 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 a path).
|
||||
*/
|
||||
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 +248,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,
|
||||
});
|
||||
|
|
@ -456,8 +471,8 @@ export function runnableCheck(criterion, opts = {}) {
|
|||
|
||||
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 +507,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 +520,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 +552,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}` };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } 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}';
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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), spawnError: r.error ? r.error.message : null, 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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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('--'));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
// 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)
|
||||
// 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
|
||||
|
|
@ -33,12 +34,14 @@
|
|||
// two-way door can) are credited to mattpocock/skills@c55ee46; the green suite
|
||||
// on the merged tree to obra/superpowers@8ca22db. Ideas only, no text copied.
|
||||
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, 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 +160,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(', ')})` : '';
|
||||
|
|
@ -269,7 +274,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 +286,14 @@ 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);
|
||||
// A relative --review is read in --repo, like every other path here (round 7).
|
||||
const { check: rev, text } = checkReview(review ? resolve(root, review) : null);
|
||||
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 +343,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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,3 +94,16 @@ 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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
// 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';
|
||||
|
|
@ -640,7 +642,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 +655,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 +707,170 @@ 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`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -462,3 +462,30 @@ 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(); }
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue