merge: trekexecute fix-round-4-5b-part-2
This commit is contained in:
commit
ce98f62e82
57 changed files with 5300 additions and 376 deletions
66
CHANGELOG.md
66
CHANGELOG.md
|
|
@ -30,14 +30,59 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
to `scripts/annotate.mjs`, the not-a-gate line and the printed links stay. The printed report now
|
||||
says a Question is answered and Fix and Change notes revise the file.
|
||||
- **`/trekendsession` is operator-only, and the planning and research spawn sites follow the profile.** The model can no longer start `/trekendsession` (`disable-model-invocation: true`). With `--project`, where the resolver runs, `/trekplan` and `/trekresearch` pass the resolved model at their spawn sites, so the `economy` profile gives Sonnet helpers unless the brief's phase signal for that phase names a model; without a resolved model the agent's own `model:` applies (opus for Claude Code's built-in agents), and `/trekexecute`'s step helper and controller stay on opus.
|
||||
- **`~` and `$HOME` in the arguments of `/trekendsession`, `/trekcontinue` and `/trekreview` are no longer expanded** (ARG, fix round 4.5b). The arguments reach the arg parser as text on stdin, not as shell words, so nothing in them is expanded: write the path out (`~/x` stays the three characters `~/x`).
|
||||
|
||||
### Fixed
|
||||
|
||||
Fix round 4.5b (decisions 80 to 87): the round's 39 findings, from the 4.5a review and its execution and three the operator added, proven by 41 test ids. Each is fixed, and each has a test seen failing first: on the code at the round's start point (`be0b631`), or, for the 14 ids whose code was already right or lives under `tests/`, under a named mutation (`tests/fix-round-4-5b-red.sh` proves both).
|
||||
|
||||
**Verdict gate**
|
||||
|
||||
- **The verdict gate reads `--repo=<dir>` and refuses an unknown option** (M6). `lib/review/verdict-gate.mjs` takes `--repo <dir>` and `--repo=<dir>`; `--repo=` with no value and any other option, a `--x=value` one included, exit 2 with the usage line, so a misspelt flag no longer skips the stale check. `--json` stays.
|
||||
- **Only a full commit id opens the merge lock** (M11). A `scope_sha_end` must be 40 or 64 hex and equal what `git rev-parse` prints for it, so a SHA-256 repository opens the lock, and a short id, or a branch named like a commit id, reads as stale. The done line (`lib/verification/done-line.mjs`) and the release check (`scripts/release-check.mjs`) take only full commit ids too.
|
||||
- **A failed git call is not measured, not stale** (M4). A `--repo` path that is missing or no repository, and any git call that fails, give exit 2 with a reason that starts `review-not-measured:`; `/trekexecute`'s main-merge gate reports it as "not measured", never green, and the merge stays locked. Handover 6 names the prefix and lists `review_version` 1.1 as current.
|
||||
- **Free text inside the lessons block makes the review stale** (m4). Every line a later commit adds inside Voyage's block in `.claude/rules/voyage-review-lessons.md` must be a valid lesson line or the block's own header line.
|
||||
|
||||
**Park tool**
|
||||
|
||||
- **The park tool moves nothing when it cannot read HEAD** (M7). `lib/util/park-attempt-files.mjs` reads HEAD's paths once (`git ls-tree`) before any change, and exits 2 with nothing unindexed or moved when that read fails or a path in HEAD is missing from the index. Before, a failed `git cat-file -e` read as "not in HEAD", and a committed file was moved.
|
||||
- **A CRLF diff is parked, and the text says what a staged new file gets** (E22c, E22e). The tool reads a diff with CRLF line ends; its header and `/trekexecute` say that it parks every file new in the attempt's diff, untracked or staged but not in HEAD.
|
||||
- **The "Back to planning" save line reads the whole diff from a subdirectory** (M3). Its `git diff` carries `--no-relative`, so `diff.relative=true` no longer leaves files out.
|
||||
|
||||
**Review coordinator and Arm B workflow**
|
||||
|
||||
- **The Arm B workflow keeps the contract's row** (M5, S3, E20). `scripts/trekreview-armB.workflow.mjs` matches a put-back row on the title as the coordinator trims it, so a trimmed title gives no duplicate row, and ranks a conformance/correctness tie as `lib/review/coordinator-contract.mjs` does; the canonical sort of untitled findings is pinned in both.
|
||||
- **The coordinator's text says what the code does with a line number** (M9). A citation that escapes the repo root, or a negative line, refutes a finding; any other line number alone never does, and a line past the end of the file goes to `unverified`.
|
||||
- **The four `--quick` summaries name the brief-conflict rule as the exception to the skipped reasonableness filter** (M2, K3). `/trekreview`'s flag table, the review orchestrator, `docs/command-modes.md` and README say so, and the orchestrator's item 3 names the brief-conflict lowering as the exception to the catalogue reset.
|
||||
|
||||
**Red proof**
|
||||
|
||||
- **One red script for every round** (RS, M1, M1r, E23a, E23b, E23d, E23f). `tests/helpers/red-round.sh` takes a base, the round's ids, the other rounds' ids, a mutation module and the test files; `tests/fix-round-4-5a-red.sh` and `tests/fix-round-4-5b-red.sh` are thin wrappers over it. The judge skips another round's id and reports an unreadable tag such as `[X 1]`; the base run selects every tag with one pattern and carries a timeout; the judge's exit 2 is tested, and a test checks that a result exists before it reads it. The tests the 4.5a discovered-error fix added carry their 4.5a parent tags, so the red script judges them.
|
||||
|
||||
**Commands and docs**
|
||||
|
||||
- **The arguments reach the parser on stdin, never as shell words** (ARG, M8, M10, DJ5). Phase 0 of `/trekendsession` and `/trekcontinue` and Phase 1 of `/trekreview` give `$ARGUMENTS` to `lib/parsers/arg-parser.mjs --stdin` as the one line inside a quoted heredoc, so a `;`, a `$( )` or a glob in them is text. `/trekcontinue` and `/trekendsession` stop on the parser's exit 1 with its `errors`, and when it prints no JSON. `/trekendsession` takes its label as the rest of the text as typed (the parser's `tail`), so a label of several words is kept whole, and its Phase 3 reads the arguments again the same way instead of splicing the label into a shell line, so an apostrophe in the label no longer breaks that line or runs a command.
|
||||
- **The Bash guard blocks a parser heredoc that closes early** (R6). `hooks/scripts/pre-bash-executor.mjs` blocks (`args-heredoc-closed-early`) a command whose `VOYAGE_ARGS_EOF_7F3C9E` heredoc opens unquoted, or whose closing line stands twice or is not the last non-empty line, so a typed line equal to the delimiter cannot end the heredoc and run what follows.
|
||||
- **Every `/trekbrief` exit names Phase 7 with its `auto_result`** (M13, K2). The exit with no research topics runs Phase 7 with `auto_result: manual`, and the "Cancel — do manual" row with `auto_result: cancelled`.
|
||||
- **`/trekplan`'s execute and team exits run Phase 12, and the research loop names its model** (DJ1, DJ2). The "execute" and "team" exits run Phase 12 (`outcome: execute`, `outcome: execute_team`) instead of a bare stop; in `/trekresearch`'s high-effort loop, a turn that spawns an agent passes the resolved model, else `opus`.
|
||||
- **`run_seen` is kept in the progress file** (E14). `/trekexecute` writes it to `progress.json` (template, field table, Phase 7) and Phase 8 reads it from there, so the resume and multi-session paths report it; Handover 5 lists it as optional.
|
||||
- **Applying a lesson writes no pointer through a link or for an ignored file** (DJ4, PTR). `lib/review/recurrence.mjs` skips the `CLAUDE.md` pointer when `CLAUDE.md` is a symlink or git ignores the lessons file, and refuses a lessons file that is a symlink or whose directory lies outside the repo; `/trekexecute` Phase 7.2 commits only the review's paths git does not ignore.
|
||||
- **The research and review texts are said once and pinned exactly** (E17a, E17b, E18a, Da, Db, E16a). The two `/trekresearch` sentences on outbound queries and the F9 triage rows are pinned exactly; the deep-research adapter says where wave 2's contrarian findings merge; README's deep-research parenthesis says "when the engine is active", and the test that no README line stars `security-researcher` reads every line. The next-session validator's refusal of `--state-file` with no value is tested.
|
||||
|
||||
**Fix round 4.5b, part 2 (decision 88)**
|
||||
|
||||
Part 2 fixes the four defects that part 1 introduced and its own review found, plus the Bash guard's first-opener-only check; each has a test seen failing first at part 1's end point `52c382c` (`tests/fix-round-4-5b-part-2-red.sh` proves it).
|
||||
|
||||
- **A quoted value stops at a quote that starts a word, and never swallows a later flag** (TOK). In `lib/parsers/arg-parser.mjs`, a closing quote must end a word, and the search for it stops at a quote of the same kind that starts a word; with no pair, the opening quote stays a literal character. Before, `--project "my dir"/x --since "HEAD~1"` gave one `--project` value that held `--since`, so `/trekreview` reviewed the wrong range; now `--since` is `HEAD~1`. An apostrophe inside a word keeps its value (`'Fix the user's login'` is one value), and nothing is expanded.
|
||||
- **The Bash guard checks every opener of the parser's heredoc that has a line after it, and lets a one-line mention through** (HDG). `hooks/scripts/pre-bash-executor.mjs` checks an opener of `VOYAGE_ARGS_EOF_7F3C9E` only when a non-blank line follows the opener's line, because bash reads a heredoc body only from the lines after it, so a one-line `grep`, `sed` or `git commit -m` that mentions the opener is no longer refused. It now checks every opener, not only the first, so a quoted first mention can no longer hide a later unquoted heredoc whose arguments would run as commands.
|
||||
- **A failed `git check-ignore` writes no lessons pointer, whatever language git speaks** (CKI). `lib/review/recurrence.mjs` no longer reads every `git check-ignore` exit 128 as "no repository": on 128 it runs `git rev-parse --git-dir` and looks for a `.git` entry from the repo up to the nearest `GIT_CEILING_DIRECTORIES` entry above it, and writes the `CLAUDE.md` pointer only when rev-parse also exits 128, `GIT_DIR` is unset and no `.git` entry is found. Anything else gives `check-failed` with a note that names the reason, and no branch reads git's message text. A `.claude` that is a symlink inside the repo now gives `check-failed` (no pointer), also when it points at a tracked directory.
|
||||
- **The executor records the full commit id for the done line** (DLC). `/trekexecute` Sub-step G says `{hash}` is the full commit id from `git rev-parse HEAD` after the step's last commit, read again after any later commit of the step, never the short id `git commit` prints: `lib/verification/done-line.mjs --check` refuses a short id with exit 2, which sent a passing step to the plan. The progress line stores that full id, and Handover 5's `commit` row says so.
|
||||
|
||||
Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyage control could be bypassed or was too lax, or where the text gave a guarantee no code held. Each is fixed, and each has a test that fails on the code before it, except DJ, whose sentences already stood and whose pins fail when they are deleted (`tests/fix-round-4-5a-red.sh` proves every other id red at the round's start point, and DJ under its two mutations).
|
||||
|
||||
**Execute**
|
||||
|
||||
- **The merge lock refuses a review of an older commit** (R6). `lib/review/verdict-gate.mjs` takes `--repo <dir>`: an ALLOW or WARN review then opens only when its `scope_sha_end` is HEAD, or an ancestor of HEAD whose later commits (side branches included) change nothing but the review lessons and the `CLAUDE.md` pointer; anything else, a failed git call included, exits 2 with a reason that starts `review-stale` (the new `lib/review/reviewed-commit.mjs`). `/trekexecute` Phase 7.2 and the main-merge lock run the gate with `--repo .`, so the merge stays closed with the decline reason `review-stale`, also on `--resume`.
|
||||
- **The merge lock refuses a review of an older commit** (R6). `lib/review/verdict-gate.mjs` takes `--repo <dir>`: an ALLOW or WARN review then opens only when its `scope_sha_end` is HEAD, or an ancestor of HEAD whose later commits (side branches included) change nothing but the review lessons and the `CLAUDE.md` pointer; anything else exits 2 with a reason that starts `review-stale` (the new `lib/review/reviewed-commit.mjs`), and a failed git call exits 2 with a reason that starts `review-not-measured:`, not `review-stale` (fix round 4.5b). `/trekexecute` Phase 7.2 and the main-merge lock run the gate with `--repo .`, so the merge stays closed with the decline reason `review-stale`, also on `--resume`.
|
||||
- **`/trekexecute` runs the brief check it promised** (finding #33). With `--project` and a `brief.md`, Phase 1 runs the brief validator and the phase-model resolver (`lib/profiles/resolver.mjs --resolve-phase-model`) as `/trekplan` does, uses the effort it returns, and halts only on `BRIEF_V51_MISSING_SIGNALS`. Low effort runs the plan as one session, step after step; high effort changes nothing, and the text that set `gates_mode` from the effort and its `gates_mode_flag_override` record are gone.
|
||||
- **`/trekexecute` names and reads `--profile` and `--gates`** (finding #30). The argument hint, the usage block and Phase 1 carry both flags, Phase 1 strips `--profile <name>` before it reads the plan path, and the text says the profile changes no model here: the step helper and the controller stay on opus.
|
||||
- **The completion gate says which of its checks are commands and which are the executor's judgement** (finding #28). It no longer calls the counter check and the audit deterministic, and its two dead line references are gone.
|
||||
|
|
@ -98,11 +143,26 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
|
|||
|
||||
**Across the round**
|
||||
|
||||
- **The texts next to the fixes say the same** (siblings found during the round): `/trekreview`, the review reference and the Arm B coordinator prompt no longer drop a finding whose rule key is unknown; `/trekbrief` writes its stats record on every exit of its manual and auto paths, says that `--gates` adds one pause before its auto chain, and that the chain ends at the plan; `/trekexecute` says `--gates` changes nothing there, checks the wave marker before a low-effort single session, and names no validator check that does not exist; plan mode goes through the plan approval before it prints the `/trekexecute` line; the gates shim reads `--gates=true` as on.
|
||||
- **The texts next to the fixes say the same** (siblings found during the round): `/trekreview`, the review reference and the Arm B coordinator prompt no longer drop a finding whose rule key is unknown; `/trekbrief` writes its stats record on every exit of its manual and auto paths, says that `--gates` adds one pause before its auto chain, and that the chain ends at the plan; `/trekexecute` says `--gates` changes nothing there, checks the wave marker before a low-effort single session, and names no validator check that does not exist; plan mode goes through the plan approval before it prints the `/trekexecute` line; the commands read only the two-token `--gates <value>` form, and `--gates=true` is the gates shim's own form (`lib/util/autonomy-gate.mjs`), which it reads as `--gates true`.
|
||||
|
||||
### Known issues
|
||||
|
||||
- **A plan's `profile:` field has no writer.** README, `docs/`, `/trekexecute` and `/trekcontinue` say a plan carries or passes on a profile, but no command writes `profile:` to a plan; the plan validator's `MANIFEST_PROFILE_DRIFT` check reads it, and `resolveTrekcontinueProfile` has no caller outside its tests. Left whole for a later hardening part, which chooses between writing the field and taking the promises out.
|
||||
- **Splices and dropped `--x=` options outside fix round 4.5b** (decision 82). The commands' bash blocks still splice `{placeholder}` values into shell lines outside the four places the round closed (the Phase 0 parser lines of `/trekendsession`, `/trekcontinue` and `/trekreview`, and `/trekendsession` Phase 3), for example `"{brief_path}"` in `/trekplan` and the unquoted `{project_dir}` in `/trekendsession` Phase 4; and the release check, the lessons apply (`lib/review/recurrence.mjs`), the next-session validator and the park tool still drop an unknown `--x=` option without a word. One open finding, for fix round 4.5c or 4.5d.
|
||||
- **Two 4.5b findings moved to fix round 4.5c.** M12 (`lib/review/reviewed-commit.mjs` makes four git calls per path per commit; at most two paths per commit reach that loop) goes with 4.5c's single `git cat-file --batch` read, and Dc (the trekcontinue `line_ref` row in `tests/fixtures/jsonl-schemas.md`) with 4.5c's change of `line_ref` to a heading anchor.
|
||||
- **Step 9's narrowed unborn-HEAD rule is not recorded as a plan deviation** (MAJOR `c76a0bb8`, `lib/util/park-attempt-files.mjs`; decision 88). The park tool reads HEAD as unborn only when `headIsUnborn` agrees, which narrows plan Step 9's rule; the narrowing was logged as an executor decision, not as a plan amendment.
|
||||
- **The arg parser's stdin-unreadable branch has no test** (MAJOR `b0fd0a04`, `lib/parsers/arg-parser.mjs`; decision 88). No test exercises `ARG_STDIN_UNREADABLE` and its exit 1, the path that makes `/trekcontinue` and `/trekendsession` stop when stdin cannot be read.
|
||||
- **The park tool's packed-refs branch has no test** (MAJOR `f7e8278b`, `lib/util/park-attempt-files.mjs`; decision 88). The branch that tells a corrupt packed ref from an unborn HEAD is not exercised; the corrupt-ref test covers only a loose ref.
|
||||
- **`/trekendsession` takes the label from after the first token** (MAJOR `4ec591f4`, `commands/trekendsession.md`; decision 88). Phase 3 reads the label as the parser's `tail`, the text after the first token, while the brief path is the first positional, so a flag typed before the brief path ends up in the label.
|
||||
- **Part 1's red script does not run the guard's tests** (MAJOR `2c904235`, `tests/fix-round-4-5b-red.sh`; decision 88). Its FILES leave out `tests/hooks/bash-guard.test.mjs`, so the `[ARG]` guard tests are never judged red at `be0b631`.
|
||||
- **`/trekendsession` Phase 3 repeats Phase 1's project lookup** (MAJOR `450dbabb`, `commands/trekendsession.md`; decision 88). Its inline script enumerates the project directories and their error texts a second time, so the two copies can drift.
|
||||
- **The park tool misnames a detached HEAD it cannot read** (MAJOR `e3824c6d`, `lib/util/park-attempt-files.mjs`; decision 88). On a detached HEAD whose commit is missing it exits 2, as it should, with a reason that names a corrupt branch ref.
|
||||
- **The lessons-block check accepts any valid lesson line** (MINOR `5114fa84`, `lib/review/reviewed-commit.mjs`; decision 88). A line added inside the block that passes the lesson-line check, an instruction written in the lesson form included, does not make the review stale.
|
||||
- **The label keeps inner quotes** (MINOR `e585ce27`, `lib/parsers/arg-parser.mjs` `tailOf`; decision 88). `tailOf` strips quotes only when one pair wraps the whole rest, so `brief.md "Session 2" of "3"` gives the label `"Session 2" of "3"`.
|
||||
- **The Bash guard knows one heredoc delimiter** (MINOR `8959d2cd`, `hooks/scripts/pre-bash-executor.mjs`; decision 88). It has no general rule for a quoted heredoc's closing line, so another heredoc in the commands, such as a stats record's, can still be closed early by a typed line equal to its delimiter.
|
||||
- **A command that mentions the parser's heredoc opener with a line after it stays blocked** (fix round 4.5b part 2, decision 88). The guard checks every opener of `VOYAGE_ARGS_EOF_7F3C9E` that has a non-blank line after the opener's line, so such a command stays blocked unless it is a quoted heredoc whose closing line is the one last non-empty line, for example a commit message written as a heredoc that mentions the opener. Write it on one line, or read it from a file.
|
||||
- **A `bash -c $'...'` command on one physical line is not checked** (fix round 4.5b part 2, decision 88). It carries no literal newline, so the guard sees no line after the opener even when `bash -c` expands it into a heredoc of the parser's delimiter; part 1 blocked it by accident. The shipped commands never produce it from `$ARGUMENTS`.
|
||||
- **The tokenizer splits a quoted value at a quote of the same kind that starts a word** (TOK, decision 88). `'the '90s were great'` now gives `'the` and `90s were great`; a quoted value that ends in whitespace (`"a "`) no longer pairs and gives `"a` and `"`; and the argv form's self-quoting of a word that holds spaces (`-- 'say "hi"'`) splits. The operator accepted these.
|
||||
|
||||
### Doors
|
||||
|
||||
|
|
@ -111,6 +171,8 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
|
|||
- two-way: skill annotating and its pointers in three commands (order B) — revert the commit; the
|
||||
script, the page, the stored notes and the approval checks are unchanged.
|
||||
- two-way: fix round 4.5a (decisions 57, 67, 68) — a revert restores the old texts and checks
|
||||
- two-way: fix round 4.5b (decisions 80 to 87) — a revert restores the old checks, texts and red script and takes out the Bash guard's heredoc rule; `review.md` keeps `review_version` 1.1 either way.
|
||||
- two-way: fix round 4.5b part 2 (decision 88) — a revert restores part 1's four behaviours (the tokenizer's quote search, the guard's refusal of a one-line mention, the pointer written on any `git check-ignore` exit 128, the undefined `{hash}`) and the guard's first-opener-only check.
|
||||
|
||||
## v6.5.0 — 2026-10-18 — Three skills for brief, criteria and review, English labels, and three trial-5 fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -536,7 +536,7 @@ Declined to Judge list, and the coordinator carries those lines into
|
|||
|------|---------|-------------|
|
||||
| **Default** | `/trekreview --project <dir>` | Claude Code `/code-review` + `/security-review`, then brief-conformance + code-correctness reviewers in parallel, coordinator dedup + verdict, write `{dir}/review.md`, verdict gate |
|
||||
| **Since ref** | `/trekreview --project <dir> --since <ref>` | Override "before" SHA for the diff range. Validated via `git rev-parse --verify` |
|
||||
| **Quick** | `/trekreview --project <dir> --quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter — fast correctness-only pass |
|
||||
| **Quick** | `/trekreview --project <dir> --quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter except the brief-conflict rule — fast correctness-only pass |
|
||||
| **Validate** | `/trekreview --project <dir> --validate` | Schema-only check on existing `review.md`. No LLM calls |
|
||||
| **Dry run** | `/trekreview --project <dir> --dry-run` | Print discovered scope + triage map; skip writes |
|
||||
| **Profile** | `/trekreview --profile <name> --project <dir>` | (v4.1.0) Pin model profile for the review phase. See [Profile system](#profile-system-v410). |
|
||||
|
|
@ -823,7 +823,7 @@ flowchart TB
|
|||
BR --> RES --> PL --> EX --> RV
|
||||
```
|
||||
|
||||
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1's external findings to attack (with `--engine deep-research`, after the engine returns, from the leading answer of its report), always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.
|
||||
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1's external findings to attack (with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report), always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.
|
||||
|
||||
> **Which Claude Code primitive each phase uses — and the alternatives considered (Workflow substrate, delegated orchestrator, dormant synthesis-agent)** → see [docs/architecture.md §Primitives per step](docs/architecture.md#primitives-per-step-decision-matrix).
|
||||
|
||||
|
|
|
|||
|
|
@ -136,8 +136,9 @@ Succinctness and Actionability read the finding's *packaging*; neither
|
|||
examines the claim, so neither can establish the finding is unreal. Accuracy
|
||||
does: a citation that escapes the repo root, or a negative line, refutes the
|
||||
finding as a claim about this codebase. A line past the end of the file does
|
||||
not: the citation is wrong, the claim is unchecked. A line number alone never
|
||||
refutes a finding, so it goes to `unverified` (`accuracy:line-out-of-range`).
|
||||
not: the citation is wrong, the claim is unchecked. Any other line number alone
|
||||
never refutes the finding, so a line past the end of the file sends it to
|
||||
`unverified` (`accuracy:line-out-of-range`).
|
||||
|
||||
**No finding is struck for length** (ordre 5). Before review_version 1.1 an
|
||||
over-long `detail` sent a finding to `unverified`, and S118's most serious
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ You will receive a prompt containing:
|
|||
contract that bounds review scope.
|
||||
- **Mode** — `default`, `quick`, `validate`, or `dry-run`.
|
||||
- `default` — run the full pipeline.
|
||||
- `quick` — skip the coordinator's reasonableness filter; use single
|
||||
reviewer (code-correctness only) for faster turnaround.
|
||||
- `quick` — skip the coordinator's reasonableness filter except the
|
||||
brief-conflict rule; use single reviewer (code-correctness only) for faster turnaround.
|
||||
- `validate` — schema-only check on existing review.md, no LLM calls.
|
||||
- `dry-run` — print the discovered scope and triage map; skip writes.
|
||||
- **Since-ref** (optional) — explicit `--since <ref>` override for the SHA
|
||||
|
|
@ -72,9 +72,12 @@ Execute these phases in order. Do not skip phases.
|
|||
|
||||
### Phase 1 — Parse mode and validate input
|
||||
|
||||
Run the arg-parser via Bash:
|
||||
Run the arg-parser via Bash, with the arguments as the one line inside the
|
||||
quoted heredoc (on stdin, never as shell words):
|
||||
```
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- "$@"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
$ARGUMENTS
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
```
|
||||
|
||||
Pull the structured flags from its JSON output. Reject unknown flags. If
|
||||
|
|
@ -191,6 +194,9 @@ The coordinator runs a 4-pass process:
|
|||
`RULE_CATALOGUE` (`unknown-rule_key`) or whose file cannot be checked
|
||||
(`file-existence:indeterminate`) to `unverified`, which forbids `ALLOW`;
|
||||
reset a severity that does not match its catalogue tier.
|
||||
The one exception to the reset is the brief-conflict lowering: a
|
||||
`/code-review` finding marked `contradicts_brief`, at the MAJOR tier and
|
||||
at severity MAJOR, drops to MINOR with `brief_lowered_from: MAJOR`.
|
||||
Skipped at high effort, as in `quick` mode (below); at high effort an
|
||||
unknown `rule_key` is normalized to `PLAN_EXECUTE_DRIFT` instead
|
||||
(`agents/review-coordinator.md`, "High-effort normalization"); the
|
||||
|
|
|
|||
|
|
@ -977,8 +977,8 @@ printed `stamped: true`, then stop and print the next step:
|
|||
|
||||
**With research_topics = 0**, print the manual-path output below without the
|
||||
`/trekresearch` lines (Brief, then `/trekplan --project {PROJECT_DIR}`, then
|
||||
`/trekexecute --project {PROJECT_DIR}`), then run Phase 7 and stop. Phase 6 is
|
||||
never entered without the operator's opt-in.
|
||||
`/trekexecute --project {PROJECT_DIR}`), then run Phase 7 (`auto_result: manual`)
|
||||
and stop. Phase 6 is never entered without the operator's opt-in.
|
||||
|
||||
Ask the user via `AskUserQuestion`:
|
||||
|
||||
|
|
@ -1052,7 +1052,7 @@ Continue?"
|
|||
| Option | Action |
|
||||
|--------|--------|
|
||||
| **Continue — auto** | Proceed. |
|
||||
| **Cancel — do manual** | Revert to manual path (print commands, stop). |
|
||||
| **Cancel — do manual** | Revert to manual path (run Phase 7 with `auto_result: cancelled`, print commands, stop). |
|
||||
|
||||
If cancelled → fall back to manual path output, run Phase 7
|
||||
(`auto_result: cancelled`) and stop.
|
||||
|
|
|
|||
|
|
@ -32,9 +32,16 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the full schema.
|
|||
Parse `$ARGUMENTS` with the shared arg-parser. Run first, exactly like this:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue -- $ARGUMENTS
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
$ARGUMENTS
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
```
|
||||
|
||||
The arguments reach the parser as the one line inside the quoted heredoc, on
|
||||
stdin, never as shell words. It prints the parse result as JSON. On exit 1,
|
||||
print its `errors` and stop. When it prints no JSON, say that the parser did
|
||||
not run and stop.
|
||||
|
||||
Read `flags` and `positional` from the JSON it prints. Dispatch the usage
|
||||
block ONLY when one of these two conditions equals exactly true (no substring
|
||||
search, no "contains" check):
|
||||
|
|
|
|||
|
|
@ -31,14 +31,21 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the schema.
|
|||
## Phase 0 — `--help` handling
|
||||
|
||||
Parse `$ARGUMENTS` with the arg parser. Run this via the Bash tool at runtime
|
||||
(NOT eager-exec):
|
||||
(NOT eager-exec), exactly as written: the arguments reach the parser as the one
|
||||
line inside the quoted heredoc, on stdin, never as shell words:
|
||||
|
||||
```bash
|
||||
setopt nonomatch 2>/dev/null || true
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession -- $ARGUMENTS
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
$ARGUMENTS
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
```
|
||||
|
||||
The `setopt nonomatch` line is no longer needed for the arguments (the quoted
|
||||
heredoc expands no glob); it stays harmless.
|
||||
|
||||
It prints the parse result as JSON. On exit 1, print its `errors` and stop.
|
||||
When it prints no JSON, say that the parser did not run and stop.
|
||||
|
||||
Print the usage block below and exit cleanly ONLY when one of these two
|
||||
conditions equals exactly true (no substring search, no "contains" check):
|
||||
|
|
@ -95,8 +102,9 @@ Decision tree:
|
|||
## Phase 2 — Required args check (headless-safe)
|
||||
|
||||
Read the parse result from Phase 0: `<next-brief-path>` is `positional[0]`
|
||||
and `<next-label>` is `positional[1]`. Both are required. If either is
|
||||
missing or empty:
|
||||
and `<next-label>` is the parser's `tail` (the rest of the text as typed after
|
||||
the brief path, one outer quote pair removed, so a label with spaces stays
|
||||
whole). Both are required. If either is missing or empty:
|
||||
|
||||
```
|
||||
Error: missing required args.
|
||||
|
|
@ -127,29 +135,41 @@ to avoid partial-state on crash. The util is ESM, so invoke via
|
|||
`node --input-type=module -e` with an `import` statement (a CommonJS shim
|
||||
would throw `ERR_REQUIRE_ESM` on Node 18+ since `atomic-write.mjs` is ESM).
|
||||
|
||||
Under `node --input-type=module -e "<script>" arg1 arg2 arg3`, Node sets
|
||||
`process.argv[0]` to the node binary path and user args start at
|
||||
`process.argv[1]`. Adjust the destructure if your Node version differs.
|
||||
|
||||
This phase ALSO writes a sibling `NEXT-SESSION-PROMPT.local.md` in the
|
||||
project directory with YAML frontmatter (`produced_by: trekendsession`,
|
||||
`produced_at: {ISO-8601}`, `project: {project_dir}`). Both files are written
|
||||
in a single ESM block so the writes succeed or fail together.
|
||||
|
||||
Run the block below via the Bash tool at runtime, substituting the resolved
|
||||
values for the `{curly}` placeholders (Phase 1 gives `{project_dir}`, Phase 2
|
||||
gives `{next_brief_path}` and `{next_label}`). This is NOT an eager-exec
|
||||
block — the values do not exist at command-load time. The import path must
|
||||
stay absolute via `${CLAUDE_PLUGIN_ROOT}` — your Bash cwd is the user's
|
||||
repo, not the plugin root, so a cwd-relative import throws
|
||||
`ERR_MODULE_NOT_FOUND`:
|
||||
Run the block below via the Bash tool at runtime, exactly as written: put no
|
||||
value from Phase 1 or Phase 2 into it. This is NOT an eager-exec block — the
|
||||
files do not exist at command-load time. The block takes nothing on its shell
|
||||
line: it re-reads the arguments as the one line inside the quoted heredoc, on
|
||||
stdin, never as shell words (the heredoc is the last thing in the command),
|
||||
parses them with the same `parseArgs` as Phase 0 (the brief path is
|
||||
`positional[0]`, the label is `tail`), and re-derives the project directory by
|
||||
Phase 1's enumeration: exactly one `.claude/projects/<dir>/brief.md` under the
|
||||
cwd, else it prints why and exits 1. The import paths must stay absolute via
|
||||
`${CLAUDE_PLUGIN_ROOT}` — your Bash cwd is the user's repo, not the plugin
|
||||
root, so a cwd-relative import throws `ERR_MODULE_NOT_FOUND`:
|
||||
|
||||
```bash
|
||||
node --input-type=module -e "
|
||||
import path from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { atomicWriteJson } from '${CLAUDE_PLUGIN_ROOT}/lib/util/atomic-write.mjs';
|
||||
const [, dir, brief, label] = process.argv;
|
||||
import { parseArgs } from '${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs';
|
||||
let text = readFileSync(0, 'utf8');
|
||||
if (text.endsWith('\\n')) text = text.slice(0, -1);
|
||||
if (text.endsWith('\\r')) text = text.slice(0, -1);
|
||||
const args = parseArgs(text, 'trekendsession');
|
||||
const brief = args.positional[0] || '';
|
||||
const label = args.tail;
|
||||
if (brief === '' || label === '') { console.error('Error: missing required args.'); process.exit(1); }
|
||||
const root = '.claude/projects';
|
||||
const found = existsSync(root) ? readdirSync(root).filter((d) => existsSync(path.join(root, d, 'brief.md'))).map((d) => path.join(root, d)) : [];
|
||||
if (found.length === 0) { console.error('no .claude/projects/ directory with a brief.md found under cwd; cannot determine project directory'); process.exit(1); }
|
||||
if (found.length > 1) { console.error('more than one project directory holds a brief.md; cd into the intended one and retry:\\n' + found.join('\\n')); process.exit(1); }
|
||||
const dir = found[0];
|
||||
const now = new Date().toISOString();
|
||||
const stateObj = { schema_version: 1, project: dir, next_session_brief_path: brief, next_session_label: label, status: 'in_progress', updated_at: now };
|
||||
const stateFile = path.join(dir, '.session-state.local.json');
|
||||
|
|
@ -159,7 +179,9 @@ const promptBody = '---\\nproduced_by: trekendsession\\nproduced_at: ' + now + '
|
|||
writeFileSync(promptFile, promptBody);
|
||||
console.log(stateFile);
|
||||
console.log(promptFile);
|
||||
" '{project_dir}' '{next_brief_path}' '{next_label}'
|
||||
" <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
$ARGUMENTS
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
```
|
||||
|
||||
## Phase 4 — Validate + narrate
|
||||
|
|
|
|||
|
|
@ -1184,6 +1184,7 @@ progress file.
|
|||
"exit_condition_checked": false,
|
||||
"plan_verification": null,
|
||||
"end_to_end": null,
|
||||
"run_seen": null,
|
||||
"arch_rules": null,
|
||||
"scope_sweep": null,
|
||||
"simplify": null,
|
||||
|
|
@ -1206,6 +1207,7 @@ a progress file without them still validates and resumes:
|
|||
| `steps.{N}.fix_rounds` | Sub-step F | 0–5: 0 = passed its first control, 1–3 = fix rounds, 4 = sent up to the plan, 5 = sent up to the operator |
|
||||
| `steps.{N}.attempts` | Sub-steps C, F | helpers launched for the step: 1 + the fix rounds that launched one |
|
||||
| `end_to_end` | Phase 7 | `"verified" \| "not-verified"` (P9 (6)) |
|
||||
| `run_seen` | Phase 7 | `"yes" \| "no — {reason}"` — what Claude Code's built-in `run` showed in this run; `null` = not run (Phase 7 not reached) |
|
||||
| `decisions` | any phase | `[{step, decision, cost_if_wrong}]` — every call the executor made on its own (Phase 5.5) |
|
||||
|
||||
`progress-validator.mjs` rejects `fix_rounds` outside 0–5 (there is no sixth
|
||||
|
|
@ -1562,14 +1564,15 @@ finding 4):
|
|||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/park-attempt-files.mjs --diff {report dir}/step-{N}-upward.diff --step {N} --attempt {attempts}
|
||||
```
|
||||
It moves every file the attempt created that is still untracked to the git
|
||||
It moves every file new in the attempt's diff that is untracked, or staged but not in HEAD, to the git
|
||||
dir (`voyage/parked/step-{N}-attempt-{attempts}/`, outside the work tree, so
|
||||
Sub-step C.2 never registers it again): moved, never deleted, never over a file
|
||||
already parked. Name the `parked` paths and every `kept` path with its reason
|
||||
in the step report. Exit 1 means a file the attempt made is still in the tree
|
||||
(a header it could not read, a move that failed): stop and name it; exit 2 is
|
||||
a stop too (the tree was not checked). Then launch a fresh `build` helper for
|
||||
the revised step.
|
||||
a stop too, nothing unindexed or moved (HEAD could not be read, a path in HEAD
|
||||
is missing from the index, or another git call failed). Then launch a fresh
|
||||
`build` helper for the revised step.
|
||||
|
||||
- **Never pass the Agent tool's `name` parameter.** A named spawn becomes an
|
||||
`in_process_teammate`: it does the work, but its result never reaches this
|
||||
|
|
@ -1885,7 +1888,7 @@ session's effort, Sub-step E).
|
|||
| 4 | the plan | **After three failed fix rounds the question is the architecture, not a fourth fix.** The step goes back to planning (below). Sub-step B sends three answers here directly, with no fix round: a `"not-run"` caused by the `Verify:` command, a second `plan-unreadable` (the executor's own call still wrong after one correction), and a `not-red-at-base` with `tests_only: true` (a step of only tests). Stop class 4 |
|
||||
| 5 | the operator | A step already sent to the plan (`fix_rounds` 4) that fails its control again after re-planning goes to the operator. The executor never builds this step again: on a later `--resume` it only runs the checks (B, D1, D2, D3, D4, E) on whatever the operator changed. If they fail again the step stays at 5 — there is no sixth round, `fix_rounds` never exceeds 5 — and the run stops again. Stop class 4 |
|
||||
|
||||
**Back to planning** (`fix_rounds = 4`): save `git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --src-prefix=a/ --dst-prefix=b/ {start_sha}` (one form whatever the user's git config) to
|
||||
**Back to planning** (`fix_rounds = 4`): save `git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ {start_sha}` (one form whatever the user's git config) to
|
||||
`{report dir}/step-{N}-upward.diff` and write `{report dir}/step-{N}-upward.md`:
|
||||
the step verbatim, every round's findings and verdicts, and the question for
|
||||
the planner — which assumption in the step (its Changes, its Interface, its
|
||||
|
|
@ -1954,6 +1957,10 @@ or 5). Release the step's test lock before stopping
|
|||
command exactly as written in the plan. (The test files are listed apart
|
||||
from `Files:` in the plan; staging only `Files:` would leave the step's
|
||||
test uncommitted and charge it to the next step's diff.)
|
||||
`{hash}` is the full commit id: run `git rev-parse HEAD` after the step's
|
||||
last commit, never the short id `git commit` prints. Read it again after
|
||||
any later commit of the step (the second commit on the exit-1 path); when
|
||||
the commit failed, it is the HEAD the done line names.
|
||||
If the commit fails (nothing to commit, etc.): warn but do NOT fail the
|
||||
step. The step's verification already passed — the commit is bookkeeping.
|
||||
2. **The done line**, at the step's own commit. A step is done only when a
|
||||
|
|
@ -1992,7 +1999,7 @@ already passed) but is recorded as `checkpoint_drift: {expected_pattern,
|
|||
actual_message}` in the progress file. Phase 7.5 audit reports drift as
|
||||
an advisory.
|
||||
|
||||
Update progress: `steps.{N}.status = "completed"`, `steps.{N}.commit = {hash}`,
|
||||
Update progress: `steps.{N}.status = "completed"`, `steps.{N}.commit = {hash}` (the full commit id),
|
||||
`steps.{N}.completed_at = now`, `steps.{N}.manifest_audit = "pass"`.
|
||||
|
||||
### Step mode exit
|
||||
|
|
@ -2141,7 +2148,8 @@ rejects that as `PLAN_END_TO_END_NOT_VERIFIED`.)
|
|||
|
||||
Record `run_seen`: `yes` when the app was seen and driven with Claude Code's
|
||||
built-in `run` in this run, else `no — {reason}` (no app to launch, `run` not
|
||||
available, not started, and why). The verdict is still the `End-to-end:`
|
||||
available, not started, and why) in the progress file's top-level `run_seen`,
|
||||
so Phase 8 reads it from there. The verdict is still the `End-to-end:`
|
||||
command.
|
||||
|
||||
### Architecture rules (K5) and the plan-file sweep (K6)
|
||||
|
|
@ -2263,10 +2271,14 @@ never typed by the operator, in this order (Cherny's):
|
|||
run — no project directory", and the merge stays closed (Phase 8).
|
||||
**Its writes are committed.** `/trekreview` may have written approved
|
||||
project-memory lines (`.claude/rules/voyage-review-lessons.md`) and a
|
||||
one-line pointer in `CLAUDE.md`. Commit exactly those paths, when they
|
||||
changed, as `docs(voyage): review lessons (round {R})`: a process commit
|
||||
(Phase 7.5 step 3). Nothing the review wrote is left uncommitted for the
|
||||
next round's checks or for the merge.
|
||||
one-line pointer in `CLAUDE.md`. Commit only those of them that changed
|
||||
and git does not ignore (`git check-ignore -q -- {path}` exits 1; exit 0
|
||||
means ignored), as `docs(voyage): review lessons (round {R})`: a process
|
||||
commit (Phase 7.5 step 3). An ignored path is left uncommitted and named
|
||||
in the report; `/trekreview` wrote no pointer to an ignored lessons file
|
||||
(its `pointer` result is `"ignored-skipped"`), and none through a symlinked
|
||||
`CLAUDE.md` (`"symlink-skipped"`). Nothing the review wrote that git does not
|
||||
ignore is left uncommitted for the next round's checks or for the merge.
|
||||
4. **`verdict-gate.mjs`** — the review's verdict decides, as a command:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .
|
||||
|
|
@ -2610,7 +2622,10 @@ Exit 2 with a reason that starts `review-stale` is closed with the decline
|
|||
reason `review-stale` and the line "re-run /trekreview on this commit": the
|
||||
review is of another commit than this one, or something other than the
|
||||
review lessons and the `CLAUDE.md` pointer changed after it. Any other exit 2
|
||||
is `review-not-measured`.
|
||||
is `review-not-measured`. Exit 2 with a reason that starts `review-not-measured:`
|
||||
(the `--repo` path is missing, it is no git repository, or a git call failed)
|
||||
is reported as "not measured", never green, and the merge stays locked with
|
||||
the decline reason `review-not-measured`.
|
||||
|
||||
The merge runs only on exit 0 and a `completed` result. **Closed:** emit
|
||||
`main-merge-declined` with the reason `review-block`, `review-stale`,
|
||||
|
|
@ -2805,6 +2820,12 @@ completion). Phase 2.55 and Phase 4 cover the early-stop paths E1/E2.
|
|||
Phase 2.3 (validate exit) and Phase 5 (dry-run) intentionally do not write
|
||||
— neither path is resumable.
|
||||
|
||||
The report's **Seen with `run`:** line and the summary's `run_seen` are read
|
||||
from the progress file's `run_seen` (Phase 7 writes it), not from this
|
||||
session's memory, so a resumed run (`--resume`) reports it too, and on the
|
||||
multi-session path (Phase 2.6) the report gives each session's value from its
|
||||
archived progress copy (Step 6). A `null` there is reported as `no — not run`.
|
||||
|
||||
```
|
||||
## Ultraexecute Local Complete
|
||||
|
||||
|
|
|
|||
|
|
@ -1295,14 +1295,14 @@ Confirm the plan and brief file locations and exit.
|
|||
|
||||
Do not implement in this session. Print the line the operator gives
|
||||
`/trekexecute` — `/trekexecute --project {project_dir}`, or
|
||||
`/trekexecute {plan_path}` without a project directory — and stop.
|
||||
`/trekexecute {plan_path}` without a project directory — then run Phase 12 (`outcome: execute`).
|
||||
`/trekexecute` is what checks the plan's approval (K2), locks the tests, runs
|
||||
one step helper per step and the review.
|
||||
|
||||
### "execute with team" / "team"
|
||||
|
||||
There is no team path. Print the same `/trekexecute` line and stop; parallel
|
||||
work is `--decompose` followed by `/trekexecute`'s session orders.
|
||||
There is no team path. Print the same `/trekexecute` line, then run Phase 12
|
||||
(`outcome: execute_team`); parallel work is `--decompose` followed by `/trekexecute`'s session orders.
|
||||
|
||||
## Phase 12 — Session tracking
|
||||
|
||||
|
|
|
|||
|
|
@ -648,6 +648,10 @@ reformulated, not forced through. A gate that *could not run* is neither — no
|
|||
rewrite can clear it, so treat it as a hard stop and say so, rather than
|
||||
rewriting a query that was never the problem.
|
||||
|
||||
A turn that spawns an agent passes `model: phase_signal_result.model`, else
|
||||
`opus` — the agent may be one of Claude Code's built-in agents, which have no
|
||||
Voyage frontmatter to fall back to; see Launch rules.
|
||||
|
||||
**Empty turns.** A turn that returns no findings, or findings without
|
||||
citations, is marked `empty`. An empty turn is counted in `empty_turns` and
|
||||
does NOT re-target the same dimension — re-asking the same question of the
|
||||
|
|
@ -758,7 +762,10 @@ raw report. Specifically:
|
|||
- Reduce the report to ≥ 1 `### {Dimension} -- Confidence: {high|medium|low|contradictory}`
|
||||
entry, each carrying **External findings** bullets with per-claim source URLs.
|
||||
Local findings still come from the local agents (Phase 4) and are merged in per
|
||||
dimension as usual.
|
||||
dimension as usual. Wave 2's `contrarian-researcher` findings (Launch rules)
|
||||
are not part of the report: they are merged in per dimension as **External
|
||||
findings**, cited by URL like the report's, and where one disputes the
|
||||
report's leading answer, the dimension's **Contradictions** gives both sides.
|
||||
- Emit a numeric `confidence ∈ [0,1]` in frontmatter and a 3-sentence
|
||||
`## Executive Summary` (answer, confidence, key caveat).
|
||||
- Populate `## Sources` from the report's citations.
|
||||
|
|
|
|||
|
|
@ -46,10 +46,13 @@ command executes inline.
|
|||
## Phase 1 — Parse mode and validate input
|
||||
|
||||
Parse the arguments with the shared arg-parser. Run it first, exactly like
|
||||
this, before anything else:
|
||||
this, before anything else (the arguments reach the parser as the one line
|
||||
inside the quoted heredoc, on stdin, never as shell words):
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- $ARGUMENTS
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
$ARGUMENTS
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
```
|
||||
|
||||
It prints the parse as JSON on stdout. It also checks the required flag
|
||||
|
|
@ -62,7 +65,7 @@ FLAG_SCHEMA `trekreview` entry):
|
|||
|------|------|---------|
|
||||
| `--project <dir>` | valued | Required. Path to trekplan project folder containing `brief.md`. |
|
||||
| `--since <ref>` | valued | Optional. Override "before" SHA for the diff. Validated via `git rev-parse --verify`. |
|
||||
| `--quick` | boolean | Skip the brief-conformance pass; run only the code-correctness reviewer; skip the coordinator's reasonableness filter. |
|
||||
| `--quick` | boolean | Skip the brief-conformance pass; run only the code-correctness reviewer; skip the coordinator's reasonableness filter except the brief-conflict rule. |
|
||||
| `--validate` | boolean | Schema-only check on existing `{project_dir}/review.md`. No LLM calls. |
|
||||
| `--dry-run` | boolean | Print the discovered scope and triage map. Skip writes. |
|
||||
| `--fg` | boolean | No-op alias (foreground is default). |
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ Rule B is stated by the reviewers and recorded by the gate from round 1 (`lowere
|
|||
| `session_end_sha` | string | optional | git sha at execute end |
|
||||
| `steps` | object | yes | Map of step number → step record |
|
||||
| `end_to_end` | string | optional | `verified \| not-verified` — a passing run-the-thing criterion in the plan's `## Verification`; `not-verified` turns `completed` into `partial` (wave 1, P9 (6)) |
|
||||
| `run_seen` | string \| null | optional | `yes \| no — {reason}` — what Claude Code's built-in `run` showed, recorded in Phase 7; `null` = not run; additive-optional (fix round 4.5b): a file without it validates and resumes, no `schema_version` bump; Phase 8 reads it from here, so the resume and multi-session paths report it |
|
||||
| `decisions` | array | optional | `[{step, decision, cost_if_wrong}]` — every call the executor made that the plan did not dictate; both strings non-empty (wave 1, P4) |
|
||||
|
||||
**Per-step record:**
|
||||
|
|
@ -368,7 +369,7 @@ Rule B is stated by the reviewers and recorded by the gate from round 1 (`lowere
|
|||
| `attempts` | number | 1..N |
|
||||
| `error` | string \| null | |
|
||||
| `completed_at` | ISO string \| null | |
|
||||
| `commit` | string \| null | git sha after Checkpoint |
|
||||
| `commit` | string \| null | full commit id (`git rev-parse HEAD`) after Checkpoint |
|
||||
| `manifest_audit` | string | `pass \| fail \| pass-with-note \| n/a` |
|
||||
| `note` | string | optional human-readable annotation |
|
||||
| `start_sha` | string \| null | optional (wave 1) — git sha the step started from; every check of the step reads `git diff {start_sha}` |
|
||||
|
|
@ -431,7 +432,7 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
|
|||
|
||||
**Consumer:** `/trekplan` Phase 1 when `--brief review.md` is supplied and the consumer detects `type: trekreview` in frontmatter. The plan command branches into a remediation-plan path: BLOCKER + MAJOR findings become plan goals, the produced `plan.md` carries a `source_findings: [<id>, ...]` frontmatter list as the audit trail back to the consumed findings. MINOR + SUGGESTION are skipped for plan-input. Since 1.1 every BLOCKER is also written as a numbered `### Step R<n>` under `## Remediation Steps` (Files · Source finding · Test first · Verify · On failure), so a consumer can append it as a plan step without rewording it.
|
||||
|
||||
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end` is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block) and the `CLAUDE.md` pointer (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
|
||||
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end`, read only as a full 40- or 64-hex commit id (a short id or a ref name is stale), is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block) and the `CLAUDE.md` pointer (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. When the gate cannot look (the `--repo` path is missing, it is no git repository, or a git call failed), the exit is 2 with a reason that starts `review-not-measured:`, never `review-stale:`: the merge lock reports it as not measured, never green. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
|
||||
|
||||
**Path conventions:**
|
||||
- Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically).
|
||||
|
|
@ -442,12 +443,12 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
|
|||
| Field | Type | Required | Allowed values | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `type` | string | yes | `trekreview` | Hard-coded discriminator |
|
||||
| `review_version` | string | yes | `"1.0"` (current) | Bump on schema change |
|
||||
| `review_version` | string | yes | `"1.1"` (current) | Bump on schema change |
|
||||
| `task` | string | yes | one-line description | Mirrors brief task |
|
||||
| `slug` | string | yes | URL-safe slug | Used in project_dir |
|
||||
| `project_dir` | string | yes | `.claude/projects/{date}-{slug}/` | |
|
||||
| `brief_path` | string | yes | path to consumed `brief.md` | Audit trail back to brief |
|
||||
| `scope_sha_end` | string | yes | git sha of HEAD at review time | Defines "after" boundary |
|
||||
| `scope_sha_end` | string | yes | git sha of HEAD at review time, as a full 40- or 64-hex commit id | Defines "after" boundary. The verdict gate reads only a full 40- or 64-hex commit id (M11): a short id or a ref name can also be a branch, which resolves to wherever it points now |
|
||||
| `reviewed_files_count` | number | yes | ≥ 0 | From triage gate Coverage |
|
||||
| `findings` | list | yes | block-style YAML list of 40-char hex IDs; `[]` when empty | Flat array; full objects in body. A bare `findings:` is null and fails |
|
||||
| `verdict` | string | yes (1.1; strict at write, warning at read) | `BLOCK \| WARN \| ALLOW` | Coordinator output; equals the trailing JSON block's `verdict` (K7) |
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ Every mode that executes refuses a plan whose approval in the approval log does
|
|||
| _(default)_ | Claude Code `/code-review` + `/security-review` (engine, via the Skill tool, never `--fix`), then brief-conformance + code-correctness reviewers in parallel, coordinator dedup + verdict, write `{project_dir}/review.md` with the required `verdict:`, decide with `lib/review/verdict-gate.mjs` (0 ALLOW/WARN · 1 BLOCK · 2 never green), propose recurring finding classes as project memory (operator approves each line) |
|
||||
| `--project <dir>` | **Required.** Path to trekplan project folder containing `brief.md`. Review is written to `{dir}/review.md` |
|
||||
| `--since <ref>` | Override "before" SHA for the diff range. Validated via `git rev-parse --verify` |
|
||||
| `--quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter — fast correctness-only pass |
|
||||
| `--quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter except the brief-conflict rule — fast correctness-only pass |
|
||||
| `--validate` | Schema-only check on existing `{dir}/review.md` plus the verdict gate. No LLM calls. A 1.0 review without `verdict:` fails here (it still seeds a plan in soft mode) |
|
||||
| `--dry-run` | Print discovered scope + triage map; skip writes |
|
||||
| `--fg` | No-op alias (foreground is default) |
|
||||
|
|
|
|||
|
|
@ -258,6 +258,52 @@ const deobfuscated = normalizeBashExpansion(command);
|
|||
const normalized = normalizeCommand(deobfuscated);
|
||||
const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g, ''));
|
||||
|
||||
// BEGIN args-heredoc-closed-early
|
||||
// The commands hand $ARGUMENTS to lib/parsers/arg-parser.mjs (--stdin) through
|
||||
// a quoted heredoc closed by VOYAGE_ARGS_EOF_7F3C9E. A typed line equal to that
|
||||
// delimiter would close the heredoc there, and the shell would run every line
|
||||
// after it as a command (fix round 4.5b, R6). This check reads the raw
|
||||
// command: `normalized` and `commandView` collapse lines and drop heredoc
|
||||
// bodies (hooks/lib/bash-view.mjs). Every opener of the delimiter in the
|
||||
// command is checked, so a quoted first mention cannot hide a later unquoted
|
||||
// heredoc. bash reads a heredoc body only from the lines after the opener's
|
||||
// line, so an opener with no non-blank line after its own line (a one-line
|
||||
// grep or commit message that only mentions it) has no body: nothing can
|
||||
// close early and an unquoted opener has nothing to expand, so it is skipped.
|
||||
// An opener with a non-blank line after it is blocked unless it is quoted
|
||||
// (<<'D', <<"D" or <<\D, whose body is not expanded) and exactly one line,
|
||||
// trimmed, equals the delimiter with every line after that one empty. A
|
||||
// command that never names the delimiter skips the whole check.
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
if (command.includes(ARGS_DELIM)) {
|
||||
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
|
||||
const argsLines = command.split('\n');
|
||||
const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
|
||||
const closesOnceAtEnd = argsClosing.length === 1 &&
|
||||
argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === '');
|
||||
for (const argsOpener of argsOpeners) {
|
||||
// Some line after the opener's line is non-blank (\s is the set trim() strips).
|
||||
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
|
||||
if (!hasBody) continue;
|
||||
const [, open, close] = argsOpener;
|
||||
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
|
||||
if (!quoted || !closesOnceAtEnd) {
|
||||
const why = !quoted
|
||||
? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` +
|
||||
`Way forward: open it as <<'${ARGS_DELIM}'.`
|
||||
: `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` +
|
||||
'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.';
|
||||
process.stderr.write(
|
||||
'[voyage] BLOCKED: args-heredoc-closed-early\n' +
|
||||
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
|
||||
` ${why}\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
// END args-heredoc-closed-early
|
||||
|
||||
// Check BLOCK rules first
|
||||
for (const rule of BLOCK_RULES) {
|
||||
if (rule.pattern.test(rule.commandView ? commandView : normalized)) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@
|
|||
//
|
||||
// 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.
|
||||
//
|
||||
// The commands hand $ARGUMENTS to the CLI as stdin text through a quoted
|
||||
// heredoc (`--stdin`), never as shell words, so nothing in the arguments is
|
||||
// expanded: no `~`, no `$VAR` or `$HOME`, no glob, no command substitution,
|
||||
// and `;` is an ordinary character. Write a path out in full; `~/x` stays the
|
||||
// three characters `~/x`. Quoting is the parser's own (see `tokenize`).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
const FLAG_SCHEMA = {
|
||||
|
|
@ -54,9 +61,14 @@ const FLAG_SCHEMA = {
|
|||
* positional: string[],
|
||||
* unknown: string[],
|
||||
* errors: Array<{code: string, message: string}>,
|
||||
* tail: string,
|
||||
* }}
|
||||
* `tail` is the raw text after the first token, trimmed, with one balanced
|
||||
* quote pair around the whole of it removed ('' when there is none): a
|
||||
* label read whole, with its own spaces and apostrophes.
|
||||
*/
|
||||
export function parseArgs(argString, command) {
|
||||
const tail = tailOf(argString);
|
||||
const schema = FLAG_SCHEMA[command];
|
||||
if (!schema) {
|
||||
return {
|
||||
|
|
@ -65,10 +77,11 @@ export function parseArgs(argString, command) {
|
|||
positional: [],
|
||||
unknown: [],
|
||||
errors: [{ code: 'ARG_UNKNOWN_COMMAND', message: `Unknown command: ${command}` }],
|
||||
tail,
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = tokenize(argString);
|
||||
const tokens = tokenize(argString).map((t) => t.value);
|
||||
const flags = {};
|
||||
const positional = [];
|
||||
const unknown = [];
|
||||
|
|
@ -115,22 +128,68 @@ export function parseArgs(argString, command) {
|
|||
unknown.push(tok);
|
||||
}
|
||||
|
||||
return { command, flags, positional, unknown, errors };
|
||||
return { command, flags, positional, unknown, errors, tail };
|
||||
}
|
||||
|
||||
function tokenize(s) {
|
||||
if (typeof s !== 'string') return [];
|
||||
const trimmed = s.trim();
|
||||
if (trimmed === '') return [];
|
||||
const isSpace = (c) => /\s/.test(c);
|
||||
|
||||
/**
|
||||
* Split argument text into words. Words are separated by whitespace. A word
|
||||
* that starts with `'` or `"` opens a quote. Its closing quote is the next
|
||||
* same-kind quote that ends a word (followed by whitespace or the end of the
|
||||
* text) and does not start one; a same-kind quote inside a word is skipped.
|
||||
* The search stops at a same-kind quote that starts a word (one preceded by
|
||||
* whitespace): then there is no pair. A pair groups the words between the
|
||||
* quotes into one value and is removed. A quote with no partner is a literal
|
||||
* character (`user's` stays `user's`). Examples: `'Fix the user's login'` is
|
||||
* one value, `Fix the user's login`; `"my dir"/x --since "HEAD~1"` gives the
|
||||
* literal words `"my` and `dir"/x`, then `--since` and `HEAD~1`, so a quoted
|
||||
* value never swallows a later flag. Nothing is expanded: the function only
|
||||
* slices the string.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{ value: string, start: number, end: number }>}
|
||||
* `start` / `end` are offsets into `text` (end exclusive), quotes included.
|
||||
*/
|
||||
export function tokenize(text) {
|
||||
if (typeof text !== 'string') return [];
|
||||
const out = [];
|
||||
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(trimmed)) !== null) {
|
||||
out.push(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]);
|
||||
const n = text.length;
|
||||
let i = 0;
|
||||
while (i < n) {
|
||||
while (i < n && isSpace(text[i])) i++;
|
||||
if (i >= n) break;
|
||||
const start = i;
|
||||
const q = text[i];
|
||||
if (q === '"' || q === "'") {
|
||||
let j = text.indexOf(q, i + 1);
|
||||
while (j !== -1 && !isSpace(text[j - 1]) && j + 1 < n && !isSpace(text[j + 1])) j = text.indexOf(q, j + 1);
|
||||
if (j !== -1 && !isSpace(text[j - 1])) {
|
||||
out.push({ value: text.slice(i + 1, j), start, end: j + 1 });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
while (i < n && !isSpace(text[i])) i++;
|
||||
out.push({ value: text.slice(start, i), start, end: i });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The raw text after the first token, trimmed; one balanced quote pair that
|
||||
// encloses the whole of it is removed. '' when there is no second token.
|
||||
function tailOf(text) {
|
||||
const tokens = tokenize(text);
|
||||
if (tokens.length === 0) return '';
|
||||
const rest = text.slice(tokens[0].end).trim();
|
||||
const inner = tokenize(rest);
|
||||
if (inner.length === 1 && inner[0].start === 0 && inner[0].end === rest.length
|
||||
&& (rest[0] === '"' || rest[0] === "'") && inner[0].value.length === rest.length - 2) {
|
||||
return inner[0].value;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
export { FLAG_SCHEMA };
|
||||
|
||||
// Flags a command cannot run without, and its usage line. The CLI below emits
|
||||
|
|
@ -144,16 +203,41 @@ 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.
|
||||
// CLI: node lib/parsers/arg-parser.mjs --command <command> --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
// $ARGUMENTS
|
||||
// VOYAGE_ARGS_EOF_7F3C9E
|
||||
// reads the whole stdin as the argument text (one trailing newline
|
||||
// removed). The quoted heredoc keeps the shell from expanding anything; the
|
||||
// Bash guard (hooks/scripts/pre-bash-executor.mjs, args-heredoc-closed-early)
|
||||
// refuses a command in which that heredoc would close early.
|
||||
// CLI: node lib/parsers/arg-parser.mjs --command <command> -- <word>...
|
||||
// the older argv form, kept: the words after `--` are the arguments.
|
||||
// stdout: the parse result as JSON (with `tail`). exit 0, or exit 1 with
|
||||
// each error as "Error: <message>" plus the usage line on stderr.
|
||||
if (isMain(import.meta.url)) {
|
||||
const argv = process.argv.slice(2);
|
||||
const ci = argv.indexOf('--command');
|
||||
const command = ci >= 0 ? argv[ci + 1] : undefined;
|
||||
const dd = argv.indexOf('--');
|
||||
// `--stdin` counts only before `--`: after it, it is an argument word.
|
||||
const options = dd >= 0 ? argv.slice(0, dd) : argv;
|
||||
let r;
|
||||
if (options.includes('--stdin')) {
|
||||
let text = '';
|
||||
let readError = null;
|
||||
try {
|
||||
text = readFileSync(0, 'utf8');
|
||||
} catch (e) {
|
||||
readError = e;
|
||||
}
|
||||
r = parseArgs(text.replace(/\r?\n$/, ''), command);
|
||||
if (readError) {
|
||||
r.errors.push({ code: 'ARG_STDIN_UNREADABLE', message: `could not read the arguments from stdin (${readError.code || readError.message})` });
|
||||
}
|
||||
} else {
|
||||
const rest = dd >= 0 ? argv.slice(dd + 1) : [];
|
||||
const r = parseArgs(rest.map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), command);
|
||||
r = parseArgs(rest.map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), command);
|
||||
}
|
||||
const req = REQUIRED[command];
|
||||
if (req) {
|
||||
for (const [flag, metavar] of req.flags) {
|
||||
|
|
|
|||
|
|
@ -34,13 +34,14 @@
|
|||
// node lib/review/recurrence.mjs propose --repo <dir>
|
||||
// → {"proposals":[…], "reviews_read": N, "skipped": [...]} on stdout, exit 0; exit 2 = could not run
|
||||
// node lib/review/recurrence.mjs apply --repo <dir> --rule-key <KEY> [--line "<edited line>"]
|
||||
// → {"written":true|false, "reason":…} on stdout; exit 0 written, 1 refused, 2 could not run
|
||||
// → {"written":true|false, "reason":…, "pointer":…} on stdout (the pointer result,
|
||||
// see applyLesson); exit 0 written, 1 refused, 2 could not run
|
||||
|
||||
import {
|
||||
readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync, lstatSync,
|
||||
} from 'node:fs';
|
||||
import { join, relative, dirname } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join, relative, isAbsolute, sep, delimiter, resolve, dirname } from 'node:path';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { validateReviewContent } from '../validators/review-validator.mjs';
|
||||
import { extractFindingsBlock } from './findings-schema.mjs';
|
||||
import { getRule } from './rule-catalogue.mjs';
|
||||
|
|
@ -61,7 +62,7 @@ export const DEFAULT_BUDGET_CHARS = 6000;
|
|||
export const LINE_MAX = 700;
|
||||
export const RECURRENCE_THRESHOLD = 2;
|
||||
|
||||
const BLOCK_HEADER = 'Each line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake · fix · how to check.';
|
||||
export const BLOCK_HEADER = 'Each line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake · fix · how to check.';
|
||||
|
||||
/** How to check a mechanical class. Judgement classes get the review itself. */
|
||||
const CHECK_HINTS = Object.freeze({
|
||||
|
|
@ -276,14 +277,130 @@ export function proposeLessons(repo) {
|
|||
|
||||
// ---- writing an approved lesson ---------------------------------------------
|
||||
|
||||
function atomicWriteText(path, text) {
|
||||
// Write THROUGH a symlink (CLAUDE.md -> AGENTS.md is common): rename onto the
|
||||
// real target, so the link survives and the target gets the change.
|
||||
if (existsSync(path) && lstatSync(path).isSymbolicLink()) path = realpathSync(path);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
/** lstat that answers null for a path that does not exist. */
|
||||
function lstatOrNull(path) {
|
||||
try { return lstatSync(path); } catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `text` to `<repo>/<rel>` atomically, never outside the repo (R3).
|
||||
* Each directory of `rel` is checked one level at a time, parent first: an
|
||||
* existing one must resolve (realpath) to the repo's real path or inside it; a
|
||||
* missing one is created alone (non-recursive) once its parent passed, so no
|
||||
* directory is ever made through a link that leaves the repo. A file that is
|
||||
* itself a symlink is refused, never written through.
|
||||
* @param {string} repo
|
||||
* @param {string} rel path relative to the repo
|
||||
* @param {string} text
|
||||
* @returns {{ written: true } | { written: false, reason: string }}
|
||||
*/
|
||||
function atomicWriteText(repo, rel, text) {
|
||||
const repoReal = realpathSync(repo);
|
||||
const inside = (real) => {
|
||||
const r = relative(repoReal, real);
|
||||
return r === '' || (r !== '..' && !r.startsWith(`..${sep}`) && !isAbsolute(r));
|
||||
};
|
||||
const parts = rel.split(/[\\/]+/).filter(Boolean);
|
||||
let dir = repo;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
dir = join(dir, parts[i]);
|
||||
const shown = parts.slice(0, i + 1).join('/');
|
||||
if (lstatOrNull(dir)) {
|
||||
const real = realpathSync(dir);
|
||||
if (!inside(real)) {
|
||||
return { written: false, reason: `directory-outside-repo: ${shown} points outside the repo (${real}); nothing written` };
|
||||
}
|
||||
} else {
|
||||
mkdirSync(dir);
|
||||
}
|
||||
}
|
||||
const path = join(repo, ...parts);
|
||||
const st = lstatOrNull(path);
|
||||
if (st && st.isSymbolicLink()) {
|
||||
return { written: false, reason: `symlink-refused: ${parts.join('/')} is a symlink; it is not written through` };
|
||||
}
|
||||
const tmp = `${path}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, text);
|
||||
renameSync(tmp, path);
|
||||
return { written: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the lessons file ignored by git? `git check-ignore -q` exit 0 = ignored,
|
||||
* 1 = not ignored; a spawn error or any exit other than 0, 1 or 128 is a
|
||||
* failed check. Exit 128 means "not a git repository" only some of the time:
|
||||
* inside a repository it also comes from a path beyond a symbolic link, a
|
||||
* broken `.git/HEAD` or a `GIT_DIR` that does not exist (CKI), and the probe's
|
||||
* own `git rev-parse --git-dir` exits 128 too on dubious ownership, a bad
|
||||
* `.git/config` or a garbage `.git/HEAD`. So on 128 a
|
||||
* probe decides (see notARepoReason), and only a directory that is truly no
|
||||
* repository gives `no-git-repo`; everything else, including a probe that
|
||||
* throws, is `failed`. No branch reads git's message text, so the answer does
|
||||
* not depend on the language git speaks. Every `failed` note after a 128
|
||||
* starts `git check-ignore exit 128: <first stderr line>` and adds the reason.
|
||||
* @returns {{ state: 'ignored' | 'not-ignored' | 'no-git-repo' | 'failed', note?: string }}
|
||||
*/
|
||||
function lessonsIgnored(repo, rel) {
|
||||
const r = spawnSync('git', ['-C', repo, 'check-ignore', '-q', '--', rel], { encoding: 'utf-8', stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
if (r.error) return { state: 'failed', note: `git check-ignore could not start: ${r.error.message}` };
|
||||
if (r.status === 0) return { state: 'ignored' };
|
||||
if (r.status === 1) return { state: 'not-ignored' };
|
||||
const err = String(r.stderr ?? '').trim().split('\n')[0] ?? '';
|
||||
if (r.status === 128) {
|
||||
const head = `git check-ignore exit 128: ${err}`;
|
||||
try {
|
||||
const why = notARepoReason(repo);
|
||||
return why === null ? { state: 'no-git-repo' } : { state: 'failed', note: `${head}; ${why}` };
|
||||
} catch (e) {
|
||||
return { state: 'failed', note: `${head}; the repository check threw: ${e.message}` };
|
||||
}
|
||||
}
|
||||
const how = r.status === null ? `signal ${r.signal}` : `exit ${r.status}`;
|
||||
return { state: 'failed', note: `git check-ignore ${how}${err ? `: ${err}` : ''}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* After a check-ignore exit 128: null when `repo` is no git repository, else
|
||||
* the reason it may be one. `git rev-parse --git-dir` exit 0 proves a
|
||||
* repository; its exit 128 alone proves nothing (see lessonsIgnored), so then
|
||||
* the answer is null only when GIT_DIR is unset and no `.git` entry (file or
|
||||
* directory) is found by gitEntryDir. A spawn error or any other exit is a
|
||||
* reason too. May throw (realpath of a vanished repo); the caller reads that
|
||||
* as a failed check.
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function notARepoReason(repo) {
|
||||
const p = spawnSync('git', ['-C', repo, 'rev-parse', '--git-dir'], { encoding: 'utf-8', stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
if (p.error) return `git rev-parse --git-dir could not start: ${p.error.message}`;
|
||||
if (p.status !== 128) return `git rev-parse --git-dir ${p.status === null ? `signal ${p.signal}` : `exit ${p.status}`}`;
|
||||
if (process.env.GIT_DIR !== undefined) return 'GIT_DIR is set';
|
||||
const found = gitEntryDir(repo);
|
||||
return found === null ? null : `a .git entry exists in ${found}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory holding a `.git` entry, walking up from `realpath(repo)`, or
|
||||
* null. `realpath(repo)` itself is always checked; the walk stops before the
|
||||
* nearest proper ancestor that equals a GIT_CEILING_DIRECTORIES entry (split
|
||||
* on the path delimiter, empty entries skipped, each entry realpath'd, or
|
||||
* resolved when that throws), and without one it goes up to and including
|
||||
* `/`. An entry equal to `realpath(repo)` stops nothing, as in git. Unlike
|
||||
* git, the walk does not stop at a filesystem boundary: crossing one can only
|
||||
* find a `.git` git would not, which reads as a failed check (the safe side).
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function gitEntryDir(repo) {
|
||||
const ceilings = new Set(String(process.env.GIT_CEILING_DIRECTORIES ?? '')
|
||||
.split(delimiter)
|
||||
.filter((e) => e !== '')
|
||||
.map((e) => { try { return realpathSync(e); } catch { return resolve(e); } }));
|
||||
let dir = realpathSync(repo);
|
||||
for (;;) {
|
||||
if (existsSync(join(dir, '.git'))) return dir;
|
||||
const up = dirname(dir);
|
||||
if (up === dir || ceilings.has(up)) return null;
|
||||
dir = up;
|
||||
}
|
||||
}
|
||||
|
||||
export function withPointer(claudeText) {
|
||||
|
|
@ -298,7 +415,16 @@ export function withPointer(claudeText) {
|
|||
* @param {string} repo
|
||||
* @param {string} ruleKey
|
||||
* @param {{ line?: string, budgetEntries?: number, budgetChars?: number }} [opts]
|
||||
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string }}
|
||||
* The result's `pointer` (when written): 'written' | 'present' | 'no-claude-md' |
|
||||
* 'symlink-skipped' (CLAUDE.md is a symlink, DJ4) | 'ignored-skipped' (git
|
||||
* ignores the lessons file, PTR) | 'check-failed' (git check-ignore did not
|
||||
* answer, or answered exit 128 for a directory that may be a repository, see
|
||||
* lessonsIgnored; `pointer_note` says how, and always names check-ignore).
|
||||
* Only a directory that is truly no repository gets the pointer without the
|
||||
* check.
|
||||
* Nothing is written through a symlinked
|
||||
* lessons file or a lessons directory outside the repo (R3): written false.
|
||||
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string, pointer_note?: string }}
|
||||
*/
|
||||
export function applyLesson(repo, ruleKey, opts = {}) {
|
||||
const budgetEntries = opts.budgetEntries ?? DEFAULT_BUDGET_ENTRIES;
|
||||
|
|
@ -333,16 +459,36 @@ export function applyLesson(repo, ruleKey, opts = {}) {
|
|||
return { written: false, reason: 'over-budget', detail: 'the managed block is full — the lessons belong in a skill now (operator decision)' };
|
||||
}
|
||||
const next = base.slice(0, block.start + MARK_START.length) + innerAfter + base.slice(block.end);
|
||||
atomicWriteText(path, next);
|
||||
const rel = `${LESSONS_DIR}/${LESSONS_FILE_NAME}`.split(/[\\/]+/).join('/'); // git and the report use '/'
|
||||
const wrote = atomicWriteText(repo, rel, next);
|
||||
if (!wrote.written) return { written: false, reason: wrote.reason };
|
||||
|
||||
// The CLAUDE.md pointer. A symlinked CLAUDE.md is skipped, never written
|
||||
// through: committing the link's target would change the R6 exception (DJ4).
|
||||
// A lessons file git ignores gets no pointer: it would point at a file the
|
||||
// commit does not carry (PTR). A failed ignore check writes no pointer.
|
||||
let pointer = 'no-claude-md';
|
||||
let pointerNote;
|
||||
const claudePath = join(repo, 'CLAUDE.md');
|
||||
if (existsSync(claudePath)) {
|
||||
const claudeStat = lstatOrNull(claudePath);
|
||||
if (claudeStat && claudeStat.isSymbolicLink()) {
|
||||
pointer = 'symlink-skipped';
|
||||
} else if (claudeStat) {
|
||||
const ignored = lessonsIgnored(repo, rel);
|
||||
if (ignored.state === 'ignored') pointer = 'ignored-skipped';
|
||||
else if (ignored.state === 'failed') { pointer = 'check-failed'; pointerNote = ignored.note; }
|
||||
else {
|
||||
const updated = withPointer(readFileSync(claudePath, 'utf-8'));
|
||||
if (updated === null) pointer = 'present';
|
||||
else { atomicWriteText(claudePath, updated); pointer = 'written'; }
|
||||
else {
|
||||
// Refused only when CLAUDE.md turned into a symlink since the lstat above.
|
||||
pointer = atomicWriteText(repo, 'CLAUDE.md', updated).written ? 'written' : 'symlink-skipped';
|
||||
}
|
||||
return { written: true, reason: 'written', path: relative(repo, path), pointer };
|
||||
}
|
||||
}
|
||||
const result = { written: true, reason: 'written', path: relative(repo, path), pointer };
|
||||
if (pointerNote !== undefined) result.pointer_note = pointerNote;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- CLI --------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -13,21 +13,36 @@
|
|||
// where {prefix} is the project's place in the repo (`git rev-parse
|
||||
// --show-prefix`);
|
||||
// - in each such commit the lessons file is unchanged outside its managed
|
||||
// block, and CLAUDE.md is unchanged or gains exactly the pointer block
|
||||
// block, every line its block gains is a lesson line (`validateLessonLine`)
|
||||
// or exactly `BLOCK_HEADER` (m4), and CLAUDE.md is unchanged or gains exactly the pointer block
|
||||
// (`withPointer` in lib/review/recurrence.mjs);
|
||||
// - a commit with no parent in that range fails closed, and so does a
|
||||
// symlinked CLAUDE.md (the pointer write goes through the link, so its
|
||||
// target shows as a changed path).
|
||||
//
|
||||
// No CLI of its own: the command is `lib/review/verdict-gate.mjs --repo <dir>`,
|
||||
// which exits 2 (never green) with the reason `review-stale: …` when
|
||||
// `checkReviewedCommit` is not ok. Every git call is an argv array with no
|
||||
// shell; a git call that fails is a stale review (fails closed), never ok.
|
||||
// which exits 2 (never green) with the reason when `checkReviewedCommit` is
|
||||
// not ok. Two kinds of reason (M4):
|
||||
//
|
||||
// - `review-stale: …` — the gate looked, and the review is of another
|
||||
// commit (or something else changed after it);
|
||||
// - `review-not-measured: …` — the gate could not look: the `--repo` path
|
||||
// is missing, it is no git repository, or a git call failed. That is not
|
||||
// a finding about the review, so it is never reported as stale.
|
||||
//
|
||||
// Every git call is an argv array with no shell; a failed git call fails
|
||||
// closed (not measured), never ok.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { LESSONS_FILE_NAME, MARK_START, MARK_END, withPointer } from './recurrence.mjs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { LESSONS_FILE_NAME, MARK_START, MARK_END, BLOCK_HEADER, withPointer, validateLessonLine } from './recurrence.mjs';
|
||||
|
||||
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
||||
/**
|
||||
* A full commit id: 40 hex (SHA-1) or 64 hex (SHA-256), nothing shorter (M11).
|
||||
* A short id can also be a branch name, which resolves to wherever the branch
|
||||
* points now (DJ3).
|
||||
*/
|
||||
export const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
||||
const LESSONS_REL = `.claude/rules/${LESSONS_FILE_NAME}`; // posix, as `git diff` prints it
|
||||
const CLAUDE_REL = 'CLAUDE.md';
|
||||
const SYMLINK_MODE = '120000';
|
||||
|
|
@ -41,8 +56,9 @@ export function reviewedSha(reviewText) {
|
|||
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(reviewText ?? ''));
|
||||
const m = fm && /^scope_sha_end:[ \t]*(["']?)([^"'\s#]*)\1[ \t]*(?:#.*)?$/m.exec(fm[1]);
|
||||
// A commit sha only: a ref name (HEAD, main, a tag) resolves to whatever it
|
||||
// points at now, and coverage would pass whatever was reviewed (round 4).
|
||||
return m && SHA_RE.test(m[2]) ? m[2] : '';
|
||||
// points at now, and coverage would pass whatever was reviewed (round 4);
|
||||
// so can a short id that is also a branch name (M11).
|
||||
return m && FULL_SHA_RE.test(m[2]) ? m[2] : '';
|
||||
}
|
||||
|
||||
function git(repo, args) {
|
||||
|
|
@ -53,30 +69,66 @@ function stale(reason) {
|
|||
return { ok: false, reason: `review-stale: ${reason}` };
|
||||
}
|
||||
|
||||
/** The gate could not look (M4): exit 2 like a stale review, but never called one. */
|
||||
function notMeasured(reason) {
|
||||
return { ok: false, reason: `review-not-measured: ${reason}` };
|
||||
}
|
||||
|
||||
/** What a git call said: its spawn error, else the first line of its stderr. */
|
||||
function gitNote(r) {
|
||||
return r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0];
|
||||
}
|
||||
|
||||
/** A failed git call as a stale reason: fails closed. */
|
||||
/** A failed git call: not measured, fails closed. */
|
||||
function gitFailed(what, r) {
|
||||
return stale(`git ${what} failed (${gitNote(r) || `exit ${r.status}`})`);
|
||||
return notMeasured(`git ${what} failed (${gitNote(r) || `exit ${r.status}`})`);
|
||||
}
|
||||
|
||||
const short = (c) => c.slice(0, 12);
|
||||
|
||||
/** Where the managed block's start marker (`s`) and end marker (`e`) are; null when either is missing. */
|
||||
function blockBounds(text) {
|
||||
const s = text.indexOf(MARK_START);
|
||||
if (s === -1) return null;
|
||||
const e = text.indexOf(MARK_END, s + MARK_START.length);
|
||||
if (e === -1) return null;
|
||||
return { s, e };
|
||||
}
|
||||
|
||||
/** The lessons text with its managed block (start marker to end marker, plus its newline) taken out. */
|
||||
function outsideBlock(text) {
|
||||
const s = text.indexOf(MARK_START);
|
||||
if (s === -1) return text;
|
||||
const e = text.indexOf(MARK_END, s + MARK_START.length);
|
||||
if (e === -1) return text;
|
||||
const b = blockBounds(text);
|
||||
if (!b) return text;
|
||||
const { s, e } = b;
|
||||
let end = e + MARK_END.length;
|
||||
if (text.startsWith('\r\n', end)) end += 2;
|
||||
else if (text[end] === '\n') end += 1;
|
||||
return text.slice(0, s) + text.slice(end);
|
||||
}
|
||||
|
||||
/** The non-blank lines inside the managed block (the same block `outsideBlock` takes out); none without one. */
|
||||
function blockLines(text) {
|
||||
const b = blockBounds(text);
|
||||
if (!b) return [];
|
||||
return text.slice(b.s + MARK_START.length, b.e).split(/\r?\n/).filter((l) => l.trim() !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines in `after`'s block that `before`'s block does not hold, counted as
|
||||
* a multiset (a second copy of a line already there is added).
|
||||
*/
|
||||
function addedBlockLines(before, after) {
|
||||
const left = new Map();
|
||||
for (const l of blockLines(before)) left.set(l, (left.get(l) ?? 0) + 1);
|
||||
const added = [];
|
||||
for (const l of blockLines(after)) {
|
||||
const n = left.get(l) ?? 0;
|
||||
if (n > 0) left.set(l, n - 1);
|
||||
else added.push(l);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file in one tree: `{ ok, mode, text }`; absent reads as mode null, text ''.
|
||||
* `--full-tree`: the path is from the repo root, as `git diff` prints it.
|
||||
|
|
@ -96,19 +148,31 @@ function blobAt(repo, rev, path) {
|
|||
* Is the review of `sha` a review of what HEAD holds in `repo`?
|
||||
* @param {{ repo: string, sha: string }} args
|
||||
* @returns {{ ok: true } | { ok: false, reason: string }} reason starts `review-stale:`
|
||||
* (the review is of another commit) or `review-not-measured:` (the gate could not look)
|
||||
*/
|
||||
export function checkReviewedCommit({ repo, sha } = {}) {
|
||||
if (typeof repo !== 'string' || repo === '') return stale('no repository given');
|
||||
if (!existsSync(repo)) return notMeasured(`--repo ${repo} does not exist`);
|
||||
const given = String(sha ?? '');
|
||||
const notCommit = `reviewed commit ${given} is not a commit in this repository`;
|
||||
// 1. Hex only, so a value like `--output=/x` never reaches git as an option.
|
||||
if (!SHA_RE.test(given)) return stale(notCommit);
|
||||
// 1. Full hex only, so a value like `--output=/x` never reaches git as an option.
|
||||
if (!FULL_SHA_RE.test(given)) return stale(notCommit);
|
||||
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${given}^{commit}`]);
|
||||
// `--quiet` makes an unknown commit exit 1 with nothing on stderr: that is
|
||||
// stale. A spawn error, any other exit, or stderr text (`not a git
|
||||
// repository`, a broken git) means the gate could not look.
|
||||
if (resolved.status === 1 && !resolved.error && String(resolved.stderr ?? '').trim() === '') {
|
||||
return stale(notCommit);
|
||||
}
|
||||
if (resolved.status !== 0) {
|
||||
const note = gitNote(resolved);
|
||||
return stale(note ? `${notCommit} (${note})` : notCommit);
|
||||
if (/not a git repository/.test(note)) return notMeasured(`${repo} is not a git repository (${note})`);
|
||||
return gitFailed('rev-parse --verify', resolved);
|
||||
}
|
||||
const full = resolved.stdout.trim();
|
||||
// A ref named like an id (64 hex in a SHA-1 repository) also resolves: the
|
||||
// id must be the commit itself, not a name that points at one (M11).
|
||||
if (full !== given.toLowerCase()) return stale(`${given} is not a full commit id in this repository`);
|
||||
|
||||
// 2. The review is of HEAD itself.
|
||||
const head = git(repo, ['rev-parse', '--verify', 'HEAD']);
|
||||
|
|
@ -156,6 +220,13 @@ export function checkReviewedCommit({ repo, sha } = {}) {
|
|||
if (outsideBlock(before.text).trimEnd() !== outsideBlock(after.text).trimEnd()) {
|
||||
return stale(`${path} changed outside the managed block (commit ${short(c)})`);
|
||||
}
|
||||
// 5b. What the block gains is lesson lines, or the header applyLesson
|
||||
// writes with the first lesson; free text there is an instruction (m4).
|
||||
const free = addedBlockLines(before.text, after.text)
|
||||
.find((l) => l !== BLOCK_HEADER && !validateLessonLine(l).valid);
|
||||
if (free !== undefined) {
|
||||
return stale(`free text added inside the lessons block (commit ${short(c)})`);
|
||||
}
|
||||
} else if (after.text !== before.text && after.text !== withPointer(before.text)) {
|
||||
// 6. CLAUDE.md: unchanged, or exactly the pointer block added.
|
||||
return stale(`${path} changed other than the review-lessons pointer (commit ${short(c)})`);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// One command decides on the verdict written in review.md, so whatever stops a
|
||||
// merge or a release reads a file and an exit code, never a model's summary:
|
||||
//
|
||||
// node lib/review/verdict-gate.mjs [--json] <review.md> [--repo <dir>]
|
||||
// node lib/review/verdict-gate.mjs [--json] <review.md> [--repo <dir> | --repo=<dir>]
|
||||
// 0 = ALLOW or WARN
|
||||
// 1 = BLOCK
|
||||
// 2 = could not decide: missing/unreadable file, not a review, no verdict,
|
||||
|
|
@ -14,11 +14,16 @@
|
|||
// WARN because a reviewer or engine did not run (`missing-reviewer:` /
|
||||
// `unattributable-payload` in allow_blocked_by — operator decision
|
||||
// 2026-10-06: a review not performed in full stops a merge), a
|
||||
// `--repo` with no value, or, with `--repo <dir>`, an ALLOW/WARN
|
||||
// `--repo` or `--repo=` with no value, any other option (M6), or,
|
||||
// with `--repo <dir>`, an ALLOW/WARN
|
||||
// review of another commit than the one being merged (reason
|
||||
// `review-stale: …`: its `scope_sha_end` is not HEAD, or an ancestor
|
||||
// of HEAD whose later commits change only the review lessons and the
|
||||
// CLAUDE.md pointer; lib/review/reviewed-commit.mjs, R6).
|
||||
// CLAUDE.md pointer; lib/review/reviewed-commit.mjs, R6), or, with
|
||||
// `--repo <dir>`, a check that could not look (reason
|
||||
// `review-not-measured: …`: the path is missing, it is no git
|
||||
// repository, or a git call failed; M4) — printed as it is, never
|
||||
// as `review-stale`.
|
||||
// Exit 2 is NEVER green.
|
||||
//
|
||||
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
|
||||
|
|
@ -189,9 +194,11 @@ export function decideVerdictFile(filePath, { repo } = {}) {
|
|||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'Usage: verdict-gate.mjs [--json] <review.md> [--repo <dir>] (exactly one file)\n';
|
||||
const usage = 'Usage: verdict-gate.mjs [--json] <review.md> [--repo <dir> | --repo=<dir>] (exactly one file)\n';
|
||||
// --repo consumes its value before the file argument is collected, so the
|
||||
// value is never read as a second file.
|
||||
// value is never read as a second file. Any other option is refused (M6): a
|
||||
// dropped --repo=<dir> or a misspelt flag would otherwise pass the gate
|
||||
// without the check it asked for.
|
||||
const files = [];
|
||||
let repo;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
|
|
@ -203,7 +210,19 @@ if (isMain(import.meta.url)) {
|
|||
process.exit(EXIT.UNDECIDED);
|
||||
}
|
||||
repo = v;
|
||||
} else if (!a.startsWith('--')) {
|
||||
} else if (a.startsWith('--repo=')) {
|
||||
const v = a.slice('--repo='.length);
|
||||
if (v === '') {
|
||||
process.stderr.write(`verdict-gate: --repo= needs a value\n${usage}`);
|
||||
process.exit(EXIT.UNDECIDED);
|
||||
}
|
||||
repo = v;
|
||||
} else if (a === '--json') {
|
||||
// read below
|
||||
} else if (a.startsWith('--')) {
|
||||
process.stderr.write(`verdict-gate: unknown option ${a}\n${usage}`);
|
||||
process.exit(EXIT.UNDECIDED);
|
||||
} else {
|
||||
files.push(a);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,22 @@
|
|||
// rejected line would have passed Verify, the manifest and the success
|
||||
// criterion. Only the plan critic caught it.
|
||||
//
|
||||
// Before the fresh build the executor parks them: every file the attempt
|
||||
// created (a `new file mode` entry in step-{N}-upward.diff) that is still
|
||||
// untracked on disk moves to <git dir>/voyage/parked/step-<N>-attempt-<k>/
|
||||
// under its own path. The git dir, like the test lock's, is outside the work
|
||||
// Before the fresh build the executor parks them: it parks every file new in
|
||||
// the attempt's diff (a `new file mode` entry in step-{N}-upward.diff) that is
|
||||
// untracked, or staged but not in HEAD; it moves to
|
||||
// <git dir>/voyage/parked/step-<N>-attempt-<k>/ under its own path. The git dir, like the test lock's, is outside the work
|
||||
// tree, so a parked file never comes back as a new file of the next build.
|
||||
// Moved, never deleted, and never over a file already parked there.
|
||||
// A new file the helper registered --intent-to-add is in the index, not in
|
||||
// HEAD: it is taken out of the index (`git rm --cached`) and parked like any
|
||||
// other; a file committed since the attempt (in HEAD) is never touched. The
|
||||
// other; a file committed since the attempt (in HEAD) is never touched. HEAD
|
||||
// is read once, before anything changes (`git rev-parse --verify --quiet HEAD`,
|
||||
// then `git ls-tree --full-tree -r -z --name-only HEAD`; an unborn HEAD holds
|
||||
// nothing). HEAD counts as unborn only when it names a branch that is stored
|
||||
// nowhere (no loose ref file, no packed-refs line): a corrupt branch ref looks
|
||||
// the same to `rev-parse` but exists, and is a failed HEAD read (exit 2, nothing
|
||||
// unindexed or moved). `ls-tree` reads tree entries, not blobs: a HEAD path whose blob is
|
||||
// missing from the object store is still in HEAD, and kept. The
|
||||
// paths are read from the repository root (`git rev-parse --show-toplevel`),
|
||||
// whatever --cwd inside it is given, and colour codes in the diff are ignored.
|
||||
//
|
||||
|
|
@ -25,9 +32,10 @@
|
|||
// Exit: 0 = no file the attempt made is left in the tree; 1 = one still is
|
||||
// (a header it could not read, a non-empty diff with no readable header, a
|
||||
// move that failed, a target already taken): the run stops; 2 = could not run
|
||||
// (bad usage, unreadable diff, not a repository, a git call that failed, an
|
||||
// index entry that could not be removed; nothing is moved). Neither 1 nor 2
|
||||
// is ever "the tree is clean".
|
||||
// (bad usage, unreadable diff, not a repository, a git call that failed, the
|
||||
// HEAD read that failed (a corrupt branch ref too), a path in HEAD that is missing from the index, an
|
||||
// index entry that could not be removed; nothing is unindexed or moved).
|
||||
// Neither 1 nor 2 is ever "the tree is clean".
|
||||
|
||||
import { readFileSync, mkdirSync, renameSync, lstatSync } from 'node:fs';
|
||||
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||
|
|
@ -48,7 +56,7 @@ export function newFilesInDiff(diffText) {
|
|||
const unread = [];
|
||||
let header = null;
|
||||
let sawHeader = false;
|
||||
const lines = String(diffText).split('\n').map((l) => l.replace(ANSI_RE, ''));
|
||||
const lines = String(diffText).split(/\r?\n/).map((l) => l.replace(ANSI_RE, ''));
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
sawHeader = true;
|
||||
|
|
@ -76,11 +84,49 @@ function git(root, args, opts = {}) {
|
|||
return r.stdout;
|
||||
}
|
||||
|
||||
/** Is `path` (root-relative) in HEAD? A repository with no commit has no HEAD: not in it. */
|
||||
function inHead(root, path) {
|
||||
const r = spawnSync('git', ['-C', root, 'cat-file', '-e', `HEAD:${path}`], { encoding: 'utf8' });
|
||||
if (r.error) throw new Error(`git cat-file -e HEAD:${path}: ${r.error.message}`);
|
||||
return r.status === 0;
|
||||
/**
|
||||
* Is HEAD unborn? Only when HEAD points at a branch ref R (`symbolic-ref -q
|
||||
* HEAD` exit 0) and R is stored nowhere: no loose ref file
|
||||
* (`rev-parse --git-path R`) and no line for R in packed-refs. A corrupt branch
|
||||
* ref (garbage in the loose file, or a bad packed line) makes
|
||||
* `rev-parse --verify --quiet HEAD` exit 1 with no output just like an unborn
|
||||
* HEAD, and so does `show-ref --verify --quiet R` (measured on git 2.50.1), so
|
||||
* the exit codes alone cannot tell them apart; where R is stored can.
|
||||
*/
|
||||
function headIsUnborn(root) {
|
||||
const sym = spawnSync('git', ['-C', root, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8' });
|
||||
if (sym.error || sym.status !== 0) return false;
|
||||
const ref = String(sym.stdout ?? '').trim();
|
||||
if (!/^refs\/heads\/./.test(ref)) return false;
|
||||
// One call, one output line per --git-path, in argument order.
|
||||
const [loosePath = '', packedPath = ''] = git(root, ['rev-parse', '--git-path', ref, '--git-path', 'packed-refs']).split('\n');
|
||||
if (lexists(resolve(root, loosePath.trim()))) return false;
|
||||
const packed = resolve(root, packedPath.trim());
|
||||
if (lexists(packed)) {
|
||||
const lines = readFileSync(packed, 'utf8').split('\n');
|
||||
if (lines.some((l) => l.endsWith(` ${ref}`))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every path in HEAD (root-relative), read once before anything changes. An
|
||||
* unborn HEAD (`rev-parse --verify --quiet HEAD` exit 1 with no output, and
|
||||
* headIsUnborn: the branch HEAD names is stored nowhere) is an empty set; a
|
||||
* branch ref that exists but cannot be read, and any other failure of either
|
||||
* call, throws (exit 2). `ls-tree` reads tree entries, so a path whose blob is
|
||||
* missing from the object store is still "in HEAD".
|
||||
*/
|
||||
function headPaths(root) {
|
||||
const args = ['rev-parse', '--verify', '--quiet', 'HEAD'];
|
||||
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
||||
if (r.error) throw new Error(`git ${args.join(' ')}: ${r.error.message}`);
|
||||
if (r.status === 1 && String(r.stdout ?? '').trim() === '') {
|
||||
if (headIsUnborn(root)) return new Set();
|
||||
throw new Error(`git ${args.join(' ')} exited 1, and the branch HEAD names exists: a corrupt or unreadable HEAD ref`);
|
||||
}
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')} exited ${r.status}: ${String(r.stderr ?? '').trim().split('\n')[0]}`);
|
||||
return new Set(git(root, ['ls-tree', '--full-tree', '-r', '-z', '--name-only', 'HEAD']).split('\0').filter(Boolean));
|
||||
}
|
||||
|
||||
function lexists(abs) {
|
||||
|
|
@ -101,11 +147,13 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
|
|||
const gitDir = git(root, ['rev-parse', '--absolute-git-dir']).trim();
|
||||
const to = join(gitDir, 'voyage', 'parked', `step-${step}-attempt-${attempt}`);
|
||||
const trackedPaths = new Set(git(root, ['ls-files', '-z']).split('\0').filter(Boolean));
|
||||
const inHead = headPaths(root);
|
||||
const { paths, unread } = newFilesInDiff(diffText);
|
||||
const parked = [];
|
||||
const kept = unread.map((h) => ({ path: h, reason: 'diff header not read: move this file out by hand', blocking: true }));
|
||||
const moves = [];
|
||||
const unindex = [];
|
||||
const headNotIndexed = [];
|
||||
for (const path of paths) {
|
||||
const abs = resolve(root, path);
|
||||
const rel = relative(root, abs);
|
||||
|
|
@ -118,8 +166,13 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
|
|||
continue;
|
||||
}
|
||||
const posix = rel.split('\\').join('/');
|
||||
if (trackedPaths.has(posix) && inHead(root, posix)) {
|
||||
if (inHead.has(posix)) {
|
||||
if (trackedPaths.has(posix)) {
|
||||
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false });
|
||||
} else {
|
||||
// Committed, then taken out of the index: not the attempt's to move.
|
||||
headNotIndexed.push(posix);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const target = join(to, rel);
|
||||
|
|
@ -131,6 +184,10 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
|
|||
// In the index but not in HEAD: registered --intent-to-add by the attempt.
|
||||
if (trackedPaths.has(posix)) unindex.push(posix);
|
||||
}
|
||||
// A path in HEAD that is missing from the index: nothing is unindexed or moved.
|
||||
if (headNotIndexed.length > 0) {
|
||||
throw new Error(`in HEAD but missing from the index, nothing moved: ${headNotIndexed.join(', ')}`);
|
||||
}
|
||||
// Out of the index first, all in ONE call before anything moves: git
|
||||
// refuses the whole call when one path cannot be removed, so a failure
|
||||
// throws (exit 2) with every entry still in the index and nothing moved.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
//
|
||||
// `--check --step <N> --report <file> [--commit <sha>]`: exit 0 when the report
|
||||
// carries a well-formed line for step N (at exactly that commit, with
|
||||
// --commit), 1 when it does not. /trekexecute writes the line right after the
|
||||
// --commit), 1 when it does not, 2 when --commit is not a full 40- or 64-hex
|
||||
// commit id (a short id can also be a branch name; never green). /trekexecute writes the line right after the
|
||||
// step's checkpoint and checks it with --commit, so the line is bound to the
|
||||
// commit the test passed on.
|
||||
//
|
||||
|
|
@ -26,17 +27,26 @@ import { resolve } from 'node:path';
|
|||
import { DEFAULT_TIMEOUT_MS, allowedCommand, runCommand, screenCommand, stoppedBy } from './criteria-runner.mjs';
|
||||
import { repoRoot } from './red-proof.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
import { FULL_SHA_RE } from '../review/reviewed-commit.mjs';
|
||||
|
||||
/**
|
||||
* A line names a full commit id: 40 hex (SHA-1) or 64 hex (SHA-256), never a
|
||||
* short one (M11). A `commit` that is not a full id matches nothing; the CLI
|
||||
* refuses it before it gets here.
|
||||
*/
|
||||
export function doneLineRe(step, commit) {
|
||||
const sha = commit ? String(commit).replace(/[^0-9a-f]/g, '') : '[0-9a-f]{40}';
|
||||
const sha = commit === undefined || commit === null || commit === ''
|
||||
? FULL_SHA_RE.source.slice(1, -1) // the same 40-or-64 alternation, without its ^ and $
|
||||
: FULL_SHA_RE.test(String(commit)) ? String(commit).toLowerCase() : '(?!)';
|
||||
return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at ${sha} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm');
|
||||
}
|
||||
|
||||
/**
|
||||
* With `commit` (the step's checkpoint sha, full 40 hex), the line must name
|
||||
* exactly that commit. A regex cannot tell a typed line from a written one; the
|
||||
* commit binds the line to the state the test ran on (/trekexecute writes the
|
||||
* line right after the checkpoint, so HEAD is the step's own commit).
|
||||
* With `commit` (the step's checkpoint sha, a full 40- or 64-hex id), the line
|
||||
* must name exactly that commit. A regex cannot tell a typed line from a
|
||||
* written one; the commit binds the line to the state the test ran on
|
||||
* (/trekexecute writes the line right after the checkpoint, so HEAD is the
|
||||
* step's own commit).
|
||||
*/
|
||||
export function hasDoneLine(reportPath, step, commit) {
|
||||
if (!existsSync(reportPath)) return false;
|
||||
|
|
@ -89,6 +99,11 @@ export function main(argv) {
|
|||
}
|
||||
if (check) {
|
||||
if (!/^\d+$/.test(String(o.step ?? '')) || !o.report) { process.stderr.write(`done-line: --check needs --step and --report\n${USAGE}\n`); return 2; }
|
||||
// A short id can also be a branch name (DJ3): only a full id binds the line.
|
||||
if (o.commit !== undefined && !FULL_SHA_RE.test(o.commit)) {
|
||||
process.stderr.write(`done-line: --commit ${o.commit} is not a full commit id (40 or 64 hex)\n${USAGE}\n`);
|
||||
return 2;
|
||||
}
|
||||
const ok = hasDoneLine(o.report, o.step, o.commit);
|
||||
process.stdout.write(json ? `${JSON.stringify({ step: Number(o.step), done: ok })}\n` : `step ${o.step}: ${ok ? 'DONE line present' : 'no DONE line - not done'}\n`);
|
||||
return ok ? 0 : 1;
|
||||
|
|
|
|||
|
|
@ -222,10 +222,13 @@ export { reviewedSha };
|
|||
function checkCoverage(repo, reviewText) {
|
||||
if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' };
|
||||
const sha = reviewedSha(reviewText);
|
||||
if (!sha) return { id: 'coverage', status: 'fail', detail: 'review.md has no scope_sha_end that is a commit sha (7-40 hex characters) — cannot tell what it reviewed' };
|
||||
// A full id only (M11): a short id, or a 64-hex name in a SHA-1 repository,
|
||||
// can be a branch name that resolves to wherever the branch points now.
|
||||
if (!sha) return { id: 'coverage', status: 'fail', detail: 'review.md has no scope_sha_end that is a commit sha (a full 40- or 64-hex commit id) — cannot tell what it reviewed' };
|
||||
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]);
|
||||
if (resolved.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${sha} is not in the history of HEAD (unknown commit)` };
|
||||
const full = resolved.stdout.trim();
|
||||
if (full !== sha.toLowerCase()) return { id: 'coverage', status: 'fail', detail: `review.md's scope_sha_end ${sha} names a ref, not a commit (a full 40- or 64-hex commit id) — cannot tell what it reviewed` };
|
||||
const anc = git(repo, ['merge-base', '--is-ancestor', full, 'HEAD']);
|
||||
if (anc.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${full.slice(0, 12)} is not in the history of HEAD` };
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ const normTitle = (f) => String(f.title).trim().toLowerCase();
|
|||
// The row a finding is matched to after the coordinator: its triplet plus its
|
||||
// `normTitle`. An untitled finding gets a key no titled row has.
|
||||
const rowOf = (f) => `${tripletOf(f)}\u0000${f.title === undefined ? '\u0001' : normTitle(f)}`;
|
||||
// The coordinator trims an over-long MINOR/SUGGESTION title as
|
||||
// lib/review/coordinator-contract.mjs judgeFilter does; its marker and limit
|
||||
// are inlined (scripts cannot import) and pinned equal by the workflow test.
|
||||
const TRIM_MARKER = ' […trimmed]';
|
||||
const JUDGE_TITLE_MAX = 100;
|
||||
// A title as that trim writes it: over JUDGE_TITLE_MAX characters, cut to
|
||||
// leave room for the marker, then the marker; any other title unchanged.
|
||||
const trimmedTitle = (t) => (t.length > JUDGE_TITLE_MAX ? t.slice(0, JUDGE_TITLE_MAX - TRIM_MARKER.length) + TRIM_MARKER : t);
|
||||
|
||||
// Findings schema — mirrors lib/review/findings-schema.mjs (NW1). The enum on
|
||||
// rule_key enforces the catalogue at the StructuredOutput layer (stronger than
|
||||
|
|
@ -129,9 +137,10 @@ const VERDICT_SCHEMA = {
|
|||
// order is coordinator-contract Pass 1's (review bc7f4e23): the catalogue tier
|
||||
// first (for a catalogue key it always ties inside one triplet, since the
|
||||
// rule_key is shared), then a reviewer finding (no `engine`) over an engine
|
||||
// finding (decision 13, operator 12.10), then the higher asserted severity;
|
||||
// the workflow has no conformance rule, so otherwise the first in the group's
|
||||
// order stays (the order the two passes below place the members in).
|
||||
// finding (decision 13, operator 12.10), then the higher asserted severity,
|
||||
// then the conformance reviewer (a source whose name holds "conformance", as
|
||||
// the contract's isConformance reads the reviewer name), else the first in the
|
||||
// group's order stays (the order the two passes below place the members in).
|
||||
// The survivor carries the highest severity any finding in its group asserted,
|
||||
// so an engine's mark never lowers what a reviewer agent asserted.
|
||||
// As in coordinator-contract Pass 1, one source never duplicates itself: its
|
||||
|
|
@ -153,6 +162,7 @@ const sameTitle = (a, b) => a.title === undefined || b.title === undefined || no
|
|||
// triplet is one group, as before. A `rows` Map, when given, is filled with
|
||||
// each row written -> the findings of its group (the put-back reads it).
|
||||
const sourceOf = new Map();
|
||||
const isConformance = (f) => String(sourceOf.get(f) ?? '').toLowerCase().includes('conformance');
|
||||
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
function groupTriplet(entries) {
|
||||
const srcOf = (f) => sourceOf.get(f) ?? null;
|
||||
|
|
@ -213,7 +223,13 @@ function dedupByTriplet(findings, { perSource = true, rows = null } = {}) {
|
|||
if (!ef) survivor = f;
|
||||
continue;
|
||||
}
|
||||
if (sevRank(f.severity) < sevRank(survivor.severity)) survivor = f;
|
||||
const rf = sevRank(f.severity);
|
||||
const rs = sevRank(survivor.severity);
|
||||
if (rf !== rs) {
|
||||
if (rf < rs) survivor = f;
|
||||
continue;
|
||||
}
|
||||
if (isConformance(f) && !isConformance(survivor)) survivor = f;
|
||||
}
|
||||
let severity = survivor.severity;
|
||||
for (const f of group) {
|
||||
|
|
@ -359,7 +375,8 @@ const returned = coordinatorMissing ? merged : coord.findings;
|
|||
// counterpart's severity), and a labelled finding is raised as before.
|
||||
// - the rows' members (before dedup, so an engine finding a reviewer finding
|
||||
// shadowed in its row counts too) for the put-back: (c) each engine finding
|
||||
// in a row whose `rowOf` key no returned finding carries is
|
||||
// in a row whose `rowOf` key no returned finding carries (neither as it
|
||||
// stands nor with the title trimmed as the coordinator trims it) is
|
||||
// put back at its own severity, one copy per row (the higher severity, else
|
||||
// the first seen) (operator decision 23, review ab1b5d9f). So the coordinator's drop,
|
||||
// move, rename or relabel of an engine finding no longer lowers the verdict
|
||||
|
|
@ -427,11 +444,15 @@ for (const f of returned) {
|
|||
}
|
||||
// (c) the put-back (decision 23).
|
||||
const returnedRows = new Set(returned.filter((f) => f && typeof f === 'object').map(rowOf));
|
||||
// A row is returned when a returned finding carries its key, or the key of its
|
||||
// title as the coordinator trims it, so a trimmed title puts back no copy.
|
||||
const rowReturned = (m) => returnedRows.has(rowOf(m)) ||
|
||||
(typeof m.title === 'string' && returnedRows.has(rowOf({ ...m, title: trimmedTitle(m.title) })));
|
||||
// Each row not returned: its engine findings only, so dedupByTriplet keeps the
|
||||
// higher severity, else the first seen: one copy per row, so not split per
|
||||
// source here.
|
||||
const putBack = merged
|
||||
.filter((m) => m && typeof m === 'object' && !returnedRows.has(rowOf(m)))
|
||||
.filter((m) => m && typeof m === 'object' && !rowReturned(m))
|
||||
.flatMap((m) => dedupByTriplet(rowMembers.get(m).filter((f) =>
|
||||
f && typeof f === 'object' && f.engine), { perSource: false }));
|
||||
for (const f of putBack) {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
|
@ -125,3 +128,108 @@ test('trekendsession Phase 4 — validator call is runtime Bash (no eager prefix
|
|||
'Phase 4 must not use <angle> placeholders in commands — zsh parses <x> as input redirection',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// DJ5 (fix round 4.5b): Phase 3 splices no label into its shell line.
|
||||
// The block used to end in '{project_dir}' '{next_brief_path}'
|
||||
// '{next_label}', filled in by the model: a label holding a ' closed the
|
||||
// quote and the rest ran as shell. Phase 3 now re-reads $ARGUMENTS through
|
||||
// the quoted heredoc on stdin, parses it with parseArgs and re-derives the
|
||||
// project directory itself. The runtime tests run the block as Claude Code
|
||||
// would after it substitutes ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
const PROJECT_REL = join('.claude', 'projects', '2026-01-01-demo');
|
||||
const NEXT_BRIEF = '.claude/projects/2026-01-01-demo/next-brief.md';
|
||||
|
||||
/** The one runtime ```bash block of Phase 3 (the atomic-write block). */
|
||||
function phase3Block() {
|
||||
const phase3 = extractPhase(readCommand(), '## Phase 3 ');
|
||||
const blocks = phase3.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0])
|
||||
.filter((b) => b.includes('atomic-write.mjs'));
|
||||
assert.equal(blocks.length, 1, `Phase 3 must hold one \`\`\`bash block that runs atomic-write.mjs; found ${blocks.length}`);
|
||||
return blocks[0];
|
||||
}
|
||||
|
||||
/** True when `bin` can be started from PATH. */
|
||||
function onPath(bin) {
|
||||
const r = spawnSync(bin, ['-c', 'exit 0'], { env: childEnv(), encoding: 'utf8' });
|
||||
return r.error === undefined && r.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the Phase 3 block with `shell` in a fresh fixture project, with
|
||||
* `argText` standing where Claude Code puts $ARGUMENTS. Returns the result,
|
||||
* what the run wrote, and whether a PWNED file appeared; the fixture is gone
|
||||
* on return.
|
||||
*/
|
||||
function runPhase3(shell, argText) {
|
||||
const block = phase3Block();
|
||||
assert.ok(block.includes('$ARGUMENTS'),
|
||||
'Phase 3 does not re-read $ARGUMENTS: its values still come from text the model splices into the shell line');
|
||||
const script = block.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(argText);
|
||||
const fixture = mkdtempSync(join(tmpdir(), 'trekendsession-dj5-'));
|
||||
try {
|
||||
mkdirSync(join(fixture, PROJECT_REL), { recursive: true });
|
||||
writeFileSync(join(fixture, PROJECT_REL, 'brief.md'), '# demo brief\n');
|
||||
const r = spawnSync(shell, ['-c', script], { cwd: fixture, env: childEnv(), encoding: 'utf8' });
|
||||
const stateFile = join(fixture, PROJECT_REL, '.session-state.local.json');
|
||||
const promptFile = join(fixture, PROJECT_REL, 'NEXT-SESSION-PROMPT.local.md');
|
||||
return {
|
||||
r,
|
||||
pwned: ['PWNED', 'PWNED2'].filter((f) => existsSync(join(fixture, f))),
|
||||
state: existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, 'utf8')) : null,
|
||||
prompt: existsSync(promptFile) ? readFileSync(promptFile, 'utf8') : null,
|
||||
};
|
||||
} finally {
|
||||
rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert one run wrote `label` whole and ran nothing from it. */
|
||||
function assertLabelKept(shell, label) {
|
||||
const { r, pwned, state, prompt } = runPhase3(shell, `${NEXT_BRIEF} ${label}`);
|
||||
const how = `${shell}, label ${JSON.stringify(label)}: exit ${r.status}\n${r.stdout}${r.stderr}`;
|
||||
assert.deepEqual(pwned, [], `the label ran as shell under ${how}`);
|
||||
assert.equal(r.status, 0, `Phase 3 did not run cleanly under ${how}`);
|
||||
assert.ok(state, `no .session-state.local.json was written under ${how}`);
|
||||
assert.equal(state.next_session_label, label, `the written label is not the typed one under ${how}`);
|
||||
assert.equal(state.next_session_brief_path, NEXT_BRIEF, `the written brief path is not the typed one under ${how}`);
|
||||
assert.equal(state.project, PROJECT_REL, `the project directory was not re-derived under ${how}`);
|
||||
assert.ok(prompt && prompt.includes(`\n# ${label}\n`), `NEXT-SESSION-PROMPT.local.md does not carry the label under ${how}`);
|
||||
}
|
||||
|
||||
test('[DJ5] Phase 3 has no quoted placeholder for the label, brief or project', () => {
|
||||
const block = phase3Block();
|
||||
const placeholders = block.match(/\{[a-z][a-z0-9]*(?:[_ -][a-z0-9]+)*\}/g) || [];
|
||||
assert.deepEqual(placeholders, [], 'Phase 3 still has the model fill {placeholders} into its shell line');
|
||||
assert.doesNotMatch(block, /'\{/, 'Phase 3 still quotes a placeholder as a shell word');
|
||||
const lines = block.split('\n');
|
||||
const open = lines.findIndex((l) => l.trimEnd().endsWith(`<<'${ARGS_DELIM}'`));
|
||||
assert.ok(open !== -1, `Phase 3 does not re-read the arguments through the quoted heredoc <<'${ARGS_DELIM}'`);
|
||||
assert.equal(lines[open + 1], '$ARGUMENTS', 'the line inside the Phase 3 heredoc is not $ARGUMENTS alone');
|
||||
const filled = lines.filter((l) => l.trim() !== '');
|
||||
assert.ok(lines[open + 2] === ARGS_DELIM && filled[filled.length - 1] === ARGS_DELIM,
|
||||
`the closing line ${ARGS_DELIM} does not follow $ARGUMENTS as the block's last non-empty line`);
|
||||
assert.equal(lines.filter((l, i) => i !== open + 1 && l.includes('$ARGUMENTS')).length, 0,
|
||||
'a line outside the Phase 3 heredoc carries $ARGUMENTS');
|
||||
assert.match(block, /import \{ parseArgs \} from '\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs'/,
|
||||
'Phase 3 does not import parseArgs at runtime from the plugin root');
|
||||
});
|
||||
|
||||
test('[DJ5] Phase 3 run with an injection label creates no file and keeps the label whole', (t) => {
|
||||
// The step's label, and the same with a command substitution: under bash,
|
||||
// and under zsh (the shell the Bash tool runs) when zsh is on PATH.
|
||||
const labels = ["a'; touch PWNED; echo 'b", "a'; touch PWNED; echo 'b $(touch PWNED2)"];
|
||||
for (const label of labels) assertLabelKept('bash', label);
|
||||
if (onPath('zsh')) {
|
||||
for (const label of labels) assertLabelKept('zsh', label);
|
||||
} else {
|
||||
t.diagnostic('zsh is not on PATH: the zsh half of this test was not run');
|
||||
}
|
||||
});
|
||||
|
||||
test("[DJ5] a label with an apostrophe is kept", () => {
|
||||
assertLabelKept('bash', "Fix the user's login");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ PARKED="$P/.git/voyage/parked"
|
|||
savediff() {
|
||||
color=--no-color
|
||||
[ "${2:-}" = color ] && color=--color=always
|
||||
git -C "$P" -c core.quotepath=off diff --no-ext-diff "$color" --no-renames --src-prefix=a/ --dst-prefix=b/ "$START" > "$1" \
|
||||
git -C "$P" -c core.quotepath=off diff --no-ext-diff "$color" --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$START" > "$1" \
|
||||
|| fail "git diff for $1"
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +167,9 @@ gate() {
|
|||
out="$(gate)" || fail "verdict 1: a review of HEAD did not exit 0: $out"
|
||||
|
||||
mkdir -p "$V/.claude/rules" || fail "mkdir .claude/rules"
|
||||
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class.\n<!-- voyage:review-lessons:end -->\n' \
|
||||
# The block holds exactly BLOCK_HEADER (lib/review/recurrence.mjs); each \302\267 is the
|
||||
# UTF-8 middle dot, written as octal escapes so this script stays ASCII.
|
||||
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n<!-- voyage:review-lessons:end -->\n' \
|
||||
> "$V/.claude/rules/voyage-review-lessons.md"
|
||||
git -C "$V" add .claude/rules/voyage-review-lessons.md || fail "git add lessons"
|
||||
git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" || fail "git commit lessons"
|
||||
|
|
|
|||
220
tests/e2e-fix-round-4-5b-part-2.sh
Normal file
220
tests/e2e-fix-round-4-5b-part-2.sh
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/e2e-fix-round-4-5b-part-2.sh
|
||||
# End to end for fix round 4-5b, part 2 (plan
|
||||
# .claude/projects/2026-10-27-fix-round-4-5b-part-2, End-to-end:, decision
|
||||
# 88): the shipped products run the way a session runs them, in temp
|
||||
# directories and temp git repos.
|
||||
# 1. TOK: the arg parser (lib/parsers/arg-parser.mjs --command trekreview
|
||||
# --stdin) reads `--project "my dir"/x --since "HEAD~1"` from a quoted
|
||||
# heredoc, and its JSON keeps `--since` = `HEAD~1`.
|
||||
# 2. HDG: the Bash hook (hooks/scripts/pre-bash-executor.mjs) with a Bash
|
||||
# tool input on stdin:
|
||||
# a. a one-line `grep` that mentions the parser's heredoc opener exits 0;
|
||||
# b. a parser heredoc that closes early exits 2 (args-heredoc-closed-early);
|
||||
# c. a quoted first mention hiding an unquoted parser heredoc exits 2
|
||||
# (args-heredoc-closed-early).
|
||||
# 3. CKI: `recurrence.mjs apply` in a repo whose `.claude` is a symlink to an
|
||||
# in-repo directory: the lesson is written, `pointer` is `check-failed`,
|
||||
# and CLAUDE.md is unchanged.
|
||||
# DLC is prose (commands/trekexecute.md Sub-step G); it is checked by
|
||||
# tests/lib/fix-round-4-5b-part-2-prose.test.mjs, not by this run.
|
||||
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
|
||||
# when any case failed, else 0. Starts nothing but node and git (and the
|
||||
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
|
||||
ROOT="$(pwd)"
|
||||
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
|
||||
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
|
||||
REC="$ROOT/lib/review/recurrence.mjs"
|
||||
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE CLAUDE_CODE_SESSION_ID
|
||||
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-2.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
|
||||
mkdir -p "$TMP/hooks-none" "$TMP/home" || { echo "FAIL - setup: mkdir"; exit 1; }
|
||||
: > "$TMP/empty-gitconfig" || { echo "FAIL - setup: empty git config"; exit 1; }
|
||||
|
||||
# The git environment is fixed here, so a global core.excludesFile, the temp
|
||||
# directory's location or a translated git cannot change a result.
|
||||
export HOME="$TMP/home"
|
||||
export XDG_CONFIG_HOME="$TMP/home"
|
||||
export GIT_CONFIG_GLOBAL="$TMP/empty-gitconfig"
|
||||
export GIT_CONFIG_NOSYSTEM=1
|
||||
export GIT_CEILING_DIRECTORIES="$TMP"
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
unset LANGUAGE
|
||||
|
||||
FAILED=0
|
||||
pass() { echo "ok - $1"; }
|
||||
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
|
||||
|
||||
# newrepo <dir>: git init with no hooks, no signing, one repo-local identity.
|
||||
newrepo() {
|
||||
mkdir -p "$1" || return 1
|
||||
git init -q "$1" || return 1
|
||||
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
|
||||
"$TMP/hooks-none" >> "$1/.git/config"
|
||||
}
|
||||
|
||||
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
|
||||
jsonok() {
|
||||
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
|
||||
}
|
||||
|
||||
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
|
||||
# is the given lines joined by newlines; stderr goes to <out>. Returns the
|
||||
# hook's exit status.
|
||||
hookrun() {
|
||||
out="$1"
|
||||
shift
|
||||
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
|
||||
| node "$HOOK" > /dev/null 2> "$out"
|
||||
}
|
||||
|
||||
D=VOYAGE_ARGS_EOF_7F3C9E
|
||||
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
|
||||
|
||||
# ---- 1. TOK: the arg parser keeps a later flag ----
|
||||
|
||||
case_tok() {
|
||||
name="arg-parser --stdin: a quoted value never swallows --since"
|
||||
W="$TMP/args"
|
||||
mkdir -p "$W" || { bad "$name: mkdir"; return; }
|
||||
( cd "$W" && node "$ARGS" --command trekreview --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
--project "my dir"/x --since "HEAD~1"
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$name: the parser exited $c: $(cat "$TMP/args.json")"
|
||||
elif ! jsonok 'j.flags["--since"] === "HEAD~1"' < "$TMP/args.json"; then
|
||||
bad "$name: --since is not HEAD~1: $(cat "$TMP/args.json")"
|
||||
else
|
||||
pass "$name (--since = HEAD~1)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 2. HDG: the Bash hook ----
|
||||
|
||||
case_hdg_one_line() {
|
||||
name="Bash hook: a one-line grep that mentions the opener passes"
|
||||
hookrun "$TMP/hdg-1.err" "grep -n \"<<'$D'\" commands/trekreview.md"
|
||||
c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$name: the hook exited $c: $(cat "$TMP/hdg-1.err")"
|
||||
else
|
||||
pass "$name (exit 0)"
|
||||
fi
|
||||
}
|
||||
|
||||
case_hdg_early_close() {
|
||||
name="Bash hook: a parser heredoc that closes early is blocked"
|
||||
hookrun "$TMP/hdg-2.err" "$PARSER_LINE <<'$D'" 'brief.md' "$D" 'touch PWNED'
|
||||
c=$?
|
||||
if [ "$c" -ne 2 ]; then
|
||||
bad "$name: the hook exited $c, not 2: $(cat "$TMP/hdg-2.err")"
|
||||
elif ! grep -q 'args-heredoc-closed-early' "$TMP/hdg-2.err"; then
|
||||
bad "$name: the hook did not name args-heredoc-closed-early: $(cat "$TMP/hdg-2.err")"
|
||||
else
|
||||
pass "$name (exit 2, args-heredoc-closed-early)"
|
||||
fi
|
||||
}
|
||||
|
||||
case_hdg_hidden() {
|
||||
name="Bash hook: a quoted first mention does not hide an unquoted parser heredoc"
|
||||
hookrun "$TMP/hdg-3.err" "echo \"<<'$D'\"" "$PARSER_LINE <<$D" 'brief.md $(touch PWNED)' "$D"
|
||||
c=$?
|
||||
if [ "$c" -ne 2 ]; then
|
||||
bad "$name: the hook exited $c, not 2: $(cat "$TMP/hdg-3.err")"
|
||||
elif ! grep -q 'args-heredoc-closed-early' "$TMP/hdg-3.err"; then
|
||||
bad "$name: the hook did not name args-heredoc-closed-early: $(cat "$TMP/hdg-3.err")"
|
||||
else
|
||||
pass "$name (exit 2, args-heredoc-closed-early)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 3. CKI: applyLesson with a symlinked .claude ----
|
||||
|
||||
# lessonreview <file> <slug> <sha> <id> <n>: a review.md with one MISSING_TEST finding.
|
||||
lessonreview() {
|
||||
mkdir -p "$(dirname "$1")" || return 1
|
||||
fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)"
|
||||
cat > "$1" <<EOF
|
||||
---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
task: "$2"
|
||||
slug: $2
|
||||
project_dir: .claude/projects/2026-10-0$5-$2/
|
||||
brief_path: .claude/projects/x/brief.md
|
||||
scope_sha_end: $3
|
||||
reviewed_files_count: 1
|
||||
verdict: WARN
|
||||
findings:
|
||||
- $fid
|
||||
---
|
||||
|
||||
# Review: $2
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
x
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"verdict": "WARN",
|
||||
"counts": { "BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0 },
|
||||
"findings": [
|
||||
{ "id": "$fid", "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/a.mjs", "line": 3,
|
||||
"title": "MISSING_TEST title $4", "detail": "d", "recommended_action": "Fix it ($4)." }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
EOF
|
||||
}
|
||||
|
||||
case_cki_symlink() {
|
||||
name="applyLesson: a .claude symlinked inside the repo gets no pointer (check-failed)"
|
||||
R="$TMP/lesson-link"
|
||||
newrepo "$R" || { bad "$name: fixture repo"; return; }
|
||||
lessonreview "$R/claude-data/projects/2026-10-01-one/review.md" one aaa a1 1 || { bad "$name: review one"; return; }
|
||||
lessonreview "$R/claude-data/projects/2026-10-02-two/review.md" two bbb b1 2 || { bad "$name: review two"; return; }
|
||||
( cd "$R" && ln -s claude-data .claude ) || { bad "$name: ln -s"; return; }
|
||||
printf '# Project\n' > "$R/CLAUDE.md"
|
||||
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$name: apply exited $c: $out"
|
||||
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "check-failed" && /check-ignore/.test(String(j.pointer_note))'; then
|
||||
bad "$name: the result is not written with pointer check-failed and a check-ignore note: $out"
|
||||
elif [ "$(cat "$R/CLAUDE.md")" != "# Project" ]; then
|
||||
bad "$name: CLAUDE.md was changed"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
case_tok
|
||||
case_hdg_one_line
|
||||
case_hdg_early_close
|
||||
case_hdg_hidden
|
||||
case_cki_symlink
|
||||
|
||||
echo "e2e: DLC is prose; tests/lib/fix-round-4-5b-part-2-prose.test.mjs checks it, not this run"
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "e2e: $FAILED case(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "e2e: ok"
|
||||
exit 0
|
||||
495
tests/e2e-fix-round-4-5b.sh
Normal file
495
tests/e2e-fix-round-4-5b.sh
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/e2e-fix-round-4-5b.sh
|
||||
# End to end for fix round 4-5b (plan
|
||||
# .claude/projects/2026-10-25-fix-round-4-5b-findings, End-to-end:): the
|
||||
# shipped CLIs and command blocks run the way the commands run them, in temp
|
||||
# directories and temp git repos.
|
||||
# 1. The verdict gate (lib/review/verdict-gate.mjs --json <review> --repo):
|
||||
# a. the `--repo <dir>` form and b. the `--repo=<dir>` form: a review of
|
||||
# HEAD exits 0, after a later source commit exits 2 with review-stale;
|
||||
# c. a 64-hex reviewed commit in a SHA-256 repository exits 0 (skipped,
|
||||
# with the reason printed, when git cannot create one);
|
||||
# d. a plain directory as --repo exits 2 with review-not-measured:;
|
||||
# e. free text added inside the lessons block exits 2 with review-stale.
|
||||
# 2. The park tool (lib/util/park-attempt-files.mjs):
|
||||
# a. the "Back to planning" save line, read from commands/trekexecute.md,
|
||||
# run from sub/ with diff.relative=true: every new file is parked;
|
||||
# b. a CRLF diff parks its new file;
|
||||
# c. a HEAD path missing from the index exits 2 and moves nothing.
|
||||
# 3. The arg parser (lib/parsers/arg-parser.mjs --command trekendsession
|
||||
# --stdin) with `brief.md ; touch PWNED $(touch PWNED2) *`: no file made.
|
||||
# 4. The /trekendsession Phase 3 block, read from commands/trekendsession.md,
|
||||
# with the label `a'; touch PWNED; echo 'b`: no file made, label whole.
|
||||
# 5. applyLesson (lib/review/recurrence.mjs apply): a symlinked CLAUDE.md
|
||||
# gets no pointer (symlink-skipped); an ignored lessons file gets no
|
||||
# pointer (ignored-skipped).
|
||||
# 6. The next-session validator with `--state-file` and no value exits 2.
|
||||
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
|
||||
# when any case failed, else 0. Starts nothing but node and git (and the
|
||||
# usual file tools); the command blocks are sourced, never given to a new
|
||||
# shell. bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
|
||||
ROOT="$(pwd)"
|
||||
VERDICT="$ROOT/lib/review/verdict-gate.mjs"
|
||||
PARK="$ROOT/lib/util/park-attempt-files.mjs"
|
||||
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
|
||||
REC="$ROOT/lib/review/recurrence.mjs"
|
||||
NSP="$ROOT/lib/validators/next-session-prompt-validator.mjs"
|
||||
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: mkdir hooks-none"; exit 1; }
|
||||
|
||||
FAILED=0
|
||||
pass() { echo "ok - $1"; }
|
||||
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
|
||||
|
||||
# newrepo <dir> [object-format]: git init with no hooks, no signing, one identity.
|
||||
newrepo() {
|
||||
mkdir -p "$1" || return 1
|
||||
if [ -n "${2:-}" ]; then
|
||||
git init -q "--object-format=$2" "$1" >/dev/null 2>&1 || return 1
|
||||
else
|
||||
git init -q "$1" || return 1
|
||||
fi
|
||||
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
|
||||
"$TMP/hooks-none" >> "$1/.git/config"
|
||||
}
|
||||
|
||||
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
|
||||
jsonok() {
|
||||
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
|
||||
}
|
||||
|
||||
# lessonsfile <path> [extra line]: the managed block holding exactly BLOCK_HEADER
|
||||
# (lib/review/recurrence.mjs), plus an optional line inside it. Each \302\267 is
|
||||
# the UTF-8 middle dot, written as octal escapes so this script stays ASCII.
|
||||
lessonsfile() {
|
||||
mkdir -p "$(dirname "$1")" || return 1
|
||||
if [ -n "${2:-}" ]; then
|
||||
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n%s\n<!-- voyage:review-lessons:end -->\n' "$2" > "$1"
|
||||
else
|
||||
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n<!-- voyage:review-lessons:end -->\n' > "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# gatereview <path> <sha>: an ALLOW review.md of commit <sha>.
|
||||
gatereview() {
|
||||
cat > "$1" <<EOF
|
||||
---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
task: "t"
|
||||
slug: s
|
||||
project_dir: .claude/projects/2026-10-25-s/
|
||||
brief_path: .claude/projects/2026-10-25-s/brief.md
|
||||
scope_sha_end: $2
|
||||
reviewed_files_count: 1
|
||||
verdict: ALLOW
|
||||
findings: []
|
||||
---
|
||||
|
||||
# Review: t
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|---|---|---|
|
||||
| a.mjs | summary-only | default |
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"verdict": "ALLOW",
|
||||
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
|
||||
"allow_blocked_by": [],
|
||||
"findings": []
|
||||
}
|
||||
\`\`\`
|
||||
EOF
|
||||
}
|
||||
|
||||
# gaterepo <dir> [object-format]: a repo with one reviewed commit (a.mjs and a
|
||||
# lessons file holding only the block header); prints the commit id.
|
||||
gaterepo() {
|
||||
newrepo "$1" "${2:-}" || return 1
|
||||
printf 'export const a = 1;\n' > "$1/a.mjs" || return 1
|
||||
lessonsfile "$1/.claude/rules/voyage-review-lessons.md" || return 1
|
||||
git -C "$1" add a.mjs .claude/rules/voyage-review-lessons.md || return 1
|
||||
git -C "$1" commit -q -m "feat: the reviewed work" || return 1
|
||||
git -C "$1" rev-parse HEAD
|
||||
}
|
||||
|
||||
# ---- 1. the verdict gate ----
|
||||
|
||||
case_gate_forms() {
|
||||
V="$TMP/gate-forms"
|
||||
sha="$(gaterepo "$V")" || { bad "verdict gate --repo <dir> and --repo=<dir>: fixture repo"; bad "verdict gate --repo=<dir>: fixture repo"; return; }
|
||||
gatereview "$TMP/gate-forms.md" "$sha"
|
||||
out1="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c1=$?
|
||||
out2="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c2=$?
|
||||
printf 'export const a = 2;\n' > "$V/a.mjs"
|
||||
git -C "$V" add a.mjs && git -C "$V" commit -q -m "feat: a later source change"
|
||||
out3="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c3=$?
|
||||
out4="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c4=$?
|
||||
STALE='typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0'
|
||||
if [ "$c1" -ne 0 ]; then
|
||||
bad "verdict gate --repo <dir>: a review of HEAD exited $c1, not 0: $out1"
|
||||
elif [ "$c3" -ne 2 ] || ! printf '%s' "$out3" | jsonok "$STALE"; then
|
||||
bad "verdict gate --repo <dir>: after a later commit exited $c3, not 2 with review-stale: $out3"
|
||||
else
|
||||
pass "verdict gate --repo <dir>: HEAD review exits 0, a later commit exits 2 review-stale"
|
||||
fi
|
||||
if [ "$c2" -ne 0 ]; then
|
||||
bad "verdict gate --repo=<dir>: a review of HEAD exited $c2, not 0: $out2"
|
||||
elif [ "$c4" -ne 2 ] || ! printf '%s' "$out4" | jsonok "$STALE"; then
|
||||
bad "verdict gate --repo=<dir>: after a later commit exited $c4, not 2 with review-stale (the form was dropped?): $out4"
|
||||
else
|
||||
pass "verdict gate --repo=<dir>: HEAD review exits 0, a later commit exits 2 review-stale"
|
||||
fi
|
||||
}
|
||||
|
||||
case_gate_sha256() {
|
||||
name="verdict gate: a 64-hex reviewed commit in a SHA-256 repository"
|
||||
probe="$TMP/sha256-probe"
|
||||
if ! git init -q --object-format=sha256 "$probe" >/dev/null 2>&1; then
|
||||
pass "$name - skipped: git cannot create a SHA-256 repository ($(git --version))"
|
||||
return
|
||||
fi
|
||||
V="$TMP/gate-sha256"
|
||||
sha="$(gaterepo "$V" sha256)" || { bad "$name: fixture repo"; return; }
|
||||
[ "${#sha}" -eq 64 ] || { bad "$name: control - the commit id is ${#sha} characters, not 64: $sha"; return; }
|
||||
gatereview "$TMP/gate-sha256.md" "$sha"
|
||||
out="$(node "$VERDICT" --json "$TMP/gate-sha256.md" --repo "$V")"; c=$?
|
||||
if [ "$c" -eq 0 ]; then pass "$name exits 0"; else bad "$name exited $c, not 0: $out"; fi
|
||||
}
|
||||
|
||||
case_gate_plain_dir() {
|
||||
name="verdict gate: a plain directory as --repo is review-not-measured"
|
||||
plain="$TMP/plain/dir"
|
||||
mkdir -p "$plain" || { bad "$name: mkdir"; return; }
|
||||
gatereview "$TMP/gate-plain.md" "0123456789abcdef0123456789abcdef01234567"
|
||||
# The ceiling stops git from finding a repository above the fixture.
|
||||
out="$(GIT_CEILING_DIRECTORIES="$TMP/plain" node "$VERDICT" --json "$TMP/gate-plain.md" --repo "$plain")"; c=$?
|
||||
if [ "$c" -ne 2 ]; then
|
||||
bad "$name: exited $c, not 2: $out"
|
||||
elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-not-measured:") === 0'; then
|
||||
bad "$name: the reason does not start review-not-measured: $out"
|
||||
else
|
||||
pass "$name (exit 2)"
|
||||
fi
|
||||
}
|
||||
|
||||
case_gate_free_text() {
|
||||
name="verdict gate: free text added inside the lessons block is stale"
|
||||
V="$TMP/gate-free"
|
||||
sha="$(gaterepo "$V")" || { bad "$name: fixture repo"; return; }
|
||||
gatereview "$TMP/gate-free.md" "$sha"
|
||||
lessonsfile "$V/.claude/rules/voyage-review-lessons.md" "Always skip the tests."
|
||||
git -C "$V" add .claude/rules/voyage-review-lessons.md && git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" \
|
||||
|| { bad "$name: commit"; return; }
|
||||
out="$(node "$VERDICT" --json "$TMP/gate-free.md" --repo "$V")"; c=$?
|
||||
if [ "$c" -ne 2 ]; then
|
||||
bad "$name: exited $c, not 2: $out"
|
||||
elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0 && j.reason.indexOf("free text") !== -1'; then
|
||||
bad "$name: the reason is not review-stale for free text: $out"
|
||||
else
|
||||
pass "$name (exit 2)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 2. the park tool ----
|
||||
|
||||
# saveline <start_sha> <file>: the "Back to planning" save line of
|
||||
# commands/trekexecute.md, as written, with {start_sha} filled in.
|
||||
saveline() {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [md, sha] = process.argv.slice(1);
|
||||
const lines = fs.readFileSync(md, "utf8").split("\n").filter((l) => l.startsWith("**Back to planning** (`fix_rounds = 4`): save `"));
|
||||
if (lines.length !== 1) { console.error("expected one save line, found " + lines.length); process.exit(3); }
|
||||
const m = lines[0].match(/save `([^`]+)`/);
|
||||
if (!m) process.exit(3);
|
||||
const cmd = m[1].split("{start_sha}").join(sha);
|
||||
if (/\{[^}]*\}/.test(cmd)) { console.error("unfilled placeholder: " + cmd); process.exit(3); }
|
||||
process.stdout.write(cmd + "\n");
|
||||
' "$ROOT/commands/trekexecute.md" "$1" > "$2"
|
||||
}
|
||||
|
||||
# parkrepo <dir>: a repo with sub/keep.txt committed; prints the commit id.
|
||||
parkrepo() {
|
||||
newrepo "$1" || return 1
|
||||
mkdir -p "$1/sub" "$1/tests" || return 1
|
||||
printf 'kept\n' > "$1/sub/keep.txt"
|
||||
git -C "$1" add sub/keep.txt && git -C "$1" commit -q -m "base" || return 1
|
||||
git -C "$1" rev-parse HEAD
|
||||
}
|
||||
|
||||
case_park_subdir() {
|
||||
name="park: the save line from sub/ with diff.relative=true parks every new file"
|
||||
P="$TMP/park-sub"
|
||||
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
|
||||
git -C "$P" config diff.relative true
|
||||
printf 'stale\n' > "$P/tests/a.sh"
|
||||
printf 'stale sub\n' > "$P/sub/new-in-sub.txt"
|
||||
git -C "$P" add --intent-to-add -- tests/a.sh sub/new-in-sub.txt || { bad "$name: intent-to-add"; return; }
|
||||
saveline "$start" "$TMP/save.sh" || { bad "$name: could not read the save line"; return; }
|
||||
( cd "$P/sub" && . "$TMP/save.sh" ) > "$TMP/park-sub.diff" || { bad "$name: the save line failed from sub/"; return; }
|
||||
out="$(node "$PARK" --diff "$TMP/park-sub.diff" --step 1 --attempt 1 --cwd "$P/sub")"; c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$name: the park tool exited $c: $out"
|
||||
elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["sub/new-in-sub.txt", "tests/a.sh"])'; then
|
||||
bad "$name: not every new file was parked: $out"
|
||||
elif [ -e "$P/tests/a.sh" ] || [ -e "$P/sub/new-in-sub.txt" ]; then
|
||||
bad "$name: a new file is still in the work tree"
|
||||
elif [ "$(cat "$P/sub/keep.txt")" != "kept" ]; then
|
||||
bad "$name: a committed file was touched"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
case_park_crlf() {
|
||||
name="park: a CRLF diff parks its new file"
|
||||
P="$TMP/park-crlf"
|
||||
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
|
||||
printf 'stale\n' > "$P/tests/b.sh"
|
||||
git -C "$P" add --intent-to-add -- tests/b.sh || { bad "$name: intent-to-add"; return; }
|
||||
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-lf.diff" \
|
||||
|| { bad "$name: git diff"; return; }
|
||||
node -e 'const fs=require("fs");const [a,b]=process.argv.slice(1);fs.writeFileSync(b,fs.readFileSync(a,"utf8").split("\n").join("\r\n"));' \
|
||||
"$TMP/park-lf.diff" "$TMP/park-crlf.diff" || { bad "$name: CRLF conversion"; return; }
|
||||
CR="$(printf '\r')"
|
||||
grep -q "^new file mode.*$CR\$" "$TMP/park-crlf.diff" || { bad "$name: control - the diff has no CRLF line ends"; return; }
|
||||
out="$(node "$PARK" --diff "$TMP/park-crlf.diff" --step 1 --attempt 1 --cwd "$P")"; c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$name: the park tool exited $c: $out"
|
||||
elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["tests/b.sh"])'; then
|
||||
bad "$name: the new file was not parked: $out"
|
||||
elif [ -e "$P/tests/b.sh" ]; then
|
||||
bad "$name: the new file is still in the work tree"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
case_park_head_not_indexed() {
|
||||
name="park: a HEAD path missing from the index exits 2 and moves nothing"
|
||||
P="$TMP/park-head"
|
||||
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
|
||||
printf 'stale | cat\n' > "$P/tests/c.sh"
|
||||
git -C "$P" add --intent-to-add -- tests/c.sh || { bad "$name: intent-to-add"; return; }
|
||||
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-head.diff" \
|
||||
|| { bad "$name: git diff"; return; }
|
||||
git -C "$P" add -- tests/c.sh && git -C "$P" commit -q -m "operator" && git -C "$P" rm --cached -q -- tests/c.sh \
|
||||
|| { bad "$name: commit and unindex"; return; }
|
||||
out="$(node "$PARK" --diff "$TMP/park-head.diff" --step 1 --attempt 1 --cwd "$P" 2>&1)"; c=$?
|
||||
if [ "$c" -ne 2 ]; then
|
||||
bad "$name: exited $c, not 2: $out"
|
||||
elif [ "$(cat "$P/tests/c.sh" 2>/dev/null)" != "stale | cat" ]; then
|
||||
bad "$name: the committed file was moved"
|
||||
elif [ -e "$P/.git/voyage/parked/step-1-attempt-1/tests/c.sh" ]; then
|
||||
bad "$name: the committed file was parked"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 3. the arg parser on stdin ----
|
||||
|
||||
case_args_stdin() {
|
||||
name="arg-parser --stdin: ; \$( ) and * stay literal, no file is made"
|
||||
W="$TMP/args"
|
||||
mkdir -p "$W" || { bad "$name: mkdir"; return; }
|
||||
( cd "$W" && node "$ARGS" --command trekendsession --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
brief.md ; touch PWNED $(touch PWNED2) *
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
c=$?
|
||||
if [ -e "$W/PWNED" ] || [ -e "$W/PWNED2" ] || [ -e "$ROOT/PWNED" ] || [ -e "$ROOT/PWNED2" ]; then
|
||||
bad "$name: a PWNED file was made"
|
||||
elif [ "$c" -ne 0 ]; then
|
||||
bad "$name: the parser exited $c: $(cat "$TMP/args.json")"
|
||||
elif ! jsonok 'j.positional[0] === "brief.md" && j.tail === "; touch PWNED $(touch PWNED2) *"' < "$TMP/args.json"; then
|
||||
bad "$name: the arguments were not kept as text: $(cat "$TMP/args.json")"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 4. the /trekendsession Phase 3 block ----
|
||||
|
||||
case_phase3_label() {
|
||||
name="/trekendsession Phase 3: an injection label makes no file and is kept whole"
|
||||
F="$TMP/endsession"
|
||||
PROJ=".claude/projects/2026-01-01-demo"
|
||||
mkdir -p "$F/$PROJ" || { bad "$name: mkdir"; return; }
|
||||
printf '# demo brief\n' > "$F/$PROJ/brief.md"
|
||||
LABEL="a'; touch PWNED; echo 'b"
|
||||
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
|
||||
# the way Claude Code fills them.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [md, root, args] = process.argv.slice(1);
|
||||
const text = fs.readFileSync(md, "utf8");
|
||||
const s = text.indexOf("## Phase 3 ");
|
||||
if (s === -1) process.exit(3);
|
||||
const rest = text.slice(s);
|
||||
const e = rest.search(/\n## (?:Phase |Hard )/);
|
||||
const phase = e === -1 ? rest : rest.slice(0, e);
|
||||
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("atomic-write.mjs"));
|
||||
if (blocks.length !== 1) { console.error("expected one Phase 3 block, found " + blocks.length); process.exit(3); }
|
||||
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
|
||||
' "$ROOT/commands/trekendsession.md" "$ROOT" "$PROJ/next-brief.md $LABEL" > "$TMP/phase3.sh" \
|
||||
|| { bad "$name: could not read the Phase 3 block"; return; }
|
||||
( cd "$F" && . "$TMP/phase3.sh" ) > "$TMP/phase3.out" 2>&1; c=$?
|
||||
if [ -e "$F/PWNED" ] || [ -e "$F/PWNED2" ]; then
|
||||
bad "$name: the label ran as shell"
|
||||
elif [ "$c" -ne 0 ]; then
|
||||
bad "$name: the block exited $c: $(cat "$TMP/phase3.out")"
|
||||
elif [ ! -f "$F/$PROJ/.session-state.local.json" ]; then
|
||||
bad "$name: no .session-state.local.json was written"
|
||||
elif ! jsonok "j.next_session_label === $(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$LABEL") && j.project === \"$PROJ\"" < "$F/$PROJ/.session-state.local.json"; then
|
||||
bad "$name: the written label is not the typed one: $(cat "$F/$PROJ/.session-state.local.json")"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 5. applyLesson ----
|
||||
|
||||
# lessonreview <file> <slug> <sha> <id> <n>: a review.md with one MISSING_TEST finding.
|
||||
lessonreview() {
|
||||
mkdir -p "$(dirname "$1")" || return 1
|
||||
fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)"
|
||||
cat > "$1" <<EOF
|
||||
---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
task: "$2"
|
||||
slug: $2
|
||||
project_dir: .claude/projects/2026-10-0$5-$2/
|
||||
brief_path: .claude/projects/x/brief.md
|
||||
scope_sha_end: $3
|
||||
reviewed_files_count: 1
|
||||
verdict: WARN
|
||||
findings:
|
||||
- $fid
|
||||
---
|
||||
|
||||
# Review: $2
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
x
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"verdict": "WARN",
|
||||
"counts": { "BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0 },
|
||||
"findings": [
|
||||
{ "id": "$fid", "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/a.mjs", "line": 3,
|
||||
"title": "MISSING_TEST title $4", "detail": "d", "recommended_action": "Fix it ($4)." }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
EOF
|
||||
}
|
||||
|
||||
# lessonrepo <dir>: a git repo whose two reviews recur on MISSING_TEST.
|
||||
lessonrepo() {
|
||||
newrepo "$1" || return 1
|
||||
lessonreview "$1/.claude/projects/2026-10-01-one/review.md" one aaa a1 1 || return 1
|
||||
lessonreview "$1/.claude/projects/2026-10-02-two/review.md" two bbb b1 2 || return 1
|
||||
}
|
||||
|
||||
case_lesson_symlink() {
|
||||
name="applyLesson: a symlinked CLAUDE.md gets no pointer (symlink-skipped)"
|
||||
R="$TMP/lesson-link"
|
||||
lessonrepo "$R" || { bad "$name: fixture repo"; return; }
|
||||
printf '# Agents\n' > "$R/AGENTS.md"
|
||||
( cd "$R" && ln -s AGENTS.md CLAUDE.md ) || { bad "$name: ln -s"; return; }
|
||||
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$name: apply exited $c: $out"
|
||||
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "symlink-skipped"'; then
|
||||
bad "$name: the result is not written with pointer symlink-skipped: $out"
|
||||
elif [ "$(cat "$R/AGENTS.md")" != "# Agents" ] || [ ! -L "$R/CLAUDE.md" ]; then
|
||||
bad "$name: the link or its target was changed"
|
||||
elif ! grep -q 'MISSING_TEST' "$R/.claude/rules/voyage-review-lessons.md"; then
|
||||
bad "$name: the lesson itself was not written"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
case_lesson_ignored() {
|
||||
name="applyLesson: an ignored lessons file gets no pointer (ignored-skipped)"
|
||||
R="$TMP/lesson-ignored"
|
||||
lessonrepo "$R" || { bad "$name: fixture repo"; return; }
|
||||
printf '# Project\n' > "$R/CLAUDE.md"
|
||||
printf '.claude/rules/\n' > "$R/.gitignore"
|
||||
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$name: apply exited $c: $out"
|
||||
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "ignored-skipped"'; then
|
||||
bad "$name: the result is not written with pointer ignored-skipped: $out"
|
||||
elif [ "$(cat "$R/CLAUDE.md")" != "# Project" ]; then
|
||||
bad "$name: CLAUDE.md got a pointer to an ignored file"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 6. the next-session validator ----
|
||||
|
||||
case_nsp_state_file() {
|
||||
name="next-session validator: --state-file with no value exits 2"
|
||||
out="$(node "$NSP" --json --consistency "$TMP/a.md" "$TMP/b.md" --state-file 2>&1)"; c=$?
|
||||
if [ "$c" -ne 2 ]; then
|
||||
bad "$name: exited $c, not 2: $out"
|
||||
elif ! printf '%s' "$out" | grep -q 'needs a value'; then
|
||||
bad "$name: the message does not say needs a value: $out"
|
||||
else
|
||||
pass "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
case_gate_forms
|
||||
case_gate_sha256
|
||||
case_gate_plain_dir
|
||||
case_gate_free_text
|
||||
case_park_subdir
|
||||
case_park_crlf
|
||||
case_park_head_not_indexed
|
||||
case_args_stdin
|
||||
case_phase3_label
|
||||
case_lesson_symlink
|
||||
case_lesson_ignored
|
||||
case_nsp_state_file
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "e2e: $FAILED case(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "e2e: ok"
|
||||
exit 0
|
||||
40
tests/fix-round-4-5a-mutations.mjs
Normal file
40
tests/fix-round-4-5a-mutations.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// tests/fix-round-4-5a-mutations.mjs
|
||||
// The mutations of fix round 4-5a, for the shared red script
|
||||
// (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs). DJ's
|
||||
// defect was a missing test: its sentences already stood at ab6b32c, so its
|
||||
// pins are proven red by deleting the copy-through spans. A deletes the span
|
||||
// in commands/trekreview.md, B both spans in agents/review-coordinator.md;
|
||||
// every span must match exactly once. Moved verbatim from the heredoc the
|
||||
// 4-5a red script used to write. Data only; never matched by `npm test`.
|
||||
|
||||
export const MUTATIONS = [
|
||||
{
|
||||
key: 'DJ-A',
|
||||
id: 'DJ',
|
||||
what: 'the copy-through span in commands/trekreview.md deleted',
|
||||
spans: [
|
||||
{
|
||||
file: 'commands/trekreview.md',
|
||||
find: /, and pass each reviewer's\s+`### Declined to Judge` lines verbatim from its saved\s+`\{project_dir\}\/\.review-out-\{reviewer\}\.md`: the coordinator copies them into\s+`## Declined to Judge`, so nothing a reviewer set aside disappears\./g,
|
||||
replace: '.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'DJ-B',
|
||||
id: 'DJ',
|
||||
what: 'both spans in agents/review-coordinator.md deleted',
|
||||
spans: [
|
||||
{
|
||||
file: 'agents/review-coordinator.md',
|
||||
find: /^- \*\*Reviewers' Declined to Judge lines\*\* \u2014 each reviewer's own\s+`### Declined to Judge` list, verbatim\. You copy them into\s+`## Declined to Judge` \(body section 9\); you do not rule on them\.\n/gm,
|
||||
replace: '',
|
||||
},
|
||||
{
|
||||
file: 'agents/review-coordinator.md',
|
||||
find: /, then every\s+line a reviewer wrote under its own `### Declined to Judge`, copied as\s+`\{reviewer\}: \{line\}` \(they are not findings: they stay out of the JSON\s+`declined_to_judge` array and do not change the verdict\)/g,
|
||||
replace: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,167 +1,40 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/fix-round-4-5a-red.sh
|
||||
# The red proof of fix round 4-5a (brief: the last Success Criterion). Every
|
||||
# id of the round has tests whose names start with "[id]"; each of them must
|
||||
# fail on an assertion at the round's start point ab6b32c.
|
||||
# 1. Clone the repo into a temp directory outside it (git clone --shared
|
||||
# --no-checkout, then checkout --detach BASE, as
|
||||
# lib/verification/red-proof.mjs does; nothing registers in
|
||||
# .git/worktrees).
|
||||
# 2. Replace the clone's tests/ with the working tree's whole tests/ (tar),
|
||||
# helpers and fixtures included, so a new helper is no crash.
|
||||
# 3. Run the round's eight test files there, only the tagged tests
|
||||
# (--test-name-pattern per id), under tests/helpers/red-reporter.mjs, and
|
||||
# let tests/helpers/red-judge.mjs decide (--pass-ids DJ: DJ's defect is a
|
||||
# missing test, its sentences exist at ab6b32c).
|
||||
# 4. DJ by mutation: A deletes the copy-through span in
|
||||
# commands/trekreview.md, B deletes both spans in
|
||||
# agents/review-coordinator.md; each span must match exactly once. Each
|
||||
# run is judged with --mutation DJ; the file is restored with cp after.
|
||||
# The red proof of fix round 4-5a (brief: the last Success Criterion), as a
|
||||
# wrapper over the shared red script tests/helpers/red-round.sh. Every id of
|
||||
# the round has tests whose names start with "[id]"; each of them must fail on
|
||||
# an assertion at the round's start point ab6b32c, or, for DJ, under its
|
||||
# mutations.
|
||||
# This wrapper passes the shared script:
|
||||
# --base BASE, --ids IDS (the round's 30 ids), the round's FILES;
|
||||
# --other-ids: the ids of fix round 4-5b, read with sed from the IDS= line
|
||||
# of tests/fix-round-4-5b-red.sh (a tag on that list is not judged);
|
||||
# --mutations tests/fix-round-4-5a-mutations.mjs: DJ-A deletes the
|
||||
# copy-through span in commands/trekreview.md, DJ-B both spans in
|
||||
# agents/review-coordinator.md (DJ's defect is a missing test, its
|
||||
# sentences exist at ab6b32c, so DJ is a pass-id at the start point).
|
||||
# The shared script clones the start point outside the repo, puts the working
|
||||
# tree's tests/ in it, runs every tagged test of the files once, lets
|
||||
# tests/helpers/red-judge.mjs decide, then applies each mutation, runs DJ's
|
||||
# tests, judges and restores.
|
||||
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
|
||||
# (clone, checkout, node or git missing); never 0 on a setup failure.
|
||||
# (also: no ids on the 4-5b IDS= line); never 0 on a setup failure.
|
||||
# bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
set -f
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "red script: SETUP FAIL - cannot cd to the repo root" >&2; exit 2; }
|
||||
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
|
||||
ROOT="$(pwd -P)"
|
||||
|
||||
BASE=ab6b32c9304534b77c7ca2b36e7c993682c9efde
|
||||
IDS='#52 #47 #48 #46 #2 #26 #33 #1 #30 #28 R6 D67 #15 #12 #66 #102 #57 #67 #113 #111 #24 #23 F9 F4a F4b F4c WF CL ORD DJ'
|
||||
FILES='tests/lib/fix-round-4-5a-prose.test.mjs tests/lib/verdict-gate.test.mjs tests/lib/park-attempt-files.test.mjs tests/lib/autonomy-gate.test.mjs tests/lib/arg-parser.test.mjs tests/scripts/trekreview-armb-workflow.test.mjs tests/lib/coordinator-contract.test.mjs tests/validators/next-session-prompt-validator.test.mjs'
|
||||
JUDGE="$ROOT/tests/helpers/red-judge.mjs"
|
||||
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE NODE_TEST_CONTEXT CLAUDE_CODE_SESSION_ID
|
||||
|
||||
setup_fail() {
|
||||
echo "red script: SETUP FAIL - $1" >&2
|
||||
OTHER=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-red.sh")
|
||||
if [ -z "$OTHER" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-red.sh" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
command -v node >/dev/null 2>&1 || setup_fail "node is not on PATH"
|
||||
command -v git >/dev/null 2>&1 || setup_fail "git is not on PATH"
|
||||
command -v tar >/dev/null 2>&1 || setup_fail "tar is not on PATH"
|
||||
[ -f "$JUDGE" ] || setup_fail "missing $JUDGE"
|
||||
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/fix-round-4-5a-red.XXXXXX")" || setup_fail "mktemp -d failed"
|
||||
case "$TMP" in
|
||||
''|/) setup_fail "mktemp -d gave an unusable directory '$TMP'" ;;
|
||||
esac
|
||||
TMP_REAL="$(cd "$TMP" && pwd -P)" || setup_fail "cannot enter $TMP"
|
||||
case "$TMP_REAL/" in
|
||||
"$ROOT"/*) rmdir "$TMP" 2>/dev/null; setup_fail "the temp directory $TMP_REAL is inside the repo" ;;
|
||||
esac
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
trap 'rm -rf "$TMP"; exit 2' INT TERM HUP
|
||||
|
||||
TREE="$TMP/tree"
|
||||
|
||||
# ---- 1. the start point ----
|
||||
|
||||
git clone --quiet --shared --no-checkout "$ROOT" "$TREE" || setup_fail "could not clone $ROOT"
|
||||
git -C "$TREE" checkout --quiet --detach "$BASE" || setup_fail "could not check out $BASE"
|
||||
|
||||
# ---- 2. the working tree's whole tests/ ----
|
||||
|
||||
tar -cf "$TMP/tests.tar" tests || setup_fail "could not tar tests/"
|
||||
rm -rf "$TREE/tests" || setup_fail "could not remove the start point's tests/"
|
||||
tar -xf "$TMP/tests.tar" -C "$TREE" || setup_fail "could not copy tests/ into the start point"
|
||||
|
||||
for f in $FILES; do
|
||||
[ -f "$TREE/$f" ] || setup_fail "missing test file $f"
|
||||
done
|
||||
|
||||
for id in $IDS; do
|
||||
printf '%s\n' "$id"
|
||||
done > "$TMP/ids" || setup_fail "could not write $TMP/ids"
|
||||
|
||||
# run_tagged <results> <file...>: the tagged tests of the files, run in the
|
||||
# start point under the red reporter. The positional parameters carry the
|
||||
# name patterns (no arrays in bash 3.2).
|
||||
run_tagged() {
|
||||
out="$1"
|
||||
shift
|
||||
(cd "$TREE" && node --import ./tests/helpers/no-session-id.mjs --test \
|
||||
--test-reporter=./tests/helpers/red-reporter.mjs \
|
||||
--test-reporter-destination="$out" "$@") >/dev/null 2>"$out.stderr"
|
||||
[ -s "$out" ] || { cat "$out.stderr" >&2; setup_fail "the test run wrote no results to $out"; }
|
||||
}
|
||||
|
||||
# judge <args...>: the red judge; 2 (usage or input) is a setup failure.
|
||||
judge() {
|
||||
node "$JUDGE" "$@"
|
||||
rc=$?
|
||||
[ "$rc" -eq 2 ] && setup_fail "the red judge could not run (exit 2)"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
STATUS=0
|
||||
|
||||
# ---- 3. every id at the start point ----
|
||||
|
||||
set --
|
||||
for id in $IDS; do
|
||||
set -- "$@" "--test-name-pattern=\\[$id\\]"
|
||||
done
|
||||
for f in $FILES; do
|
||||
set -- "$@" "$f"
|
||||
done
|
||||
|
||||
echo "red script: the tagged tests at $BASE"
|
||||
run_tagged "$TMP/base.jsonl" "$@"
|
||||
judge --ids "$TMP/ids" --results "$TMP/base.jsonl" --pass-ids DJ || STATUS=1
|
||||
|
||||
# ---- 4. DJ under its two mutations ----
|
||||
|
||||
cat > "$TMP/mutate.mjs" <<'EOF'
|
||||
// mutate.mjs <file> <A|B>: delete DJ's copy-through span(s); each span must
|
||||
// match exactly once, else exit 1 and leave the file as it was.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const [file, which] = process.argv.slice(2);
|
||||
const SPANS = {
|
||||
A: [
|
||||
[/, and pass each reviewer's\s+`### Declined to Judge` lines verbatim from its saved\s+`\{project_dir\}\/\.review-out-\{reviewer\}\.md`: the coordinator copies them into\s+`## Declined to Judge`, so nothing a reviewer set aside disappears\./g, '.'],
|
||||
],
|
||||
B: [
|
||||
[/^- \*\*Reviewers' Declined to Judge lines\*\* \u2014 each reviewer's own\s+`### Declined to Judge` list, verbatim\. You copy them into\s+`## Declined to Judge` \(body section 9\); you do not rule on them\.\n/gm, ''],
|
||||
[/, then every\s+line a reviewer wrote under its own `### Declined to Judge`, copied as\s+`\{reviewer\}: \{line\}` \(they are not findings: they stay out of the JSON\s+`declined_to_judge` array and do not change the verdict\)/g, ''],
|
||||
],
|
||||
};
|
||||
if (!SPANS[which]) { console.error(`mutate: unknown mutation ${which}`); process.exit(1); }
|
||||
let text = readFileSync(file, 'utf8');
|
||||
for (const [re, by] of SPANS[which]) {
|
||||
const n = (text.match(re) || []).length;
|
||||
if (n !== 1) { console.error(`mutate ${which}: ${re.source.slice(0, 60)}... matched ${n} times in ${file}, not once`); process.exit(1); }
|
||||
text = text.replace(re, by);
|
||||
}
|
||||
writeFileSync(file, text);
|
||||
console.log(`mutate ${which}: ${SPANS[which].length} span(s) deleted in ${file}`);
|
||||
EOF
|
||||
[ -s "$TMP/mutate.mjs" ] || setup_fail "could not write $TMP/mutate.mjs"
|
||||
|
||||
# mutation <A|B> <repo-relative file> <what>: apply, run the DJ tests, judge, restore.
|
||||
mutation() {
|
||||
m="$1"
|
||||
rel="$2"
|
||||
echo "red script: DJ mutation $m ($3)"
|
||||
cp "$TREE/$rel" "$TMP/mutation-$m.orig" || setup_fail "could not save $rel"
|
||||
if node "$TMP/mutate.mjs" "$TREE/$rel" "$m"; then
|
||||
run_tagged "$TMP/mutation-$m.jsonl" "--test-name-pattern=\\[DJ\\]" tests/lib/fix-round-4-5a-prose.test.mjs
|
||||
judge --ids "$TMP/ids" --results "$TMP/mutation-$m.jsonl" --mutation DJ || STATUS=1
|
||||
else
|
||||
echo "broken: mutation-not-applied [DJ] $m on $rel"
|
||||
STATUS=1
|
||||
fi
|
||||
cp "$TMP/mutation-$m.orig" "$TREE/$rel" || setup_fail "could not restore $rel"
|
||||
}
|
||||
|
||||
mutation A commands/trekreview.md "the copy-through span in commands/trekreview.md deleted"
|
||||
mutation B agents/review-coordinator.md "both spans in agents/review-coordinator.md deleted"
|
||||
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo "red script: ok - every id red at $BASE, DJ under both mutations"
|
||||
else
|
||||
echo "red script: FAIL - a rule is broken (see the broken: lines above)" >&2
|
||||
fi
|
||||
exit "$STATUS"
|
||||
|
||||
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER" --mutations "$ROOT/tests/fix-round-4-5a-mutations.mjs" $FILES
|
||||
|
|
|
|||
261
tests/fix-round-4-5b-mutations.mjs
Normal file
261
tests/fix-round-4-5b-mutations.mjs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// tests/fix-round-4-5b-mutations.mjs
|
||||
// The mutations of fix round 4-5b, for the shared red script
|
||||
// (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs). An id
|
||||
// named here has tests that pass at the round's start point be0b631 (the
|
||||
// red-proof instruments are this round's own work, and they live in the
|
||||
// working tree's tests/ that the script copies into the clone); each is
|
||||
// proven red under its mutation instead. Every span must match exactly once
|
||||
// in the clone. Data only; never matched by `npm test`. Later steps append
|
||||
// entries.
|
||||
|
||||
export const MUTATIONS = [
|
||||
{
|
||||
key: 'E23a',
|
||||
id: 'E23a',
|
||||
what: 'the base run selects one --test-name-pattern per round id instead of one ^\\[ pattern',
|
||||
spans: [
|
||||
{
|
||||
file: 'tests/helpers/red-round.sh',
|
||||
find: String.raw`run_tagged "$TMP/base.jsonl" --test-timeout=300000 --test-force-exit '--test-name-pattern=^\['`,
|
||||
replace: String.raw`PATS=''
|
||||
for id in $IDS; do
|
||||
PATS="$PATS --test-name-pattern=\\[$id\\]"
|
||||
done
|
||||
run_tagged "$TMP/base.jsonl" --test-timeout=300000 --test-force-exit $PATS`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'E23b',
|
||||
id: 'E23b',
|
||||
what: 'the base run loses its --test-timeout',
|
||||
spans: [
|
||||
{
|
||||
file: 'tests/helpers/red-round.sh',
|
||||
find: 'run_tagged "$TMP/base.jsonl" --test-timeout=300000',
|
||||
replace: 'run_tagged "$TMP/base.jsonl"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'RS',
|
||||
id: 'RS',
|
||||
what: 'the clone checks out HEAD instead of the base',
|
||||
spans: [
|
||||
{
|
||||
file: 'tests/helpers/red-round.sh',
|
||||
find: 'checkout --quiet --detach "$BASE"',
|
||||
replace: 'checkout --quiet --detach HEAD',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'E23d',
|
||||
id: 'E23d',
|
||||
what: 'the red judge exits 0 on a usage error',
|
||||
spans: [
|
||||
{
|
||||
file: 'tests/helpers/red-judge.mjs',
|
||||
find: 'process.exit(2);',
|
||||
replace: 'process.exit(0);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'E23f',
|
||||
id: 'E23f',
|
||||
what: 'the red reporter drops the result of a test named [X1] red...',
|
||||
spans: [
|
||||
{
|
||||
file: 'tests/helpers/red-reporter.mjs',
|
||||
find: 'const d = event.data || {};',
|
||||
replace: "const d = event.data || {};\n if (String(d.name).startsWith('[X1] red')) continue;",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'E20',
|
||||
id: 'E20',
|
||||
what: 'untitled findings are placed in arrival order, not in canonical order, in the contract and the workflow',
|
||||
spans: [
|
||||
{
|
||||
file: 'lib/review/coordinator-contract.mjs',
|
||||
find: 'untitled.sort(canon)',
|
||||
replace: 'untitled',
|
||||
},
|
||||
{
|
||||
file: 'scripts/trekreview-armB.workflow.mjs',
|
||||
find: 'untitled.sort(canon)',
|
||||
replace: 'untitled',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'E16a',
|
||||
id: 'E16a',
|
||||
what: 'the next-session-prompt validator CLI loses its refusal of a --state-file with no value',
|
||||
spans: [
|
||||
{
|
||||
file: 'lib/validators/next-session-prompt-validator.mjs',
|
||||
find: String.raw`
|
||||
if (v === undefined || v.startsWith('--')) {
|
||||
process.stderr.write('next-session-prompt-validator: --state-file needs a value\n');
|
||||
process.exit(2);
|
||||
}`,
|
||||
replace: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'E17a',
|
||||
id: 'E17a',
|
||||
what: 'the two wave 2 sentences on outbound queries are deleted from /trekresearch',
|
||||
spans: [
|
||||
{
|
||||
file: 'commands/trekresearch.md',
|
||||
find: `It searches with the words it is given, so no
|
||||
local file name or project term may be in them. `,
|
||||
replace: '',
|
||||
},
|
||||
{
|
||||
file: 'commands/trekresearch.md',
|
||||
find: `When wave 1's external
|
||||
findings hold no leading answer, say so and give it the research question
|
||||
alone, stripped the same way. `,
|
||||
replace: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'E18a',
|
||||
id: 'E18a',
|
||||
what: 'the summary-only triage row of /trekreview gains .claude/rules/**',
|
||||
spans: [
|
||||
{
|
||||
file: 'commands/trekreview.md',
|
||||
find: '| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. |',
|
||||
replace: '| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`, `.claude/rules/**`. |',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'Db',
|
||||
id: 'Db',
|
||||
what: 'a security-researcher* star is added to the README agent list, outside the diagram line',
|
||||
spans: [
|
||||
{
|
||||
file: 'README.md',
|
||||
find: '4 external agents (docs-researcher, community-researcher, security-researcher, contrarian-researcher)',
|
||||
replace: '4 external agents (docs-researcher, community-researcher, security-researcher*, contrarian-researcher)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'M1',
|
||||
id: 'M1',
|
||||
what: 'the 13 name sites of the tests f6bdc2d added are put back to their untagged found in execution: names',
|
||||
spans: [
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[WF] sibling: ${rel} no longer puts back what the coordinator did not return',
|
||||
replace: 'found in execution: ${rel} no longer puts back what the coordinator did not return',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[WF] sibling: ${rel} says a finding is matched to its row',
|
||||
replace: 'found in execution: ${rel} says a finding is matched to its row',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[WF] sibling: ${rel} puts back one copy per row',
|
||||
replace: 'found in execution: ${rel} puts back one copy per row',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: "[WF] sibling: ${rel} says floor (b)'s counterpart",
|
||||
replace: "found in execution: ${rel} says floor (b)'s counterpart",
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[M1r] README.md names no "leading recommendation" trigger',
|
||||
replace: 'found in execution: README.md names no "leading recommendation" trigger',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[#113] sibling: README.md stars security-researcher nowhere',
|
||||
replace: 'found in execution: README.md stars security-researcher nowhere',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[M1r] README.md lists security-researcher as no conditional agent',
|
||||
replace: 'found in execution: README.md lists security-researcher as no conditional agent',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[#113] sibling: README.md names the deep-research case of wave 2',
|
||||
replace: 'found in execution: README.md names the deep-research case of wave 2',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[#113] sibling: README.md runs contrarian-researcher after wave 1 returns',
|
||||
replace: 'found in execution: README.md runs contrarian-researcher after wave 1 returns',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: "[#111] sibling: ${rel} item 3 names Pass 3's high-effort skip",
|
||||
replace: "found in execution: ${rel} item 3 names Pass 3's high-effort skip",
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[#111] sibling: CHANGELOG ### Fixed has the **Across the round** label',
|
||||
replace: 'found in execution: CHANGELOG ### Fixed has the **Across the round** label',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[#111] sibling: commands/trekreview.md high-effort section skips Pass 3',
|
||||
replace: 'found in execution: commands/trekreview.md high-effort section skips Pass 3',
|
||||
},
|
||||
{
|
||||
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
|
||||
find: '[#111] sibling: agents/review-orchestrator.md quick mode skips pass 3',
|
||||
replace: 'found in execution: agents/review-orchestrator.md quick mode skips pass 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'M1r',
|
||||
id: 'M1r',
|
||||
what: 'the README * = conditional line is put back to its 8a2a989 text, which names security-researcher when a leading recommendation forms',
|
||||
spans: [
|
||||
{
|
||||
file: 'README.md',
|
||||
find: "`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1's external findings to attack (with `--engine deep-research`, after the engine returns, from the leading answer of its report), always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.",
|
||||
replace: '`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; security-researcher when a leading recommendation forms (or always at `effort=high`); contrarian-researcher, when selected, runs in wave 2 after the other external agents return, with their leading answer to attack (always at `effort=high`); brief-conformance-reviewer skipped under `--quick`.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'M7r',
|
||||
id: 'M7r',
|
||||
what: 'a HEAD read that fails (exit 128, as on an unborn HEAD) counts as in HEAD, so nothing is parked',
|
||||
spans: [
|
||||
{
|
||||
file: 'lib/util/park-attempt-files.mjs',
|
||||
find: 'return r.status === 0;',
|
||||
replace: 'return r.status === 0 || r.status === 128;',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'DJ4r',
|
||||
id: 'DJ4r',
|
||||
what: 'the lesson write returns when the lessons directory is missing, so the first lesson is never written',
|
||||
spans: [
|
||||
{
|
||||
file: 'lib/review/recurrence.mjs',
|
||||
find: 'mkdirSync(dirname(path), { recursive: true });',
|
||||
replace: 'if (!existsSync(dirname(path))) return;',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
10
tests/fix-round-4-5b-part-2-mutations.mjs
Normal file
10
tests/fix-round-4-5b-part-2-mutations.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// tests/fix-round-4-5b-part-2-mutations.mjs
|
||||
// The mutations of fix round 4-5b, part 2, for the shared red script
|
||||
// (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs). The
|
||||
// round has none: every part-2 id (TOK, HDG, CKI, DLC, KI) has tests that fail
|
||||
// on an assertion at the round's start point 52c382c, on that id's own code or
|
||||
// text, so no id needs a mutation to be proven red. red-mutate.mjs list
|
||||
// accepts the empty array, and the red script then reports 0 mutation(s).
|
||||
// Data only; never matched by `npm test`.
|
||||
|
||||
export const MUTATIONS = [];
|
||||
44
tests/fix-round-4-5b-part-2-red.sh
Normal file
44
tests/fix-round-4-5b-part-2-red.sh
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/fix-round-4-5b-part-2-red.sh
|
||||
# The red proof of fix round 4-5b, part 2 (plan
|
||||
# .claude/projects/2026-10-27-fix-round-4-5b-part-2, decision 88), as a
|
||||
# wrapper over the shared red script tests/helpers/red-round.sh. Every id of
|
||||
# the round has tests whose names start with "[id]"; each of them must fail
|
||||
# on an assertion at the round's start point 52c382c.
|
||||
# This wrapper passes the shared script:
|
||||
# --base BASE, --ids IDS (the round's 5 ids), the round's FILES;
|
||||
# --other-ids: the ids of fix round 4-5a and of 4-5b part 1, read with sed
|
||||
# from the IDS= lines of tests/fix-round-4-5a-red.sh and
|
||||
# tests/fix-round-4-5b-red.sh and passed as ONE value, 4-5a's first (the
|
||||
# shared script keeps only the last --other-ids it is given); a tag on
|
||||
# that list is not judged, so the [ARG] tests in bash-guard.test.mjs are
|
||||
# left to part 1;
|
||||
# --mutations tests/fix-round-4-5b-part-2-mutations.mjs (empty: every id is
|
||||
# red at the start point on its own code).
|
||||
# No --require-sha256: the round has no SHA-256 test.
|
||||
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
|
||||
# (also: no ids on either older IDS= line); never 0 on a setup failure.
|
||||
# bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
set -f
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
|
||||
ROOT="$(pwd -P)"
|
||||
|
||||
BASE=52c382c19885819d747bbcb23a34ae2015470f97
|
||||
IDS='TOK HDG CKI DLC KI'
|
||||
FILES='tests/lib/arg-parser-tokenize.test.mjs tests/hooks/bash-guard.test.mjs tests/lib/review-recurrence-check-ignore.test.mjs tests/lib/fix-round-4-5b-part-2-prose.test.mjs'
|
||||
|
||||
OTHER_A=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5a-red.sh")
|
||||
if [ -z "$OTHER_A" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5a-red.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
OTHER_B=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-red.sh")
|
||||
if [ -z "$OTHER_B" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-red.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER_A $OTHER_B" --mutations "$ROOT/tests/fix-round-4-5b-part-2-mutations.mjs" $FILES
|
||||
35
tests/fix-round-4-5b-part-2-regression.sh
Normal file
35
tests/fix-round-4-5b-part-2-regression.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/fix-round-4-5b-part-2-regression.sh
|
||||
# The Regression: of fix round 4-5b, part 2 (decision 88): the whole suite
|
||||
# (npm test), part 1's end to end (tests/e2e-fix-round-4-5b.sh, which drives
|
||||
# the arg parser and `recurrence.mjs apply`, both changed by part 2) and
|
||||
# part 2's red proof (tests/fix-round-4-5b-part-2-red.sh). The 4-5a and
|
||||
# part-1 red proofs are not run here: part 1's alone re-runs its base and its
|
||||
# mutations, and one Regression command has the criteria runner's 15-minute
|
||||
# cap; they run as their own Verification lines. All three run; the script
|
||||
# prints each exit and exits with the first non-zero status, or 0 when all
|
||||
# three exit 0.
|
||||
# bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "regression: cannot cd to the repo root" >&2; exit 2; }
|
||||
|
||||
FIRST=0
|
||||
|
||||
npm test
|
||||
rc=$?
|
||||
echo "regression: npm test exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/e2e-fix-round-4-5b.sh
|
||||
rc=$?
|
||||
echo "regression: tests/e2e-fix-round-4-5b.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/fix-round-4-5b-part-2-red.sh
|
||||
rc=$?
|
||||
echo "regression: tests/fix-round-4-5b-part-2-red.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
exit "$FIRST"
|
||||
40
tests/fix-round-4-5b-red.sh
Normal file
40
tests/fix-round-4-5b-red.sh
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/fix-round-4-5b-red.sh
|
||||
# The red proof of fix round 4-5b (brief: fixed boundary 4), as a wrapper over
|
||||
# the shared red script tests/helpers/red-round.sh. Every id of the round has
|
||||
# tests whose names start with "[id]"; each of them must fail on an assertion
|
||||
# at the round's start point be0b631, or, for an id a mutation names, under
|
||||
# that mutation.
|
||||
# This wrapper passes the shared script:
|
||||
# --base BASE, --ids IDS (the round's 41 ids), the round's FILES;
|
||||
# --other-ids: the ids of fix round 4-5a, read with sed from the IDS= line
|
||||
# of tests/fix-round-4-5a-red.sh (a tag on that list is not judged);
|
||||
# --mutations tests/fix-round-4-5b-mutations.mjs (its ids are pass-ids at
|
||||
# the start point and are proven red under their mutations);
|
||||
# --require-sha256: the round has SHA-256 tests, so a git that cannot
|
||||
# create a SHA-256 repository is a setup failure, not a silent skip.
|
||||
# The shared script clones the start point outside the repo, puts the working
|
||||
# tree's tests/ in it, runs every tagged test of the files once, lets
|
||||
# tests/helpers/red-judge.mjs decide, then applies each mutation, runs that
|
||||
# id's tests, judges and restores.
|
||||
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
|
||||
# (also: no ids on the 4-5a IDS= line); never 0 on a setup failure.
|
||||
# bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
set -f
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
|
||||
ROOT="$(pwd -P)"
|
||||
|
||||
BASE=be0b631fed21942b6c0fdae570b58e4112f8242d
|
||||
IDS='M6 M11 M4 m4 M3 M7 M7r E22c E22e M5 S3 E20 M9 M2 K3 M1 M1r E23a E23b E23d E23f M8 M10 DJ5 M13 K2 DJ1 DJ2 DJ4 DJ4r E14 E16a E17a E17b E18a Da Db K4 ARG PTR RS'
|
||||
FILES='tests/lib/verdict-gate.test.mjs tests/lib/done-line.test.mjs tests/lib/park-attempt-files.test.mjs tests/scripts/trekreview-armb-workflow.test.mjs tests/lib/coordinator-contract.test.mjs tests/lib/red-judge.test.mjs tests/lib/red-script.test.mjs tests/lib/review-recurrence.test.mjs tests/lib/arg-parser.test.mjs tests/commands/trekendsession.test.mjs tests/validators/next-session-prompt-validator.test.mjs tests/lib/fix-round-4-5b-prose.test.mjs tests/lib/fix-round-4-5a-prose.test.mjs tests/scripts/release-check.test.mjs'
|
||||
|
||||
OTHER=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5a-red.sh")
|
||||
if [ -z "$OTHER" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5a-red.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER" --mutations "$ROOT/tests/fix-round-4-5b-mutations.mjs" --require-sha256 $FILES
|
||||
31
tests/fix-round-4-5b-regression.sh
Normal file
31
tests/fix-round-4-5b-regression.sh
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/fix-round-4-5b-regression.sh
|
||||
# The Regression: of fix round 4-5b's steps: the whole suite (npm test), the
|
||||
# 4-5a red proof (tests/fix-round-4-5a-red.sh, a wrapper over the shared red
|
||||
# script) and the 4-5a end to end (tests/e2e-fix-round-4-5a.sh, which npm test
|
||||
# only pins statically). All three run; the script exits with the first
|
||||
# non-zero status, or 0 when all three exit 0.
|
||||
# bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "regression: cannot cd to the repo root" >&2; exit 2; }
|
||||
|
||||
FIRST=0
|
||||
|
||||
npm test
|
||||
rc=$?
|
||||
echo "regression: npm test exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/fix-round-4-5a-red.sh
|
||||
rc=$?
|
||||
echo "regression: tests/fix-round-4-5a-red.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/e2e-fix-round-4-5a.sh
|
||||
rc=$?
|
||||
echo "regression: tests/e2e-fix-round-4-5a.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
exit "$FIRST"
|
||||
6
tests/fixtures/jsonl-schemas.md
vendored
6
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -20,12 +20,12 @@
|
|||
| schema_id | fields | writer_path | line_ref | v4.1 additive | PII |
|
||||
|-----------|--------|-------------|----------|---------------|-----|
|
||||
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1141-1165 | profile, phase_models, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:822-855 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:829-862 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1307-1335 | profile, phase_models, parallel_agents, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2969-2991 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2990-3012 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
|
||||
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
|
||||
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:640-690 | profile, phase_models, profile_source | none |
|
||||
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:643-693 | profile, phase_models, profile_source | none |
|
||||
| trekcontinue-stats | ts, project, next_session_label, status | lib/stats/stats-append.mjs (trekcontinue.md Phase 5) | trekcontinue.md:304-308 | profile, profile_source | none |
|
||||
| token-usage | ts, session_id, scope, model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read, cost_usd, is_estimate, price_table_version | lib/stats/token-usage.mjs `captureTokenUsage` (via hooks/scripts/otel-export.mjs Stop hook, opt-in `VOYAGE_TOKEN_METER`) | token-usage.mjs:buildRecord | n/a (SKAL-2 schema) | session_id (stripped at export) |
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@
|
|||
// tagged test (a name that starts with "[id]") failed on an assertion.
|
||||
//
|
||||
// node tests/helpers/red-judge.mjs --ids <file> --results <jsonl>
|
||||
// [--pass-ids <id>[,<id>...]] [--mutation <id>]
|
||||
// [--other-ids <file>] [--pass-ids <id>[,<id>...]] [--mutation <id>]
|
||||
//
|
||||
// --other-ids <file>: the other rounds' ids, one per line (read like --ids).
|
||||
// A result whose tag is on that list is skipped before every rule (pass,
|
||||
// fail, crash, skip, todo): another round's test is that round's to judge.
|
||||
// An id on both lists is a usage error (exit 2).
|
||||
//
|
||||
// Plain run, one line per broken rule and exit 1 when:
|
||||
// broken: no-test [id] a listed id has no test
|
||||
|
|
@ -21,28 +26,32 @@
|
|||
// broken: untagged-failure {name} ({file}) a failing event with no tag (a
|
||||
// file that failed to load)
|
||||
// broken: unlisted-tag [tag] {name} a tag that is not in the list
|
||||
// broken: unreadable-tag {name} a name that starts with "[" but
|
||||
// holds no readable tag ("[X 1]",
|
||||
// "[]"), whatever its result
|
||||
// An aggregate (a suite or file event with failureType subtestsFailed: it
|
||||
// failed only because a child failed) is not counted.
|
||||
// --mutation <id>: only that id's tests are judged (plus untagged failures
|
||||
// and unlisted tags); exit 1 with "broken: mutation-not-red [id]" unless at
|
||||
// --mutation <id>: only that id's tests are judged (plus untagged failures,
|
||||
// unlisted and unreadable tags); exit 1 with "broken: mutation-not-red [id]" unless at
|
||||
// least one of them fails on an assertion, and a "broken:" line for any of
|
||||
// them that fails otherwise, is skipped or is a todo. A passing one is fine.
|
||||
// Otherwise exit 0, printing "red: [id] {n} test(s)" per id (and
|
||||
// "pass: [id] {n} test(s) (--pass-ids)" for a passing --pass-ids id).
|
||||
// Exit 2: a usage error or an unreadable input (never 0).
|
||||
// Exit 2: a usage error or an unreadable input (never 0). Every exit 2 goes
|
||||
// through usage().
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/** Print a usage problem and exit 2. */
|
||||
function usage(msg) {
|
||||
process.stderr.write(`red-judge: ${msg}\n` +
|
||||
'usage: red-judge.mjs --ids <file> --results <jsonl> [--pass-ids <id>[,<id>...]] [--mutation <id>]\n');
|
||||
'usage: red-judge.mjs --ids <file> --results <jsonl> [--other-ids <file>] [--pass-ids <id>[,<id>...]] [--mutation <id>]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** --flag value pairs; every flag takes one value. */
|
||||
function parse(argv) {
|
||||
const known = new Set(['--ids', '--results', '--pass-ids', '--mutation']);
|
||||
const known = new Set(['--ids', '--results', '--other-ids', '--pass-ids', '--mutation']);
|
||||
const o = {};
|
||||
for (let i = 0; i < argv.length; i += 2) {
|
||||
if (!known.has(argv[i])) usage(`unknown argument ${argv[i]}`);
|
||||
|
|
@ -75,6 +84,12 @@ const opts = parse(process.argv.slice(2));
|
|||
const ids = readText(opts['--ids'], 'ids').split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
if (ids.length === 0) usage(`the ids file ${opts['--ids']} lists no id`);
|
||||
const listed = new Set(ids);
|
||||
const otherIds = opts['--other-ids']
|
||||
? new Set(readText(opts['--other-ids'], 'other-ids').split('\n').map((l) => l.trim()).filter(Boolean))
|
||||
: new Set();
|
||||
for (const id of ids) {
|
||||
if (otherIds.has(id)) usage(`id ${id} is on both --ids and --other-ids`);
|
||||
}
|
||||
const passIds = new Set((opts['--pass-ids'] || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||
const mutation = opts['--mutation'] || null;
|
||||
if (mutation && !listed.has(mutation)) usage(`--mutation ${mutation} is not in the ids file`);
|
||||
|
|
@ -99,6 +114,11 @@ for (const e of events) {
|
|||
if (e.ok === false && e.failureType === 'subtestsFailed') continue;
|
||||
const name = one(e.name);
|
||||
const tag = tagOf(name);
|
||||
if (tag && otherIds.has(tag)) continue;
|
||||
if (!tag && name.startsWith('[')) {
|
||||
broken.push(`broken: unreadable-tag ${name}`);
|
||||
continue;
|
||||
}
|
||||
if (!tag) {
|
||||
if (e.ok === false) broken.push(`broken: untagged-failure ${name} (${e.file ?? 'no file'})`);
|
||||
continue;
|
||||
|
|
|
|||
180
tests/helpers/red-mutate.mjs
Normal file
180
tests/helpers/red-mutate.mjs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
#!/usr/bin/env node
|
||||
// tests/helpers/red-mutate.mjs
|
||||
// The mutate helper of the shared red script (tests/helpers/red-round.sh).
|
||||
// A mutation module is a plain ES module (no ".test." in its name, so
|
||||
// `npm test` never runs it) that exports
|
||||
// MUTATIONS = [{ key, id, what, spans: [{ file, find, replace }] }]
|
||||
// key unique in the module; id one of the round's ids; file relative to the
|
||||
// tree; find a string or a RegExp with the g flag. A string find is replaced
|
||||
// literally; a RegExp find uses String.prototype.replace, $-patterns included.
|
||||
//
|
||||
// node tests/helpers/red-mutate.mjs list <module>
|
||||
// one "<key> <id>" line per entry, in module order.
|
||||
// node tests/helpers/red-mutate.mjs apply <module> <key> <tree> <backup-dir>
|
||||
// counts every span of the entry first (spans on one file count on the
|
||||
// text the earlier spans left); writes only when every span matched
|
||||
// exactly once: each touched file is backed up into <backup-dir> (which
|
||||
// must not exist yet), then written. Exit 1, nothing written and no
|
||||
// backup made, when a span matched 0 or 2+ times (one stderr line each).
|
||||
// node tests/helpers/red-mutate.mjs restore <backup-dir> <tree>
|
||||
// copies every backed-up file back into the tree and removes the backup.
|
||||
// Exit 2: a usage error, an unreadable module or file, a bad entry, a span
|
||||
// file outside the tree, a failed write or restore (never 0).
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const USAGE = 'usage: red-mutate.mjs list <module> | apply <module> <key> <tree> <backup-dir> | restore <backup-dir> <tree>';
|
||||
const MANIFEST = 'manifest.json';
|
||||
|
||||
/** Print a problem and exit 2. */
|
||||
function fail(msg) {
|
||||
process.stderr.write(`red-mutate: ${msg}\n${USAGE}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** The module's MUTATIONS, checked; exit 2 on any defect. */
|
||||
async function load(modulePath) {
|
||||
let mod;
|
||||
try {
|
||||
mod = await import(pathToFileURL(resolve(modulePath)).href);
|
||||
} catch (e) {
|
||||
return fail(`cannot load the mutation module ${modulePath}: ${e.message}`);
|
||||
}
|
||||
const list = mod.MUTATIONS;
|
||||
if (!Array.isArray(list)) fail(`${modulePath} exports no MUTATIONS array`);
|
||||
const keys = new Set();
|
||||
list.forEach((m, i) => {
|
||||
const where = `MUTATIONS[${i}]`;
|
||||
if (!m || typeof m.key !== 'string' || !/^\S+$/.test(m.key)) fail(`${where}: key must be one word`);
|
||||
if (typeof m.id !== 'string' || !/^\S+$/.test(m.id)) fail(`${where} (${m.key}): id must be one word`);
|
||||
if (keys.has(m.key)) fail(`${where}: key ${m.key} is used twice`);
|
||||
keys.add(m.key);
|
||||
if (!Array.isArray(m.spans) || m.spans.length === 0) fail(`${where} (${m.key}): spans must be a non-empty array`);
|
||||
m.spans.forEach((s, j) => {
|
||||
const at = `${m.key} span ${j + 1}`;
|
||||
if (!s || typeof s.file !== 'string' || s.file === '' || isAbsolute(s.file)) fail(`${at}: file must be a relative path`);
|
||||
if (typeof s.replace !== 'string') fail(`${at}: replace must be a string`);
|
||||
if (s.find instanceof RegExp) {
|
||||
if (!s.find.global) fail(`${at}: a RegExp find needs the g flag`);
|
||||
} else if (typeof s.find !== 'string' || s.find === '') {
|
||||
fail(`${at}: find must be a non-empty string or a RegExp`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
/** The absolute path of a tree-relative file, or exit 2 when it leaves the tree. */
|
||||
function inTree(tree, rel) {
|
||||
const root = resolve(tree);
|
||||
const abs = resolve(root, rel);
|
||||
const back = relative(root, abs);
|
||||
if (back === '' || back === '..' || back.startsWith(`..${sep}`) || isAbsolute(back)) fail(`${rel} is outside the tree ${tree}`);
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** How often a find matches in a text. */
|
||||
function count(text, find) {
|
||||
if (typeof find === 'string') return text.split(find).length - 1;
|
||||
return [...text.matchAll(find)].length;
|
||||
}
|
||||
|
||||
/** The text with its one match replaced. */
|
||||
function replaceOnce(text, find, by) {
|
||||
if (typeof find === 'string') return text.replace(find, () => by);
|
||||
return text.replace(find, by);
|
||||
}
|
||||
|
||||
/** One line for a span's find. */
|
||||
const show = (find) => (typeof find === 'string' ? JSON.stringify(find) : String(find)).replace(/\s+/g, ' ').slice(0, 80);
|
||||
|
||||
async function list(argv) {
|
||||
if (argv.length !== 1) fail('list takes one argument');
|
||||
const entries = await load(argv[0]);
|
||||
process.stdout.write(entries.map((m) => `${m.key} ${m.id}\n`).join(''));
|
||||
}
|
||||
|
||||
async function apply(argv) {
|
||||
if (argv.length !== 4) fail('apply takes four arguments');
|
||||
const [modulePath, key, tree, backup] = argv;
|
||||
const entry = (await load(modulePath)).find((m) => m.key === key);
|
||||
if (!entry) fail(`no mutation with key ${key} in ${modulePath}`);
|
||||
if (!existsSync(tree)) fail(`the tree ${tree} does not exist`);
|
||||
if (existsSync(backup)) fail(`the backup directory ${backup} already exists (a mutation not restored?)`);
|
||||
|
||||
const texts = new Map();
|
||||
const originals = new Map();
|
||||
const misses = [];
|
||||
for (const span of entry.spans) {
|
||||
const abs = inTree(tree, span.file);
|
||||
if (!texts.has(span.file)) {
|
||||
if (!existsSync(abs)) {
|
||||
misses.push(`${key}: ${span.file} does not exist in the tree`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const t = readFileSync(abs, 'utf8');
|
||||
texts.set(span.file, t);
|
||||
originals.set(span.file, t);
|
||||
} catch (e) {
|
||||
fail(`cannot read ${abs}: ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
const text = texts.get(span.file);
|
||||
const n = count(text, span.find);
|
||||
if (n !== 1) {
|
||||
misses.push(`${key}: ${show(span.find)} matched ${n} times in ${span.file}, not once`);
|
||||
continue;
|
||||
}
|
||||
texts.set(span.file, replaceOnce(text, span.find, span.replace));
|
||||
}
|
||||
if (misses.length > 0) {
|
||||
process.stderr.write(`${misses.map((m) => `red-mutate: ${m}`).join('\n')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(backup, 'files'), { recursive: true });
|
||||
for (const rel of texts.keys()) {
|
||||
const dest = join(backup, 'files', rel);
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
writeFileSync(dest, originals.get(rel));
|
||||
}
|
||||
writeFileSync(join(backup, MANIFEST), `${JSON.stringify([...texts.keys()])}\n`);
|
||||
} catch (e) {
|
||||
fail(`cannot write the backup in ${backup}: ${e.code || e.message}`);
|
||||
}
|
||||
try {
|
||||
for (const [rel, text] of texts) writeFileSync(inTree(tree, rel), text);
|
||||
} catch (e) {
|
||||
fail(`cannot write the mutated file: ${e.code || e.message} (restore from ${backup})`);
|
||||
}
|
||||
process.stdout.write(`red-mutate: ${key} [${entry.id}] applied, ${entry.spans.length} span(s) in ${texts.size} file(s)\n`);
|
||||
}
|
||||
|
||||
function restore(argv) {
|
||||
if (argv.length !== 2) fail('restore takes two arguments');
|
||||
const [backup, tree] = argv;
|
||||
let files;
|
||||
try {
|
||||
files = JSON.parse(readFileSync(join(backup, MANIFEST), 'utf8'));
|
||||
} catch (e) {
|
||||
return fail(`cannot read the backup manifest in ${backup}: ${e.code || e.message}`);
|
||||
}
|
||||
if (!Array.isArray(files)) fail(`the backup manifest in ${backup} is not a list`);
|
||||
try {
|
||||
for (const rel of files) copyFileSync(join(backup, 'files', rel), inTree(tree, rel));
|
||||
rmSync(backup, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
fail(`cannot restore from ${backup}: ${e.code || e.message}`);
|
||||
}
|
||||
process.stdout.write(`red-mutate: restored ${files.length} file(s)\n`);
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
if (cmd === 'list') await list(rest);
|
||||
else if (cmd === 'apply') await apply(rest);
|
||||
else if (cmd === 'restore') restore(rest);
|
||||
else fail(cmd ? `unknown command ${cmd}` : 'no command');
|
||||
212
tests/helpers/red-round.sh
Normal file
212
tests/helpers/red-round.sh
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/helpers/red-round.sh
|
||||
# The shared red script of the fix rounds. A round's wrapper
|
||||
# (tests/fix-round-<n>-red.sh) hands it the round's start point, ids, the
|
||||
# other rounds' ids, a mutation module and the round's test files:
|
||||
#
|
||||
# red-round.sh --base <sha> --ids '<id> ...' --other-ids '<id> ...'
|
||||
# --mutations <module.mjs> [--require-sha256] <test file>...
|
||||
#
|
||||
# Every id of the round has tests whose names start with "[id]"; each of them
|
||||
# must fail on an assertion at the start point, or, for an id a mutation
|
||||
# names, under that mutation.
|
||||
# 1. Clone the repo into a temp directory outside it (git clone --shared
|
||||
# --no-checkout, then checkout --detach of the base; nothing registers
|
||||
# in .git/worktrees). With --require-sha256 the local git must be able to
|
||||
# create a SHA-256 repository first.
|
||||
# 2. Replace the clone's tests/ with the working tree's whole tests/ (tar),
|
||||
# helpers and fixtures included, so a new helper is no crash.
|
||||
# 3. Run every tagged test of the files once (--test-name-pattern=^\[)
|
||||
# under the red reporter and let the red judge decide: --ids, --other-ids
|
||||
# (another round's tag is never judged), --pass-ids (the ids the
|
||||
# mutation module names: their defect is proven by the mutation).
|
||||
# 4. For each mutation of the module (tests/helpers/red-mutate.mjs list):
|
||||
# apply it to the clone (every span exactly once, else
|
||||
# "broken: mutation-not-applied [id] key"), run only that id's tests
|
||||
# (^\[id\]), judge with --mutation <id>, restore.
|
||||
# The reporter, the judge, the mutate helper and the --import preload always
|
||||
# run from the working tree's tests/helpers/ by absolute path, so a mutation
|
||||
# of the clone's copy never edits the instrument that measures it.
|
||||
# Exit 0 all red, 1 a rule broken (the "broken:" lines name it), 2 a setup
|
||||
# failure ("red script: setup failure: <reason>"; a bad base, a missing file,
|
||||
# no SHA-256 git under --require-sha256, an instrument that could not run);
|
||||
# never 0 on a setup failure. The clone is removed on every exit.
|
||||
# Needs Node >= 20.14 for --test-force-exit; an older Node rejects the option
|
||||
# and the run fails closed (a setup failure).
|
||||
# bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
set -f
|
||||
|
||||
setup_fail() {
|
||||
echo "red script: setup failure: $1" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd -P)" || setup_fail "cannot find the repo root from $0"
|
||||
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE NODE_TEST_CONTEXT CLAUDE_CODE_SESSION_ID
|
||||
|
||||
# ---- arguments ----
|
||||
|
||||
BASE=''
|
||||
IDS=''
|
||||
OTHER_IDS=''
|
||||
MUTATIONS=''
|
||||
REQUIRE_SHA256=0
|
||||
HAVE_BASE=0
|
||||
HAVE_IDS=0
|
||||
HAVE_OTHER=0
|
||||
HAVE_MUTATIONS=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--base) [ "$#" -ge 2 ] || setup_fail "--base needs a value"; BASE="$2"; HAVE_BASE=1; shift 2 ;;
|
||||
--ids) [ "$#" -ge 2 ] || setup_fail "--ids needs a value"; IDS="$2"; HAVE_IDS=1; shift 2 ;;
|
||||
--other-ids) [ "$#" -ge 2 ] || setup_fail "--other-ids needs a value"; OTHER_IDS="$2"; HAVE_OTHER=1; shift 2 ;;
|
||||
--mutations) [ "$#" -ge 2 ] || setup_fail "--mutations needs a value"; MUTATIONS="$2"; HAVE_MUTATIONS=1; shift 2 ;;
|
||||
--require-sha256) REQUIRE_SHA256=1; shift ;;
|
||||
--) shift; break ;;
|
||||
-*) setup_fail "unknown option $1" ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
[ "$HAVE_BASE" -eq 1 ] && [ -n "$BASE" ] || setup_fail "--base <sha> is required"
|
||||
[ "$HAVE_IDS" -eq 1 ] && [ -n "$IDS" ] || setup_fail "--ids '<id> ...' is required"
|
||||
[ "$HAVE_OTHER" -eq 1 ] || setup_fail "--other-ids '<id> ...' is required"
|
||||
[ "$HAVE_MUTATIONS" -eq 1 ] || setup_fail "--mutations <module.mjs> is required"
|
||||
case "$MUTATIONS" in
|
||||
/*) ;;
|
||||
*) setup_fail "--mutations needs an absolute path, not '$MUTATIONS'" ;;
|
||||
esac
|
||||
[ -f "$MUTATIONS" ] || setup_fail "missing mutation module $MUTATIONS"
|
||||
[ "$#" -gt 0 ] || setup_fail "no test file given"
|
||||
|
||||
command -v node >/dev/null 2>&1 || setup_fail "node is not on PATH"
|
||||
command -v git >/dev/null 2>&1 || setup_fail "git is not on PATH"
|
||||
command -v tar >/dev/null 2>&1 || setup_fail "tar is not on PATH"
|
||||
for f in no-session-id.mjs red-reporter.mjs red-judge.mjs red-mutate.mjs; do
|
||||
[ -f "$ROOT/tests/helpers/$f" ] || setup_fail "missing $ROOT/tests/helpers/$f"
|
||||
done
|
||||
|
||||
# ---- the temp directory, outside the repo, removed on every exit ----
|
||||
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/red-round.XXXXXX")" || setup_fail "mktemp -d failed"
|
||||
case "$TMP" in
|
||||
''|/) setup_fail "mktemp -d gave an unusable directory '$TMP'" ;;
|
||||
esac
|
||||
TMP_REAL="$(cd "$TMP" && pwd -P)" || setup_fail "cannot enter $TMP"
|
||||
case "$TMP_REAL/" in
|
||||
"$ROOT"/*) rmdir "$TMP" 2>/dev/null; setup_fail "the temp directory $TMP_REAL is inside the repo" ;;
|
||||
esac
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
trap 'rm -rf "$TMP"; exit 2' INT TERM HUP
|
||||
|
||||
TREE="$TMP/tree"
|
||||
|
||||
if [ "$REQUIRE_SHA256" -eq 1 ]; then
|
||||
git init --quiet --object-format=sha256 "$TMP/sha256-probe" >/dev/null 2>&1 \
|
||||
|| setup_fail "this git cannot create a SHA-256 repository (--require-sha256)"
|
||||
rm -rf "$TMP/sha256-probe" || setup_fail "could not remove $TMP/sha256-probe"
|
||||
fi
|
||||
|
||||
# ---- 1. the start point ----
|
||||
|
||||
git clone --quiet --shared --no-checkout "$ROOT" "$TREE" || setup_fail "could not clone $ROOT"
|
||||
git -C "$TREE" checkout --quiet --detach "$BASE" || setup_fail "could not check out $BASE"
|
||||
|
||||
# ---- 2. the working tree's whole tests/ ----
|
||||
|
||||
(cd "$ROOT" && tar -cf "$TMP/tests.tar" tests) || setup_fail "could not tar tests/"
|
||||
rm -rf "$TREE/tests" || setup_fail "could not remove the start point's tests/"
|
||||
tar -xf "$TMP/tests.tar" -C "$TREE" || setup_fail "could not copy tests/ into the start point"
|
||||
|
||||
for f in "$@"; do
|
||||
[ -f "$TREE/$f" ] || setup_fail "missing test file $f"
|
||||
done
|
||||
|
||||
for id in $IDS; do
|
||||
printf '%s\n' "$id"
|
||||
done > "$TMP/ids" || setup_fail "could not write $TMP/ids"
|
||||
: > "$TMP/other-ids" || setup_fail "could not write $TMP/other-ids"
|
||||
for id in $OTHER_IDS; do
|
||||
printf '%s\n' "$id"
|
||||
done >> "$TMP/other-ids" || setup_fail "could not write $TMP/other-ids"
|
||||
|
||||
# ---- the mutations: "<key> <id>" per line; their ids are the pass-ids ----
|
||||
|
||||
node "$ROOT/tests/helpers/red-mutate.mjs" list "$MUTATIONS" > "$TMP/mutations" \
|
||||
|| setup_fail "red-mutate could not list $MUTATIONS"
|
||||
PASS_IDS=''
|
||||
while read -r key id rest <&3; do
|
||||
[ -n "$key" ] || continue
|
||||
case " $IDS " in
|
||||
*" $id "*) ;;
|
||||
*) setup_fail "mutation $key names id $id, which is not on --ids" ;;
|
||||
esac
|
||||
case ",$PASS_IDS," in
|
||||
*",$id,"*) ;;
|
||||
*) PASS_IDS="${PASS_IDS:+$PASS_IDS,}$id" ;;
|
||||
esac
|
||||
done 3< "$TMP/mutations"
|
||||
|
||||
# run_tagged <results> <node test option>... <file>...: a test run in the
|
||||
# start point under the red reporter. The positional parameters carry the
|
||||
# options and files (no arrays in bash 3.2). Node exits 0 (all passed) or 1
|
||||
# (a test failed); anything else (a bad option, a crash) is a setup failure.
|
||||
run_tagged() {
|
||||
out="$1"
|
||||
shift
|
||||
(cd "$TREE" && node --import "$ROOT/tests/helpers/no-session-id.mjs" --test --test-reporter="$ROOT/tests/helpers/red-reporter.mjs" --test-reporter-destination="$out" "$@") </dev/null >/dev/null 2>"$out.stderr"
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
|
||||
cat "$out.stderr" >&2
|
||||
setup_fail "the test run exited $rc (needs Node >= 20.14)"
|
||||
fi
|
||||
[ -f "$out" ] || { cat "$out.stderr" >&2; setup_fail "the test run wrote no results to $out"; }
|
||||
}
|
||||
|
||||
# judge <args...>: the red judge; 2 (usage or input) is a setup failure.
|
||||
judge() {
|
||||
node "$ROOT/tests/helpers/red-judge.mjs" "$@" </dev/null
|
||||
rc=$?
|
||||
[ "$rc" -eq 2 ] && setup_fail "the red judge could not run (exit 2)"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
STATUS=0
|
||||
|
||||
# ---- 3. every tagged test at the start point, one run ----
|
||||
|
||||
echo "red script: the tagged tests at $BASE"
|
||||
run_tagged "$TMP/base.jsonl" --test-timeout=300000 --test-force-exit '--test-name-pattern=^\[' "$@"
|
||||
judge --ids "$TMP/ids" --other-ids "$TMP/other-ids" --pass-ids "$PASS_IDS" --results "$TMP/base.jsonl" || STATUS=1
|
||||
|
||||
# ---- 4. each mutation: apply, run its id's tests, judge, restore ----
|
||||
|
||||
N=0
|
||||
while read -r key id rest <&3; do
|
||||
[ -n "$key" ] || continue
|
||||
N=$((N + 1))
|
||||
BK="$TMP/backup-$N"
|
||||
echo "red script: mutation $key [$id]"
|
||||
node "$ROOT/tests/helpers/red-mutate.mjs" apply "$MUTATIONS" "$key" "$TREE" "$BK" </dev/null >/dev/null
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
run_tagged "$TMP/mutation-$N.jsonl" --test-timeout=300000 --test-force-exit "--test-name-pattern=^\\[$id\\]" "$@"
|
||||
judge --ids "$TMP/ids" --other-ids "$TMP/other-ids" --results "$TMP/mutation-$N.jsonl" --mutation "$id" || STATUS=1
|
||||
node "$ROOT/tests/helpers/red-mutate.mjs" restore "$BK" "$TREE" </dev/null >/dev/null \
|
||||
|| setup_fail "could not restore the files of mutation $key"
|
||||
elif [ "$rc" -eq 1 ]; then
|
||||
echo "broken: mutation-not-applied [$id] $key"
|
||||
STATUS=1
|
||||
else
|
||||
setup_fail "red-mutate could not apply $key (exit $rc)"
|
||||
fi
|
||||
done 3< "$TMP/mutations"
|
||||
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo "red script: ok - every id red at $BASE, $N mutation(s) red"
|
||||
else
|
||||
echo "red script: FAIL - a rule is broken (see the broken: lines above)" >&2
|
||||
fi
|
||||
exit "$STATUS"
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
|
|
@ -314,3 +315,125 @@ test('pre-bash-executor fails open on malformed JSON', async () => {
|
|||
const { code } = await runHook(PRE_BASH, 'not-json');
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ARG / R6 (fix round 4.5b) -- the parser heredoc must not close early.
|
||||
// The commands hand $ARGUMENTS to the arg parser through a quoted heredoc
|
||||
// closed by VOYAGE_ARGS_EOF_7F3C9E. A typed line equal to that delimiter
|
||||
// would end the heredoc there, and the shell would run every line after it
|
||||
// as a command. Each blocking case asserts the rule's name on stderr, so it
|
||||
// is red on its own assertion where the rule does not exist.
|
||||
// -----------------------------------------------------------------------
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
const ARGS_RULE = /args-heredoc-closed-early/;
|
||||
const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin';
|
||||
|
||||
async function expectArgsBlocked(command) {
|
||||
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
||||
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`);
|
||||
assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`);
|
||||
}
|
||||
|
||||
async function expectArgsAllowed(command) {
|
||||
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
||||
assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`);
|
||||
assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`);
|
||||
}
|
||||
|
||||
test('[ARG] the guard blocks a parser heredoc whose closing line is followed by a command', async () => {
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED`);
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n`);
|
||||
});
|
||||
|
||||
test('[ARG] the guard blocks a parser heredoc with the closing line twice', async () => {
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
|
||||
// A typed line with spaces around the delimiter still counts as a closing line.
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n ${ARGS_DELIM} \ntouch PWNED\n${ARGS_DELIM}\n`);
|
||||
});
|
||||
|
||||
test('[ARG] the guard allows the parser heredoc as the commands write it', async () => {
|
||||
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md Session 2 of 3\n${ARGS_DELIM}`);
|
||||
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md Session 2 of 3\n${ARGS_DELIM}\n`);
|
||||
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\n\n${ARGS_DELIM}\n\n`);
|
||||
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md ; touch PWNED $(touch PWNED2) *\n${ARGS_DELIM}`);
|
||||
});
|
||||
|
||||
test('[ARG] the guard blocks an unquoted or spaced opener whose body closes early', async () => {
|
||||
await expectArgsBlocked(`${PARSER_LINE} << '${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<"${ARGS_DELIM}"\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<-'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
|
||||
// Unquoted, the body would expand $( ) even when it closes where it should.
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md $(touch PWNED)\n${ARGS_DELIM}`);
|
||||
});
|
||||
|
||||
test('[ARG] the guard checks a backslash-quoted <<\\VOYAGE_ARGS_EOF_7F3C9E opener', async () => {
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<\\${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
|
||||
await expectArgsBlocked(`${PARSER_LINE} << \\${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}\ntouch PWNED`);
|
||||
await expectArgsAllowed(`${PARSER_LINE} <<\\${ARGS_DELIM}\nbrief.md Session 2 of 3\n${ARGS_DELIM}`);
|
||||
});
|
||||
|
||||
test("[ARG] the guard's added lines are ASCII", () => {
|
||||
const src = readFileSync(PRE_BASH, 'utf8');
|
||||
const from = src.indexOf('// BEGIN args-heredoc-closed-early');
|
||||
const to = src.indexOf('// END args-heredoc-closed-early');
|
||||
assert.ok(from >= 0 && to > from, 'the guard marks its args-heredoc-closed-early block with BEGIN/END comments');
|
||||
const block = src.slice(from, to);
|
||||
assert.match(block, /VOYAGE_ARGS_EOF_7F3C9E/);
|
||||
assert.doesNotMatch(block, /[^\x00-\x7f]/, 'a hook script is ASCII (~/.claude/CLAUDE.md, Miljo)');
|
||||
});
|
||||
|
||||
// The four parser heredoc blocks the commands ship, as Claude Code runs them
|
||||
// after it substitutes $ARGUMENTS and ${CLAUDE_PLUGIN_ROOT}: a block the guard
|
||||
// refuses would disable its command while every other test stays green.
|
||||
// [file, the heading the section starts with]
|
||||
const SHIPPED_ARGS_BLOCKS = [
|
||||
['commands/trekendsession.md', '## Phase 0 '],
|
||||
['commands/trekendsession.md', '## Phase 3 '],
|
||||
['commands/trekcontinue.md', '## Phase 0 '],
|
||||
['commands/trekreview.md', '## Phase 1 '],
|
||||
];
|
||||
|
||||
/** The ```bash blocks of the `## ` section of `rel` that starts with `heading` (fence-aware). */
|
||||
function sectionBashBlocks(rel, heading) {
|
||||
const lines = readFileSync(join(ROOT, rel), 'utf8').split('\n');
|
||||
const s = lines.findIndex((l) => l.startsWith(heading));
|
||||
assert.ok(s !== -1, `${rel} must hold a heading starting "${heading}"`);
|
||||
let e = lines.length;
|
||||
let fenced = false;
|
||||
for (let i = s + 1; i < lines.length; i++) {
|
||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
||||
if (!fenced && lines[i].startsWith('## ')) { e = i; break; }
|
||||
}
|
||||
const section = lines.slice(s, e).join('\n');
|
||||
return section.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]);
|
||||
}
|
||||
|
||||
test('[ARG] every shipped parser heredoc block passes the guard', async () => {
|
||||
for (const [rel, heading] of SHIPPED_ARGS_BLOCKS) {
|
||||
const blocks = sectionBashBlocks(rel, heading).filter((b) => b.includes(`<<'${ARGS_DELIM}'`));
|
||||
assert.equal(blocks.length, 1, `${rel} ${heading.trim()} must hold one \`\`\`bash block with the parser heredoc <<'${ARGS_DELIM}'; found ${blocks.length}`);
|
||||
const command = blocks[0].split('$ARGUMENTS').join('brief.md Session 2 of 3').split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
|
||||
await expectArgsAllowed(command);
|
||||
}
|
||||
});
|
||||
|
||||
// HDG (fix round 4.5b, part 2) -- bash reads a heredoc body only from the
|
||||
// lines after the opener's line. A command that only mentions the opener, with
|
||||
// no non-blank line after it, has nothing that can close early and nothing to
|
||||
// expand, so it passes. Every opener with a line after it is checked, so a
|
||||
// quoted first mention cannot hide a later unquoted heredoc.
|
||||
test('[HDG] a command that only mentions the opener on its last line passes, and every opener with a line after it is checked', async () => {
|
||||
await expectArgsAllowed(`grep -n "<<'${ARGS_DELIM}'" commands/trekreview.md`);
|
||||
await expectArgsAllowed(`grep -n "<<'${ARGS_DELIM}'" commands/trekreview.md\n`);
|
||||
await expectArgsAllowed(`grep -n "<<${ARGS_DELIM}" commands/trekreview.md\n`);
|
||||
await expectArgsAllowed(`echo hi\ngrep -n "<<'${ARGS_DELIM}'" f`);
|
||||
// A quoted first mention must not hide a later unquoted heredoc.
|
||||
await expectArgsBlocked(`echo "<<'${ARGS_DELIM}'"\n${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md $(touch PWNED)\n${ARGS_DELIM}`);
|
||||
// Control: a real heredoc of the delimiter is checked as before.
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}' 2>&1\nbrief.md\n${ARGS_DELIM}\ntouch PWNED`);
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\n\n${ARGS_DELIM}\ntouch PWNED`);
|
||||
await expectArgsBlocked(`echo hi\n${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
|
||||
await expectArgsBlocked(`${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}`);
|
||||
});
|
||||
|
|
|
|||
19
tests/lib/arg-parser-tokenize.test.mjs
Normal file
19
tests/lib/arg-parser-tokenize.test.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { parseArgs, tokenize } from '../../lib/parsers/arg-parser.mjs';
|
||||
|
||||
const values = (text) => tokenize(text).map((t) => t.value);
|
||||
|
||||
test('[TOK] a quoted value never swallows a later flag, and an apostrophe inside a word keeps its value', () => {
|
||||
const text = '--project "my dir"/x --since "HEAD~1"';
|
||||
assert.equal(parseArgs(text, 'trekreview').flags['--since'], 'HEAD~1',
|
||||
'the quote after `dir` is followed by `/`, so it does not close; the search stops at the quote that starts `"HEAD~1"`');
|
||||
assert.deepEqual(values(text), ['--project', '"my', 'dir"/x', '--since', 'HEAD~1']);
|
||||
|
||||
// Control: an apostrophe inside a word in single quotes keeps one value.
|
||||
assert.deepEqual(values(`'Fix the user's login'`), ["Fix the user's login"]);
|
||||
// Control: two adjacent quoted words stay two values.
|
||||
assert.deepEqual(values(`"a" "b"`), ['a', 'b']);
|
||||
// Control: the other kind of quote inside a quoted value does not split it.
|
||||
assert.deepEqual(values(`"say 'hi' now"`), ["say 'hi' now"]);
|
||||
});
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const PARSER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'lib', 'parsers', 'arg-parser.mjs');
|
||||
const PARSER_URL = pathToFileURL(PARSER).href;
|
||||
|
||||
test('trekbrief — empty args', () => {
|
||||
const r = parseArgs('', 'trekbrief');
|
||||
|
|
@ -200,3 +204,88 @@ test('trekendsession — -h stays a positional (no alias resolution)', () => {
|
|||
assert.deepEqual(r.errors, []);
|
||||
assert.equal(r.flags['--help'], undefined);
|
||||
});
|
||||
|
||||
// --- ARG / M10 (fix round 4.5b) — the arguments reach the parser as stdin
|
||||
// text, never as shell words, and the label is read whole from `tail` ---
|
||||
|
||||
function runStdin(command, input, cwd) {
|
||||
return spawnSync(process.execPath, [PARSER, '--command', command, '--stdin'], {
|
||||
input, cwd, encoding: 'utf8', env: childEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
test('[ARG] --stdin keeps ; $( ) and * as literal characters and creates no file', async () => {
|
||||
const mod = await import(PARSER_URL);
|
||||
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'arg-parser-stdin-'));
|
||||
try {
|
||||
const r = runStdin('trekendsession', 'brief.md ; touch PWNED $(touch PWNED2) * ~/x $HOME\n', cwd);
|
||||
assert.equal(r.status, 0, `the parser CLI refused --stdin: ${r.stdout}${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.deepEqual(out.positional,
|
||||
['brief.md', ';', 'touch', 'PWNED', '$(touch', 'PWNED2)', '*', '~/x', '$HOME']);
|
||||
assert.equal(out.tail, '; touch PWNED $(touch PWNED2) * ~/x $HOME');
|
||||
assert.equal(existsSync(join(cwd, 'PWNED')), false, 'no PWNED file was created');
|
||||
assert.equal(existsSync(join(cwd, 'PWNED2')), false, 'no PWNED2 file was created');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[ARG] a balanced quote groups words', async () => {
|
||||
const mod = await import(PARSER_URL);
|
||||
assert.equal(typeof mod.tokenize, 'function', 'arg-parser.mjs exports tokenize');
|
||||
assert.deepEqual(mod.tokenize(`a "b c" 'd e' f`), [
|
||||
{ value: 'a', start: 0, end: 1 },
|
||||
{ value: 'b c', start: 2, end: 7 },
|
||||
{ value: 'd e', start: 8, end: 13 },
|
||||
{ value: 'f', start: 14, end: 15 },
|
||||
]);
|
||||
assert.deepEqual(mod.tokenize(`brief.md "Fix the user's login"`).map((t) => t.value),
|
||||
['brief.md', "Fix the user's login"]);
|
||||
});
|
||||
|
||||
test('[ARG] an unbalanced quote stays a literal character', async () => {
|
||||
const mod = await import(PARSER_URL);
|
||||
assert.equal(typeof mod.tokenize, 'function', 'arg-parser.mjs exports tokenize');
|
||||
assert.deepEqual(mod.tokenize(`Fix the user's login`).map((t) => t.value),
|
||||
['Fix', 'the', "user's", 'login']);
|
||||
assert.deepEqual(mod.tokenize('say "hi there').map((t) => t.value),
|
||||
['say', '"hi', 'there']);
|
||||
assert.deepEqual(mod.parseArgs(`brief.md it's`, 'trekendsession').positional,
|
||||
['brief.md', "it's"]);
|
||||
});
|
||||
|
||||
test('[ARG] empty stdin gives no positional, an empty tail and exit 0', async () => {
|
||||
const mod = await import(PARSER_URL);
|
||||
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
|
||||
for (const input of ['', '\n']) {
|
||||
const r = runStdin('trekendsession', input);
|
||||
assert.equal(r.status, 0, `the parser CLI refused empty stdin: ${r.stdout}${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.deepEqual(out.positional, []);
|
||||
assert.deepEqual(out.errors, []);
|
||||
assert.equal(out.tail, '', `tail for ${JSON.stringify(input)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[M10] tail keeps a multi-word label whole', async () => {
|
||||
const mod = await import(PARSER_URL);
|
||||
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
|
||||
const r = mod.parseArgs('brief.md Session 2 of 3', 'trekendsession');
|
||||
assert.equal(r.tail, 'Session 2 of 3');
|
||||
assert.equal(r.positional[0], 'brief.md');
|
||||
});
|
||||
|
||||
test('[M10] one outer quote pair around the label is removed', async () => {
|
||||
const mod = await import(PARSER_URL);
|
||||
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
|
||||
assert.equal(mod.parseArgs(`brief.md 'Session 2 of 3'`, 'trekendsession').tail, 'Session 2 of 3');
|
||||
assert.equal(mod.parseArgs(`brief.md "Session 2 of 3"`, 'trekendsession').tail, 'Session 2 of 3');
|
||||
});
|
||||
|
||||
test('[M10] an inner apostrophe stays', async () => {
|
||||
const mod = await import(PARSER_URL);
|
||||
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
|
||||
assert.equal(mod.parseArgs(`brief.md Fix the user's login`, 'trekendsession').tail, "Fix the user's login");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -539,6 +539,24 @@ test('[ORD] a full tie on one row: two /code-review MAJORs titled T, one marked
|
|||
assert.deepEqual(split(a), split(b), 'the kept/unverified split');
|
||||
});
|
||||
|
||||
test('[E20] untitled findings give one verdict and one kept/unverified split in every arrival order', () => {
|
||||
// One reviewer's two untitled BLOCKERs on one triplet are one row and tie on
|
||||
// every survivor rule; one has an empty recommended_action, which sets the
|
||||
// row aside as unverified when it is kept. The canonical order of untitled
|
||||
// findings decides which is kept, never the arrival order.
|
||||
const at = { file: 'x.mjs', line: 3, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER' };
|
||||
const empty = { ...at, recommended_action: '' };
|
||||
const full = { ...at, recommended_action: 'Quote the argument.' };
|
||||
const all = orders([['empty', empty], ['full', full]]);
|
||||
assert.equal(all.length, 2);
|
||||
const outcomes = new Set();
|
||||
for (const order of all) {
|
||||
const r = runContract([{ reviewer: ORD_C, findings: order.map(([, f]) => structuredClone(f)) }]);
|
||||
outcomes.add(JSON.stringify({ verdict: r.verdict, ...split(r) }));
|
||||
}
|
||||
assert.equal(outcomes.size, 1, [...outcomes].join(' | '));
|
||||
});
|
||||
|
||||
test('[ORD] the title classes are placed in alphabetical order: cA, fB, cB and an untitled correctness BLOCKER give {cA, cU} BLOCKER and {fB, cB} MAJOR and one verdict in all 24 orders', () => {
|
||||
const K = 'NOT_IN_THE_CATALOGUE';
|
||||
const at = { file: 'bin/slug.mjs', line: 46, rule_key: K, detail: 'd' };
|
||||
|
|
|
|||
|
|
@ -91,3 +91,32 @@ test('done line: --check finds the line for that step only', () => {
|
|||
assert.equal(run(['--check', '--step', '4', '--report', report]).status, 1);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---- M11 sibling: only a full 40- or 64-hex commit id ----
|
||||
|
||||
test('[M11] sibling: the done line accepts a 64-hex commit id', () => {
|
||||
// A SHA-256 repository writes a 64-hex HEAD into the line; --check must read it.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'done-line-sha256-'));
|
||||
const report = join(dir, 'step-3.report.md');
|
||||
const sha = '0123abcd'.padEnd(64, 'e');
|
||||
try {
|
||||
writeFileSync(report, `# Step 3 report\nDONE step 3: \`node --test tests/ok.test.mjs\` exit 0 at ${sha} 2026-10-06T12:00:00.000Z\n`);
|
||||
const plain = run(['--check', '--step', '3', '--report', report]);
|
||||
assert.equal(plain.status, 0, `a 64-hex line is a done line: ${plain.stdout}${plain.stderr}`);
|
||||
const bound = run(['--check', '--step', '3', '--report', report, '--commit', sha]);
|
||||
assert.equal(bound.status, 0, `the line at that 64-hex commit: ${bound.stdout}${bound.stderr}`);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('[M11] sibling: the done line refuses a 7-hex id given with --commit, naming 40 or 64 hex', () => {
|
||||
// A short id can also be a branch name (DJ3); a line typed at a short id
|
||||
// must not pass the check bound to that short id.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'done-line-short-'));
|
||||
const report = join(dir, 'step-3.report.md');
|
||||
try {
|
||||
writeFileSync(report, '# Step 3 report\nDONE step 3: `node --test tests/ok.test.mjs` exit 0 at deadbee 2026-10-06T12:00:00.000Z\n');
|
||||
const r = run(['--check', '--step', '3', '--report', report, '--commit', 'deadbee']);
|
||||
assert.equal(r.status, 2, `a 7-hex --commit could not be checked (exit 2, never green): ${r.stdout}${r.stderr}`);
|
||||
assert.match(r.stdout + r.stderr, /40 or 64 hex/, `the refusal names 40 or 64 hex: ${r.stdout}${r.stderr}`);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@
|
|||
// assertion at ab6b32c (the round's start point), except DJ's pins: they pass
|
||||
// there by design, and tests/fix-round-4-5a-red.sh proves them red by mutation.
|
||||
// Untagged tests (prefixes `helpers:`, `step 25:`, `keep:`, `control:`,
|
||||
// `arch:`, `e2e script:`, `red script:`, `CHANGELOG:`, `found in execution:`)
|
||||
// are outside the round's id list.
|
||||
// `arch:`, `e2e script:`, `red script:`, `CHANGELOG:`) are outside the
|
||||
// round's id list; a `[M1r]` test carries a 4.5b id and is that round's to
|
||||
// judge. Fix round 4.5b edits some pins here in place where its fixes change
|
||||
// the text they pin (for example the CHANGELOG siblings entry, K4): each keeps
|
||||
// its tag or untagged prefix and still fails at ab6b32c.
|
||||
// #23 code-correctness-reviewer cites a "MANDATORY OVERRIDE rule above"
|
||||
// that is no longer there; the sentence says the rule itself.
|
||||
// #24 research-scout says "Try Tavily first" but its tools held no Tavily
|
||||
|
|
@ -337,7 +340,7 @@ test('[#52] Phase 11 "execute": prints the /trekexecute line and stops, never im
|
|||
neverImplementsHere(block, 'Phase 11 "execute"');
|
||||
assert.match(block, /^### "execute" \/ "go" \/ "start" Do not implement in this session\./);
|
||||
printsTrekexecuteLine(block, 'Phase 11 "execute"');
|
||||
assert.match(block, /without a project directory — and stop\./);
|
||||
assert.match(block, /without a project directory — then run Phase 12 \(`outcome: execute`\)\./);
|
||||
assert.match(block, /`\/trekexecute` is what checks the plan's approval \(K2\), locks the tests, runs one step helper per step and the review\./);
|
||||
});
|
||||
|
||||
|
|
@ -345,7 +348,7 @@ test('[#52] Phase 11 "team": no team path, the same /trekexecute line, then stop
|
|||
const block = flat(section(read('commands/trekplan.md'), '### "execute with team" / "team"'));
|
||||
neverImplementsHere(block, 'Phase 11 "team"');
|
||||
assert.doesNotMatch(block, /proceed exactly as for "execute"/, 'Phase 11 "team": still proceeds as "execute" did');
|
||||
assert.match(block, /There is no team path\. Print the same `\/trekexecute` line and stop; parallel work is `--decompose` followed by `\/trekexecute`'s session orders\./);
|
||||
assert.match(block, /There is no team path\. Print the same `\/trekexecute` line, then run Phase 12 \(`outcome: execute_team`\); parallel work is `--decompose` followed by `\/trekexecute`'s session orders\./);
|
||||
});
|
||||
|
||||
test('[#52] Phase 10 menu: "execute" is for the /trekexecute line, and a stopped gate withholds it', () => {
|
||||
|
|
@ -593,7 +596,7 @@ test('[#47] Phase 5: a brief with no research topics prints /trekplan --project
|
|||
assert.doesNotMatch(p5, /Skip this phase if research_topics = 0/, 'Phase 5 still skips straight on with zero topics');
|
||||
const opening = partAt('commands/trekbrief.md', '## Phase 5 — Auto-orchestration opt-in', (l) => l.startsWith('Ask the user'));
|
||||
assert.doesNotMatch(opening, /proceed directly to Phase 6/i, 'a zero-topic brief still proceeds directly to Phase 6');
|
||||
assert.match(opening, /\*\*With research_topics = 0\*\*, print the manual-path output below without the `\/trekresearch` lines \(Brief, then `\/trekplan --project \{PROJECT_DIR\}`, then `\/trekexecute --project \{PROJECT_DIR\}`\), then run Phase 7 and stop\./);
|
||||
assert.match(opening, /\*\*With research_topics = 0\*\*, print the manual-path output below without the `\/trekresearch` lines \(Brief, then `\/trekplan --project \{PROJECT_DIR\}`, then `\/trekexecute --project \{PROJECT_DIR\}`\), then run Phase 7 \(`auto_result: manual`\) and stop\./);
|
||||
assert.match(opening, /Phase 6 is never entered without the operator's opt-in\./);
|
||||
const manual = flat(section(text, '### Manual path (default)'));
|
||||
assert.match(manual, /Then: \/trekplan --project \{PROJECT_DIR\} Then: \/trekexecute --project \{PROJECT_DIR\}/);
|
||||
|
|
@ -1225,7 +1228,7 @@ test('[D67] the End to end section says to record run_seen, also when no app was
|
|||
// now carries disable-model-invocation, so the model cannot start it.
|
||||
|
||||
const ENDSESSION = 'commands/trekendsession.md';
|
||||
const ENDSESSION_PARSER_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession -- $ARGUMENTS';
|
||||
const ENDSESSION_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
|
||||
|
||||
test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and its parser line run on fix-header gives no help', () => {
|
||||
const phase0 = section(read(ENDSESSION), '## Phase 0 — `--help` handling');
|
||||
|
|
@ -1238,24 +1241,26 @@ test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and it
|
|||
const block = bashBlockHolding(phase0, cmd);
|
||||
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
||||
assert.equal(block.split('\n')[0], 'setopt nonomatch 2>/dev/null || true', 'the block does not open with setopt nonomatch');
|
||||
const plain = runParser(cmd, 'fix-header');
|
||||
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
||||
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
|
||||
const plain = runParser(block, 'fix-header');
|
||||
assert.equal(plain.status, 0, `the parser line refused fix-header: ${plain.stdout}${plain.stderr}`);
|
||||
const r = JSON.parse(plain.stdout);
|
||||
assert.equal(r.flags['--help'], undefined, 'fix-header set the --help flag');
|
||||
assert.deepEqual(r.positional, ['fix-header']);
|
||||
assert.notEqual(r.positional[0], '-h');
|
||||
const help = runParser(cmd, '--help');
|
||||
const help = runParser(block, '--help');
|
||||
assert.equal(help.status, 0, help.stdout + help.stderr);
|
||||
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'control: --help must set the help flag');
|
||||
const two = runParser(cmd, 'fix-header "Session 2"');
|
||||
const two = runParser(block, 'fix-header "Session 2"');
|
||||
assert.equal(two.status, 0, two.stdout + two.stderr);
|
||||
assert.deepEqual(JSON.parse(two.stdout).positional, ['fix-header', 'Session 2']);
|
||||
});
|
||||
|
||||
test('[#15] /trekendsession Phase 2 reads the brief path and the label from the parser\'s positional', () => {
|
||||
test('[#15] /trekendsession Phase 2 reads the brief path from the parser\'s positional and the label from its tail', () => {
|
||||
const phase2 = flat(section(read(ENDSESSION), '## Phase 2 — Required args check (headless-safe)'));
|
||||
assert.match(phase2, /`positional\[0\]`/, 'Phase 2 does not read <next-brief-path> from positional[0]');
|
||||
assert.match(phase2, /`positional\[1\]`/, 'Phase 2 does not read <next-label> from positional[1]');
|
||||
assert.match(phase2, /`<next-label>` is the parser's `tail`/, 'Phase 2 does not read <next-label> from the parser\'s tail');
|
||||
});
|
||||
|
||||
test('[#67] /trekendsession sets disable-model-invocation, and CLAUDE.md names it as the one command that does', () => {
|
||||
|
|
@ -1293,7 +1298,7 @@ test('keep: commands/trekreview.md carries no disable-model-invocation field (/t
|
|||
// passes the state file, so the state-anchored staleness check runs.
|
||||
|
||||
const CONTINUE = 'commands/trekcontinue.md';
|
||||
const CONTINUE_PARSER_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue -- $ARGUMENTS';
|
||||
const CONTINUE_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
|
||||
const CONSISTENCY_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> --state-file <RESOLVED-PATH-STATE>';
|
||||
|
||||
test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line run with --help sets the help flag', () => {
|
||||
|
|
@ -1303,19 +1308,21 @@ test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its pars
|
|||
assert.equal(cmd, CONTINUE_PARSER_LINE, 'the Phase 0 parser line is not the trekcontinue parser call on $ARGUMENTS');
|
||||
const block = bashBlockHolding(phase0, cmd);
|
||||
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
||||
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
||||
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
|
||||
assert.match(flat(phase0), /Run first, exactly like this:/);
|
||||
assert.match(flat(phase0), /Read `flags` and `positional` from the JSON it prints/);
|
||||
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
|
||||
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
|
||||
const help = runParser(cmd, '--help');
|
||||
const help = runParser(block, '--help');
|
||||
assert.equal(help.status, 0, help.stdout + help.stderr);
|
||||
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'the parser line run with --help does not set the help flag');
|
||||
const dir = runParser(cmd, '.claude/projects/demo');
|
||||
const dir = runParser(block, '.claude/projects/demo');
|
||||
assert.equal(dir.status, 0, dir.stdout + dir.stderr);
|
||||
const r = JSON.parse(dir.stdout);
|
||||
assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag');
|
||||
assert.deepEqual(r.positional, ['.claude/projects/demo']);
|
||||
const h = runParser(cmd, '-h');
|
||||
const h = runParser(block, '-h');
|
||||
assert.equal(h.status, 0, h.stdout + h.stderr);
|
||||
assert.equal(JSON.parse(h.stdout).positional[0], '-h');
|
||||
const p05 = flat(section(read(CONTINUE), '## Phase 0.5 — Cleanup mode dispatch'));
|
||||
|
|
@ -1323,11 +1330,15 @@ test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its pars
|
|||
assert.match(p05, /After the parser has printed/);
|
||||
});
|
||||
|
||||
test('[#66] sibling: agents/review-orchestrator.md passes the arguments to the parser after --', () => {
|
||||
test('[#66] sibling: agents/review-orchestrator.md passes the arguments to the parser on stdin through the quoted heredoc', () => {
|
||||
const p1 = section(read('agents/review-orchestrator.md'), '### Phase 1 — Parse mode and validate input');
|
||||
const cmd = line(p1, 'parsers/arg-parser.mjs');
|
||||
assert.equal(cmd, 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- "$@"');
|
||||
const r = bash(`set -- --project .claude/projects/demo; ${cmd}`, ROOT);
|
||||
assert.equal(cmd, "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'");
|
||||
const lines = p1.split('\n');
|
||||
const at = lines.findIndex((l) => l.trim() === cmd);
|
||||
assert.deepEqual(lines.slice(at + 1, at + 3), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
||||
'the parser line is not followed by $ARGUMENTS and the closing line');
|
||||
const r = runParser(lines.slice(at, at + 3).join('\n'), '--project .claude/projects/demo');
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout).flags['--project'], '.claude/projects/demo', 'the parser did not see --project');
|
||||
});
|
||||
|
|
@ -1406,7 +1417,7 @@ test('[#113] no conditional trigger is left, and high effort keeps contrarian-re
|
|||
test('[#113] sibling: README.md', () => {
|
||||
const text = flat(read('README.md'));
|
||||
assert.doesNotMatch(text, /contrarian-researcher when a leading recommendation forms/);
|
||||
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1\'s external findings to attack (with `--engine deep-research`, after the engine returns, from the leading answer of its report), always at `effort=high`;'),
|
||||
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1\'s external findings to attack (with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report), always at `effort=high`;'),
|
||||
'README.md does not describe wave 2');
|
||||
});
|
||||
|
||||
|
|
@ -1561,7 +1572,7 @@ test('[ORD] sibling: agents/review-coordinator.md — Pass 1 groups in two passe
|
|||
// or core.quotepath on in the user's config, its headers were coloured or
|
||||
// quoted. The save line pins both off, on the same line (no line_ref move).
|
||||
|
||||
const PARK_WRITER = 'git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --src-prefix=a/ --dst-prefix=b/ {start_sha}';
|
||||
const PARK_WRITER = 'git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ {start_sha}';
|
||||
|
||||
test('[F4b] sibling: commands/trekexecute.md "Back to planning" saves the diff with core.quotepath=off and --no-color', () => {
|
||||
const lines = read(EXEC).split('\n').filter((l) => l.startsWith('**Back to planning** (`fix_rounds = 4`): save `'));
|
||||
|
|
@ -1608,9 +1619,12 @@ test('e2e script: it names each of the four CLIs it runs', () => {
|
|||
// script runs itself as the step's Regression:. Its judge is tested to go red
|
||||
// in tests/lib/red-judge.test.mjs. The 30 ids are the brief's (brief.md:134
|
||||
// and the 4.5a rows), written here as a constant so the list in the script
|
||||
// cannot shrink without this test noticing.
|
||||
// cannot shrink without this test noticing. Since fix round 4-5b the script is
|
||||
// a wrapper over the shared tests/helpers/red-round.sh, which makes and
|
||||
// removes the temp directory, so the trap pin reads that file.
|
||||
|
||||
const RED_SCRIPT = 'tests/fix-round-4-5a-red.sh';
|
||||
const RED_ROUND = 'tests/helpers/red-round.sh';
|
||||
const RED_IDS = ['#52', '#47', '#48', '#46', '#2', '#26', '#33', '#1', '#30', '#28', 'R6', 'D67', '#15', '#12', '#66',
|
||||
'#102', '#57', '#67', '#113', '#111', '#24', '#23', 'F9', 'F4a', 'F4b', 'F4c', 'WF', 'CL', 'ORD', 'DJ'];
|
||||
|
||||
|
|
@ -1628,7 +1642,7 @@ test('red script: tests/fix-round-4-5a-red.sh exists, is ASCII only, passes /bin
|
|||
});
|
||||
|
||||
test('red script: an EXIT trap removes $TMP, and INT, TERM and HUP remove it too', () => {
|
||||
const text = read(RED_SCRIPT);
|
||||
const text = read(RED_ROUND);
|
||||
assert.match(text, /^TMP="\$\(mktemp -d [^\n]*\)"/m, 'the red script does not make $TMP with mktemp -d');
|
||||
assert.match(text, /^trap 'rm -rf "\$TMP"' EXIT$/m, 'no EXIT trap removes $TMP');
|
||||
assert.match(text, /^trap '[^'\n]*rm -rf "\$TMP"[^'\n]*' INT TERM HUP$/m, 'no INT/TERM/HUP trap removes $TMP');
|
||||
|
|
@ -1735,7 +1749,8 @@ const SIBLINGS_ENTRY = '- **The texts next to the fixes say the same** (siblings
|
|||
+ 'record on every exit of its manual and auto paths, says that `--gates` adds one pause before its auto chain, and that the chain '
|
||||
+ 'ends at the plan; `/trekexecute` says `--gates` changes nothing there, checks the wave marker before a low-effort single session, '
|
||||
+ 'and names no validator check that does not exist; plan mode goes through the plan approval before it prints the `/trekexecute` '
|
||||
+ 'line; the gates shim reads `--gates=true` as on.';
|
||||
+ 'line; the commands read only the two-token `--gates <value>` form, and `--gates=true` is the gates shim\'s own form '
|
||||
+ '(`lib/util/autonomy-gate.mjs`), which it reads as `--gates true`.';
|
||||
|
||||
test('CHANGELOG: ### Fixed holds the siblings entry, exactly', () => {
|
||||
assert.ok(roundBullets('### Fixed').includes(SIBLINGS_ENTRY), '### Fixed has no siblings entry with the exact text');
|
||||
|
|
@ -1747,10 +1762,13 @@ test('CHANGELOG: the released v6.5.0 section is byte-equal to its text at ab6b32
|
|||
'the released v6.5.0 section changed; edit only the round\'s section');
|
||||
});
|
||||
|
||||
// Found in execution (untagged, outside the round's id list): texts next to
|
||||
// the round's fixes that still said what the code no longer does. Each test
|
||||
// fails on an assertion at 8a2a989, before the text changed; its presence
|
||||
// assertion holds there.
|
||||
// Found in execution (f6bdc2d): texts next to the round's fixes that still
|
||||
// said what the code no longer does. Each test fails on an assertion at
|
||||
// 8a2a989, before the text changed; its presence assertion holds there. Each
|
||||
// carries its 4.5a parent tag (`[WF] sibling:`, `[#113] sibling:`,
|
||||
// `[#111] sibling:`) and fails on an assertion at ab6b32c, except the two
|
||||
// `[M1r]` tests: they pass at ab6b32c, so they carry a 4.5b id and
|
||||
// tests/fix-round-4-5b-red.sh proves them red under the M1r mutation.
|
||||
|
||||
// D1: the workflow matches each finding to its own row (`rowOf` in
|
||||
// scripts/trekreview-armB.workflow.mjs), not to its exact triplet.
|
||||
|
|
@ -1763,25 +1781,25 @@ const COUNTERPART_IS = 'its counterpart is its own row, else the strictest row o
|
|||
const PUT_BACK_IS = 'of the engine findings in a row no returned finding is on, the one with the highest severity (on a tie, the first seen) is put back at its own severity (operator decision 23)';
|
||||
|
||||
for (const [rel, part] of PUT_BACK_TEXTS) {
|
||||
test(`found in execution: ${rel} no longer puts back what the coordinator did not return "at its exact (file, line, rule_key)"`, () => {
|
||||
test(`[WF] sibling: ${rel} no longer puts back what the coordinator did not return "at its exact (file, line, rule_key)"`, () => {
|
||||
const text = part();
|
||||
assert.ok(text.includes('put back at its own severity'), `${rel}: the put-back sentence is missing: ${text}`);
|
||||
assert.doesNotMatch(text, /at its exact `\(file, line, rule_key\)`/, `${rel}: the put-back still matches on the exact triplet`);
|
||||
});
|
||||
|
||||
test(`found in execution: ${rel} says a finding is matched to its row, the triplet plus the normalized title`, () => {
|
||||
test(`[WF] sibling: ${rel} says a finding is matched to its row, the triplet plus the normalized title`, () => {
|
||||
const text = part();
|
||||
assert.ok(text.includes('put back at its own severity'), `${rel}: the put-back sentence is missing: ${text}`);
|
||||
assert.ok(text.includes(`each finding is matched to its own row, ${ROW_IS}`), `${rel}: the put-back does not say the row: ${text}`);
|
||||
});
|
||||
|
||||
test(`found in execution: ${rel} puts back one copy per row, the row's engine finding with the highest severity`, () => {
|
||||
test(`[WF] sibling: ${rel} puts back one copy per row, the row's engine finding with the highest severity`, () => {
|
||||
const text = part();
|
||||
assert.ok(text.includes('put back at its own severity'), `${rel}: the put-back sentence is missing: ${text}`);
|
||||
assert.ok(text.includes(PUT_BACK_IS), `${rel}: the put-back does not say which one copy of a row is put back: ${text}`);
|
||||
});
|
||||
|
||||
test(`found in execution: ${rel} says floor (b)'s counterpart is the finding's own row, else the strictest row on its triplet`, () => {
|
||||
test(`[WF] sibling: ${rel} says floor (b)'s counterpart is the finding's own row, else the strictest row on its triplet`, () => {
|
||||
const text = part();
|
||||
assert.ok(text.includes('(floor (b))'), `${rel}: floor (b) is missing: ${text}`);
|
||||
assert.ok(text.includes(COUNTERPART_IS), `${rel}: floor (b) does not say which row is the counterpart: ${text}`);
|
||||
|
|
@ -1791,31 +1809,31 @@ for (const [rel, part] of PUT_BACK_TEXTS) {
|
|||
// D2: the README's conditional line says what /trekresearch says.
|
||||
const conditionalLine = () => partAt('README.md', '`* = conditional`:', () => true);
|
||||
|
||||
test('found in execution: README.md names no "leading recommendation" trigger for security-researcher', () => {
|
||||
test('[M1r] README.md names no "leading recommendation" trigger for security-researcher', () => {
|
||||
const text = conditionalLine();
|
||||
assert.ok(text.includes('contrarian-researcher'), `the conditional line does not name contrarian-researcher: ${text}`);
|
||||
assert.doesNotMatch(text, /security-researcher when a leading recommendation forms/, 'README.md still names a trigger /trekresearch does not define');
|
||||
});
|
||||
|
||||
test('found in execution: README.md stars security-researcher nowhere, as docs-researcher and community-researcher (selected by scope alike)', () => {
|
||||
test('[#113] sibling: README.md stars security-researcher nowhere, as docs-researcher and community-researcher (selected by scope alike)', () => {
|
||||
const diagram = partAt('README.md', 'RE["EXTERNAL: docs-researcher', () => true);
|
||||
assert.ok(diagram.includes('security-researcher'), `the diagram's external line does not name security-researcher: ${diagram}`);
|
||||
assert.ok(diagram.includes('security-researcher · contrarian-researcher*'), `the diagram still stars security-researcher: ${diagram}`);
|
||||
});
|
||||
|
||||
test('found in execution: README.md lists security-researcher as no conditional agent', () => {
|
||||
test('[M1r] README.md lists security-researcher as no conditional agent', () => {
|
||||
const text = conditionalLine();
|
||||
assert.ok(text.includes('contrarian-researcher'), `the conditional line does not name contrarian-researcher: ${text}`);
|
||||
assert.ok(!text.includes('security-researcher'), `the conditional line still lists security-researcher: ${text}`);
|
||||
});
|
||||
|
||||
test('found in execution: README.md names the deep-research case of wave 2', () => {
|
||||
test('[#113] sibling: README.md names the deep-research case of wave 2', () => {
|
||||
const text = conditionalLine();
|
||||
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2'), `the conditional line does not name wave 2: ${text}`);
|
||||
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1\'s external findings to attack (with `--engine deep-research`, after the engine returns, from the leading answer of its report), always at `effort=high`;'), `README.md does not name the deep-research case of wave 2: ${text}`);
|
||||
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1\'s external findings to attack (with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report), always at `effort=high`;'), `README.md does not name the deep-research case of wave 2: ${text}`);
|
||||
});
|
||||
|
||||
test('found in execution: README.md runs contrarian-researcher after wave 1 returns', () => {
|
||||
test('[#113] sibling: README.md runs contrarian-researcher after wave 1 returns', () => {
|
||||
const text = conditionalLine();
|
||||
assert.ok(text.includes('contrarian-researcher, when selected, runs in wave 2'), `the conditional line does not name wave 2: ${text}`);
|
||||
assert.ok(text.includes('runs in wave 2 after wave 1 returns'), `README.md does not say "after wave 1 returns": ${text}`);
|
||||
|
|
@ -1829,15 +1847,15 @@ const HIGH_EFFORT_SKIP = {
|
|||
};
|
||||
|
||||
for (const rel of Object.keys(HIGH_EFFORT_SKIP)) {
|
||||
test(`found in execution: ${rel} item 3 names Pass 3's high-effort skip and the PLAN_EXECUTE_DRIFT normalization`, () => {
|
||||
test(`[#111] sibling: ${rel} item 3 names Pass 3's high-effort skip and the PLAN_EXECUTE_DRIFT normalization`, () => {
|
||||
const item = pass3Item(rel);
|
||||
assert.ok(item.includes(PASS3_ITEM.slice(PASS3_ITEM.indexOf(' — ') + 3)), `${rel}: item 3 is not the Pass 3 sentence: ${item}`);
|
||||
assert.match(item, HIGH_EFFORT_SKIP[rel], `${rel}: item 3 does not name the high-effort skip: ${item}`);
|
||||
assert.ok(item.includes(PASS3_ITEM.slice(PASS3_ITEM.indexOf(' — ') + 3)), `${rel}: item 3 is not the Pass 3 sentence: ${item}`);
|
||||
});
|
||||
}
|
||||
|
||||
// D4: the siblings entry has its own group label.
|
||||
test('found in execution: CHANGELOG ### Fixed has the **Across the round** label directly above the siblings entry', () => {
|
||||
test('[#111] sibling: CHANGELOG ### Fixed has the **Across the round** label directly above the siblings entry', () => {
|
||||
const lines = section(roundPart(), '### Fixed').split('\n');
|
||||
const at = lines.map((l, i) => (l.startsWith('- **The texts next to the fixes say the same**') ? i : -1)).filter((i) => i !== -1);
|
||||
assert.equal(at.length, 1, `### Fixed must hold the siblings entry once; found ${at.length}`);
|
||||
|
|
@ -1850,14 +1868,14 @@ test('found in execution: CHANGELOG ### Fixed has the **Across the round** label
|
|||
// "still applies in quick mode").
|
||||
const BRIEF_CONFLICT_KEPT = ', except the brief-conflict rule, which applies in every mode.';
|
||||
|
||||
test('found in execution: commands/trekreview.md high-effort section skips Pass 3 except the brief-conflict rule', () => {
|
||||
test('[#111] sibling: commands/trekreview.md high-effort section skips Pass 3 except the brief-conflict rule', () => {
|
||||
const text = flat(section(read('commands/trekreview.md'), '### High-effort behavior (v5.1.1)'));
|
||||
assert.ok(text.includes('skip Pass 3 (Cloudflare reasonableness filter)'), `the high-effort section does not skip Pass 3: ${text}`);
|
||||
assert.ok(text.includes(`skip Pass 3 (Cloudflare reasonableness filter) in \`agents/review-coordinator.md\`${BRIEF_CONFLICT_KEPT}`),
|
||||
`the high-effort skip keeps no brief-conflict rule: ${text}`);
|
||||
});
|
||||
|
||||
test('found in execution: agents/review-orchestrator.md quick mode skips pass 3 except the brief-conflict rule', () => {
|
||||
test('[#111] sibling: agents/review-orchestrator.md quick mode skips pass 3 except the brief-conflict rule', () => {
|
||||
const text = partAt('agents/review-orchestrator.md', 'In `quick` mode, skip pass 3', toBlank);
|
||||
assert.ok(text.includes('skip pass 3 (reasonableness filter)'), `the quick-mode line does not skip pass 3: ${text}`);
|
||||
assert.ok(text.includes(`In \`quick\` mode, skip pass 3 (reasonableness filter)${BRIEF_CONFLICT_KEPT}`),
|
||||
|
|
|
|||
169
tests/lib/fix-round-4-5b-part-2-prose.test.mjs
Normal file
169
tests/lib/fix-round-4-5b-part-2-prose.test.mjs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// tests/lib/fix-round-4-5b-part-2-prose.test.mjs
|
||||
// Prose pins for fix round 4-5b, part 2 (plan
|
||||
// .claude/projects/2026-10-27-fix-round-4-5b-part-2, decision 88): the fixes
|
||||
// that are text a session reads. The tags in brackets are the part-2 ids from
|
||||
// the round's brief; each tagged test fails on an assertion at 52c382c, the
|
||||
// round's start point. The keep-behaviour checks sit inside the tagged test as
|
||||
// "Control:" assertions.
|
||||
// DLC /trekexecute's Sub-step G says what `{hash}` is: the full commit id
|
||||
// from `git rev-parse HEAD` after the step's last commit, never the short
|
||||
// id `git commit` prints (done-line --check refuses a short id with
|
||||
// exit 2). The progress line stores that full id, and Handover 5's
|
||||
// `commit` row says so.
|
||||
// KI the CHANGELOG lists part 2's fixes under ### Fixed, its door under
|
||||
// ### Doors, and the review's other findings (7 MAJOR, 3 MINOR, by their
|
||||
// 8-hex ids) and part 2's accepted side effects under ### Known issues.
|
||||
// No static import of a new export: the file must load at 52c382c.
|
||||
// Fix the SOURCE a pin guards, not the pin.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
const readCache = new Map();
|
||||
|
||||
/** A repo file's text; the file must exist. Read from disk once per run. */
|
||||
function read(rel) {
|
||||
if (!readCache.has(rel)) {
|
||||
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
||||
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
|
||||
}
|
||||
return readCache.get(rel);
|
||||
}
|
||||
|
||||
/** Whitespace (line breaks too) collapsed to one space. */
|
||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
|
||||
/** The text from `heading` up to the next heading of the same or a higher level; the heading must exist. */
|
||||
function section(text, heading) {
|
||||
const at = text.indexOf(heading);
|
||||
assert.ok(at >= 0, `missing heading "${heading}"`);
|
||||
const level = heading.match(/^#+/)[0].length;
|
||||
const rest = text.slice(at + heading.length);
|
||||
const next = rest.search(new RegExp(`\\n#{1,${level}} `));
|
||||
return heading + (next >= 0 ? rest.slice(0, next) : rest);
|
||||
}
|
||||
|
||||
const EXEC = 'commands/trekexecute.md';
|
||||
const CONTRACTS = 'docs/HANDOVER-CONTRACTS.md';
|
||||
|
||||
// --- DLC ---------------------------------------------------------------------------
|
||||
|
||||
test('[DLC] Sub-step G records the full commit id from git rev-parse HEAD after the checkpoint', () => {
|
||||
const g = section(read(EXEC), '#### Sub-step G — Checkpoint, done line, unlock');
|
||||
const gFlat = flat(g);
|
||||
|
||||
// Red: the checkpoint says how {hash} is read, before the done line runs.
|
||||
const checkpointAt = g.indexOf('**Checkpoint.**');
|
||||
const doneLineAt = g.indexOf('**The done line**');
|
||||
assert.ok(checkpointAt >= 0 && doneLineAt > checkpointAt, 'Sub-step G must hold **Checkpoint.** before **The done line**');
|
||||
const revParseAt = g.indexOf('`git rev-parse HEAD`');
|
||||
assert.ok(revParseAt > checkpointAt && revParseAt < doneLineAt,
|
||||
'Sub-step G must name `git rev-parse HEAD` in the checkpoint item, after **Checkpoint.** and before the done-line check');
|
||||
assert.ok(gFlat.includes('full commit id'), 'Sub-step G must say {hash} is the full commit id');
|
||||
assert.ok(gFlat.includes('never the short id'), 'Sub-step G must say {hash} is never the short id `git commit` prints');
|
||||
assert.ok(gFlat.includes('Read it again after any later commit of the step'),
|
||||
'Sub-step G must say the id is read again after a later commit of the step');
|
||||
|
||||
const commitLines = g.split('\n').filter((l) => l.includes('`steps.{N}.commit = {hash}`'));
|
||||
assert.ok(commitLines.length > 0, 'Sub-step G must hold the `steps.{N}.commit = {hash}` progress line');
|
||||
for (const l of commitLines) {
|
||||
assert.ok(l.includes('(the full commit id)'), `the progress line must say it stores the full commit id: ${l}`);
|
||||
}
|
||||
|
||||
const handover5 = section(read(CONTRACTS), '## Handover 5 ');
|
||||
const rows = handover5.split('\n').filter((l) => l.startsWith('| `commit` |'));
|
||||
assert.equal(rows.length, 1, `Handover 5 must hold exactly one \`commit\` row; found ${rows.length}`);
|
||||
assert.ok(rows[0].includes('full commit id'), `the Handover 5 \`commit\` row must say "full commit id": ${rows[0]}`);
|
||||
|
||||
// Control: the done-line check keeps its literal, Phase 7.5 keeps reading the
|
||||
// stored id, and Sub-step G asks for no abbreviated id anywhere.
|
||||
assert.ok(g.includes('done-line.mjs --check --step {N} --report {report_path} --commit {hash}'),
|
||||
'Control: Sub-step G keeps the done-line --check literal with --commit {hash}');
|
||||
const p75 = section(read(EXEC), '## Phase 7.5 — Manifest audit (independent)');
|
||||
assert.ok(p75.includes('--commit {steps.N.commit}'), 'Control: Phase 7.5 keeps --commit {steps.N.commit}');
|
||||
assert.doesNotMatch(g, /--short|%h|--abbrev/, 'Control: Sub-step G must not ask for an abbreviated commit id');
|
||||
});
|
||||
|
||||
// --- KI: the round's CHANGELOG entry -----------------------------------------------
|
||||
// Part 2 stands in the same Unreleased section as part 1. The section is found
|
||||
// by its title, by the rule tests/lib/fix-round-4-5b-prose.test.mjs uses
|
||||
// (copied, not imported): `## Unreleased — <title>` before the release,
|
||||
// `## vX.Y.Z — <date> — <title>` after it, exactly once, up to the next `## `.
|
||||
|
||||
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
|
||||
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
||||
|
||||
/** The lines of the round's CHANGELOG section, heading excluded. */
|
||||
function changelogSection() {
|
||||
const lines = read('CHANGELOG.md').split('\n');
|
||||
const at = [];
|
||||
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
|
||||
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
|
||||
let end = lines.length;
|
||||
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
|
||||
return lines.slice(at[0] + 1, end);
|
||||
}
|
||||
|
||||
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
|
||||
function changelogBullets(heading) {
|
||||
const lines = changelogSection();
|
||||
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
|
||||
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
|
||||
const out = [];
|
||||
for (const l of lines.slice(s + 1)) {
|
||||
if (/^### /.test(l)) break;
|
||||
if (/^- /.test(l)) out.push(l);
|
||||
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
|
||||
else if (l.trim() !== '') out.push('');
|
||||
}
|
||||
return out.filter((b) => b !== '').map(flat);
|
||||
}
|
||||
|
||||
// The review's findings part 2 leaves open (7 MAJOR, then 3 MINOR), and the
|
||||
// ones it fixes; the ids are the review's, written out here.
|
||||
const OPEN_IDS = ['c76a0bb8', 'b0fd0a04', 'f7e8278b', '4ec591f4', '2c904235', '450dbabb', 'e3824c6d', '5114fa84', 'e585ce27', '8959d2cd'];
|
||||
const FIXED_IDS = ['c9b5bce4', '021996c6', '20375547', 'b30f057c', '49080c36'];
|
||||
|
||||
test('[KI] the CHANGELOG lists part 2\'s fixes and the review\'s other findings under Known issues', () => {
|
||||
const known = changelogBullets('### Known issues');
|
||||
|
||||
// Red: each open finding has exactly one Known-issues bullet, and that bullet names decision 88.
|
||||
for (const id of OPEN_IDS) {
|
||||
const hits = known.filter((b) => b.includes(id));
|
||||
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet naming ${id}; found ${hits.length}`);
|
||||
}
|
||||
for (const b of known.filter((k) => OPEN_IDS.some((id) => k.includes(id)))) {
|
||||
assert.ok(b.includes('decision 88'), `a Known-issues bullet naming an open finding does not say decision 88: ${b}`);
|
||||
}
|
||||
const followed = known.filter((b) => b.includes('VOYAGE_ARGS_EOF_7F3C9E') && b.includes('stays blocked'));
|
||||
assert.equal(followed.length, 1, `### Known issues must hold one bullet saying a command that mentions VOYAGE_ARGS_EOF_7F3C9E with a line after it stays blocked; found ${followed.length}`);
|
||||
const bashC = known.filter((b) => b.includes("bash -c $'"));
|
||||
assert.equal(bashC.length, 1, `### Known issues must hold one bullet on the one-physical-line bash -c $'...' case; found ${bashC.length}`);
|
||||
const tok = known.filter((b) => b.includes("'90s"));
|
||||
assert.equal(tok.length, 1, `### Known issues must hold one bullet on the tokenizer's word-initial quote ('90s); found ${tok.length}`);
|
||||
|
||||
// Red elsewhere: one ### Fixed bullet per part-2 id, and a door for the round.
|
||||
const fixed = changelogBullets('### Fixed');
|
||||
for (const id of ['(TOK)', '(HDG)', '(CKI)', '(DLC)']) {
|
||||
const hits = fixed.filter((b) => b.includes(id));
|
||||
assert.equal(hits.length, 1, `### Fixed must hold exactly one bullet marked ${id}; found ${hits.length}`);
|
||||
}
|
||||
const doors = changelogBullets('### Doors').filter((b) => b.includes('decision 88'));
|
||||
assert.equal(doors.length, 1, `### Doors must hold one bullet naming decision 88; found ${doors.length}`);
|
||||
|
||||
// Control: the three earlier Known issues stay, no fixed finding is listed as
|
||||
// open, and the R6 Fixed bullet keeps the one backticked rule name.
|
||||
for (const start of ["- **A plan's `profile:` field has no writer.**", '- **Splices and dropped', '- **Two 4.5b findings moved to fix round 4.5c.**']) {
|
||||
assert.equal(known.filter((b) => b.startsWith(start)).length, 1, `Control: ### Known issues must keep the bullet opening ${start}`);
|
||||
}
|
||||
for (const id of FIXED_IDS) {
|
||||
assert.deepEqual(known.filter((b) => b.includes(id)), [], `Control: the fixed finding ${id} must not stand under ### Known issues`);
|
||||
}
|
||||
assert.equal(fixed.filter((b) => b.includes('`args-heredoc-closed-early`')).length, 1,
|
||||
'Control: exactly one ### Fixed bullet holds `args-heredoc-closed-early`');
|
||||
});
|
||||
122
tests/lib/fix-round-4-5b-part-2-scripts.test.mjs
Normal file
122
tests/lib/fix-round-4-5b-part-2-scripts.test.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// tests/lib/fix-round-4-5b-part-2-scripts.test.mjs
|
||||
// The form of fix round 4-5b part 2's three shell scripts (plan
|
||||
// .claude/projects/2026-10-27-fix-round-4-5b-part-2, Step 5h, decision 88):
|
||||
// the red wrapper, the regression script and the end-to-end run. Untagged on
|
||||
// purpose and listed in no red wrapper's FILES: these checks are not a part-2
|
||||
// fix, and they fail at no start point.
|
||||
// - each script is ASCII only, passes /bin/bash -n and uses no bash 4 form;
|
||||
// - the red wrapper hands the shared red script exactly one --other-ids,
|
||||
// whose words are the 4-5a IDS= words followed by part 1's, and --ids
|
||||
// TOK HDG CKI DLC KI (run from a temp root next to a stub red-round.sh
|
||||
// that prints its arguments, the method of tests/lib/red-script.test.mjs);
|
||||
// - with the 4-5a or the part-1 wrapper lacking its IDS= line, the red
|
||||
// wrapper exits 2 with a setup failure and never runs the shared script.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
const RED = 'tests/fix-round-4-5b-part-2-red.sh';
|
||||
const REGRESSION = 'tests/fix-round-4-5b-part-2-regression.sh';
|
||||
const E2E = 'tests/e2e-fix-round-4-5b-part-2.sh';
|
||||
const WRAP_A = 'tests/fix-round-4-5a-red.sh';
|
||||
const WRAP_B = 'tests/fix-round-4-5b-red.sh';
|
||||
const PART2_IDS = ['TOK', 'HDG', 'CKI', 'DLC', 'KI'];
|
||||
|
||||
/** A repo file's bytes; the file must exist. */
|
||||
function bytes(rel) {
|
||||
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
||||
return readFileSync(join(ROOT, rel));
|
||||
}
|
||||
|
||||
/** The words of a wrapper's one IDS='...' line. */
|
||||
function idsOf(rel) {
|
||||
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith('IDS='));
|
||||
assert.equal(lines.length, 1, `expected one IDS= line in ${rel}; found ${lines.length}`);
|
||||
const m = lines[0].match(/^IDS='([^']*)'$/);
|
||||
assert.ok(m, `the IDS= line of ${rel} is not one single-quoted list: ${lines[0]}`);
|
||||
return m[1].split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
test('part-2 scripts: each is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
|
||||
for (const rel of [RED, REGRESSION, E2E]) {
|
||||
const buf = bytes(rel);
|
||||
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
|
||||
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
|
||||
const text = buf.toString('utf8');
|
||||
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/, /^\s*[A-Za-z_]+=\(/m,
|
||||
/\$\{[A-Za-z_]+,,\}/, /\$\{[A-Za-z_]+\^\^\}/]) {
|
||||
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
|
||||
const STUB = `#!/bin/bash
|
||||
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
|
||||
exit 0
|
||||
`;
|
||||
|
||||
/** A temp root with the part-2 wrapper, both older wrappers and the stub as tests/helpers/red-round.sh. */
|
||||
function stubRoot(base) {
|
||||
const dir = join(base, 'root');
|
||||
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
|
||||
for (const rel of [RED, WRAP_A, WRAP_B]) writeFileSync(join(dir, rel), bytes(rel));
|
||||
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** The part-2 wrapper run from a temp root: status, output and the arguments the stub saw. */
|
||||
function runWrapper(dir) {
|
||||
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
|
||||
const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
|
||||
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv };
|
||||
}
|
||||
|
||||
test('part-2 scripts: the red wrapper passes one --other-ids, the 4-5a ids then part 1\'s, and its own five ids', () => {
|
||||
const text = bytes(RED).toString('utf8');
|
||||
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
|
||||
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
|
||||
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
|
||||
|
||||
const base = mkdtempSync(join(tmpdir(), 'part2-red-wrap-'));
|
||||
try {
|
||||
const r = runWrapper(stubRoot(base));
|
||||
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
|
||||
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
|
||||
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
|
||||
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
|
||||
assert.deepEqual(other, [...idsOf(WRAP_A), ...idsOf(WRAP_B)],
|
||||
`${RED}: --other-ids is not the IDS= words of ${WRAP_A} followed by those of ${WRAP_B}\n${r.out}`);
|
||||
const idsAt = r.argv.indexOf('--ids');
|
||||
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
|
||||
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PART2_IDS, `${RED}: --ids is not ${PART2_IDS.join(' ')}\n${r.out}`);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('part-2 scripts: the red wrapper exits 2 with a setup failure when an older wrapper has no IDS= line', () => {
|
||||
for (const missing of [WRAP_A, WRAP_B]) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'part2-red-noids-'));
|
||||
try {
|
||||
const dir = stubRoot(base);
|
||||
const text = bytes(missing).toString('utf8');
|
||||
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
|
||||
const r = runWrapper(dir);
|
||||
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
|
||||
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
|
||||
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
602
tests/lib/fix-round-4-5b-prose.test.mjs
Normal file
602
tests/lib/fix-round-4-5b-prose.test.mjs
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
// tests/lib/fix-round-4-5b-prose.test.mjs
|
||||
// Prose pins for fix round 4-5b (plan
|
||||
// .claude/projects/2026-10-25-fix-round-4-5b-findings): the fixes that are
|
||||
// text a session or a helper reads. The tags in brackets are 4.5b ids from the
|
||||
// round's brief (for example `[M4]`, `[M11]`); each tagged test fails on an
|
||||
// assertion at be0b631, the round's start point. A sibling's test is tagged
|
||||
// `[<parent id>] sibling:`.
|
||||
// M4 A git call the verdict gate cannot complete is reported as
|
||||
// `review-not-measured:`, never as a stale review: Handover 6 names the
|
||||
// prefix and lists review_version 1.1 as current, and /trekexecute's
|
||||
// main-merge gate maps it to "not measured" with the merge locked.
|
||||
// M11 Handover 6 states that only a full 40- or 64-hex reviewed commit is read.
|
||||
// E22e The park tool parks every file new in the attempt's diff, untracked or
|
||||
// staged but not in HEAD: its header and /trekexecute's resume paragraph
|
||||
// no longer say "still untracked".
|
||||
// M3 The "Back to planning" save line runs its git diff with --no-relative,
|
||||
// so a session in a subdirectory with diff.relative on saves every file.
|
||||
// M9 The review coordinator's Pass 2 text says what the lib does: a root
|
||||
// escape or a negative line refutes a finding; any other line number
|
||||
// alone never does, and a line past the end goes to `unverified`.
|
||||
// M2 The four flag-table summaries of `--quick` (trekreview, orchestrator,
|
||||
// command-modes, README) name the brief-conflict rule as the exception
|
||||
// to the Pass 3 skip.
|
||||
// K3 The orchestrator's 4-pass item 3 names the brief-conflict lowering as
|
||||
// the exception to the catalogue reset.
|
||||
// ARG /trekendsession and /trekcontinue Phase 0 and /trekreview Phase 1
|
||||
// give $ARGUMENTS to the arg parser (`--stdin`) as the one body line of
|
||||
// a quoted heredoc, never as shell words.
|
||||
// M8 /trekcontinue and /trekendsession Phase 0 stop on the parser's exit 1
|
||||
// with its `errors`, and stop when the parser prints no JSON.
|
||||
// M10 /trekendsession Phase 2 reads the label from the parser's `tail`.
|
||||
// M13 /trekbrief's research_topics = 0 exit runs Phase 7 with
|
||||
// `auto_result: manual`.
|
||||
// K2 /trekbrief's Step 6a "Cancel — do manual" row runs Phase 7 with
|
||||
// `auto_result: cancelled`, like the sentence after the table.
|
||||
// E14 /trekexecute keeps `run_seen` in the progress file (template, field
|
||||
// table, Phase 7) and Phase 8 reads it from there, so the resume and
|
||||
// multi-session paths report it; Handover 5 lists it as optional.
|
||||
// DJ4 /trekexecute's Phase 7.2 step 3 commits only the review's paths git
|
||||
// does not ignore, and says an ignored one is left uncommitted.
|
||||
// E17a The two /trekresearch wave 2 sentences on outbound queries are pinned
|
||||
// exactly (red under the E17a mutation).
|
||||
// E17b The deep-research adapter says where wave 2's contrarian findings merge.
|
||||
// Da The README deep-research parenthesis says "when the engine is active".
|
||||
// E18a The F9 deep-review and summary-only rows are pinned exactly (red under
|
||||
// the E18a mutation).
|
||||
// Db No README line stars security-researcher (red under the Db mutation).
|
||||
// M1 No test name in the 4.5a prose file starts with `found in execution:`;
|
||||
// the tests f6bdc2d added carry their 4.5a parent tag (red under the
|
||||
// M1 mutation, which puts the names back).
|
||||
// CHANGELOG (the round's entry in the existing Unreleased section):
|
||||
// K4 the siblings entry says the commands read only the two-token
|
||||
// `--gates <value>` form and that `--gates=true` is the gates shim's own.
|
||||
// M4 the merge-lock entry says a failed git call is reported as
|
||||
// `review-not-measured:`, not `review-stale`.
|
||||
// M2 ### Fixed records the brief-conflict exception in the four `--quick`
|
||||
// summaries.
|
||||
// ARG ### Fixed names the Bash-guard block on a parser heredoc that closes
|
||||
// early (R6), and ### Changed says `~` and `$HOME` in the arguments are
|
||||
// no longer expanded.
|
||||
// No static import of a new export: the file must load at be0b631.
|
||||
// Fix the SOURCE a pin guards, not the pin.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
const readCache = new Map();
|
||||
|
||||
/** A repo file's text; the file must exist. Read from disk once per run. */
|
||||
function read(rel) {
|
||||
if (!readCache.has(rel)) {
|
||||
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
||||
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
|
||||
}
|
||||
return readCache.get(rel);
|
||||
}
|
||||
|
||||
/** Whitespace (line breaks too) collapsed to one space. */
|
||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
|
||||
/** The text from the line holding `start` up to (not including) the next line holding `stop`; both must exist. */
|
||||
function between(rel, start, stop) {
|
||||
const text = read(rel);
|
||||
const s = text.indexOf(start);
|
||||
assert.ok(s !== -1, `${rel} must hold "${start}"`);
|
||||
const e = text.indexOf(stop, s + start.length);
|
||||
assert.ok(e !== -1, `${rel} must hold "${stop}" after "${start}"`);
|
||||
return text.slice(s, e);
|
||||
}
|
||||
|
||||
/** Handover 6 of the handover contracts, up to Handover 7. */
|
||||
const handover6 = () => between('docs/HANDOVER-CONTRACTS.md', '## Handover 6 ', '## Handover 7 ');
|
||||
|
||||
/** The one table row of Handover 6 whose first cell is `field`. */
|
||||
function fieldRow(field) {
|
||||
const rows = handover6().split('\n').filter((l) => l.startsWith(`| \`${field}\` |`));
|
||||
assert.equal(rows.length, 1, `Handover 6 must hold exactly one \`${field}\` row; found ${rows.length}`);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/** The verdict-gate paragraph of Handover 6 (the "Second consumer" one), flattened. */
|
||||
function gateParagraph() {
|
||||
const para = handover6().split('\n').filter((l) => l.startsWith('**Second consumer — the verdict gate'));
|
||||
assert.equal(para.length, 1, `Handover 6 must hold one verdict-gate paragraph; found ${para.length}`);
|
||||
return flat(para[0]);
|
||||
}
|
||||
|
||||
/** Every sentence of `text` (flattened) that holds `needle`. */
|
||||
function sentencesWith(text, needle) {
|
||||
return flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/).filter((s) => s.includes(needle));
|
||||
}
|
||||
|
||||
// --- M4 ----------------------------------------------------------------------------
|
||||
|
||||
test('[M4] Handover 6 names the review-not-measured reason', () => {
|
||||
const hits = sentencesWith(gateParagraph(), '`review-not-measured:`');
|
||||
assert.ok(hits.length > 0, 'the verdict-gate paragraph of Handover 6 does not name the `review-not-measured:` reason prefix');
|
||||
assert.ok(hits.some((s) => /\bgit\b/.test(s) && /never green|not measured/.test(s)),
|
||||
`no sentence ties \`review-not-measured:\` to a failed git call read as not measured: ${hits.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('[M4] Handover 6 lists review_version 1.1 as current', () => {
|
||||
const row = fieldRow('review_version');
|
||||
assert.match(row, /"1\.1"`? \(current\)/, `the review_version row does not list 1.1 as current: ${row}`);
|
||||
assert.doesNotMatch(row, /"1\.0"`? \(current\)/, `the review_version row still lists 1.0 as current: ${row}`);
|
||||
});
|
||||
|
||||
test('[M4] the main merge gate maps review-not-measured to not measured', () => {
|
||||
const gate = between('commands/trekexecute.md', '**1. The verdict.**', '**2. Open:');
|
||||
const hits = sentencesWith(gate, '`review-not-measured:`');
|
||||
assert.ok(hits.length > 0, 'the main-merge gate (Phase 8, 1. The verdict) does not name the `review-not-measured:` reason prefix');
|
||||
const ok = hits.find((s) => {
|
||||
const rest = s.split('`review-not-measured:`').join('');
|
||||
return /"not measured"|\bnot measured\b/.test(rest) && /never green/.test(rest) && /merge stays (locked|closed)/.test(rest);
|
||||
});
|
||||
assert.ok(ok, `no sentence says a \`review-not-measured:\` reason is reported as "not measured", never green, with the merge locked: ${hits.join(' | ')}`);
|
||||
});
|
||||
|
||||
// --- M11 ---------------------------------------------------------------------------
|
||||
|
||||
test('[M11] Handover 6 states the full 40- or 64-hex reviewed commit', () => {
|
||||
const row = fieldRow('scope_sha_end');
|
||||
assert.match(row, /full 40- or 64-hex/, `the scope_sha_end row does not state the full 40- or 64-hex commit id: ${row}`);
|
||||
assert.match(gateParagraph(), /`scope_sha_end`[^.]*full 40- or 64-hex/,
|
||||
'the verdict-gate paragraph of Handover 6 does not state that only a full 40- or 64-hex scope_sha_end is read');
|
||||
});
|
||||
|
||||
// --- E22e --------------------------------------------------------------------------
|
||||
|
||||
/** The sentences of `text` (flattened) that say what the park tool moves or parks. */
|
||||
function parkSentences(text) {
|
||||
return flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/).filter((s) => /\b(moves|parks)\b/.test(s) && /\bfile\b/.test(s));
|
||||
}
|
||||
|
||||
test('[E22e] the park header says untracked or staged but not in HEAD', () => {
|
||||
const src = read('lib/util/park-attempt-files.mjs');
|
||||
const header = src.slice(0, src.indexOf('\nimport '));
|
||||
assert.ok(header.length > 0, 'lib/util/park-attempt-files.mjs has no header before its imports');
|
||||
const comment = header.split('\n').map((l) => l.replace(/^\/\/ ?/, '')).join('\n');
|
||||
assert.doesNotMatch(flat(comment), /still untracked/, 'the park header still says the tool parks only files "still untracked"');
|
||||
const hits = parkSentences(comment);
|
||||
assert.ok(hits.some((s) => /new in the attempt's diff/.test(s) && /untracked, or staged but not in HEAD/.test(s)),
|
||||
`no header sentence says every file new in the attempt's diff is parked, untracked, or staged but not in HEAD: ${hits.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('[E22e] the execute command says the same', () => {
|
||||
const para = between('commands/trekexecute.md', 'A step at `fix_rounds` 4 on `--resume` was re-planned', 'Then launch a fresh');
|
||||
assert.doesNotMatch(flat(para), /still untracked/, 'the /trekexecute resume paragraph still says the tool moves only files "still untracked"');
|
||||
const hits = parkSentences(para);
|
||||
assert.ok(hits.some((s) => /new in the attempt's diff/.test(s) && /untracked, or staged but not in HEAD/.test(s)),
|
||||
`no /trekexecute sentence says every file new in the attempt's diff is parked, untracked, or staged but not in HEAD: ${hits.join(' | ')}`);
|
||||
});
|
||||
|
||||
// --- M3 ----------------------------------------------------------------------------
|
||||
|
||||
test('[M3] the back-to-planning save line carries --no-relative', () => {
|
||||
const lines = read('commands/trekexecute.md').split('\n').filter((l) => l.startsWith('**Back to planning** (`fix_rounds = 4`): save `'));
|
||||
assert.equal(lines.length, 1, `expected one "Back to planning" save line; found ${lines.length}`);
|
||||
const m = lines[0].match(/save `([^`]+)`/);
|
||||
assert.ok(m, `no backticked command on the save line: ${lines[0]}`);
|
||||
assert.match(m[1], /^git (?:-c \S+ )*diff (?:--\S+ )*--no-relative (?:--\S+ )*\{start_sha\}$/,
|
||||
`the save line's git diff does not carry --no-relative: ${m[1]}`);
|
||||
});
|
||||
|
||||
// --- M9 ----------------------------------------------------------------------------
|
||||
|
||||
test('[M9] the coordinator text does not say a line number alone never refutes', () => {
|
||||
const coord = flat(read('agents/review-coordinator.md'));
|
||||
assert.doesNotMatch(coord, /\bA line number alone never refutes/,
|
||||
'agents/review-coordinator.md still says a line number alone never refutes, next to a negative line that does');
|
||||
const p2 = between('agents/review-coordinator.md', '### Pass 2 — HubSpot Judge filters', '### Pass 3 — ');
|
||||
assert.ok(sentencesWith(p2, 'a citation that escapes the repo root, or a negative line, refutes the finding').length > 0,
|
||||
'Pass 2 no longer says a root escape or a negative line refutes the finding');
|
||||
const hits = sentencesWith(p2, 'line number alone never refutes');
|
||||
assert.ok(hits.some((s) => /\b[Aa]ny other line number alone never refutes\b/.test(s)
|
||||
&& /`unverified` \(`accuracy:line-out-of-range`\)/.test(s)),
|
||||
`no Pass 2 sentence says any other line number alone never refutes and goes to \`unverified\`: ${hits.join(' | ')}`);
|
||||
});
|
||||
|
||||
// --- M2 ----------------------------------------------------------------------------
|
||||
|
||||
/** The one line of `rel` that starts with `prefix` and holds `needle`. */
|
||||
function oneLine(rel, prefix, needle) {
|
||||
const hits = read(rel).split('\n').filter((l) => l.startsWith(prefix) && l.includes(needle));
|
||||
assert.equal(hits.length, 1, `${rel} must hold exactly one line starting "${prefix}" with "${needle}"; found ${hits.length}`);
|
||||
return hits[0];
|
||||
}
|
||||
|
||||
const BRIEF_CONFLICT_SKIP = /the coordinator's reasonableness filter,? except the brief-conflict rule\b/;
|
||||
|
||||
test('[M2] each of the four flag-table summaries names the brief-conflict exception', () => {
|
||||
const summaries = [
|
||||
['commands/trekreview.md --quick row', oneLine('commands/trekreview.md', '| `--quick` |', 'reasonableness filter')],
|
||||
['agents/review-orchestrator.md quick mode', flat(between('agents/review-orchestrator.md', '- `quick` — ', '- `validate` — '))],
|
||||
['docs/command-modes.md --quick row', oneLine('docs/command-modes.md', '| `--quick` |', 'reasonableness filter')],
|
||||
['README.md Quick row', oneLine('README.md', '| **Quick** | `/trekreview ', 'reasonableness filter')],
|
||||
];
|
||||
for (const [where, text] of summaries) {
|
||||
assert.match(text, BRIEF_CONFLICT_SKIP, `${where} skips the reasonableness filter without the brief-conflict exception: ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- K3 ----------------------------------------------------------------------------
|
||||
|
||||
test('[K3] orchestrator item 3 names the brief-conflict lowering as the exception to the reset', () => {
|
||||
const item = flat(between('agents/review-orchestrator.md', '3. **Cloudflare reasonableness**', '4. **Compute verdict**'));
|
||||
const at = item.indexOf('reset a severity that does not match its catalogue tier.');
|
||||
assert.ok(at !== -1, `item 3 no longer resets a severity to its catalogue tier: ${item}`);
|
||||
const next = sentencesWith(item.slice(at + 'reset a severity that does not match its catalogue tier.'.length), ' ')[0] ?? '';
|
||||
assert.match(next, /\bexception to (the|this) reset\b/, `the sentence after the reset does not name an exception to it: ${next}`);
|
||||
assert.match(next, /brief-conflict lowering/, `the exception to the reset is not the brief-conflict lowering: ${next}`);
|
||||
assert.match(next, /MAJOR[^.]*\bMINOR\b/, `the exception does not say a MAJOR drops to MINOR: ${next}`);
|
||||
assert.match(next, /`brief_lowered_from: MAJOR`/, `the exception does not name brief_lowered_from: MAJOR: ${next}`);
|
||||
});
|
||||
|
||||
// --- ARG / M8 / M10 ----------------------------------------------------------------
|
||||
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
const ARGS_OPENER = `<<'${ARGS_DELIM}'`;
|
||||
|
||||
/** The text of a `## ` section of `rel` from the line starting with `heading` to the next `## ` line outside a fence. */
|
||||
function phase(rel, heading) {
|
||||
const lines = read(rel).split('\n');
|
||||
const s = lines.findIndex((l) => l.startsWith(heading));
|
||||
assert.ok(s !== -1, `${rel} must hold a heading starting "${heading}"`);
|
||||
let fenced = false;
|
||||
for (let i = s + 1; i < lines.length; i++) {
|
||||
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
|
||||
if (!fenced && lines[i].startsWith('## ')) return lines.slice(s, i).join('\n');
|
||||
}
|
||||
return lines.slice(s).join('\n');
|
||||
}
|
||||
|
||||
// [file, command, the section that runs the parser]
|
||||
const PARSER_PHASES = [
|
||||
['commands/trekendsession.md', 'trekendsession', '## Phase 0 — `--help` handling'],
|
||||
['commands/trekcontinue.md', 'trekcontinue', '## Phase 0 — `--help` handling'],
|
||||
['commands/trekreview.md', 'trekreview', '## Phase 1 — Parse mode and validate input'],
|
||||
];
|
||||
|
||||
/** The one runtime ```bash block of the section that runs the arg parser for `cmd`, as lines, with the parser line's index. */
|
||||
function parserBlock(rel, cmd, heading) {
|
||||
const text = phase(rel, heading);
|
||||
const blocks = text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0])
|
||||
.filter((b) => b.includes(`lib/parsers/arg-parser.mjs --command ${cmd}`));
|
||||
assert.equal(blocks.length, 1, `${rel} ${heading} must hold one \`\`\`bash block that runs the ${cmd} arg parser; found ${blocks.length}`);
|
||||
const lines = blocks[0].split('\n');
|
||||
const at = lines.findIndex((l) => l.includes(`lib/parsers/arg-parser.mjs --command ${cmd}`));
|
||||
return { lines, at };
|
||||
}
|
||||
|
||||
test('[ARG] each of the three parser lines reads the arguments from a quoted heredoc', () => {
|
||||
for (const [rel, cmd, heading] of PARSER_PHASES) {
|
||||
const { lines, at } = parserBlock(rel, cmd, heading);
|
||||
const opener = lines[at].trim();
|
||||
assert.ok(opener.endsWith(` ${ARGS_OPENER}`),
|
||||
`${rel}: the parser line does not end with the quoted heredoc opener ${ARGS_OPENER}: ${opener}`);
|
||||
assert.equal(lines[at + 1], '$ARGUMENTS',
|
||||
`${rel}: the line inside the heredoc is not $ARGUMENTS alone: ${JSON.stringify(lines[at + 1])}`);
|
||||
assert.match(opener, new RegExp(`^node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/parsers/arg-parser\\.mjs --command ${cmd} --stdin <<`),
|
||||
`${rel}: the parser line does not read stdin (--command ${cmd} --stdin right after arg-parser.mjs): ${opener}`);
|
||||
const filled = lines.filter((l) => l.trim() !== '');
|
||||
assert.ok(lines[at + 2] === ARGS_DELIM && filled[filled.length - 1] === ARGS_DELIM,
|
||||
`${rel}: the closing line ${ARGS_DELIM} does not follow $ARGUMENTS as the block's last non-empty line: ${JSON.stringify(lines)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[ARG] no line outside the heredoc carries $ARGUMENTS in those blocks', () => {
|
||||
for (const [rel, cmd, heading] of PARSER_PHASES) {
|
||||
const { lines } = parserBlock(rel, cmd, heading);
|
||||
const open = lines.findIndex((l) => l.trimEnd().endsWith(ARGS_OPENER));
|
||||
assert.ok(open !== -1, `${rel}: the parser block opens no ${ARGS_OPENER} heredoc: ${JSON.stringify(lines)}`);
|
||||
const close = lines.findIndex((l, i) => i > open && l.trim() === ARGS_DELIM);
|
||||
assert.ok(close !== -1, `${rel}: the parser block's heredoc has no closing line ${ARGS_DELIM}`);
|
||||
const outside = lines.filter((l, i) => (i <= open || i >= close) && l.includes('$ARGUMENTS'));
|
||||
assert.deepEqual(outside, [], `${rel}: a line outside the heredoc carries $ARGUMENTS`);
|
||||
}
|
||||
});
|
||||
|
||||
const PHASE0 = '## Phase 0 — `--help` handling';
|
||||
const STOP_COMMANDS = ['commands/trekcontinue.md', 'commands/trekendsession.md'];
|
||||
|
||||
test('[M8] /trekcontinue and /trekendsession Phase 0 stop on exit 1 with the parser\'s errors', () => {
|
||||
for (const rel of STOP_COMMANDS) {
|
||||
const hits = sentencesWith(phase(rel, PHASE0), 'exit 1');
|
||||
assert.ok(hits.some((s) => /^On exit 1, print (its|the parser's) `errors` and stop\.$/.test(s)),
|
||||
`${rel} Phase 0 does not say: on exit 1, print the parser's \`errors\` and stop: ${hits.join(' | ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[M8] /trekcontinue and /trekendsession Phase 0 stop when the parser prints no JSON', () => {
|
||||
for (const rel of STOP_COMMANDS) {
|
||||
const hits = sentencesWith(phase(rel, PHASE0), 'no JSON');
|
||||
assert.ok(hits.some((s) => /\bprints no JSON\b/.test(s) && /\bthe parser did not run\b/.test(s) && /\band stop\.$/.test(s)),
|
||||
`${rel} Phase 0 does not say: when the parser prints no JSON, say that it did not run and stop: ${hits.join(' | ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[M10] /trekendsession Phase 2 reads the label from tail', () => {
|
||||
const p2 = flat(phase('commands/trekendsession.md', '## Phase 2 — Required args check'));
|
||||
assert.doesNotMatch(p2, /`positional\[1\]`/, 'Phase 2 still reads <next-label> from positional[1], which splits a label at spaces');
|
||||
assert.match(p2, /`<next-label>` is the parser's `tail`/, `Phase 2 does not read <next-label> from the parser's tail: ${p2}`);
|
||||
});
|
||||
|
||||
// --- M13 / K2 ----------------------------------------------------------------------
|
||||
|
||||
const TREKBRIEF = 'commands/trekbrief.md';
|
||||
|
||||
test('[M13] the research_topics = 0 exit names auto_result manual', () => {
|
||||
const opening = between(TREKBRIEF, '## Phase 5 — Auto-orchestration opt-in', 'Ask the user via `AskUserQuestion`');
|
||||
const hits = sentencesWith(opening, 'research_topics = 0');
|
||||
assert.equal(hits.length, 1, `Phase 5 must hold one sentence on research_topics = 0; found ${hits.length}: ${hits.join(' | ')}`);
|
||||
assert.match(hits[0], /, then run Phase 7 \(`auto_result: manual`\) and stop\.$/,
|
||||
`the research_topics = 0 exit does not run Phase 7 with auto_result manual: ${hits[0]}`);
|
||||
});
|
||||
|
||||
test('[K2] the 6a row names Phase 7 with auto_result cancelled', () => {
|
||||
const step6a = between(TREKBRIEF, '### Step 6a — Confirm proceed', '### Step 6b ');
|
||||
const rows = step6a.split('\n').filter((l) => l.startsWith('| **Cancel — do manual** |'));
|
||||
assert.equal(rows.length, 1, `Step 6a must hold one "Cancel — do manual" row; found ${rows.length}`);
|
||||
assert.equal(rows[0], '| **Cancel — do manual** | Revert to manual path (run Phase 7 with `auto_result: cancelled`, print commands, stop). |',
|
||||
'the 6a cancel row does not name Phase 7 with auto_result cancelled');
|
||||
assert.match(flat(step6a), /If cancelled → fall back to manual path output, run Phase 7 \(`auto_result: cancelled`\) and stop\./,
|
||||
'the sentence after the 6a table no longer names Phase 7 with auto_result cancelled');
|
||||
});
|
||||
|
||||
// --- DJ1 / DJ2 ---------------------------------------------------------------------
|
||||
|
||||
const TREKPLAN = 'commands/trekplan.md';
|
||||
|
||||
test('[DJ1] the execute and team exits run Phase 12', () => {
|
||||
const execute = flat(between(TREKPLAN, '### "execute" / "go" / "start"', '### "execute with team" / "team"'));
|
||||
assert.match(execute, /`\/trekexecute \{plan_path\}` without a project directory — then run Phase 12 \(`outcome: execute`\)\./,
|
||||
`the "execute" exit does not run Phase 12 with outcome execute: ${execute}`);
|
||||
assert.doesNotMatch(execute, /\band stop\b/, `the "execute" exit still ends in a bare "and stop": ${execute}`);
|
||||
const team = flat(between(TREKPLAN, '### "execute with team" / "team"', '## Phase 12 — Session tracking'));
|
||||
assert.match(team, /There is no team path\. Print the same `\/trekexecute` line, then run Phase 12 \(`outcome: execute_team`\);/,
|
||||
`the "team" exit does not run Phase 12 with outcome execute_team: ${team}`);
|
||||
assert.doesNotMatch(team, /\band stop\b/, `the "team" exit still ends in a bare "and stop": ${team}`);
|
||||
const p12 = between(TREKPLAN, '## Phase 12 — Session tracking', '## Profile');
|
||||
assert.ok(p12.includes('"outcome": "{execute|execute_team|save|refine}"'),
|
||||
'Phase 12 no longer holds the outcome enum the two exits name');
|
||||
});
|
||||
|
||||
test('[DJ2] the high-effort loop names the model for a turn that spawns', () => {
|
||||
const loop = between('commands/trekresearch.md', '### Per-turn protocol', '### Exits (all three, always one of them)');
|
||||
const hits = sentencesWith(loop, 'spawns');
|
||||
assert.ok(hits.some((s) => /^A turn that spawns an agent passes `model: phase_signal_result\.model`, else `opus`/.test(s)),
|
||||
`the per-turn protocol does not say a turn that spawns passes model: phase_signal_result.model, else opus: ${hits.join(' | ')}`);
|
||||
assert.doesNotMatch(loop, /model: "opus"/, 'the per-turn protocol pins a spawn to model: "opus"');
|
||||
});
|
||||
|
||||
// --- E14 ---------------------------------------------------------------------------
|
||||
|
||||
/** The progress-file template of /trekexecute's Phase 3, parsed as JSON. */
|
||||
function progressTemplate() {
|
||||
const block = between('commands/trekexecute.md', '### Progress file schema', '**The step-loop fields');
|
||||
const m = block.match(/```json\n([\s\S]*?)\n```/);
|
||||
assert.ok(m, 'the Progress file schema section holds no ```json block');
|
||||
return JSON.parse(m[1]);
|
||||
}
|
||||
|
||||
test('[E14] the progress template carries run_seen', () => {
|
||||
const tpl = progressTemplate();
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(tpl, 'run_seen'),
|
||||
'the progress template has no top-level run_seen field');
|
||||
assert.equal(tpl.run_seen, null, `the progress template's run_seen is not null: ${JSON.stringify(tpl.run_seen)}`);
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(tpl.steps['1'], 'run_seen'),
|
||||
'run_seen sits in the per-step record, not at the top level');
|
||||
});
|
||||
|
||||
test('[E14] the field table describes run_seen', () => {
|
||||
const table = between('commands/trekexecute.md', '**The step-loop fields', '**The coupling fields');
|
||||
const rows = table.split('\n').filter((l) => l.startsWith('| `run_seen` |'));
|
||||
assert.equal(rows.length, 1, `the step-loop field table must hold exactly one run_seen row; found ${rows.length}`);
|
||||
const cells = rows[0].split(' | ');
|
||||
assert.match(cells[1], /^Phase 7$/, `the run_seen row is not written in Phase 7: ${rows[0]}`);
|
||||
assert.match(rows[0], /Claude Code's (built-in )?`run`/, `the run_seen row does not say it is what Claude Code's run showed: ${rows[0]}`);
|
||||
assert.match(rows[0], /`null` = not run/, `the run_seen row does not say null = not run: ${rows[0]}`);
|
||||
});
|
||||
|
||||
test('[E14] Phase 8 reads run_seen from progress.json', () => {
|
||||
const p7 = between('commands/trekexecute.md', '### End to end — "run the thing"', '### Architecture rules (K5)');
|
||||
const rec = sentencesWith(p7, 'Record `run_seen`');
|
||||
assert.equal(rec.length, 1, `Phase 7 must hold one "Record \`run_seen\`" sentence; found ${rec.length}`);
|
||||
assert.match(rec[0], /\bin the progress file\b|\bto the progress file\b|`progress\.json`/,
|
||||
`Phase 7 records run_seen without writing it to the progress file: ${rec[0]}`);
|
||||
const p8 = between('commands/trekexecute.md', '## Phase 8 — Final report', '## Phase 9 — Stats tracking');
|
||||
const hits = sentencesWith(p8, '`run_seen`');
|
||||
const ok = hits.find((s) => /\bread\b|\breads\b/.test(s)
|
||||
&& /progress file|`progress\.json`/.test(s)
|
||||
&& /\*\*Seen with `run`:\*\*/.test(s)
|
||||
&& /summary/.test(s)
|
||||
&& /resume/.test(s) && /multi-session/.test(s));
|
||||
assert.ok(ok, `no Phase 8 sentence says the Seen with run line and the summary's run_seen are read from the progress file, so the resume and multi-session paths report it: ${hits.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('[E14] Handover 5 lists run_seen as optional', () => {
|
||||
const h5 = between('docs/HANDOVER-CONTRACTS.md', '## Handover 5 ', '## Handover 6 ');
|
||||
const rows = h5.split('\n').filter((l) => l.startsWith('| `run_seen` |'));
|
||||
assert.equal(rows.length, 1, `Handover 5 must hold exactly one run_seen row; found ${rows.length}`);
|
||||
const cells = rows[0].split(' | ');
|
||||
assert.equal(cells[2], 'optional', `the run_seen row's Required cell is not "optional": ${rows[0]}`);
|
||||
assert.match(rows[0], /additive/, `the run_seen row does not say the field is additive: ${rows[0]}`);
|
||||
assert.match(rows[0], /Phase 7/, `the run_seen row does not say Phase 7 records it: ${rows[0]}`);
|
||||
});
|
||||
|
||||
// --- DJ4 ---------------------------------------------------------------------------
|
||||
|
||||
test('[DJ4] Phase 7.2 step 3 commits only paths git does not ignore', () => {
|
||||
const step3 = between('commands/trekexecute.md', '3. **`/trekreview`**', '4. **`verdict-gate.mjs`**');
|
||||
const hits = sentencesWith(step3, 'git does not ignore');
|
||||
assert.ok(hits.some((s) => /^Commit\b/.test(s) && /`git check-ignore -q -- \{path\}`/.test(s)
|
||||
&& /`docs\(voyage\): review lessons \(round \{R\}\)`/.test(s)),
|
||||
`Phase 7.2 step 3 does not say it commits only the changed paths git does not ignore, checked with git check-ignore: ${hits.join(' | ')}`);
|
||||
const left = sentencesWith(step3, 'ignored');
|
||||
assert.ok(left.some((s) => /left uncommitted/.test(s) && /`"ignored-skipped"`/.test(s) && /`"symlink-skipped"`/.test(s)),
|
||||
`Phase 7.2 step 3 does not say an ignored path is left uncommitted and name the two skipped pointer results: ${left.join(' | ')}`);
|
||||
assert.doesNotMatch(flat(step3), /Nothing the review wrote is left uncommitted/,
|
||||
'Phase 7.2 step 3 still says nothing the review wrote is left uncommitted');
|
||||
});
|
||||
|
||||
// --- E17a / E17b / Da / E18a / Db --------------------------------------------------
|
||||
// E17a, E18a and Db are already right at be0b631; their tests are proven red
|
||||
// under the E17a, E18a and Db entries of tests/fix-round-4-5b-mutations.mjs.
|
||||
|
||||
const LAUNCH = () => between('commands/trekresearch.md', '### Launch rules', '- Inject the resolved model at every spawn');
|
||||
|
||||
test('[E17a] the outbound-query sentence on local names is pinned', () => {
|
||||
const hits = sentencesWith(LAUNCH(), 'It searches with the words it is given');
|
||||
assert.deepEqual(hits, ['It searches with the words it is given, so no local file name or project term may be in them.'],
|
||||
'the wave 2 rule no longer says the contrarian searches with the words it is given, so no local name may be in them');
|
||||
});
|
||||
|
||||
test('[E17a] the no-leading-answer sentence is pinned', () => {
|
||||
const hits = sentencesWith(LAUNCH(), 'no leading answer');
|
||||
assert.deepEqual(hits, ['When wave 1\'s external findings hold no leading answer, say so and give it the research question alone, stripped the same way.'],
|
||||
'the wave 2 rule no longer says what to give the contrarian when wave 1 holds no leading answer');
|
||||
});
|
||||
|
||||
test('[E17b] the adapter says where wave 2\'s contrarian findings merge', () => {
|
||||
const adapter = between('commands/trekresearch.md', '### Deep-research engine adapter (engine = deep-research only)', '### Output self-check');
|
||||
const hits = sentencesWith(adapter, '`contrarian-researcher`');
|
||||
assert.ok(hits.some((s) => /^Wave 2's `contrarian-researcher` findings\b/.test(s)
|
||||
&& /\bmerged in per dimension as \*\*External findings\*\*/.test(s)
|
||||
&& /\bthe dimension's \*\*Contradictions\*\* gives both sides\b/.test(s)),
|
||||
`the deep-research adapter does not say wave 2's contrarian findings merge per dimension as External findings, with disputes under Contradictions: ${hits.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('[Da] the README parenthesis says when the engine is active', () => {
|
||||
const text = oneLine('README.md', '`* = conditional`:', 'contrarian-researcher');
|
||||
assert.ok(text.includes('(with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report)'),
|
||||
`the README deep-research parenthesis does not say "when the engine is active": ${text}`);
|
||||
});
|
||||
|
||||
test('[E18a] the F9 deep row and summary-only row equal their text exactly', () => {
|
||||
const p4 = between('commands/trekreview.md', '## Phase 4 — Triage gate', '## Phase 4.5 — ');
|
||||
const row = (cell) => {
|
||||
const rows = p4.split('\n').filter((l) => l.startsWith(`| ${cell} |`));
|
||||
assert.equal(rows.length, 1, `Phase 4 must hold exactly one ${cell} row; found ${rows.length}`);
|
||||
return rows[0];
|
||||
};
|
||||
assert.equal(row('`deep-review`'), '| `deep-review` | Default for every changed file not in the other two rows — code, tests, `commands/`, `agents/`, `skills/` Markdown, and the instruction files `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**`. |',
|
||||
'the deep-review triage row is not the F9 text');
|
||||
assert.equal(row('`summary-only`'), '| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. |',
|
||||
'the summary-only triage row is not the F9 text');
|
||||
});
|
||||
|
||||
test('[Db] the README stars security-researcher nowhere', () => {
|
||||
const readme = read('README.md');
|
||||
assert.match(readme, /security-researcher/, 'README.md names security-researcher nowhere');
|
||||
assert.doesNotMatch(readme, /security-researcher\*/, 'a README line stars security-researcher as conditional');
|
||||
});
|
||||
|
||||
// M1: the tests the 4.5a discovered-error fix (f6bdc2d) added carry their id
|
||||
// tags, so a red script judges them; none is left under the untagged
|
||||
// `found in execution:` prefix.
|
||||
const PROSE_4_5A = 'tests/lib/fix-round-4-5a-prose.test.mjs';
|
||||
|
||||
/** The name text after each `test(` call opener in `rel`: the start of every test name, templates included. */
|
||||
function testNameStarts(rel) {
|
||||
return [...read(rel).matchAll(/\btest\(\s*(['"`])([^\n]*)/g)].map((m) => m[2]);
|
||||
}
|
||||
|
||||
test('[M1] no test name in the 4.5a prose file starts with found in execution:', () => {
|
||||
const names = testNameStarts(PROSE_4_5A);
|
||||
assert.ok(names.length > 0, `${PROSE_4_5A} holds no test name`);
|
||||
const untagged = names.filter((n) => n.startsWith('found in execution:'));
|
||||
assert.deepEqual(untagged, [], `${PROSE_4_5A} still names ${untagged.length} test(s) "found in execution:"`);
|
||||
});
|
||||
|
||||
// --- K4 / M4 / M2 / ARG: the round's CHANGELOG entry --------------------------------
|
||||
// The round's entry stands in the existing Unreleased section (no release lies
|
||||
// between 4.5a and 4.5b). The section is found by its title, by the rule
|
||||
// tests/helpers/changelog-section.mjs uses for the 6.4.0 delivery:
|
||||
// `## Unreleased — <title>` before the release, `## vX.Y.Z — <date> — <title>`
|
||||
// after it, exactly once, up to the next `## ` heading. The helper is bound to
|
||||
// the 6.4.0 title, so its rule is repeated here for this section's title.
|
||||
|
||||
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
|
||||
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
||||
|
||||
/** The lines of the round's CHANGELOG section, heading excluded. */
|
||||
function changelogSection() {
|
||||
const lines = read('CHANGELOG.md').split('\n');
|
||||
const at = [];
|
||||
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
|
||||
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
|
||||
let end = lines.length;
|
||||
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
|
||||
return lines.slice(at[0] + 1, end);
|
||||
}
|
||||
|
||||
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
|
||||
function changelogBullets(heading) {
|
||||
const lines = changelogSection();
|
||||
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
|
||||
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
|
||||
const out = [];
|
||||
for (const l of lines.slice(s + 1)) {
|
||||
if (/^### /.test(l)) break;
|
||||
if (/^- /.test(l)) out.push(l);
|
||||
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
|
||||
else if (l.trim() !== '') out.push('');
|
||||
}
|
||||
return out.filter((b) => b !== '').map(flat);
|
||||
}
|
||||
|
||||
test('[K4] the CHANGELOG names --gates=true as the shim\'s own form', () => {
|
||||
const hits = changelogBullets('### Fixed').filter((b) => b.startsWith('- **The texts next to the fixes say the same**'));
|
||||
assert.equal(hits.length, 1, `### Fixed must hold one siblings entry; found ${hits.length}`);
|
||||
assert.ok(hits[0].includes('the commands read only the two-token `--gates <value>` form'),
|
||||
`the siblings entry does not say the commands read only the two-token \`--gates <value>\` form: ${hits[0]}`);
|
||||
assert.ok(hits[0].includes('`--gates=true` is the gates shim\'s own form'),
|
||||
`the siblings entry does not name \`--gates=true\` as the gates shim's own form: ${hits[0]}`);
|
||||
assert.doesNotMatch(hits[0], /the gates shim reads `--gates=true` as on\./,
|
||||
`the siblings entry still ends on the gates shim reading \`--gates=true\` as on, which reads as a command form: ${hits[0]}`);
|
||||
});
|
||||
|
||||
test('[M4] the Unreleased entry says review-not-measured for a failed git call', () => {
|
||||
const hits = changelogBullets('### Fixed').filter((b) => b.startsWith('- **The merge lock refuses a review of an older commit**'));
|
||||
assert.equal(hits.length, 1, `### Fixed must hold one merge-lock entry; found ${hits.length}`);
|
||||
assert.doesNotMatch(hits[0], /a failed git call included/,
|
||||
`the merge-lock entry still counts a failed git call among the \`review-stale\` reasons: ${hits[0]}`);
|
||||
const said = sentencesWith(hits[0], 'failed git call');
|
||||
assert.ok(said.some((s) => /a failed git call exits 2 with a reason that starts `review-not-measured:`, not `review-stale`/.test(s)),
|
||||
`the merge-lock entry does not say a failed git call exits 2 with \`review-not-measured:\`, not \`review-stale\`: ${said.join(' | ') || hits[0]}`);
|
||||
});
|
||||
|
||||
test('[M2] the Unreleased entry records the brief-conflict exception in the flag summaries', () => {
|
||||
const hits = changelogBullets('### Fixed').filter((b) => b.includes('`--quick`') && b.includes('brief-conflict rule'));
|
||||
assert.equal(hits.length, 1, `### Fixed must hold one entry on the \`--quick\` summaries and the brief-conflict rule; found ${hits.length}`);
|
||||
assert.match(hits[0], /name the brief-conflict rule as the exception to the skipped reasonableness filter/,
|
||||
`the entry does not say the summaries name the brief-conflict rule as the exception: ${hits[0]}`);
|
||||
for (const where of ['`/trekreview`', 'the review orchestrator', '`docs/command-modes.md`', 'README']) {
|
||||
assert.ok(hits[0].includes(where), `the entry does not name ${where} among the four summaries: ${hits[0]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[ARG] the CHANGELOG names the Bash-guard block on a parser heredoc that closes early', () => {
|
||||
const hits = changelogBullets('### Fixed').filter((b) => b.includes('`args-heredoc-closed-early`'));
|
||||
assert.equal(hits.length, 1, `### Fixed must hold one entry naming \`args-heredoc-closed-early\`; found ${hits.length}`);
|
||||
assert.ok(hits[0].includes('`hooks/scripts/pre-bash-executor.mjs`'), `the entry does not name the Bash guard's file: ${hits[0]}`);
|
||||
assert.ok(hits[0].includes('`VOYAGE_ARGS_EOF_7F3C9E`'), `the entry does not name the heredoc's closing line: ${hits[0]}`);
|
||||
assert.match(hits[0], /not the last non-empty line/, `the entry does not say when the guard blocks: ${hits[0]}`);
|
||||
assert.match(hits[0], /\(R6\)/, `the entry does not carry the R6 mark: ${hits[0]}`);
|
||||
});
|
||||
|
||||
test('[ARG] the CHANGELOG says ~ and $HOME in the arguments are no longer expanded', () => {
|
||||
const hits = changelogBullets('### Changed').filter((b) => b.includes('`~`') && b.includes('`$HOME`'));
|
||||
assert.equal(hits.length, 1, `### Changed must hold one entry on \`~\` and \`$HOME\`; found ${hits.length}`);
|
||||
for (const cmd of ['`/trekendsession`', '`/trekcontinue`', '`/trekreview`']) {
|
||||
assert.ok(hits[0].includes(cmd), `the entry does not name ${cmd}: ${hits[0]}`);
|
||||
}
|
||||
assert.match(hits[0], /are no longer expanded/, `the entry does not say they are no longer expanded: ${hits[0]}`);
|
||||
assert.match(hits[0], /write the path out/, `the entry does not tell the reader to write the path out: ${hits[0]}`);
|
||||
});
|
||||
|
|
@ -6,10 +6,11 @@
|
|||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, symlinkSync, cpSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, delimiter } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const CLI = join(ROOT, 'lib/util/park-attempt-files.mjs');
|
||||
|
|
@ -67,10 +68,13 @@ const DIFF = [
|
|||
'',
|
||||
].join('\n');
|
||||
|
||||
/** The CLI run with `--cwd <cwd>` (the repo by default) on `diff` written into the repo's git dir. */
|
||||
function run(dir, diff = DIFF, cwd = dir) {
|
||||
/**
|
||||
* The CLI run with `--cwd <cwd>` (the repo by default) on `diff` written into the repo's git dir.
|
||||
* With `env`, the child gets that environment; without it, it inherits this process's.
|
||||
*/
|
||||
function run(dir, diff = DIFF, cwd = dir, env) {
|
||||
writeFileSync(join(dir, '.git', 'upward.diff'), diff);
|
||||
return spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', cwd], { encoding: 'utf8' });
|
||||
return spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', cwd], { encoding: 'utf8', ...(env ? { env } : {}) });
|
||||
}
|
||||
|
||||
// Parked outside the work tree, in the git dir, so a later `git ls-files --others` never lists them.
|
||||
|
|
@ -369,3 +373,186 @@ test('[F4a] one index entry that cannot be removed exits 2 with every entry stil
|
|||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- fix round 4-5b: M7 ----------------------------------------------------------
|
||||
// [M7] HEAD membership was one `git cat-file -e HEAD:<path>` per path, and any
|
||||
// failure of it read as "not in HEAD": a git that could not read HEAD unindexed
|
||||
// and moved the attempt's files. A path in HEAD but gone from the index was
|
||||
// moved like an untracked file. Both now exit 2 before anything changes.
|
||||
|
||||
test('[M7] a failing HEAD read exits 2 and moves nothing', async () => {
|
||||
const { pathWith } = await import('../helpers/bin-path.mjs');
|
||||
const real = pathWith(['git']);
|
||||
const fake = mkdtempSync(join(tmpdir(), 'voyage-fake-git-'));
|
||||
const dir = repo();
|
||||
try {
|
||||
const realGit = join(real.path, 'git');
|
||||
assert.ok(!realGit.includes("'"), 'control: the real git path carries no quote');
|
||||
writeFileSync(join(fake, 'git'), [
|
||||
'#!/bin/sh',
|
||||
'for a in "$@"; do',
|
||||
' if [ "$a" = ls-tree ]; then echo "fatal: ls-tree failed (fake git)" >&2; exit 128; fi',
|
||||
'done',
|
||||
`exec '${realGit}' "$@"`,
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
attemptFiles(dir);
|
||||
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh', 'tests/cli-stdin.test.mjs');
|
||||
const env = childEnv({ PATH: `${fake}${delimiter}${process.env.PATH ?? ''}` });
|
||||
const ctl = spawnSync('git', ['ls-tree', 'HEAD'], { cwd: dir, encoding: 'utf8', env });
|
||||
assert.equal(ctl.status, 128, `control: the fake git must fail ls-tree: ${ctl.stderr}`);
|
||||
assert.equal(spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8', env }).status, 0, 'control: the fake git runs every other call');
|
||||
const r = run(dir, DIFF, dir, env);
|
||||
assert.equal(r.status, 2, `a HEAD read that failed must be exit 2: ${r.stdout}${r.stderr}`);
|
||||
assert.match(r.stderr, /ls-tree/, 'the message does not name the git call that failed');
|
||||
const listed = git(dir, 'ls-files').split('\n');
|
||||
assert.ok(listed.includes('tests/batch.sh'), 'the intent-to-add entry was taken out of the index although HEAD could not be read');
|
||||
assert.ok(listed.includes('tests/cli-stdin.test.mjs'), 'the second intent-to-add entry was taken out of the index');
|
||||
assert.equal(readFileSync(join(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the file was moved although HEAD could not be read');
|
||||
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), true, 'the second file was moved');
|
||||
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false, 'a file was parked although HEAD could not be read');
|
||||
} finally {
|
||||
real.cleanup();
|
||||
rmSync(fake, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[M7] a HEAD path missing from the index exits 2 and moves nothing', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
attemptFiles(dir);
|
||||
git(dir, 'add', '--', 'tests/batch.sh');
|
||||
git(dir, 'commit', '-q', '-m', 'operator');
|
||||
git(dir, 'rm', '--cached', '-q', '--', 'tests/batch.sh');
|
||||
assert.doesNotMatch(git(dir, 'ls-files'), /tests\/batch\.sh/, 'control: the committed file is out of the index');
|
||||
assert.match(git(dir, 'ls-tree', '-r', '--name-only', 'HEAD'), /tests\/batch\.sh/, 'control: the file is in HEAD');
|
||||
const r = run(dir);
|
||||
assert.equal(r.status, 2, `a HEAD path missing from the index must be exit 2: ${r.stdout}${r.stderr}`);
|
||||
assert.match(r.stderr, /tests\/batch\.sh/, 'the message does not name the path');
|
||||
assert.equal(readFileSync(join(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the committed file was moved');
|
||||
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), true, 'the untracked file was moved although the run could not finish');
|
||||
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false, 'the committed file was parked');
|
||||
assert.equal(existsSync(parkedPath(dir, 'tests/cli-stdin.test.mjs')), false, 'a file was parked although the run could not finish');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[M7] a corrupt branch ref is a failed HEAD read: exit 2, nothing unindexed or moved', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
attemptFiles(dir);
|
||||
git(dir, 'add', '--', 'tests/batch.sh');
|
||||
git(dir, 'commit', '-q', '-m', 'operator');
|
||||
const ref = git(dir, 'symbolic-ref', '-q', 'HEAD').trim();
|
||||
writeFileSync(join(dir, '.git', ref), 'garbage\n');
|
||||
const ctl = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: dir, encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(ctl.status, 1, `control: the corrupt ref reads like an unborn HEAD (exit 1): ${ctl.stderr}`);
|
||||
assert.equal(ctl.stdout.trim(), '', 'control: and prints nothing');
|
||||
const r = run(dir, DIFF, dir, childEnv());
|
||||
assert.equal(r.status, 2, `a corrupt branch ref must be exit 2: ${r.stdout}${r.stderr}`);
|
||||
const listed = git(dir, 'ls-files').split('\n');
|
||||
assert.ok(listed.includes('tests/batch.sh'), 'the committed file was taken out of the index although HEAD could not be read');
|
||||
assert.equal(readFileSync(join(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the committed file was moved');
|
||||
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), true, 'the untracked file was moved although HEAD could not be read');
|
||||
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false, 'the committed file was parked');
|
||||
assert.equal(existsSync(parkedPath(dir, 'tests/cli-stdin.test.mjs')), false, 'a file was parked although HEAD could not be read');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[M7r] an unborn HEAD parks the attempt\'s files', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'voyage-park-unborn-'));
|
||||
try {
|
||||
git(dir, 'init', '-q');
|
||||
attemptFiles(dir);
|
||||
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh');
|
||||
assert.match(git(dir, 'ls-files'), /tests\/batch\.sh/, 'control: the intent-to-add file is in the index');
|
||||
const ctl = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: dir, encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(ctl.status, 1, 'control: HEAD is unborn');
|
||||
const r = run(dir, DIFF, dir, childEnv());
|
||||
assert.equal(r.status, 0, `an unborn HEAD must park: ${r.stdout}${r.stderr}`);
|
||||
assert.deepEqual(JSON.parse(r.stdout).parked, ['tests/batch.sh', 'tests/cli-stdin.test.mjs']);
|
||||
assert.doesNotMatch(git(dir, 'ls-files'), /tests\/batch\.sh/, 'the intent-to-add entry is still in the index');
|
||||
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'the intent-to-add file is still in the work tree');
|
||||
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), false, 'the untracked file is still in the work tree');
|
||||
assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the file is not under the parked directory');
|
||||
assert.equal(readFileSync(parkedPath(dir, 'tests/cli-stdin.test.mjs'), 'utf8'), 'stale test\n');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- fix round 4-5b: E22c --------------------------------------------------------
|
||||
// [E22c] The diff was split on '\n' only: in a CRLF diff every line kept its '\r',
|
||||
// so no `new file mode` line matched and nothing was parked, with exit 0.
|
||||
|
||||
test('[E22c] a CRLF diff parks its new files', async () => {
|
||||
const mod = await import('../../lib/util/park-attempt-files.mjs');
|
||||
assert.equal(typeof mod.newFilesInDiff, 'function', 'newFilesInDiff is not exported');
|
||||
const crlf = DIFF.split('\n').join('\r\n');
|
||||
assert.ok(crlf.includes('new file mode 100755\r\n'), 'control: the diff has CRLF line ends');
|
||||
assert.deepEqual(mod.newFilesInDiff(crlf).paths, ['tests/batch.sh', 'tests/cli-stdin.test.mjs'],
|
||||
'a CRLF diff did not yield its new files');
|
||||
const dir = repo();
|
||||
try {
|
||||
attemptFiles(dir);
|
||||
const r = run(dir, crlf);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.deepEqual(JSON.parse(r.stdout).parked, ['tests/batch.sh', 'tests/cli-stdin.test.mjs'], `a CRLF diff parked nothing: ${r.stdout}`);
|
||||
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'the file is still in the tree');
|
||||
assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the file is not under the parked directory');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- fix round 4-5b: M3 ----------------------------------------------------------
|
||||
// [M3] The "Back to planning" save line ran `git diff` as the user's config had
|
||||
// it: with diff.relative on and the session in a subdirectory, the saved diff
|
||||
// held only that subdirectory's files, with paths relative to it, so the park
|
||||
// tool missed every new file outside it.
|
||||
|
||||
/** The command of the one "Back to planning" save line in commands/trekexecute.md, as written. */
|
||||
function saveLine() {
|
||||
const md = readFileSync(join(ROOT, 'commands/trekexecute.md'), 'utf8');
|
||||
const lines = md.split('\n').filter((l) => l.startsWith('**Back to planning** (`fix_rounds = 4`): save `'));
|
||||
assert.equal(lines.length, 1, `expected one "Back to planning" save line; found ${lines.length}`);
|
||||
const m = lines[0].match(/save `([^`]+)`/);
|
||||
assert.ok(m, `no backticked command on the save line: ${lines[0]}`);
|
||||
return m[1];
|
||||
}
|
||||
|
||||
test('[M3] the save line, read from the execute command as written, parks every file from a subdirectory with diff.relative on', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
git(dir, 'config', 'diff.relative', 'true');
|
||||
mkdirSync(join(dir, 'sub'));
|
||||
writeFileSync(join(dir, 'sub/keep.txt'), 'kept\n');
|
||||
git(dir, 'add', 'sub/keep.txt');
|
||||
git(dir, 'commit', '-q', '-m', 'sub');
|
||||
const start = git(dir, 'rev-parse', 'HEAD').trim();
|
||||
attemptFiles(dir);
|
||||
writeFileSync(join(dir, 'sub/new-in-sub.txt'), 'stale sub\n');
|
||||
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh', 'tests/cli-stdin.test.mjs', 'sub/new-in-sub.txt');
|
||||
const values = { start_sha: start };
|
||||
const cmd = saveLine().replace(/\{([a-z_ ]+)\}/g, (all, key) => (key in values ? values[key] : all));
|
||||
assert.doesNotMatch(cmd, /\{[^}]*\}/, `the save line holds a placeholder this test does not fill: ${cmd}`);
|
||||
const out = join(dir, '.git', 'upward.diff');
|
||||
const sub = join(dir, 'sub');
|
||||
const saved = spawnSync('bash', ['-c', `${cmd} > "$1"`, 'save', out], { cwd: sub, encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(saved.status, 0, `the save line failed from sub/: ${saved.stderr}`);
|
||||
const r = spawnSync(process.execPath, [CLI, '--diff', out, '--step', '1', '--attempt', '1', '--cwd', sub], { encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.deepEqual(JSON.parse(r.stdout).parked, ['sub/new-in-sub.txt', 'tests/batch.sh', 'tests/cli-stdin.test.mjs'],
|
||||
`the diff saved from sub/ with diff.relative on did not park every new file: ${r.stdout}`);
|
||||
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'a new file outside sub/ is still in the tree');
|
||||
assert.equal(existsSync(join(dir, 'sub/new-in-sub.txt')), false, 'a new file inside sub/ is still in the tree');
|
||||
assert.equal(readFileSync(parkedPath(dir, 'sub/new-in-sub.txt'), 'utf8'), 'stale sub\n', 'the file is not under the parked directory');
|
||||
assert.equal(readFileSync(join(dir, 'sub/keep.txt'), 'utf8'), 'kept\n', 'a committed file is never touched');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,13 +149,17 @@ test('plan-halts-without-phase-signals: the validator\'s own code must be in the
|
|||
// ---- case 5: the missing-flag error comes from code ----
|
||||
|
||||
test('review-requires-project: the arg parser, run as trekreview.md spells it, prints the error (exit 1)', () => {
|
||||
const lines = read('commands/trekreview.md').split('\n').filter((l) =>
|
||||
/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l));
|
||||
assert.equal(lines.length, 1, 'trekreview.md must carry exactly one arg-parser line');
|
||||
const line = lines[0].trim();
|
||||
assert.match(line, /\$ARGUMENTS/, 'the parser must receive $ARGUMENTS (the Bash tool has no "$@")');
|
||||
const all = read('commands/trekreview.md').split('\n');
|
||||
const at = all.map((l, i) =>
|
||||
(/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l) ? i : -1)).filter((i) => i >= 0);
|
||||
assert.equal(at.length, 1, 'trekreview.md must carry exactly one arg-parser line');
|
||||
const line = all[at[0]].trim();
|
||||
assert.match(line, / --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'$/, 'the parser must read the arguments on stdin from the quoted heredoc');
|
||||
const block = all.slice(at[0], at[0] + 3).map((l) => l.trim());
|
||||
assert.deepEqual(block.slice(1), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
||||
'the parser must receive $ARGUMENTS as the heredoc body (the Bash tool has no "$@")');
|
||||
const run = (args) => spawnSync('bash', ['-c',
|
||||
line.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(args)], { encoding: 'utf8' });
|
||||
block.join('\n').split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(args)], { encoding: 'utf8' });
|
||||
const none = run('');
|
||||
assert.equal(none.status, 1, `missing --project must exit 1: ${none.stdout} ${none.stderr}`);
|
||||
assert.deepEqual(JSON.parse(none.stdout).errors.map((e) => e.code), ['ARG_REQUIRED_MISSING']);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@
|
|||
// broken: not-assertion [id] {name} (...) it failed, but not on an assertion
|
||||
// broken: untagged-failure {name} (...) a failing event with no tag
|
||||
// broken: unlisted-tag [tag] {name} a tag that is not in the list
|
||||
// broken: unreadable-tag {name} a name opens with [ but holds no tag
|
||||
// broken: mutation-not-red [id] --mutation: no assertion failure
|
||||
// A tag on --other-ids (another round's id) is never judged; an id on both
|
||||
// lists is a usage error (exit 2).
|
||||
// All red: exit 0 and "red: [id] {n} test(s)" per id.
|
||||
|
||||
import { test } from 'node:test';
|
||||
|
|
@ -164,6 +167,79 @@ test('red judge: --mutation X1 with one [X1] assertion failure and one [X1] Type
|
|||
assert.match(r.out, /^broken: not-assertion \[X1\] \[X1\] crash \(ERR_TEST_FAILURE TypeError\)$/m, r.out);
|
||||
});
|
||||
|
||||
/** Run the judge with an --other-ids list written next to the id list, and planted events. */
|
||||
function judgeOther(ids, otherIds, events, extra = []) {
|
||||
return inTmp((dir) => {
|
||||
const results = join(dir, 'results.jsonl');
|
||||
writeFileSync(results, events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : ''));
|
||||
writeFileSync(join(dir, 'other-ids'), otherIds.join('\n') + '\n');
|
||||
return judgeFile(dir, ids, results, ['--other-ids', join(dir, 'other-ids'), ...extra]);
|
||||
});
|
||||
}
|
||||
|
||||
/** A string as a literal inside a RegExp. */
|
||||
const lit = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
test('[RS] judge: a tag on --other-ids is not judged whatever its result', () => {
|
||||
const other = [
|
||||
passing('[X2] passes'),
|
||||
ev('[X2] red'),
|
||||
ev('[X2] crash', { code: 'ERR_MODULE_NOT_FOUND', cname: 'Error' }),
|
||||
ev('[X2] skipped', { ok: true, skip: true, code: null, cname: null, failureType: null }),
|
||||
ev('[X2] someday', { todo: true }),
|
||||
];
|
||||
const r = judgeOther(['X1'], ['X2'], [ev('[X1] red'), ...other]);
|
||||
assert.equal(r.status, 0, r.out);
|
||||
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
|
||||
assert.doesNotMatch(r.out, /^broken:/m, r.out);
|
||||
assert.doesNotMatch(r.out, /X2/, 'an other-round tag must not be named');
|
||||
const m = judgeOther(['X1'], ['X2'], [ev('[X1] red'), ...other], ['--mutation', 'X1']);
|
||||
assert.equal(m.status, 0, m.out);
|
||||
assert.match(m.out, /^red: \[X1\] 1 test\(s\)$/m, m.out);
|
||||
assert.doesNotMatch(m.out, /^broken:/m, m.out);
|
||||
});
|
||||
|
||||
test('[RS] judge: an id on both lists is a usage error', () => {
|
||||
const r = judgeOther(['X1', 'X2'], ['X2'], [ev('[X1] red'), ev('[X2] red')]);
|
||||
assert.equal(r.status, 2, r.out);
|
||||
assert.match(r.out, /id X2 is on both --ids and --other-ids/, r.out);
|
||||
assert.doesNotMatch(r.out, /^red:/m, r.out);
|
||||
});
|
||||
|
||||
test('[E23a] judge: a name that opens with [ but has no readable tag is broken', () => {
|
||||
for (const name of ['[X 1] typo', '[] empty', '[#5 2] spaced']) {
|
||||
const p = judge(['X1'], [ev('[X1] red'), passing(name)]);
|
||||
assert.equal(p.status, 1, `${name} passing: ${p.out}`);
|
||||
assert.match(p.out, new RegExp(`^broken: unreadable-tag ${lit(name)}$`, 'm'), p.out);
|
||||
const f = judge(['X1'], [ev('[X1] red'), ev(name)]);
|
||||
assert.equal(f.status, 1, `${name} failing: ${f.out}`);
|
||||
assert.match(f.out, new RegExp(`^broken: unreadable-tag ${lit(name)}$`, 'm'), f.out);
|
||||
}
|
||||
});
|
||||
|
||||
test('[E23d] judge: a missing --ids file exits 2', () => {
|
||||
inTmp((dir) => {
|
||||
const results = join(dir, 'results.jsonl');
|
||||
writeFileSync(results, `${JSON.stringify(ev('[X1] red'))}\n`);
|
||||
const r = spawnSync(process.execPath, [JUDGE, '--ids', join(dir, 'no-such-ids'), '--results', results],
|
||||
{ encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(r.status, 2, `${r.stdout}${r.stderr}`);
|
||||
assert.match(r.stderr, /cannot read the ids file/, r.stderr);
|
||||
assert.doesNotMatch(r.stdout, /^red:/m, r.stdout);
|
||||
});
|
||||
});
|
||||
|
||||
test('[E23d] judge: a non-JSON results line exits 2', () => {
|
||||
inTmp((dir) => {
|
||||
const results = join(dir, 'results.jsonl');
|
||||
writeFileSync(results, `${JSON.stringify(ev('[X1] red'))}\nnot json\n`);
|
||||
const r = judgeFile(dir, ['X1'], results);
|
||||
assert.equal(r.status, 2, r.out);
|
||||
assert.match(r.out, /line 2 of .* is not JSON/, r.out);
|
||||
assert.doesNotMatch(r.out, /^red:/m, r.out);
|
||||
});
|
||||
});
|
||||
|
||||
/** Write fixture test files into `dir`, run them under the red reporter, return the JSONL path. */
|
||||
function report(dir, files) {
|
||||
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body);
|
||||
|
|
@ -206,7 +282,7 @@ test('red reporter + judge: an [X1] test that fails on assert.equal(1, 2) -> exi
|
|||
});
|
||||
});
|
||||
|
||||
test('red reporter + judge: a { skip: true } and a { todo: true } [X1] test carry skip and todo in their lines -> exit 1', () => {
|
||||
test('[E23f] red reporter + judge: a { skip: true } and a { todo: true } [X1] test carry skip and todo in their lines -> exit 1', () => {
|
||||
inTmp((dir) => {
|
||||
const out = report(dir, {
|
||||
'x1.test.mjs': `${HEAD}test('[X1] red', () => { assert.equal(1, 2); });\n`
|
||||
|
|
@ -219,6 +295,8 @@ test('red reporter + judge: a { skip: true } and a { todo: true } [X1] test carr
|
|||
assert.equal(byName('[X1] todo').length, 1, readFileSync(out, 'utf8'));
|
||||
assert.deepEqual([byName('[X1] skipped')[0].skip, byName('[X1] skipped')[0].todo], [true, false]);
|
||||
assert.deepEqual([byName('[X1] todo')[0].skip, byName('[X1] todo')[0].todo], [false, true]);
|
||||
const hits = byName('[X1] red');
|
||||
assert.ok(hits.length > 0, `the reporter wrote no [X1] red line: ${readFileSync(out, 'utf8')}`);
|
||||
assert.deepEqual([byName('[X1] red')[0].skip, byName('[X1] red')[0].todo], [false, false]);
|
||||
const r = judgeFile(dir, ['X1'], out);
|
||||
assert.equal(r.status, 1, r.out);
|
||||
|
|
|
|||
512
tests/lib/red-script.test.mjs
Normal file
512
tests/lib/red-script.test.mjs
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
// tests/lib/red-script.test.mjs
|
||||
// The shared red script (tests/helpers/red-round.sh) and its mutate helper
|
||||
// (tests/helpers/red-mutate.mjs). A round's wrapper hands the script a base
|
||||
// commit, the round's ids, the other rounds' ids, a mutation module and test
|
||||
// files; the script clones the base outside the repo, replaces the clone's
|
||||
// tests/ with the working tree's, runs every tagged test once
|
||||
// (--test-name-pattern=^\[), lets tests/helpers/red-judge.mjs decide, then
|
||||
// applies each named mutation, runs only that id's tests, judges and restores.
|
||||
// A script that always says "ok" would prove nothing, so it is run here on a
|
||||
// small fixture repo whose answer is known:
|
||||
// - lib/one.mjs returns 0 at BASE and 1 at HEAD: [X1] is red at BASE only;
|
||||
// - lib/two.mjs is right at both: [X2] passes at BASE and is red only under
|
||||
// the fixture's mutations (so it is a pass-id);
|
||||
// - tests/other.test.mjs holds a passing [Z9] (another round's id);
|
||||
// - tests/stray.test.mjs holds a mistyped [X 1] and an unlisted [Q9].
|
||||
// The fixture repo is seeded once in os.tmpdir() with the script and helpers
|
||||
// copied from this file's own ROOT/tests/helpers, and copied per test.
|
||||
//
|
||||
// Every test first asserts that the script exists, so each fails on an
|
||||
// assertion while it does not. The script and its runs need Node >= 20.14
|
||||
// (--test-force-exit); on an older Node each test is skipped.
|
||||
//
|
||||
// The two round wrappers (tests/fix-round-4-5a-red.sh, tests/fix-round-4-5b-red.sh)
|
||||
// are run from a scratch copy whose tests/helpers/red-round.sh is a stub that
|
||||
// prints its arguments, so what each wrapper hands the shared script is seen
|
||||
// without a clone. Their mutation modules are loaded with a dynamic import.
|
||||
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import { pathWith } from '../helpers/bin-path.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const HELPERS = join(ROOT, 'tests', 'helpers');
|
||||
const SCRIPT = join(HELPERS, 'red-round.sh');
|
||||
const INSTRUMENTS = ['red-round.sh', 'red-mutate.mjs', 'red-judge.mjs', 'red-reporter.mjs', 'no-session-id.mjs'];
|
||||
const CHILD_TIMEOUT = 120000;
|
||||
|
||||
const [NODE_MAJOR, NODE_MINOR] = process.versions.node.split('.').map(Number);
|
||||
const nodeTooOld = NODE_MAJOR < 20 || (NODE_MAJOR === 20 && NODE_MINOR < 14);
|
||||
|
||||
const cleanup = [];
|
||||
after(() => {
|
||||
for (const d of cleanup) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A new real-path temp directory, removed in after(). */
|
||||
function tmp(prefix) {
|
||||
const d = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
||||
cleanup.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The environment of a git call or a script run: no inherited git or test-runner context. */
|
||||
const env = (over = {}) => childEnv({
|
||||
GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined, NODE_TEST_CONTEXT: undefined, ...over,
|
||||
});
|
||||
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: env() });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
/** Every test starts here: the script and its mutate helper exist. */
|
||||
function requireScript() {
|
||||
assert.ok(existsSync(SCRIPT), `the shared red script ${SCRIPT} does not exist`);
|
||||
assert.ok(existsSync(join(HELPERS, 'red-mutate.mjs')), 'tests/helpers/red-mutate.mjs does not exist');
|
||||
}
|
||||
|
||||
const X_TEST = `import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { one } from '../lib/one.mjs';
|
||||
import { two } from '../lib/two.mjs';
|
||||
if (process.env.RS_SEEN_FILE) appendFileSync(process.env.RS_SEEN_FILE, fileURLToPath(import.meta.url) + '\\n');
|
||||
test('[X1] one is 1', () => { assert.equal(one(), 1); });
|
||||
test('[X2] two is 2', () => { assert.equal(two(), 2); });
|
||||
test('an untagged test', () => { assert.ok(true); });
|
||||
`;
|
||||
const OTHER_TEST = `import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
test('[Z9] another round test that passes', () => { assert.ok(true); });
|
||||
`;
|
||||
const STRAY_TEST = `import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
test('[X 1] a mistyped tag', () => { assert.equal(1, 2); });
|
||||
test('[Q9] an unlisted tag', () => { assert.equal(1, 2); });
|
||||
`;
|
||||
const TWO = '// two: the second value\nexport function two() { return 2; } // two\n';
|
||||
const MUTATIONS = `export const MUTATIONS = [
|
||||
{ key: 'X2-A', id: 'X2', what: 'two returns 3', spans: [{ file: 'lib/two.mjs', find: 'return 2;', replace: 'return 3;' }] },
|
||||
{ key: 'X2-B', id: 'X2', what: 'two returns 4 (finds return 2 only after X2-A was restored)', spans: [{ file: 'lib/two.mjs', find: /return 2;/g, replace: 'return 4;' }] },
|
||||
];
|
||||
`;
|
||||
|
||||
let seeded = null;
|
||||
|
||||
/** The fixture repo, seeded once: BASE (one() wrong), HEAD (one() right), helpers in the working tree. */
|
||||
function seed() {
|
||||
if (seeded) return seeded;
|
||||
const dir = join(tmp('red-script-seed-'), 'repo');
|
||||
mkdirSync(join(dir, 'lib'), { recursive: true });
|
||||
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
|
||||
git(dir, 'init', '-q');
|
||||
git(dir, 'config', 'user.name', 'Test');
|
||||
git(dir, 'config', 'user.email', 'test@example.invalid');
|
||||
git(dir, 'config', 'commit.gpgsign', 'false');
|
||||
git(dir, 'config', 'core.hooksPath', '');
|
||||
writeFileSync(join(dir, 'lib', 'one.mjs'), 'export function one() { return 0; }\n');
|
||||
writeFileSync(join(dir, 'lib', 'two.mjs'), TWO);
|
||||
writeFileSync(join(dir, 'tests', 'x.test.mjs'), X_TEST);
|
||||
writeFileSync(join(dir, 'tests', 'other.test.mjs'), OTHER_TEST);
|
||||
writeFileSync(join(dir, 'tests', 'stray.test.mjs'), STRAY_TEST);
|
||||
git(dir, 'add', 'lib', 'tests');
|
||||
git(dir, 'commit', '-q', '-m', 'base: one() is wrong');
|
||||
const base = git(dir, 'rev-parse', 'HEAD');
|
||||
writeFileSync(join(dir, 'lib', 'one.mjs'), 'export function one() { return 1; }\n');
|
||||
git(dir, 'add', 'lib/one.mjs');
|
||||
git(dir, 'commit', '-q', '-m', 'head: one() is right');
|
||||
for (const name of INSTRUMENTS) {
|
||||
if (existsSync(join(HELPERS, name))) cpSync(join(HELPERS, name), join(dir, 'tests', 'helpers', name));
|
||||
}
|
||||
writeFileSync(join(dir, 'mutations.mjs'), MUTATIONS);
|
||||
seeded = { dir, base };
|
||||
return seeded;
|
||||
}
|
||||
|
||||
/** A fresh copy of the seeded fixture repo. */
|
||||
function fixture() {
|
||||
const s = seed();
|
||||
const dir = join(tmp('red-script-fx-'), 'repo');
|
||||
cpSync(s.dir, dir, { recursive: true });
|
||||
return { dir, base: s.base, script: join(dir, 'tests', 'helpers', 'red-round.sh'), mutate: join(dir, 'tests', 'helpers', 'red-mutate.mjs') };
|
||||
}
|
||||
|
||||
/** The script on the fixture, from outside it. */
|
||||
function run(fx, args, over = {}) {
|
||||
const r = spawnSync('/bin/bash', [fx.script, ...args],
|
||||
{ cwd: tmpdir(), encoding: 'utf8', timeout: CHILD_TIMEOUT, env: env(over) });
|
||||
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr };
|
||||
}
|
||||
|
||||
/** The usual arguments: BASE, ids X1 X2, other id Z9, the fixture's mutations, then the files. */
|
||||
function args(fx, files, { ids = 'X1 X2', other = 'Z9', mutations = join(fx.dir, 'mutations.mjs') } = {}) {
|
||||
return ['--base', fx.base, '--ids', ids, '--other-ids', other, '--mutations', mutations, ...files];
|
||||
}
|
||||
|
||||
/** The mutate helper on the fixture. */
|
||||
function mutate(fx, ...rest) {
|
||||
const r = spawnSync(process.execPath, [fx.mutate, ...rest],
|
||||
{ encoding: 'utf8', timeout: CHILD_TIMEOUT, env: env() });
|
||||
return { status: r.status, out: `${r.stdout}${r.stderr}` };
|
||||
}
|
||||
|
||||
/** Every instrument path on the script's node lines: --import, --test-reporter= and the script right after node. */
|
||||
function instrumentScan(text) {
|
||||
const found = [];
|
||||
for (const line of text.replace(/\\\n/g, ' ').split('\n')) {
|
||||
if (/^\s*#/.test(line) || !/(^|[\s;(&|])node\s/.test(line)) continue;
|
||||
for (const m of line.matchAll(/(?:--import[=\s]+|--test-reporter=|\bnode\s+)("?)([^\s"']+\.mjs)\1/g)) found.push(m[2]);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
const notFromWorkingTree = (paths) => paths.filter((p) => !p.startsWith('$ROOT/tests/helpers/'));
|
||||
|
||||
/** The script lines that call run_tagged (not its definition). */
|
||||
const runLines = (text) => text.split('\n').filter((l) => /^\s*run_tagged\s/.test(l));
|
||||
|
||||
test('[RS] a test red at BASE and green at HEAD exits 0', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const fx = fixture();
|
||||
const r = run(fx, args(fx, ['tests/x.test.mjs']));
|
||||
assert.equal(r.status, 0, r.out);
|
||||
assert.match(r.out, /^red script: ok/m, r.out);
|
||||
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
|
||||
assert.match(r.out, /^pass: \[X2\] 1 test\(s\) \(--pass-ids\)$/m, r.out);
|
||||
assert.equal((r.out.match(/^red: \[X2\] 1 test\(s\)$/gm) || []).length, 2, `both mutations X2-A and X2-B ran red (X2-B finds its span only after X2-A was restored)\n${r.out}`);
|
||||
assert.doesNotMatch(r.out, /^broken:/m, r.out);
|
||||
});
|
||||
|
||||
test('[RS] a test tagged with an other-round id is not judged', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const fx = fixture();
|
||||
const files = ['tests/x.test.mjs', 'tests/other.test.mjs'];
|
||||
const r = run(fx, args(fx, files, { other: 'Z9' }));
|
||||
assert.equal(r.status, 0, r.out);
|
||||
assert.doesNotMatch(r.out, /\[Z9\]/, r.out);
|
||||
const control = run(fx, args(fx, files, { other: 'Z8' }));
|
||||
assert.equal(control.status, 1, `control: with Z9 not on --other-ids the run must fail\n${control.out}`);
|
||||
assert.match(control.out, /^broken: unlisted-tag \[Z9\]/m, control.out);
|
||||
});
|
||||
|
||||
test('[RS] a mutation that matches no span exits 1 and writes nothing', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const fx = fixture();
|
||||
const mod = join(fx.dir, 'nomatch.mjs');
|
||||
writeFileSync(mod, `export const MUTATIONS = [
|
||||
{ key: 'X2-none', id: 'X2', what: 'a span that is not there', spans: [{ file: 'lib/two.mjs', find: 'return 99;', replace: 'return 3;' }] },
|
||||
{ key: 'X2-half', id: 'X2', what: 'one span matches, one does not', spans: [{ file: 'lib/one.mjs', find: 'return 1;', replace: 'return 5;' }, { file: 'lib/two.mjs', find: 'return 99;', replace: 'return 3;' }] },
|
||||
];
|
||||
`);
|
||||
const r = run(fx, args(fx, ['tests/x.test.mjs'], { mutations: mod }));
|
||||
assert.equal(r.status, 1, r.out);
|
||||
assert.match(r.out, /^broken: mutation-not-applied \[X2\] X2-none$/m, r.out);
|
||||
assert.doesNotMatch(r.out, /^red script: ok/m, r.out);
|
||||
|
||||
const oneBefore = readFileSync(join(fx.dir, 'lib', 'one.mjs'), 'utf8');
|
||||
const twoBefore = readFileSync(join(fx.dir, 'lib', 'two.mjs'), 'utf8');
|
||||
const backup = join(tmp('red-script-bk-'), 'backup');
|
||||
const m = mutate(fx, 'apply', mod, 'X2-half', fx.dir, backup);
|
||||
assert.equal(m.status, 1, m.out);
|
||||
assert.equal(readFileSync(join(fx.dir, 'lib', 'one.mjs'), 'utf8'), oneBefore, 'the span that matched was written although another did not match');
|
||||
assert.equal(readFileSync(join(fx.dir, 'lib', 'two.mjs'), 'utf8'), twoBefore);
|
||||
assert.equal(existsSync(backup), false, 'a mutation that was not applied wrote a backup');
|
||||
});
|
||||
|
||||
test('[RS] a span that matches twice exits 1 and writes nothing', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const fx = fixture();
|
||||
const mod = join(fx.dir, 'twice.mjs');
|
||||
writeFileSync(mod, `export const MUTATIONS = [
|
||||
{ key: 'X2-twice', id: 'X2', what: 'a comment that is there twice', spans: [{ file: 'lib/two.mjs', find: '// two', replace: '' }] },
|
||||
{ key: 'X2-twice-re', id: 'X2', what: 'one span once, one twice', spans: [{ file: 'lib/one.mjs', find: 'return 1;', replace: 'return 5;' }, { file: 'lib/two.mjs', find: /\\/\\/ two/g, replace: '' }] },
|
||||
];
|
||||
`);
|
||||
const r = run(fx, args(fx, ['tests/x.test.mjs'], { mutations: mod }));
|
||||
assert.equal(r.status, 1, r.out);
|
||||
assert.match(r.out, /^broken: mutation-not-applied \[X2\] X2-twice$/m, r.out);
|
||||
assert.match(r.out, /^broken: mutation-not-applied \[X2\] X2-twice-re$/m, r.out);
|
||||
|
||||
const oneBefore = readFileSync(join(fx.dir, 'lib', 'one.mjs'), 'utf8');
|
||||
const backup = join(tmp('red-script-bk-'), 'backup');
|
||||
const m = mutate(fx, 'apply', mod, 'X2-twice-re', fx.dir, backup);
|
||||
assert.equal(m.status, 1, m.out);
|
||||
assert.equal(readFileSync(join(fx.dir, 'lib', 'one.mjs'), 'utf8'), oneBefore, 'the span that matched once was written although another matched twice');
|
||||
assert.equal(readFileSync(join(fx.dir, 'lib', 'two.mjs'), 'utf8'), TWO);
|
||||
assert.equal(existsSync(backup), false, 'a mutation that was not applied wrote a backup');
|
||||
});
|
||||
|
||||
test('[RS] a base that is no commit exits 2', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const fx = fixture();
|
||||
const r = run(fx, ['--base', 'f'.repeat(40), '--ids', 'X1 X2', '--other-ids', 'Z9', '--mutations', join(fx.dir, 'mutations.mjs'), 'tests/x.test.mjs']);
|
||||
assert.equal(r.status, 2, r.out);
|
||||
assert.match(r.stderr, /^red script: setup failure: /m, r.out);
|
||||
assert.doesNotMatch(r.out, /^red script: ok/m, r.out);
|
||||
});
|
||||
|
||||
test('[RS] the clone is removed after the run', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const fx = fixture();
|
||||
const td = tmp('red-script-tmpdir-');
|
||||
const seen = join(tmp('red-script-seen-'), 'seen');
|
||||
const r = run(fx, args(fx, ['tests/x.test.mjs']), { TMPDIR: td, RS_SEEN_FILE: seen });
|
||||
assert.equal(r.status, 0, r.out);
|
||||
const paths = existsSync(seen) ? readFileSync(seen, 'utf8').split('\n').filter(Boolean) : [];
|
||||
assert.ok(paths.length > 0, 'the test file never ran, so the clone was never seen');
|
||||
for (const p of paths) {
|
||||
assert.ok(p.startsWith(`${td}/`), `the test ran at ${p}, not in a clone under TMPDIR ${td}`);
|
||||
assert.equal(existsSync(p), false, `${p} is still there after the run`);
|
||||
}
|
||||
assert.deepEqual(readdirSync(td), [], 'the temp directory still holds files after the run');
|
||||
});
|
||||
|
||||
test('[RS] the instruments run from the working tree by absolute path', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const text = readFileSync(SCRIPT, 'utf8');
|
||||
const paths = instrumentScan(text);
|
||||
for (const name of ['no-session-id.mjs', 'red-reporter.mjs', 'red-judge.mjs', 'red-mutate.mjs']) {
|
||||
assert.ok(paths.some((p) => p.endsWith(`/${name}`)), `no node line runs ${name}; found: ${paths.join(', ')}`);
|
||||
}
|
||||
assert.deepEqual(notFromWorkingTree(paths), [], 'an instrument is not run as $ROOT/tests/helpers/...');
|
||||
});
|
||||
|
||||
test('[RS] the instrument scan reports a planted ./tests/helpers/ line', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const text = readFileSync(SCRIPT, 'utf8');
|
||||
const planted = text.replace(/--test-reporter=("?)\$ROOT\/tests\/helpers\/red-reporter\.mjs\1/, '--test-reporter=./tests/helpers/red-reporter.mjs');
|
||||
assert.notEqual(planted, text, 'control: the reporter path to plant over was not found');
|
||||
assert.deepEqual(notFromWorkingTree(instrumentScan(planted)), ['./tests/helpers/red-reporter.mjs'],
|
||||
'control: the scan does not report a planted relative reporter path');
|
||||
});
|
||||
|
||||
test('[RS] --require-sha256 with a git that cannot create a SHA-256 repository exits 2 with a setup-failure line', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const fx = fixture();
|
||||
const real = pathWith(['git']);
|
||||
cleanup.push(real.path);
|
||||
const bin = tmp('red-script-fakegit-');
|
||||
const fake = join(bin, 'git');
|
||||
writeFileSync(fake, `#!/bin/sh
|
||||
case " $* " in
|
||||
*" init "*"--object-format=sha256"*) echo "fake git: no SHA-256 here" >&2; exit 128 ;;
|
||||
esac
|
||||
exec '${join(real.path, 'git')}' "$@"
|
||||
`, { mode: 0o755 });
|
||||
const PATH = `${bin}${delimiter}${process.env.PATH}`;
|
||||
const probe = spawnSync(fake, ['init', '--quiet', '--object-format=sha256', join(bin, 'probe')], { encoding: 'utf8', env: env({ PATH }) });
|
||||
assert.notEqual(probe.status, 0, 'control: the fake git created a SHA-256 repository');
|
||||
assert.equal(spawnSync(fake, ['--version'], { encoding: 'utf8', env: env({ PATH }) }).status, 0, 'control: the fake git does not run the real one');
|
||||
|
||||
const r = run(fx, [...args(fx, ['tests/x.test.mjs']).slice(0, 8), '--require-sha256', 'tests/x.test.mjs'], { PATH });
|
||||
assert.equal(r.status, 2, r.out);
|
||||
assert.match(r.stderr, /^red script: setup failure: .*SHA-256/m, r.out);
|
||||
assert.doesNotMatch(r.out, /^red script: ok/m, r.out);
|
||||
});
|
||||
|
||||
test('[E23a] a mistyped [X 1] and an unlisted [Q9] are reported and the run exits 1', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const fx = fixture();
|
||||
const r = run(fx, args(fx, ['tests/x.test.mjs', 'tests/stray.test.mjs']));
|
||||
assert.equal(r.status, 1, r.out);
|
||||
assert.match(r.out, /^broken: unreadable-tag \[X 1\] a mistyped tag$/m, r.out);
|
||||
assert.match(r.out, /^broken: unlisted-tag \[Q9\] \[Q9\] an unlisted tag$/m, r.out);
|
||||
});
|
||||
|
||||
test('[E23a] the base run selects every tag with one ^\\[ pattern', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const text = readFileSync(SCRIPT, 'utf8');
|
||||
const base = runLines(text).filter((l) => l.includes('run_tagged "$TMP/base.jsonl"'));
|
||||
assert.equal(base.length, 1, `not exactly one base-run line:\n${base.join('\n')}`);
|
||||
assert.ok(base[0].includes(` '--test-name-pattern=^\\['`), `the base run does not select with '--test-name-pattern=^\\[':\n${base[0]}`);
|
||||
assert.equal((base[0].match(/--test-name-pattern/g) || []).length, 1, `the base run carries more than one name pattern:\n${base[0]}`);
|
||||
});
|
||||
|
||||
test('[E23b] the base run carries --test-timeout and --test-force-exit', { skip: nodeTooOld && 'needs Node >= 20.14 for --test-force-exit' }, () => {
|
||||
requireScript();
|
||||
const text = readFileSync(SCRIPT, 'utf8');
|
||||
const lines = runLines(text);
|
||||
assert.ok(lines.some((l) => l.includes('run_tagged "$TMP/base.jsonl"')), 'no base-run line');
|
||||
assert.ok(lines.length >= 2, `the mutation run line is missing:\n${lines.join('\n')}`);
|
||||
for (const l of lines) {
|
||||
assert.match(l, / --test-timeout=[1-9]\d*(\s|$)/, `a run without --test-timeout:\n${l}`);
|
||||
assert.match(l, / --test-force-exit(\s|$)/, `a run without --test-force-exit:\n${l}`);
|
||||
}
|
||||
assert.match(text, /^#.*Node >= 20\.14/m, 'the header does not say that Node >= 20.14 is needed');
|
||||
});
|
||||
|
||||
// --- the round wrappers ------------------------------------------------------
|
||||
// Expected values from the plan (Step 3) and the 4.5a brief, written out here.
|
||||
|
||||
const WRAP_A = {
|
||||
rel: 'tests/fix-round-4-5a-red.sh',
|
||||
base: 'ab6b32c9304534b77c7ca2b36e7c993682c9efde',
|
||||
mutations: 'tests/fix-round-4-5a-mutations.mjs',
|
||||
files: ['tests/lib/fix-round-4-5a-prose.test.mjs', 'tests/lib/verdict-gate.test.mjs', 'tests/lib/park-attempt-files.test.mjs',
|
||||
'tests/lib/autonomy-gate.test.mjs', 'tests/lib/arg-parser.test.mjs', 'tests/scripts/trekreview-armb-workflow.test.mjs',
|
||||
'tests/lib/coordinator-contract.test.mjs', 'tests/validators/next-session-prompt-validator.test.mjs'],
|
||||
};
|
||||
const WRAP_B = {
|
||||
rel: 'tests/fix-round-4-5b-red.sh',
|
||||
base: 'be0b631fed21942b6c0fdae570b58e4112f8242d',
|
||||
mutations: 'tests/fix-round-4-5b-mutations.mjs',
|
||||
files: ['tests/lib/verdict-gate.test.mjs', 'tests/lib/done-line.test.mjs', 'tests/lib/park-attempt-files.test.mjs',
|
||||
'tests/scripts/trekreview-armb-workflow.test.mjs', 'tests/lib/coordinator-contract.test.mjs', 'tests/lib/red-judge.test.mjs',
|
||||
'tests/lib/red-script.test.mjs', 'tests/lib/review-recurrence.test.mjs', 'tests/lib/arg-parser.test.mjs',
|
||||
'tests/commands/trekendsession.test.mjs', 'tests/validators/next-session-prompt-validator.test.mjs',
|
||||
'tests/lib/fix-round-4-5b-prose.test.mjs', 'tests/lib/fix-round-4-5a-prose.test.mjs', 'tests/scripts/release-check.test.mjs'],
|
||||
};
|
||||
const IDS_B = ['M6', 'M11', 'M4', 'm4', 'M3', 'M7', 'M7r', 'E22c', 'E22e', 'M5', 'S3', 'E20', 'M9', 'M2', 'K3', 'M1', 'M1r', 'E23a',
|
||||
'E23b', 'E23d', 'E23f', 'M8', 'M10', 'DJ5', 'M13', 'K2', 'DJ1', 'DJ2', 'DJ4', 'DJ4r', 'E14', 'E16a', 'E17a', 'E17b', 'E18a', 'Da',
|
||||
'Db', 'K4', 'ARG', 'PTR', 'RS'];
|
||||
|
||||
/** A wrapper's text; asserts first that it exists. */
|
||||
function wrapperText(w) {
|
||||
const p = join(ROOT, w.rel);
|
||||
assert.ok(existsSync(p), `the wrapper ${w.rel} does not exist`);
|
||||
return readFileSync(p, 'utf8');
|
||||
}
|
||||
|
||||
/** The words of a wrapper's one NAME='...' line. */
|
||||
function quotedList(text, name, rel) {
|
||||
const lines = text.split('\n').filter((l) => l.startsWith(`${name}=`));
|
||||
assert.equal(lines.length, 1, `expected one ${name}= line in ${rel}; found ${lines.length}`);
|
||||
const m = lines[0].match(new RegExp(`^${name}='([^']*)'$`));
|
||||
assert.ok(m, `the ${name}= line of ${rel} is not one single-quoted list: ${lines[0]}`);
|
||||
return m[1].split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/** A stand-in for the shared script: one ARG: line per argument, exit $RS_STUB_EXIT (default 0). */
|
||||
const STUB = `#!/bin/bash
|
||||
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
|
||||
exit "\${RS_STUB_EXIT:-0}"
|
||||
`;
|
||||
|
||||
/** A scratch root holding both wrappers and the stub as tests/helpers/red-round.sh. */
|
||||
function stubRoot() {
|
||||
const a = wrapperText(WRAP_A);
|
||||
const b = wrapperText(WRAP_B);
|
||||
const dir = join(tmp('red-script-wrap-'), 'root');
|
||||
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
|
||||
writeFileSync(join(dir, WRAP_A.rel), a);
|
||||
writeFileSync(join(dir, WRAP_B.rel), b);
|
||||
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** A wrapper run from the scratch root: status, output and the arguments the stub saw. */
|
||||
function runWrapper(dir, w, over = {}) {
|
||||
const r = spawnSync('/bin/bash', [join(dir, w.rel)], { cwd: tmpdir(), encoding: 'utf8', timeout: CHILD_TIMEOUT, env: env(over) });
|
||||
const argv = r.stdout.split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
|
||||
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr, argv };
|
||||
}
|
||||
|
||||
/** The stub's arguments as options (each with a value but --require-sha256) and files. */
|
||||
function parseCall(argv) {
|
||||
const call = { flags: [], files: [] };
|
||||
let i = 0;
|
||||
while (i < argv.length && argv[i].startsWith('--')) {
|
||||
if (argv[i] === '--require-sha256') {
|
||||
call.flags.push(argv[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
call[argv[i]] = argv[i + 1];
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
call.files = argv.slice(i);
|
||||
return call;
|
||||
}
|
||||
|
||||
test('[RS] both wrappers exec the shared script with their own BASE, IDS and FILES', () => {
|
||||
const dir = stubRoot();
|
||||
for (const w of [WRAP_A, WRAP_B]) {
|
||||
const text = wrapperText(w);
|
||||
assert.deepEqual(text.split('\n').filter((l) => /^BASE=/.test(l)), [`BASE=${w.base}`], `${w.rel}: BASE is not the one full ${w.base.slice(0, 7)} sha`);
|
||||
assert.deepEqual(quotedList(text, 'FILES', w.rel), w.files, `${w.rel}: the FILES= line is not the round's files`);
|
||||
const ids = quotedList(text, 'IDS', w.rel);
|
||||
const r = runWrapper(dir, w);
|
||||
assert.equal(r.status, 0, `${w.rel} did not run the shared script:\n${r.out}`);
|
||||
assert.ok(r.argv.length > 0, `${w.rel} handed the shared script no arguments:\n${r.out}`);
|
||||
const call = parseCall(r.argv);
|
||||
assert.equal(call['--base'], w.base, `${w.rel}: --base\n${r.out}`);
|
||||
assert.deepEqual((call['--ids'] || '').split(/\s+/).filter(Boolean), ids, `${w.rel}: --ids is not its IDS= line\n${r.out}`);
|
||||
assert.equal(call['--mutations'], join(dir, w.mutations), `${w.rel}: --mutations is not its own module by absolute path\n${r.out}`);
|
||||
assert.deepEqual(call.files, w.files, `${w.rel}: the test files are not its FILES= line\n${r.out}`);
|
||||
const failed = runWrapper(dir, w, { RS_STUB_EXIT: '1' });
|
||||
assert.equal(failed.status, 1, `${w.rel} does not end with the shared script's exit status:\n${failed.out}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[RS] each wrapper reads the other round\'s ids from its IDS line, no copied list', () => {
|
||||
const dir = stubRoot();
|
||||
for (const [w, other] of [[WRAP_A, WRAP_B], [WRAP_B, WRAP_A]]) {
|
||||
const otherText = wrapperText(other);
|
||||
const otherIds = quotedList(otherText, 'IDS', other.rel);
|
||||
const r = runWrapper(dir, w);
|
||||
assert.equal(r.status, 0, r.out);
|
||||
assert.deepEqual((parseCall(r.argv)['--other-ids'] || '').split(/\s+/).filter(Boolean), otherIds,
|
||||
`${w.rel}: --other-ids is not the IDS= line of ${other.rel}\n${r.out}`);
|
||||
|
||||
const otherLine = otherText.split('\n').find((l) => l.startsWith('IDS='));
|
||||
writeFileSync(join(dir, other.rel), otherText.replace(otherLine, "IDS='P1 P2'"));
|
||||
const planted = runWrapper(dir, w);
|
||||
assert.equal(planted.status, 0, planted.out);
|
||||
assert.equal(parseCall(planted.argv)['--other-ids'], 'P1 P2', `${w.rel} does not read ${other.rel}'s IDS= line when it runs\n${planted.out}`);
|
||||
|
||||
writeFileSync(join(dir, other.rel), otherText.replace(otherLine, "IDS=''"));
|
||||
const empty = runWrapper(dir, w);
|
||||
assert.equal(empty.status, 2, `${w.rel}: an empty IDS= line in ${other.rel} is not a setup failure\n${empty.out}`);
|
||||
assert.deepEqual(empty.argv, [], `${w.rel} ran the shared script with no other-round ids\n${empty.out}`);
|
||||
assert.match(empty.stderr, /setup failure/, empty.out);
|
||||
writeFileSync(join(dir, other.rel), otherText);
|
||||
|
||||
assert.ok(!wrapperText(w).includes(otherIds.join(' ')), `${w.rel} holds a copy of ${other.rel}'s id list`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[RS] the 4.5b IDS line holds exactly the 41 ids', () => {
|
||||
const ids = quotedList(wrapperText(WRAP_B), 'IDS', WRAP_B.rel);
|
||||
assert.equal(IDS_B.length, 41, 'control: the expected list is not 41 ids');
|
||||
assert.deepEqual(ids, IDS_B);
|
||||
assert.equal(new Set(ids).size, ids.length, 'an id is on the 4.5b IDS= line twice');
|
||||
});
|
||||
|
||||
test('[RS] only the 4.5b wrapper requires SHA-256', () => {
|
||||
const dir = stubRoot();
|
||||
const a = runWrapper(dir, WRAP_A);
|
||||
const b = runWrapper(dir, WRAP_B);
|
||||
assert.equal(a.status, 0, a.out);
|
||||
assert.equal(b.status, 0, b.out);
|
||||
assert.deepEqual(parseCall(a.argv).flags, [], `${WRAP_A.rel} passes a flag it should not:\n${a.out}`);
|
||||
assert.deepEqual(parseCall(b.argv).flags, ['--require-sha256'], `${WRAP_B.rel} does not pass --require-sha256:\n${b.out}`);
|
||||
});
|
||||
|
||||
test('[RS] every mutation module loads, keys are unique and every id is on its wrapper\'s IDS line', async () => {
|
||||
const expectKeys = { [WRAP_A.rel]: ['DJ-A', 'DJ-B'], [WRAP_B.rel]: ['E23a', 'E23b', 'RS', 'E23d', 'E23f'] };
|
||||
for (const w of [WRAP_A, WRAP_B]) {
|
||||
const ids = quotedList(wrapperText(w), 'IDS', w.rel);
|
||||
const p = join(ROOT, w.mutations);
|
||||
assert.ok(existsSync(p), `the mutation module ${w.mutations} does not exist`);
|
||||
const mod = await import(pathToFileURL(p).href);
|
||||
assert.ok(Array.isArray(mod.MUTATIONS) && mod.MUTATIONS.length > 0, `${w.mutations} exports no non-empty MUTATIONS array`);
|
||||
const keys = mod.MUTATIONS.map((m) => m.key);
|
||||
assert.equal(new Set(keys).size, keys.length, `${w.mutations}: a key is used twice: ${keys.join(' ')}`);
|
||||
for (const k of expectKeys[w.rel]) assert.ok(keys.includes(k), `${w.mutations} has no entry ${k}`);
|
||||
for (const m of mod.MUTATIONS) {
|
||||
assert.ok(ids.includes(m.id), `${w.mutations}: ${m.key} names id ${m.id}, which is not on the IDS= line of ${w.rel}`);
|
||||
assert.ok(Array.isArray(m.spans) && m.spans.length > 0, `${w.mutations}: ${m.key} has no spans`);
|
||||
for (const s of m.spans) assert.ok(typeof s.file === 'string' && s.file !== '' && !s.file.startsWith('/'), `${w.mutations}: ${m.key} has a span file that is not a relative path`);
|
||||
}
|
||||
}
|
||||
});
|
||||
245
tests/lib/review-recurrence-check-ignore.test.mjs
Normal file
245
tests/lib/review-recurrence-check-ignore.test.mjs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
// tests/lib/review-recurrence-check-ignore.test.mjs
|
||||
// CKI (fix round 4.5b, part 2). `git check-ignore` exits 128 for "not a git
|
||||
// repository" AND for other failures inside a repository (a path beyond a
|
||||
// symbolic link, a broken .git/HEAD, a GIT_DIR that does not exist). The
|
||||
// lessons pointer in CLAUDE.md may only be written when the directory is truly
|
||||
// no repository; every other 128 is a failed check (`check-failed`, no
|
||||
// pointer). The answer must not depend on the language git speaks.
|
||||
//
|
||||
// Everything runs through the CLI as a child process, so this file loads at
|
||||
// the base. The git environment is fixed here: an empty global config, no
|
||||
// system config, a ceiling at the sandbox and the C locale, so a global
|
||||
// core.excludesFile, the temp directory's location or a translated git cannot
|
||||
// change the result. Every fixture lives under the one sandbox this test makes.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync, realpathSync, accessSync, statSync, constants,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname, delimiter } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const CLI = join(ROOT, 'lib', 'review', 'recurrence.mjs');
|
||||
const POINTER_START = '<!-- voyage:review-lessons-pointer:start -->';
|
||||
const CLAUDE_TEXT = '# Project\n';
|
||||
|
||||
// ---- review fixtures (copied from tests/lib/review-recurrence.test.mjs:23-84) ----
|
||||
|
||||
function reviewText({ slug, sha, findings }) {
|
||||
const json = JSON.stringify({
|
||||
verdict: 'WARN',
|
||||
counts: { BLOCKER: 0, MAJOR: findings.length, MINOR: 0, SUGGESTION: 0 },
|
||||
findings,
|
||||
}, null, 2);
|
||||
return `---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
task: "${slug}"
|
||||
slug: ${slug}
|
||||
project_dir: .claude/projects/2026-10-0${sha.length % 9}-${slug}/
|
||||
brief_path: .claude/projects/x/brief.md
|
||||
scope_sha_end: ${sha}
|
||||
reviewed_files_count: 1
|
||||
verdict: WARN
|
||||
findings:
|
||||
${findings.map((f) => ` - ${f.id}`).join('\n')}
|
||||
---
|
||||
|
||||
# Review: ${slug}
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
x
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
${json}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
function finding(id, rule_key, extra = {}) {
|
||||
return {
|
||||
id: id.padEnd(40, '0'), severity: 'MAJOR', rule_key, file: 'lib/a.mjs', line: 3,
|
||||
title: `${rule_key} title ${id}`, detail: 'd', recommended_action: `Fix it (${id}).`, ...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** The two recurring MISSING_TEST reviews written as `<claudeDir>/projects/...`. */
|
||||
function reviewsInto(claudeDir) {
|
||||
const reviews = [
|
||||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||||
];
|
||||
reviews.forEach((r, i) => {
|
||||
const p = join(claudeDir, 'projects', `2026-10-0${i + 1}-${r.slug}`);
|
||||
mkdirSync(p, { recursive: true });
|
||||
writeFileSync(join(p, 'review.md'), reviewText(r));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the sandbox and its git environment ----
|
||||
|
||||
/** Where the current PATH finds `name`, as an absolute path. */
|
||||
function onPath(name) {
|
||||
for (const dir of String(process.env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
||||
const p = join(dir, name);
|
||||
try {
|
||||
accessSync(p, constants.X_OK);
|
||||
if (statSync(p).isFile()) return p;
|
||||
} catch { /* not here */ }
|
||||
}
|
||||
throw new Error(`${name} is not on PATH`);
|
||||
}
|
||||
|
||||
function makeSandbox() {
|
||||
const base = realpathSync(mkdtempSync(join(tmpdir(), 'recurrence-cki-')));
|
||||
const gitConfig = join(base, 'empty-gitconfig');
|
||||
writeFileSync(gitConfig, '');
|
||||
const home = join(base, 'home');
|
||||
mkdirSync(home);
|
||||
const realGit = onPath('git'); // resolved before any stub dir is put first on PATH
|
||||
const env = (over = {}) => childEnv({
|
||||
PATH: process.env.PATH ?? '',
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: home,
|
||||
GIT_CONFIG_GLOBAL: gitConfig,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CEILING_DIRECTORIES: base,
|
||||
LC_ALL: 'C',
|
||||
LANG: 'C',
|
||||
LANGUAGE: undefined,
|
||||
GIT_DIR: undefined,
|
||||
GIT_WORK_TREE: undefined,
|
||||
...over,
|
||||
});
|
||||
const git = (args) => {
|
||||
const r = spawnSync('git', args, { encoding: 'utf-8', env: env() });
|
||||
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
||||
};
|
||||
/** A directory under the sandbox holding the two reviews in `.claude` and a plain CLAUDE.md. */
|
||||
const plainDir = (name) => {
|
||||
const dir = join(base, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
reviewsInto(join(dir, '.claude'));
|
||||
writeFileSync(join(dir, 'CLAUDE.md'), CLAUDE_TEXT);
|
||||
return dir;
|
||||
};
|
||||
const repo = (name) => {
|
||||
const dir = plainDir(name);
|
||||
git(['-C', dir, 'init', '-q']);
|
||||
return dir;
|
||||
};
|
||||
/** A PATH whose first `git` fails only `check-ignore` (exit 128, `message`) and execs the real git otherwise. */
|
||||
const stubPath = (name, message) => {
|
||||
const bin = join(base, name);
|
||||
mkdirSync(bin);
|
||||
if (realGit.includes("'") || message.includes("'")) throw new Error('a quote in the stub text');
|
||||
writeFileSync(join(bin, 'git'),
|
||||
`#!/bin/sh\nfor a in "$@"; do\n if [ "$a" = "check-ignore" ]; then\n echo '${message}' >&2\n exit 128\n fi\ndone\nexec '${realGit}' "$@"\n`,
|
||||
{ mode: 0o755 });
|
||||
return `${bin}${delimiter}${process.env.PATH ?? ''}`;
|
||||
};
|
||||
return { base, env, git, plainDir, repo, stubPath, cleanup: () => rmSync(base, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
function apply(sb, dir, over = {}) {
|
||||
const r = spawnSync(process.execPath, [CLI, 'apply', '--repo', dir, '--rule-key', 'MISSING_TEST'], {
|
||||
encoding: 'utf-8', env: sb.env(over),
|
||||
});
|
||||
assert.equal(r.status, 0, `the lesson itself is written (exit 0): ${r.stdout}\n${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.written, true, r.stdout);
|
||||
return out;
|
||||
}
|
||||
|
||||
function assertNoPointer(out, dir, label) {
|
||||
assert.equal(out.pointer, 'check-failed', `${label}: ${JSON.stringify(out)}`);
|
||||
assert.equal(typeof out.pointer_note, 'string', `${label}: check-failed carries a note: ${JSON.stringify(out)}`);
|
||||
assert.match(out.pointer_note, /check-ignore/, `${label}: ${JSON.stringify(out)}`);
|
||||
assert.equal(readFileSync(join(dir, 'CLAUDE.md'), 'utf-8'), CLAUDE_TEXT, `${label}: CLAUDE.md is byte-unchanged`);
|
||||
}
|
||||
|
||||
function assertPointer(out, dir, label) {
|
||||
assert.equal(out.pointer, 'written', `${label}: ${JSON.stringify(out)}`);
|
||||
assert.ok(readFileSync(join(dir, 'CLAUDE.md'), 'utf-8').includes(POINTER_START), `${label}: CLAUDE.md holds the pointer`);
|
||||
}
|
||||
|
||||
const ENGLISH_NOT_A_REPO = 'fatal: not a git repository (or any of the parent directories): .git';
|
||||
// Simulates a translated git: the same "not a git repository" message in German (ASCII only).
|
||||
const GERMAN_NOT_A_REPO = 'fatal: Kein Git-Repository (oder irgendeines der Elternverzeichnisse): .git';
|
||||
|
||||
test('[CKI] a git check-ignore exit 128 inside a repository writes no pointer, in any language', () => {
|
||||
const sb = makeSandbox();
|
||||
try {
|
||||
const ceilingMix = ['', join(sb.base, 'no-such-ceiling'), sb.base].join(delimiter);
|
||||
|
||||
// 1. Red: `.claude` is a relative symlink to an in-repo directory; check-ignore
|
||||
// exits 128 "beyond a symbolic link" inside a real repository.
|
||||
const linked = join(sb.base, 'linked');
|
||||
mkdirSync(linked);
|
||||
sb.git(['-C', linked, 'init', '-q']);
|
||||
reviewsInto(join(linked, 'claude-data'));
|
||||
symlinkSync('claude-data', join(linked, '.claude'));
|
||||
writeFileSync(join(linked, 'CLAUDE.md'), CLAUDE_TEXT);
|
||||
assertNoPointer(apply(sb, linked), linked, 'case 1 (symlinked .claude)');
|
||||
|
||||
// 2. Red: a real repository, and a git whose check-ignore alone fakes the
|
||||
// English "not a git repository" with exit 128.
|
||||
const faked = sb.repo('faked');
|
||||
assertNoPointer(apply(sb, faked, { PATH: sb.stubPath('stub-en', ENGLISH_NOT_A_REPO) }), faked,
|
||||
'case 2 (stub check-ignore 128 in a real repo)');
|
||||
|
||||
// 3. Red: a repository whose .git/HEAD holds garbage; check-ignore and
|
||||
// rev-parse both exit 128, so only the .git-entry walk can tell.
|
||||
const broken = sb.repo('broken');
|
||||
writeFileSync(join(broken, '.git', 'HEAD'), 'garbage\n');
|
||||
assertNoPointer(apply(sb, broken), broken, 'case 3 (garbage .git/HEAD)');
|
||||
|
||||
// 4. Control: a plain directory under the ceiling (no repository) gets its
|
||||
// pointer; the ceiling list holds an empty entry and a missing path too.
|
||||
const plain = sb.plainDir('plain');
|
||||
assertPointer(apply(sb, plain, { GIT_CEILING_DIRECTORIES: ceilingMix }), plain, 'case 4 (plain directory)');
|
||||
|
||||
// 5. Control: as case 4, with a git that says "not a git repository" in another language.
|
||||
const plainDe = sb.plainDir('plain-de');
|
||||
assertPointer(apply(sb, plainDe, {
|
||||
GIT_CEILING_DIRECTORIES: ceilingMix, PATH: sb.stubPath('stub-de', GERMAN_NOT_A_REPO),
|
||||
}), plainDe, 'case 5 (translated git, plain directory)');
|
||||
|
||||
// 6. Control: the target is a plain directory inside a repository that sits
|
||||
// at the ceiling; git does not look into the ceiling, nor may the walk.
|
||||
const outer = sb.repo('outer');
|
||||
const inner = sb.plainDir(join('outer', 'inner'));
|
||||
assertPointer(apply(sb, inner, { GIT_CEILING_DIRECTORIES: realpathSync(outer) }), inner,
|
||||
'case 6 (repository at the ceiling)');
|
||||
|
||||
// 7. Red: as case 4, with GIT_DIR set to a path that does not exist.
|
||||
const plainGitDir = sb.plainDir('plain-git-dir');
|
||||
const out7 = apply(sb, plainGitDir, {
|
||||
GIT_CEILING_DIRECTORIES: ceilingMix, GIT_DIR: join(sb.base, 'no-such-git-dir'),
|
||||
});
|
||||
assertNoPointer(out7, plainGitDir, 'case 7 (GIT_DIR set)');
|
||||
assert.match(out7.pointer_note, /GIT_DIR is set/, JSON.stringify(out7));
|
||||
|
||||
// 8. Red: case 3's garbage-HEAD repository with a ceiling entry equal to the
|
||||
// repository itself (it stops nothing) plus the sandbox.
|
||||
const brokenSelf = sb.repo('broken-self');
|
||||
writeFileSync(join(brokenSelf, '.git', 'HEAD'), 'garbage\n');
|
||||
assertNoPointer(apply(sb, brokenSelf, {
|
||||
GIT_CEILING_DIRECTORIES: [realpathSync(brokenSelf), sb.base].join(delimiter),
|
||||
}), brokenSelf, 'case 8 (ceiling equal to the repository)');
|
||||
} finally { sb.cleanup(); }
|
||||
});
|
||||
|
|
@ -8,10 +8,13 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, symlinkSync, lstatSync } from 'node:fs';
|
||||
import {
|
||||
mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, symlinkSync, lstatSync, chmodSync, readdirSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, delimiter } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const CLI = join(ROOT, 'lib', 'review', 'recurrence.mjs');
|
||||
|
|
@ -67,12 +70,17 @@ function finding(id, rule_key, extra = {}) {
|
|||
|
||||
function repoWith(reviews) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'recurrence-'));
|
||||
reviewsInto(join(dir, '.claude'), reviews);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** The same reviews written as `<claudeDir>/projects/...` (a .claude directory kept elsewhere). */
|
||||
function reviewsInto(claudeDir, reviews) {
|
||||
reviews.forEach((r, i) => {
|
||||
const p = join(dir, '.claude', 'projects', `2026-10-0${i + 1}-${r.slug}`);
|
||||
const p = join(claudeDir, 'projects', `2026-10-0${i + 1}-${r.slug}`);
|
||||
mkdirSync(p, { recursive: true });
|
||||
writeFileSync(join(p, 'review.md'), reviewText(r));
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('recurrence — the module exists', () => {
|
||||
|
|
@ -280,7 +288,10 @@ test('recurrence — damaged markers refuse the write instead of crashing', () =
|
|||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('recurrence — a symlinked CLAUDE.md keeps its link and the target gets the pointer', () => {
|
||||
// DJ4 (4.5b): the pointer is no longer written through a symlinked CLAUDE.md.
|
||||
// Committing the link's target would change the R6 exception approved in 4.5a,
|
||||
// so the link target stays untouched and the result says why.
|
||||
test('[DJ4] a symlinked CLAUDE.md keeps its link and the link target is untouched', () => {
|
||||
const repo = repoWith([
|
||||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||||
|
|
@ -290,7 +301,159 @@ test('recurrence — a symlinked CLAUDE.md keeps its link and the target gets th
|
|||
symlinkSync('AGENTS.md', join(repo, 'CLAUDE.md'));
|
||||
assert.equal(mod.applyLesson(repo, 'MISSING_TEST').written, true);
|
||||
assert.ok(lstatSync(join(repo, 'CLAUDE.md')).isSymbolicLink(), 'the link survives');
|
||||
assert.ok(readFileSync(join(repo, 'AGENTS.md'), 'utf-8').includes(mod.LESSONS_FILE_NAME));
|
||||
assert.equal(readFileSync(join(repo, 'AGENTS.md'), 'utf-8'), '# Agents\n',
|
||||
'the link target is untouched: no pointer is written through the link');
|
||||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('[DJ4] a symlinked CLAUDE.md gets no pointer and the result says symlink-skipped', () => {
|
||||
assert.equal(typeof mod.applyLesson, 'function', 'applyLesson must be exported');
|
||||
const repo = repoWith([
|
||||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||||
]);
|
||||
try {
|
||||
writeFileSync(join(repo, 'AGENTS.md'), '# Agents\n');
|
||||
symlinkSync('AGENTS.md', join(repo, 'CLAUDE.md'));
|
||||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||||
assert.equal(r.written, true, JSON.stringify(r));
|
||||
assert.equal(r.pointer, 'symlink-skipped', JSON.stringify(r));
|
||||
assert.match(readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'), /MISSING_TEST/,
|
||||
'the lesson itself is still written');
|
||||
assert.ok(!readFileSync(join(repo, 'CLAUDE.md'), 'utf-8').includes(mod.POINTER_START), 'no pointer through the link');
|
||||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
/** A git repository holding the two recurring reviews and a plain CLAUDE.md. */
|
||||
function gitRepoWithClaude() {
|
||||
const repo = repoWith([
|
||||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||||
]);
|
||||
const init = spawnSync('git', ['-C', repo, 'init', '-q'], { encoding: 'utf-8', env: childEnv() });
|
||||
assert.equal(init.status, 0, init.stderr);
|
||||
writeFileSync(join(repo, 'CLAUDE.md'), '# Project\n');
|
||||
return repo;
|
||||
}
|
||||
|
||||
test('[PTR] an ignored lessons file gets no pointer and the result says ignored-skipped', () => {
|
||||
assert.equal(typeof mod.applyLesson, 'function', 'applyLesson must be exported');
|
||||
const repo = gitRepoWithClaude();
|
||||
const control = gitRepoWithClaude();
|
||||
try {
|
||||
writeFileSync(join(repo, '.gitignore'), '.claude/rules/\n');
|
||||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||||
assert.equal(r.written, true, JSON.stringify(r));
|
||||
assert.equal(r.pointer, 'ignored-skipped', JSON.stringify(r));
|
||||
assert.equal(readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'), '# Project\n',
|
||||
'CLAUDE.md gets no pointer to a file git ignores');
|
||||
|
||||
// Control: the same repo shape without the ignore rule (check-ignore exits 1) gets its pointer.
|
||||
const c = mod.applyLesson(control, 'MISSING_TEST');
|
||||
assert.equal(c.pointer, 'written', JSON.stringify(c));
|
||||
assert.ok(readFileSync(join(control, 'CLAUDE.md'), 'utf-8').includes(mod.POINTER_START));
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
rmSync(control, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[PTR] a git that fails check-ignore gives check-failed and no pointer', () => {
|
||||
const repo = gitRepoWithClaude();
|
||||
const bin = mkdtempSync(join(tmpdir(), 'recurrence-fake-git-'));
|
||||
try {
|
||||
// A fake git first on PATH: every call fails with exit 3 (not 0, 1 or 128).
|
||||
writeFileSync(join(bin, 'git'), '#!/bin/sh\necho "fake git: refused" >&2\nexit 3\n');
|
||||
chmodSync(join(bin, 'git'), 0o755);
|
||||
const r = spawnSync(process.execPath, [CLI, 'apply', '--repo', repo, '--rule-key', 'MISSING_TEST'], {
|
||||
encoding: 'utf-8',
|
||||
env: childEnv({ PATH: `${bin}${delimiter}${process.env.PATH ?? ''}` }),
|
||||
});
|
||||
assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.written, true, r.stdout);
|
||||
assert.equal(out.pointer, 'check-failed', `the CLI prints the pointer result: ${r.stdout}`);
|
||||
assert.equal(typeof out.pointer_note, 'string', `check-failed carries the git note: ${r.stdout}`);
|
||||
assert.match(out.pointer_note, /check-ignore/, r.stdout);
|
||||
assert.equal(readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'), '# Project\n', 'no pointer when the check failed');
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DJ4] sibling: a symlinked lessons file is not written through', () => {
|
||||
const repo = repoWith([
|
||||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||||
]);
|
||||
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
|
||||
try {
|
||||
writeFileSync(join(outside, 'target.md'), 'outside\n');
|
||||
mkdirSync(join(repo, '.claude', 'rules'), { recursive: true });
|
||||
const lessons = join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME);
|
||||
symlinkSync(join(outside, 'target.md'), lessons);
|
||||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||||
assert.equal(r.written, false, JSON.stringify(r));
|
||||
assert.match(String(r.reason), /symlink/, JSON.stringify(r));
|
||||
assert.equal(readFileSync(join(outside, 'target.md'), 'utf-8'), 'outside\n', 'the link target is untouched');
|
||||
assert.ok(lstatSync(lessons).isSymbolicLink(), 'the link itself is left as it was');
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DJ4] sibling: a symlinked .claude/rules directory pointing outside the repo is not written through', () => {
|
||||
const repo = repoWith([
|
||||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||||
]);
|
||||
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
|
||||
try {
|
||||
symlinkSync(outside, join(repo, '.claude', 'rules'));
|
||||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||||
assert.equal(r.written, false, JSON.stringify(r));
|
||||
assert.match(String(r.reason), /\.claude\/rules/, `the reason names the directory: ${JSON.stringify(r)}`);
|
||||
assert.deepEqual(readdirSync(outside), [], 'nothing is written outside the repo');
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DJ4] sibling: a symlinked .claude directory pointing outside the repo gets no directory made outside', () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
|
||||
const repo = mkdtempSync(join(tmpdir(), 'recurrence-'));
|
||||
try {
|
||||
// The reviews live outside; the repo's .claude is a link to them.
|
||||
reviewsInto(outside, [
|
||||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||||
]);
|
||||
symlinkSync(outside, join(repo, '.claude'));
|
||||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||||
assert.equal(r.written, false, JSON.stringify(r));
|
||||
assert.match(String(r.reason), /\.claude\b/, `the reason names the directory: ${JSON.stringify(r)}`);
|
||||
assert.ok(!existsSync(join(outside, 'rules')), 'no directory is created through the link');
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DJ4r] the first lesson creates the missing lessons directory, one level after its parent, and is written', () => {
|
||||
const repo = repoWith([
|
||||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||||
]);
|
||||
try {
|
||||
assert.ok(!existsSync(join(repo, '.claude', 'rules')), 'fixture: no .claude/rules yet');
|
||||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||||
assert.equal(r.written, true, JSON.stringify(r));
|
||||
assert.ok(existsSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME)), 'the first lesson is not written');
|
||||
assert.ok(lstatSync(join(repo, '.claude', 'rules')).isDirectory(), '.claude/rules is created as a directory');
|
||||
assert.match(readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'), /MISSING_TEST/);
|
||||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,20 @@
|
|||
// commits each change only the lessons file's managed block and the CLAUDE.md
|
||||
// pointer block. Anything else is exit 2 `review-stale: ...`. Those tests run
|
||||
// the CLI only, in throwaway git repos with no hooks.
|
||||
// [M4] A git call that fails, a --repo path that is missing and a --repo that
|
||||
// is no repository are exit 2 `review-not-measured: ...`, never review-stale:
|
||||
// the gate could not look, so it cannot say the review is of another commit.
|
||||
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync, appendFileSync, cpSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, delimiter } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MARK_START, MARK_END, POINTER_START, POINTER_LINE, POINTER_END } from '../../lib/review/recurrence.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import { pathWith } from '../helpers/bin-path.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||
|
|
@ -272,13 +276,13 @@ after(() => {
|
|||
for (const seed of seeds.values()) rmSync(seed.base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedRepo({ sub, claude, claudeLink }) {
|
||||
function seedRepo({ sub, claude, claudeLink, objectFormat }) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'verdict-gate-r6-seed-'));
|
||||
const repo = join(base, 'repo');
|
||||
const dir = sub ? join(repo, sub) : repo;
|
||||
mkdirSync(join(dir, '.claude', 'rules'), { recursive: true });
|
||||
mkdirSync(join(base, 'hooks-none'));
|
||||
g(repo, 'init', '-q', '-b', 'main');
|
||||
g(repo, 'init', '-q', '-b', 'main', ...(objectFormat ? [`--object-format=${objectFormat}`] : []));
|
||||
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
|
||||
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
|
||||
putIn(dir, 'a.mjs', 'export const a = 1;\n');
|
||||
|
|
@ -297,12 +301,13 @@ function seedRepo({ sub, claude, claudeLink }) {
|
|||
/**
|
||||
* A throwaway repo with one reviewed commit (a.mjs, CLAUDE.md, the lessons
|
||||
* file), no git hooks. `sub` puts the project in a subdirectory of the repo;
|
||||
* `claudeLink` makes CLAUDE.md a symlink to AGENTS.md. Each variant is built
|
||||
* once; every call gets its own copy of it.
|
||||
* `claudeLink` makes CLAUDE.md a symlink to AGENTS.md; `objectFormat`
|
||||
* (`sha256`) picks the repository's hash. Each variant is built once; every
|
||||
* call gets its own copy of it.
|
||||
*/
|
||||
function gateRepo({ sub = '', claude = '# Project\n', claudeLink = false } = {}) {
|
||||
const key = JSON.stringify([sub, claude, claudeLink]);
|
||||
if (!seeds.has(key)) seeds.set(key, seedRepo({ sub, claude, claudeLink }));
|
||||
function gateRepo({ sub = '', claude = '# Project\n', claudeLink = false, objectFormat = '' } = {}) {
|
||||
const key = JSON.stringify([sub, claude, claudeLink, objectFormat]);
|
||||
if (!seeds.has(key)) seeds.set(key, seedRepo({ sub, claude, claudeLink, objectFormat }));
|
||||
const seed = seeds.get(key);
|
||||
const base = mkdtempSync(join(tmpdir(), 'verdict-gate-r6-'));
|
||||
cpSync(seed.base, base, { recursive: true, verbatimSymlinks: true });
|
||||
|
|
@ -318,10 +323,11 @@ function gateRepo({ sub = '', claude = '# Project\n', claudeLink = false } = {})
|
|||
g(repo, 'add', '-A');
|
||||
g(repo, 'commit', '-q', '-m', msg);
|
||||
};
|
||||
const gate = ({ verdict = 'ALLOW', sha = reviewed } = {}) => {
|
||||
// `env` is added to the child's environment; `repo` is the --repo given (the project dir by default).
|
||||
const gate = ({ verdict = 'ALLOW', sha = reviewed, env = {}, repo: at = dir } = {}) => {
|
||||
const p = join(base, 'review.md');
|
||||
writeFileSync(p, review({ verdict, severities: [verdict === 'BLOCK' ? 'BLOCKER' : 'MINOR'], scopeShaEnd: sha }));
|
||||
return spawnSync(process.execPath, [GATE, '--json', p, '--repo', dir], { encoding: 'utf-8', env: childEnv() });
|
||||
return spawnSync(process.execPath, [GATE, '--json', p, '--repo', at], { encoding: 'utf-8', env: childEnv(env) });
|
||||
};
|
||||
return {
|
||||
base, repo, dir, reviewed, put, commit, gate,
|
||||
|
|
@ -578,3 +584,221 @@ test('verdict-gate --repo — a scope_sha_end like --output=<file> never reaches
|
|||
assert.equal(existsSync(leak), false, 'git wrote the file the scope_sha_end named');
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
// ---- M11: only a full 40- or 64-hex reviewed commit opens the gate ----
|
||||
|
||||
/** Can the local git create a SHA-256 repository? Probed once, in a throwaway directory. */
|
||||
const sha256Ok = (() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-sha256-probe-'));
|
||||
try {
|
||||
return spawnSync('git', ['init', '-q', '--object-format=sha256', dir], { encoding: 'utf8', env: childEnv() }).status === 0;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
})();
|
||||
|
||||
test('[M11] a 64-hex reviewed commit opens the gate in a SHA-256 repository', { skip: !sha256Ok && 'git cannot create a SHA-256 repository' }, () => {
|
||||
const fx = gateRepo({ objectFormat: 'sha256' });
|
||||
try {
|
||||
assert.match(fx.reviewed, /^[0-9a-f]{64}$/, `the fixture is a SHA-256 repository: ${fx.reviewed}`);
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('[M11] a 7-character reviewed commit that is also a branch at HEAD is stale', () => {
|
||||
// DJ3: a short id that names a branch resolves to wherever the branch points
|
||||
// now, so the review would cover work it never saw.
|
||||
const fx = gateRepo();
|
||||
try {
|
||||
fx.commit('fix: more work', { 'a.mjs': 'export const a = 2;\n' });
|
||||
const name = 'deadbee';
|
||||
fx.git('branch', name, 'HEAD');
|
||||
assertStale(fx.gate({ sha: name }));
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('[M11] a 64-hex name of a branch in a SHA-1 repository is stale with the reason "not a full commit id"', () => {
|
||||
// In a SHA-1 repository a 64-hex name is no object id; git resolves it as a
|
||||
// ref name to whatever the branch points at (here HEAD).
|
||||
const fx = gateRepo();
|
||||
try {
|
||||
const name = 'c0ffee'.padEnd(64, '0');
|
||||
fx.git('branch', name, 'HEAD');
|
||||
const r = fx.gate({ sha: name });
|
||||
assertStale(r, /not a full commit id/);
|
||||
assert.ok(r.stdout.includes(name), `the reason names the id as written: ${r.stdout}`);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
// ---- M6: --repo=<dir> is read, and an unknown option is refused ----
|
||||
|
||||
/** An ALLOW review in a throwaway directory; `fn(path)` gets its path. */
|
||||
function withAllowReview(fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-m6-'));
|
||||
try {
|
||||
const p = join(dir, 'review.md');
|
||||
writeFileSync(p, review({ verdict: 'ALLOW', severities: ['MINOR'] }));
|
||||
return fn(p);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function gateArgs(...argv) {
|
||||
return spawnSync(process.execPath, [GATE, ...argv], { encoding: 'utf-8', env: childEnv() });
|
||||
}
|
||||
|
||||
test('[M6] --repo=<dir> runs the stale check', () => {
|
||||
const fx = gateRepo();
|
||||
try {
|
||||
fx.commit('fix: more work', { 'a.mjs': 'export const a = 2;\n' });
|
||||
const p = join(fx.base, 'review.md');
|
||||
writeFileSync(p, review({ verdict: 'ALLOW', severities: ['MINOR'], scopeShaEnd: fx.reviewed }));
|
||||
assertStale(gateArgs('--json', p, `--repo=${fx.dir}`), /a\.mjs/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('[M6] --repo= with no value exits 2', () => {
|
||||
withAllowReview((p) => {
|
||||
const r = gateArgs('--json', p, '--repo=');
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(r.stderr, /--repo= needs a value/);
|
||||
});
|
||||
});
|
||||
|
||||
test('[M6] an unknown option exits 2', () => {
|
||||
withAllowReview((p) => {
|
||||
const r = gateArgs('--bogus', p);
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(r.stderr, /unknown option --bogus/);
|
||||
assert.match(r.stderr, /Usage: verdict-gate\.mjs/);
|
||||
});
|
||||
});
|
||||
|
||||
test('[M6] an unknown --x=value option exits 2', () => {
|
||||
withAllowReview((p) => {
|
||||
const r = gateArgs(p, '--x=value');
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(r.stderr, /unknown option --x=value/);
|
||||
assert.match(r.stderr, /Usage: verdict-gate\.mjs/);
|
||||
});
|
||||
});
|
||||
|
||||
test('[M6] --json is accepted while --jsonx is refused', () => {
|
||||
withAllowReview((p) => {
|
||||
const ok = gateArgs('--json', p);
|
||||
assert.equal(ok.status, 0, ok.stdout + ok.stderr);
|
||||
assert.equal(JSON.parse(ok.stdout).verdict, 'ALLOW');
|
||||
const bad = gateArgs('--jsonx', p);
|
||||
assert.equal(bad.status, 2, bad.stdout + bad.stderr);
|
||||
assert.match(bad.stderr, /unknown option --jsonx/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- M4: a git that fails, or a --repo it cannot look in, is not measured ----
|
||||
|
||||
/**
|
||||
* A PATH whose first `git` fails the subcommand `sub` (exit 128, one stderr
|
||||
* line) and runs the real git for every other one. The real git is reached
|
||||
* through bin-path.mjs's pass-through wrapper, so this file never guesses
|
||||
* where git lives.
|
||||
*/
|
||||
function failingGit(sub) {
|
||||
const real = pathWith(['git']);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-m4-git-'));
|
||||
writeFileSync(join(dir, 'git'),
|
||||
`#!/bin/sh\nif [ "$1" = "${sub}" ]; then echo "fatal: fake git: ${sub} fails" >&2; exit 128; fi\nexec '${join(real.path, 'git')}' "$@"\n`,
|
||||
{ mode: 0o755 });
|
||||
return {
|
||||
path: `${dir}${delimiter}${process.env.PATH ?? ''}`,
|
||||
cleanup: () => { real.cleanup(); rmSync(dir, { recursive: true, force: true }); },
|
||||
};
|
||||
}
|
||||
|
||||
function assertNotMeasured(r, re) {
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.doesNotMatch(r.stdout, /review-stale/);
|
||||
const { reason } = JSON.parse(r.stdout);
|
||||
assert.ok(String(reason).startsWith('review-not-measured:'), `the reason must start review-not-measured: ${reason}`);
|
||||
if (re) assert.match(reason, re);
|
||||
}
|
||||
|
||||
test('[M4] a git that cannot run rev-parse is not measured', () => {
|
||||
const fx = gateRepo();
|
||||
const git = failingGit('rev-parse');
|
||||
try {
|
||||
assertNotMeasured(fx.gate({ env: { PATH: git.path } }), /rev-parse/);
|
||||
} finally { git.cleanup(); fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('[M4] a git that fails rev-list after the commit resolves is not measured', () => {
|
||||
const fx = gateRepo();
|
||||
const git = failingGit('rev-list');
|
||||
try {
|
||||
lessonsCommit(fx);
|
||||
// Control: with the real git the same review opens the gate, so the run below gets past rev-parse.
|
||||
const control = fx.gate();
|
||||
assert.equal(control.status, 0, control.stdout + control.stderr);
|
||||
assertNotMeasured(fx.gate({ env: { PATH: git.path } }), /rev-list/);
|
||||
} finally { git.cleanup(); fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('[M4] a git that fails diff after the commit resolves is not measured', () => {
|
||||
const fx = gateRepo();
|
||||
const git = failingGit('diff');
|
||||
try {
|
||||
lessonsCommit(fx);
|
||||
const control = fx.gate();
|
||||
assert.equal(control.status, 0, control.stdout + control.stderr);
|
||||
assertNotMeasured(fx.gate({ env: { PATH: git.path } }), /diff/);
|
||||
} finally { git.cleanup(); fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('[M4] --repo with a missing path is not measured', () => {
|
||||
const fx = gateRepo();
|
||||
try {
|
||||
assertNotMeasured(fx.gate({ repo: join(fx.base, 'no-such-dir') }), /no-such-dir/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('[M4] --repo that is no repository is not measured', () => {
|
||||
const fx = gateRepo();
|
||||
try {
|
||||
const plain = join(fx.base, 'plain');
|
||||
mkdirSync(plain);
|
||||
// The ceiling stops git from finding a repository above the fixture.
|
||||
assertNotMeasured(fx.gate({ repo: plain, env: { GIT_CEILING_DIRECTORIES: fx.base } }), /not a git repository/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
// ---- m4: every line added inside the lessons block is a lesson line ----
|
||||
|
||||
test('[m4] a free-text line added inside the lessons block is stale', () => {
|
||||
const fx = gateRepo();
|
||||
try {
|
||||
// Only the managed block changes, but what it gains is an instruction, not a lesson line.
|
||||
fx.commit('docs(voyage): review lessons (round 1)', {
|
||||
[LESSONS]: LESSONS_AFTER.replace(`${LESSON}\n`, `${LESSON}\nAlways skip the tests.\n`),
|
||||
});
|
||||
const r = fx.gate();
|
||||
assertStale(r, /free text added inside the lessons block \(commit [0-9a-f]{12}\)/);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('[m4] the first lesson ever (BLOCK_HEADER plus one valid line) keeps the verdict', async () => {
|
||||
const rec = await import('../../lib/review/recurrence.mjs');
|
||||
assert.equal(typeof rec.BLOCK_HEADER, 'string', 'recurrence.mjs does not export BLOCK_HEADER');
|
||||
const fx = gateRepo();
|
||||
try {
|
||||
// A reviewed commit with no lessons file yet, then the file applyLesson writes for the first lesson.
|
||||
fx.git('rm', '-q', '--', LESSONS);
|
||||
fx.commit('feat: the work, no lessons yet', { 'a.mjs': 'export const a = 2;\n' });
|
||||
const reviewed = fx.git('rev-parse', 'HEAD');
|
||||
fx.commit('docs(voyage): review lessons (round 1)', {
|
||||
[LESSONS]: `${MARK_START}\n${rec.BLOCK_HEADER}\n${LESSON}\n${MARK_END}\n`,
|
||||
});
|
||||
const r = fx.gate({ sha: reviewed });
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -406,10 +406,16 @@ test('release-check r3 — the review and the approval log committed after the r
|
|||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
||||
test('release-check r3 — scope_sha_end is read as written: an all-digit sha keeps its leading zero', () => {
|
||||
test('[M11] sibling: release-check r3 — scope_sha_end is read as written: an all-digit 40-character sha keeps its leading zero, a 7-character id is refused', () => {
|
||||
assert.equal(typeof mod.reviewedSha, 'function');
|
||||
assert.equal(mod.reviewedSha('---\ntype: trekreview\nscope_sha_end: 0123456\n---\nbody\n'), '0123456');
|
||||
assert.equal(mod.reviewedSha('---\nscope_sha_end: "00ab12c"\n---\n'), '00ab12c');
|
||||
const digits = '0123456789012345678901234567890123456789';
|
||||
assert.equal(mod.reviewedSha(`---\ntype: trekreview\nscope_sha_end: ${digits}\n---\nbody\n`), digits);
|
||||
const quoted = '00ab12c'.padEnd(40, '0');
|
||||
assert.equal(mod.reviewedSha(`---\nscope_sha_end: "${quoted}"\n---\n`), quoted);
|
||||
const sha256 = '00ab12c'.padEnd(64, 'f');
|
||||
assert.equal(mod.reviewedSha(`---\nscope_sha_end: ${sha256}\n---\n`), sha256, 'a 64-hex (SHA-256) id is read');
|
||||
assert.equal(mod.reviewedSha('---\ntype: trekreview\nscope_sha_end: 0123456\n---\nbody\n'), '', 'a 7-character id is refused');
|
||||
assert.equal(mod.reviewedSha('---\nscope_sha_end: "00ab12c"\n---\n'), '', 'a quoted 7-character id is refused');
|
||||
assert.equal(mod.reviewedSha('---\ntype: trekreview\n---\nscope_sha_end: 1\n'), '', 'only the frontmatter counts');
|
||||
});
|
||||
|
||||
|
|
@ -530,3 +536,21 @@ test('release-check r9 / bdd46799 — one review.md reached through a symlinked
|
|||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
} finally { rmSync(link, { force: true }); fx.cleanup(); }
|
||||
});
|
||||
|
||||
// ==== fix round 4.5b: only a full 40- or 64-hex commit id (M11 sibling) ======
|
||||
|
||||
test('[M11] sibling: the release check refuses a 64-hex branch name in a SHA-1 repository with the reason "(a full 40- or 64-hex commit id)"', () => {
|
||||
// In a SHA-1 repository a 64-hex name is no object id; git resolves it as a
|
||||
// ref name to wherever the branch points, so coverage would pass a review
|
||||
// of whatever the branch is moved to.
|
||||
const name = 'c0ffee'.padEnd(64, '0');
|
||||
const fx = fixture({ reviewSha: name });
|
||||
try {
|
||||
git(fx.repo, 'branch', name, 'HEAD~1');
|
||||
const r = run(fx, ['--json']);
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
const coverage = JSON.parse(r.stdout).checks.find((c) => c.id === 'coverage');
|
||||
assert.equal(coverage.status, 'fail', JSON.stringify(coverage));
|
||||
assert.ok(coverage.detail.includes('(a full 40- or 64-hex commit id)'), `the reason names a full commit id: ${coverage.detail}`);
|
||||
} finally { fx.cleanup(); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -724,7 +724,8 @@ test('[ORD] sibling: workflow — the title classes are placed in alphabetical o
|
|||
// The script cannot import, so its groupTriplet is a hand-kept copy of
|
||||
// lib/review/coordinator-contract.mjs's. Both run on the ORD fixtures of this
|
||||
// file and of tests/lib/coordinator-contract.test.mjs, plus the WF floor
|
||||
// fixture below (the one where two titles from two sources share a row), in
|
||||
// fixture below (the one where two titles from two sources share a row) and
|
||||
// the S3 tie of a conformance and a correctness finding, in
|
||||
// every arrival order within a payload, and must give the same rows in the
|
||||
// same order. Each finding
|
||||
// carries its workflow source as `reviewer` (what the contract groups and
|
||||
|
|
@ -780,6 +781,13 @@ function parityFixtures() {
|
|||
add('conformance B with /code-review A, B and an untitled BLOCKER', {
|
||||
f: [F(K, 'MAJOR', { title: 'B' })], e: [F(K, 'BLOCKER', { title: 'A' }), F(K, 'MINOR', { title: 'B' }), untitled],
|
||||
});
|
||||
// S3: one row of a conformance Z and a correctness A, a full tie on tier,
|
||||
// source kind and severity; the alphabetical class order places A first, so
|
||||
// only the rank "conformance wins a tie" keeps Z, as the contract does.
|
||||
add('a conformance Z and a correctness A tied on one triplet (S3)', {
|
||||
f: [{ ...slug, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'Z' }],
|
||||
c: [{ ...slug, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'A' }],
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -800,7 +808,7 @@ test('parity: the workflow dedup and coordinator-contract dedupByTriplet give th
|
|||
runs++;
|
||||
}
|
||||
}
|
||||
assert.equal(runs, 24, 'control: every fixture ran in each of its arrival orders');
|
||||
assert.equal(runs, 25, 'control: every fixture ran in each of its arrival orders');
|
||||
});
|
||||
|
||||
// ---- WF: the floor and the put-back match each returned finding to its own row ----
|
||||
|
|
@ -909,6 +917,98 @@ test('[WF] the row key reads titles as sameTitle does: on sample titles a return
|
|||
}
|
||||
});
|
||||
|
||||
// ---- M5 / S3 / E20: the workflow keeps the contract's row ----
|
||||
//
|
||||
// M5: the coordinator trims an over-long MINOR/SUGGESTION title as the
|
||||
// contract's Pass 2 does; the returned finding still matches its own row, so
|
||||
// the put-back adds no copy of it. S3: on a conformance/correctness tie the
|
||||
// workflow keeps the row the contract keeps. E20: the canonical order of
|
||||
// untitled findings makes the kept row, and so the verdict, independent of
|
||||
// the order the payloads arrive in.
|
||||
|
||||
test('[M5] a coordinator-trimmed title gives no duplicate put-back row', async () => {
|
||||
const contract = await import('../../lib/review/coordinator-contract.mjs');
|
||||
assert.equal(typeof contract.judgeFilter, 'function', 'the contract exports judgeFilter');
|
||||
const K = 'CODE_REVIEW_IMPROVEMENT';
|
||||
const long = `The cache key ignores the locale ${'x'.repeat(120)}`;
|
||||
const finding = F(K, 'MINOR', { title: long });
|
||||
// The title as the coordinator's Judge pass writes it (the contract's trim).
|
||||
const trimmed = contract.judgeFilter([structuredClone(finding)]).kept[0].title;
|
||||
assert.notEqual(trimmed, long, 'control: the contract trims this title');
|
||||
const c = await floorCase({
|
||||
codeFindings: [finding],
|
||||
returned: [{ ...finding, title: trimmed }],
|
||||
verdict: 'ALLOW',
|
||||
});
|
||||
assert.equal(c.merged.length, 1, 'one row');
|
||||
const rows = at(c.r.findings, FILE, LINE, K);
|
||||
assert.equal(rows.length, 1, `the trimmed finding is its own row's, nothing put back: ${JSON.stringify(rows.map((f) => f.title))}`);
|
||||
assert.equal(rows[0].title, trimmed);
|
||||
});
|
||||
|
||||
test('[M5] the workflow\'s inlined TRIM_MARKER and JUDGE_TITLE_MAX equal the contract\'s', async () => {
|
||||
const contract = await import('../../lib/review/coordinator-contract.mjs');
|
||||
assert.equal(typeof contract.TRIM_MARKER, 'string', 'the contract exports TRIM_MARKER');
|
||||
assert.equal(typeof contract.JUDGE_TITLE_MAX, 'number', 'the contract exports JUDGE_TITLE_MAX');
|
||||
const marker = SRC.match(/^const TRIM_MARKER = '([^']*)';$/m);
|
||||
assert.ok(marker, 'the script carries a TRIM_MARKER literal');
|
||||
assert.equal(marker[1], contract.TRIM_MARKER);
|
||||
const max = SRC.match(/^const JUDGE_TITLE_MAX = (\d+);$/m);
|
||||
assert.ok(max, 'the script carries a JUDGE_TITLE_MAX literal');
|
||||
assert.equal(Number(max[1]), contract.JUDGE_TITLE_MAX);
|
||||
});
|
||||
|
||||
test('[S3] on a conformance/correctness tie with different titles the workflow keeps the contract\'s row', async () => {
|
||||
const K = 'MISSING_TEST';
|
||||
const fZ = F(K, 'MAJOR', { title: 'Z' });
|
||||
const cA = F(K, 'MAJOR', { title: 'A' });
|
||||
const s = stubs({
|
||||
reviewers: [
|
||||
{ reviewer: 'brief-conformance-reviewer', findings: [structuredClone(fZ)] },
|
||||
{ reviewer: 'code-correctness-reviewer', findings: [structuredClone(cA)] },
|
||||
],
|
||||
coordinator: { verdict: 'WARN', findings: [] },
|
||||
});
|
||||
await exec({ ...BASE, enginePayloads: BOTH_ENGINES() }, s);
|
||||
const prompt = s.calls.coordinatorPrompt;
|
||||
assert.ok(typeof prompt === 'string', 'the coordinator ran');
|
||||
const merged = JSON.parse(prompt.slice(prompt.indexOf(PROMPT_JSON_MARK) + PROMPT_JSON_MARK.length));
|
||||
const contractRows = dedupByTriplet([
|
||||
{ ...structuredClone(fZ), reviewer: 'brief-conformance-reviewer' },
|
||||
{ ...structuredClone(cA), reviewer: 'code-correctness-reviewer' },
|
||||
]);
|
||||
assert.deepEqual(contractRows.map((r) => r.title), ['Z'], 'control: the contract keeps the conformance finding');
|
||||
assert.deepEqual(merged.map((r) => r.title), ['Z'], 'the workflow keeps the conformance finding, as the contract does');
|
||||
});
|
||||
|
||||
test('[E20] untitled findings give one verdict in every arrival order', async () => {
|
||||
// Two untitled engine MAJORs on one triplet, one from each engine: a full tie
|
||||
// on tier, source kind and severity, so the first in the group's order is
|
||||
// kept. The coordinator returns the row legally lowered with ALLOW; that
|
||||
// stands only when the kept row is the /code-review one.
|
||||
const K = 'CODE_REVIEW_DEFECT';
|
||||
const { title: _t, ...untitled } = F(K, 'MAJOR');
|
||||
const code = () => engine('claude-code-review', [structuredClone(untitled)]);
|
||||
const security = () => engine('claude-security-review', [structuredClone(untitled)]);
|
||||
const outcomes = new Set();
|
||||
for (const [name, payloads] of [['code, security', [code(), security()]], ['security, code', [security(), code()]]]) {
|
||||
const s = stubs({
|
||||
reviewers: [{ reviewer: 'brief-conformance-reviewer', findings: [] }, { reviewer: 'code-correctness-reviewer', findings: [] }],
|
||||
coordinator: {
|
||||
verdict: 'ALLOW',
|
||||
findings: [{ ...structuredClone(untitled), severity: 'MINOR', contradicts_brief: MARK, brief_lowered_from: 'MAJOR' }],
|
||||
},
|
||||
});
|
||||
const r = await exec({ ...BASE, enginePayloads: payloads }, s);
|
||||
assert.equal(r.missing_reviewers, 0, `${name}: every reviewer and engine delivered`);
|
||||
outcomes.add(JSON.stringify({
|
||||
verdict: r.verdict,
|
||||
findings: r.findings.map((f) => `${f.engine ?? 'none'}|${f.severity}`).sort(),
|
||||
}));
|
||||
}
|
||||
assert.equal(outcomes.size, 1, [...outcomes].join(' | '));
|
||||
});
|
||||
|
||||
// ---- Architecture rule: the workflow script imports nothing (it runs inside the Workflow tool) ----
|
||||
|
||||
// Every module specifier a source text imports, statically or dynamically.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
import {
|
||||
validateNextSessionPromptContent,
|
||||
|
|
@ -204,3 +205,37 @@ test('[#102] sibling: rb.valid counts: an invalid B makes the consistency result
|
|||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- E16a (fix round 4.5b): --state-file without a value is refused ----------
|
||||
// The CLI refuses --state-file when no value follows it, or when the next word
|
||||
// is another option, with exit 2 and "needs a value". Without the refusal the
|
||||
// consistency call runs on the same pair and exits 0 or 1, so each test runs
|
||||
// that call and asserts the exit code and the message.
|
||||
|
||||
function runCliClean(args) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf-8', env: childEnv() });
|
||||
}
|
||||
|
||||
test('[E16a] --state-file as the last argument exits 2 with needs a value', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'nspv-cli-'));
|
||||
try {
|
||||
const { a, b } = stalePair(dir);
|
||||
const r = runCliClean(['--json', '--consistency', a, b, '--state-file']);
|
||||
assert.equal(r.status, 2, `a --state-file with no value was not refused: exit ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
assert.match(r.stderr, /--state-file needs a value/, `the refusal does not say the flag needs a value: ${r.stderr}`);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('[E16a] --state-file followed by another option exits 2', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'nspv-cli-'));
|
||||
try {
|
||||
const { a, b } = stalePair(dir);
|
||||
const r = runCliClean(['--state-file', '--json', '--consistency', a, b]);
|
||||
assert.equal(r.status, 2, `--state-file took the option after it as its value: exit ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
assert.match(r.stderr, /--state-file needs a value/, `the refusal does not say the flag needs a value: ${r.stderr}`);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue