diff --git a/CHANGELOG.md b/CHANGELOG.md index 98816baf1..9db9954ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,298 @@ ### Documentation - **`docs/capy-install.md`**: the workflow-lease paragraph now says the team-mode SessionStart hook (`bin/gstack-session-update`) honors the same leases. It logs `SKIP leased pid=

/m` to `analytics/session-update.log` and tries again at the next session start, without writing its hourly throttle stamp. +## [1.91.207.0] - 2026-10-25 + +**Per-repository session counts include sessions from removed Claude Code worktrees (ported from #3334 by @Shweta-Mishra-ai).** Claude Code creates worktrees at `/.claude/worktrees/` and removes them once their branch merges, but their session history stays. `gstack-global-discover` resolved each session's recorded directory with git, so a removed worktree resolved to nothing: its sessions counted in the tool total and in no repository. Reproduced on main: 2 sessions in a repo plus 3 in a removed worktree gave `total_sessions 5` but 2 for the repo. + +### Fixed +- **`bin/gstack-global-discover.ts`**: when a session's directory no longer exists and sits inside `/.claude/worktrees/` (or a subdirectory of it), the session counts with ``. Any other missing directory, and a worktree whose repository is gone too, still count in no repository. A live worktree keeps its own path, as before. + +### Tests +- `test/global-discover.test.ts` gains six end-to-end cases built with real `git worktree add` and `remove` (removed worktree, a subdirectory of one, a repo with no remote, plus the live-worktree, gone-repo and plain-missing-directory controls) and table tests for the path matcher. The three positive cases and the matcher tests fail on main. + +## [1.91.206.0] - 2026-10-25 + +**A repo that keeps `.gstack/` out of git can give pregate a policy, and name the CI lanes a local run satisfies (#3286).** On a shared repo where gstack is one contributor's tool, pregate fell back to defaults, so every platform lane the machine could not run (an `ubuntu-latest` suite on a Mac, say) became a `requires-remote` obligation that never cleared, even when that suite runs the same here. Ported from #3286 by arlophoenix, whose 13 commits carry the feature (merged with their history kept); our commits on top close two findings from a security-focused review of the port. + +### Added +- **`lib/pregate/context.ts`, `lib/pregate/config.ts`** (arlophoenix): when the base branch carries no `.gstack/pregate.json`, `gstack-pregate run` reads `/projects//pregate.json`. Never under `--policy-from head`, never a relative path or one inside the checkout, and never chosen by a checkout's `.env` (the bin ignores it, and a bare `bun gstack-pregate` beside one refuses). An unreadable file, a dangling link on its path, `sealed` or `price_file` refuses with `PREGATE_CONFIG_INVALID`; a base copy git cannot read refuses with `POLICY_SOURCE_UNAVAILABLE` unless `git ls-tree` confirms it is absent. +- **`local`** (arlophoenix): `/` lanes a local run satisfies run here instead of becoming `requires-remote`. Under 8 cores with a `remote_gate` or remote `test_backend`, the small-machine obligation still stands. +- **`lib/pregate/index.ts`** (arlophoenix): the record names the user file and its digest, and `verify` goes `PREGATE_STALE` when it is edited, deleted, or appears after a run under the defaults. Fix texts for missing lanes, strays, stalls and `remote_gate` name the active policy file. + +### Fixed +- **`lib/pregate/context.ts`**: a user policy whose path reaches the checkout through any symlink is ignored. Before, only the lexical path and the final target were checked, so a state root linked into the checkout, holding a link back out to another existing policy, let a branch choose a more permissive file without changing its bytes. +- **`lib/pregate/index.ts`**: `verify` goes `PREGATE_STALE` when a link on the way to the absent user path starts pointing nowhere. A fresh run refuses that state, but verify looked only at the leaf, so an old defaults pass stayed publishable. The loader and verify now share one absence check. + +### Tests +- `test/pregate-user-policy.test.ts` (new, arlophoenix) covers the user file, `local`, the refusals, `.env` isolation and stale receipts. New cases: a link chain through the checkout is refused for both a linked state root and a single in-repo hop, while a direct outside link is still read; and verify goes stale on a dangling parent, with the plain-absent path as the control. +- `test/pregate-rust.test.ts`: the two cargo-lane cases skipped on Windows carry a `TODO(#3380)` naming the gap. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or skip changed. The user file can relax lanes only where the base has no policy at all, and only from outside the repository; the two fixes make that containment stricter. +- No new dependency, no network call, no workflow change. + +## [1.91.205.0] - 2026-10-25 + +**/ship's Step 9 can re-review just the fixes after a fixing pass, and a narrowed pass can never be the review that ships (#3339).** Before, every fix cycle reran every specialist and the Red Team over the whole branch. On a large branch each full pass tends to find something new in code earlier passes already read, so the loop often ran to the three-cycle stop without converging. Now the pass after a fixing pass can review only what changed since that pass. One full pass still has to come last: a fix-range record is never CURRENT, and Step 11.5 can't bind it. Garry approved this on 2026-10-24 ("only with a mandatory final full pass"). + +### Added +- **`bin/gstack-review-log --start review --fix-range`** prints the start token, then `FIX_BASE=`. The base comes from the review log, never from the caller or the working tree, so `gstack-wtree`'s fingerprint is untouched. It is the commit of the latest pass of the same skill, and that commit's tree must be exactly the content the pass started from, so `git diff ` holds every fix, committed or not. The start refuses (exit 1, "run a full pass") in each of these cases: there is no earlier pass, that pass didn't complete (restoring coverage always takes a full pass), it made no fixes, it has no captured start, it ran on another branch, a Step 11 review followed it, it started from uncommitted or untracked content, or its commit no longer resolves or is no longer an ancestor of HEAD (after a rebase). On the start-work-then-ship flow, where Step 9 first reviews uncommitted work, the first repeat therefore stays full. +- **`lib/review-evidence.ts`**: the start token carries `scope: "fix-range"` and `fix_base`. The logged row takes `scope` and `fix_base` only from the token (a caller's values are dropped either way), and a token that names any fix base stays narrowed even if its scope is rewritten. On unchanged content its `review_binding` is `{state: "fix-range", range_start_wtree, range_end_wtree, …}`, with no `start_wtree`/`end_wtree` and no root `wtree`. `reviewFreshness` returns UNVERIFIED for any fix-range row ("fix-range pass; only a later full pass can clear review"), even one with a verified-looking binding. +- **`bin/gstack-specialist-stats`** skips fix-range rows, so narrowed dispatches don't push specialists toward `[GATE_CANDIDATE]`. + +### Changed +- **`ship/sections/review-army.md.tmpl`**: the core checklist explains the fix-range start, and that `FIX_BASE` stands in for `origin/` and `` for that pass. Decision 3 allows the repeat to be a fix-range pass. Decision 4 continues to Step 10 only from a full pass. Nearby prose was tightened to pay for the growth, so ship's skeleton plus sections is 313,246 bytes against its 313,281 cap (35 bytes below main). + +### Tests +- `test/review-fix-range.test.ts` (20 cases) follows Step 9.4's real order (fixes applied before the row is logged). It covers the base with committed and uncommitted fixes, chained fix-range passes, the eight refusals, a rewritten start token, a caller claiming `scope: "full"` on a fix-range token (it is still recorded as fix-range) or `"fix-range"` on a full one (it is dropped), Step 11.5's missing binding fields, UNVERIFIED freshness followed by CURRENT after a full pass, shared-libs reuse refusing a fix-range row, the stats exclusion, and the rendered Step 9 rules. + +### Gate integrity +- One gate change to review: Step 9 may narrow a repeat pass. Verdict: not a weakening of what ships, because the narrowed pass can't be the final review. Three things enforce that: its row is never CURRENT, Step 11.5 has no `start_wtree`/`end_wtree` to bind, and decision 4 requires a full pass before Step 10. Mutation checks, each caught by the suite: dropping the freshness check, giving a narrowed row the normal binding, allowing a fix-range start after a clean, an incomplete or a Step 11-followed pass, accepting a non-ancestor base, accepting a start from uncommitted content, trusting the token's scope alone, and counting fix-range rows in stats. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.204.0] - 2026-10-25 + +**An unattended run is told to end with `GSTACK_RESULT` only when its skill can write that line.** The unattended block `gstack-skill-start` prints told every unattended skill to log choices in `decisions.jsonl`, write a gate list and end with `GSTACK_RESULT: skill= status=gate_pending run=

`. Only `/autoplan` has helpers that write a run directory, so `/ship`, `/review`, `/qa` and the rest were asked to produce files and a result line nothing writes, and a parent that trusted the block would wait for them. `docs/unattended.md` already names which skills write the line and what a parent reads for the others (#3281); the runtime text now says the same thing. + +### Fixed +- **`bin/gstack-skill-start`**: the decision rules in the unattended block are the same for every skill: take the recommended option, leave unrecommended questions, consents, destructive options and the final gate pending, and never approve, publish or push. Only the sentence about the run's record changes by skill. `/autoplan` keeps its run directory, its `decisions.jsonl` rows, the gate list and `GSTACK_RESULT: skill=autoplan status=gate_pending run=`. `/eval-plan` ends at its spend approval with `GSTACK_RESULT: skill=eval-plan status=gate_pending run=`, and the block says no helper writes a `gate.json` for it yet. Every other skill is told it writes no run directory and prints no result line, to stop at its first pending item and list it in its final report, and which record the parent reads: the ship receipt (`gstack-ship-receipt read --pr --json`) for `/ship`, the review-log rows (`gstack-review-read`) for `/review`, the `qa-report-*.md` file for `/qa`, and the skill's own record in `docs/unattended.md` otherwise. + +### Tests +- `test/gstack-skill-start.test.ts` renders the unattended block for `autoplan`, `eval-plan`, `ship`, `review`, `qa` and `investigate`. It checks that each keeps the shared decision rules, that only `autoplan` and `eval-plan` get a result line (with their own skill name, never ``), and that the other four get no result line, gate list, `decisions.jsonl` or `run.json` and name their record. Against the old block it fails, and so does the existing unattended test, which now expects `skill=autoplan`. + +### Gate integrity +- One test expectation changed: the existing unattended test expects `GSTACK_RESULT: skill=autoplan …` instead of the `skill=` placeholder, which is stricter. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.203.0] - 2026-10-25 + +**Asking gstack to fix a failure no longer always opens /investigate (#3157, #3158).** The router sent every bug report straight to the full /investigate workflow, even when the user had named the cause or one look at the failing code showed it. Now the router checks first. When the user names a cause and a narrow fix, it makes one read-only check of the evidence. When that confirms the cause, it makes the fix and runs the focused test. When the cause is unknown, it makes one bounded, read-only triage pass over the supplied evidence and the directly implicated test and code, with no reproduction, hypothesis testing or wider search, and fixes the failure directly only when that pass establishes the root cause. Anything still unclear, including a cause the check contradicts, goes to /investigate before any edit, so the root-cause bar holds. A failing test or stack trace no longer counts as proof of its cause. + +### Changed +- **`SKILL.md.tmpl`** (root router): the bug-report rule points to a new **Failures** block that carries the two paths. Its text comes from `generateFailureRouting()` in `scripts/resolvers/preamble/generate-test-failure-triage.ts`. +- **`bin/gstack-skill-start`** and `CLAUDE.md`: the routing section gstack offers for a project's CLAUDE.md now reads "Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test)", so it agrees with the router. +- **`/ship` Test Failure Ownership Triage**, "Investigate and fix now": "Switch to /investigate mindset" becomes "One read-only root-cause pass, then `/investigate` if unclear." + +### Tests +- New paid eval `test/skill-e2e-failure-routing.test.ts` (gate, `rule`): four fixture repos, two per issue. `router-failure-verified-cause` and `router-failure-triage-resolves` must fix the bug without /investigate and leave the suite green. `router-failure-contradicted-cause` (a user-supplied cause the code contradicts) and `router-failure-triage-unclear` (an intermittent CI-only failure) must invoke /investigate, and an edit before it is a contract violation. Every case needs one to four read-only calls before it routes. `GSTACK_ROUTER_ARM=before` runs the same cases against the base branch's router. +- `test/skill-validation.test.ts` checks that the rendered router carries the rule and no longer sends bug reports straight to /investigate, that both paths keep the /investigate fallback, that the shipped CLAUDE.md routing line matches this repo's own, and that /ship's fix-now step uses the bounded pass. + +### Gate integrity +- No threshold, budget, timeout, trial count or existing case kind changed. The ship skill's parity size stays under its cap (313,260 of 313,281 bytes; 313,276 with #3372 merged). The new eval's negative controls are the two /investigate cases, and on the base router the triage cases go red. +- Registries and pins the new gate-tier file needs, each with a stated reason: `test/eng-finding-retry-budget.test.ts` counts 110 paid test files (was 109). `test/helpers/e2e-hosts.ts` lists the four cases as `skill-tool` Claude-only, because they assert on the Claude Skill tool call. `test/codex-host.test.ts` counts 33 Claude-specific gate cases (was 29). +- Each train gate now runs the four new cases, one trial each, for about $0.37 (measured at this head: $0.06, $0.06, $0.11 and $0.14; about 67 s wall when they run in parallel). + +## [1.91.202.0] - 2026-10-25 + +**Team-mode session auto-update waits for live gstack sessions instead of moving the checkout under them (#3363).** `bin/gstack-upgrade` already refuses with `UPGRADE_BUSY` while another live session holds a workflow lease on the installation (`bin/gstack-lease`), but the SessionStart hook `bin/gstack-session-update` never read the leases. A shared `./setup --team` install could fast-forward and re-run `./setup` while another session was mid-workflow. Reproduced on main: with a live lease held, the hook logged `UPDATING` and moved the install's HEAD. + +### Fixed +- **`bin/gstack-session-update`**: after taking its setup lock and before fetching, the background updater asks `gstack-lease live --root --exclude-ancestry-of `. Any live lease from another session logs `SKIP leased pid=/` and exits without fetching, fast-forwarding, retrying a pending setup stage or writing the hourly throttle stamp, so the next session start tries again. `GSTACK_UPGRADE_FORCE=1` overrides, as it does for `gstack-upgrade`. A dead or stale lease never blocks. + +### Tests +- `test/session-update-stages.test.ts` holds a lease from a live process: the install's HEAD, setup runs and the throttle stamp are unchanged, and the first session start after the holder exits updates. A second case shows a lease also holds a pending setup retry. Both fail on main. Controls: `GSTACK_UPGRADE_FORCE=1` updates under a live lease, and a lease whose process is dead does not block. + +## [1.91.201.0] - 2026-10-25 + +**/ship's review army and /plan-eng-review now ask for one severity scale, CRITICAL or INFORMATIONAL, the same scale /review, the specialist JSON and the review checklist already use (#3349).** The shared confidence rubric those two skills render told the model to label findings `[P1]` and `[P2]` and to report a 1-2 confidence finding "only if severity would be P0", while the rest of /ship's Step 9 (Pass 1 CRITICAL, Pass 2 INFORMATIONAL, `"severity":"CRITICAL|INFORMATIONAL"`) used the other scale. A finding from one skill couldn't be compared, deduplicated or gated against a finding from another. Garry picked CRITICAL/INFORMATIONAL for plan-eng-review on 2026-10-24. + +### Fixed +- **`scripts/resolvers/confidence.ts`** (default branch, rendered into `ship/sections/review-army.md` and `plan-eng-review/sections/review-sections.md`): every finding carries a severity, CRITICAL (blocks shipping) or INFORMATIONAL (lower severity, still actioned). The format is `[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description` with CRITICAL and INFORMATIONAL examples. A 1-2 score is reported only for a suspected release-blocking catastrophe, at CRITICAL severity and marked explicitly speculative, the rule /review already uses. Repeated sentences in the verification gate and the calibration-learning note were shortened, so ship's skeleton-plus-sections size falls by 77 bytes (313,204 against its 313,281 cap). + +### Tests +- `test/eng-first-review.test.ts`: the two captured plan-eng-review first-review questions (`eng-annotated-cache-au`, `eng-declarative-as`) still open review when their `[P1]` label becomes `[CRITICAL]` or `[INFORMATIONAL]`. They stay closed for `[HIGH]`, `[critical]`, `[Critical]`, `[P4]`, `[CRITICAL|INFORMATIONAL]` and no label. + +### Gate integrity +- One accepted-input change to review: the paid-test detectors `test/helpers/eng-cache-writer-decision.ts` and `test/helpers/pty/boundaries.ts` accept `[CRITICAL]` and `[INFORMATIONAL]` alongside `[P0]`-`[P3]` in a plan finding's annotation, because the skill now asks for those labels. Without it, both captures relabelled `[CRITICAL]` are rejected. Verdict: not a weakening. The label is metadata, and every substantive check (decision, assessment, actors, options) is unchanged. Mutation checks: restoring `P[0-3]` alone fails the positive cases; `[^\]]+`, `P[0-9]` or a case-insensitive CRITICAL each fail the negative controls. `scripts/harness-version.json` records `test/helpers/pty/boundaries.ts` as non-behavioral at HARNESS_VERSION 6: only the changed plan-eng-review prompt asks for the new labels, and the cases that run it already start their own series. The 11 plan-eng-review size pins in `test/fixtures/skill-md-size-allowlist.json` come down by 75 characters. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.200.0] - 2026-10-25 + +**/cso doctor no longer calls a Go repository "stack: python".** A repository with a root `go.mod` and no Node, Bun, Rails or JVM build matched no supported stack, so preparation fell back to Python and doctor reported `stack: python` with `MISSING_METADATA requirements.txt`, which sent Go users looking for a Python file they never needed (#3263). Go runtime support itself is not planned for now (Garry, 2026-10-24), so /cso keeps running static assessment on Go repositories; the runtime catalog and the /cso release contract are unchanged. + +### Fixed +- **`lib/cso/preparation.ts`**: a root `go.mod` with no other supported stack is reported as `stack: go` with `UNSUPPORTED_STACK` ("Go (go.mod) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails."), the way a Maven or Gradle build is already reported as Java. Python helper metadata beside `go.mod` (a docs `requirements.txt`, say) no longer turns the repository into Python. A `go.mod` beside a Node, Bun or Rails lockfile leaves that stack's detection as it was. + +### Tests +- `test/cso-preparation.test.ts` checks that a Go module is `go` with exactly that prerequisite and never mentions `requirements.txt`, and that a hashed `requirements.txt` beside `go.mod` stays Go. Both fail on main. Controls: `go.mod` beside a Node lockfile stays a ready Node plan, a Python project without `go.mod` stays Python, and a repository with no stack metadata keeps the Python fallback. +- `test/helpers/cso-runtime-catalog.ts` leaves `go` out of its qualified-runtime fixture list, as it already did `java`. + +### Gate integrity +- No gate edit. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.199.0] - 2026-10-25 + +**When /sync-gbrain refuses because a gbrain autopilot is running, it now says which process it found and what to do.** The guard against syncing while `gbrain autopilot` runs is machine-wide on purpose: two syncs sharing one brain or working tree can race and delete files (#1734). But the refusal only said `(process:gbrain autopilot)`, so someone whose autopilot served a different brain could not tell what was blocking them (#3360, part 1). Garry decided on 2026-10-24 to keep the check machine-wide and improve the message. + +### Changed +- **`lib/gbrain-guards.ts`**: the live-process probe still runs `pgrep -f "gbrain autopilot"` across the whole machine, then reads each match's command line with `ps`. The refusal names every match as `pid \`\``, says the check counts any process on the machine whose command line contains "gbrain autopilot" (an autopilot serving another brain or repo included), and says to stop that autopilot or its `gbrain autopilot --install` service, or end a process that only mentions it. Commands are cut at 200 characters and the user and password part of a URL is masked. A pid that `ps` cannot describe still refuses, as `command unavailable`. The header comment now gives the machine-wide reason in place of the stale gbrain #1226 note. + +### Tests +- `test/gbrain-guards.test.ts` pins the full refusal for one matched process, lists two matches, keeps an empty match inactive, and runs the real probe against a live process whose command line carries "gbrain autopilot" and a database URL with a password: the refusal names its pid and masks the password. All four fail on main. The existing probe, lock and stale-lock tests are unchanged. + +### Gate integrity +- No gate edit: the guard refuses in exactly the cases it did before, and only its message changed. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.198.0] - 2026-10-25 + +**A second-opinion reviewer that says "not ready" is no longer reported as clean.** When the outside reviewer (Codex or Claude Code) ended its reply with `Verdict: not ready` but listed only advisory P2 or P3 findings, `lib/outside-review-result.ts` printed `VERDICT: clean` and exited 0, so /ship and /codex carried on as if the reviewer had approved. The not-ready check only ran when no severity parsed at all, so a parsed P2 or P3 switched it off (#3329). Ported from #3364 by @mvanhorn. + +### Fixed +- **`lib/outside-review-result.ts`**: when the reply's last recognized `Verdict:` line says `not ready`, the result is `findings` (exit 3) whatever severities parsed, and `FINDINGS:` still names the highest one. Advisory P2/P3 findings with a ready verdict, or with no verdict, stay `clean`. A `Verdict:` line quoted in a code fence or inline code still does not count, and the proposal and spec gates ignore it as before. +- **`docs/troubleshooting.md`**: the "Every review gate" rule under *How the verdict is read from a review* now says a not-ready reply blocks even when P2 or P3 parsed. + +### Tests +- `test/outside-review-unparsed-severity.test.ts` covers the issue's reply (numbered bold P2 plus P3, then `Verdict: not ready`) on the execution, structured and review gates, P3-only and unlabeled not-ready replies, the last verdict winning in both directions, bold, bulleted and heading verdict lines, and an unparsed label beside a parsed P2. Five of its tests fail on main's classifier and pass with the fix. Controls that stay `clean`: P2/P3 with `Verdict: ready` or no verdict, a not-ready verdict inside a code fence or inline code, and `ready-one-p3.txt`. Missing review markers, a failed execution, and the proposal and spec gates keep their old results. + +### Gate integrity +- One gate edit, and it strengthens the gate: the pinned `bare-dash-p2.txt` fixture (two P2 lines, then `Verdict: not ready (2 P2)`) moves from `VERDICT: clean`, exit 0 to `VERDICT: findings`, exit 3. Garry approved this reversal on 2026-10-24. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.197.0] - 2026-10-25 + +**Pregate gives touched Rust files a test lane, and runs a nested pnpm or vitest package with its own runner (#3344).** In a mixed Cargo and JavaScript repo, every touched `.rs` file failed `lanes` with "has no test lane", so /ship could not get a receipt, and a nested package's vitest tests ran under `bun test` from the repo root (about 98 false failures in the reporter's repo). Ported from #3346 by vilano93, whose two commits carry the feature; our commits on top close four findings from a security-focused review of the port and make the new fixtures hermetic. + +### Fixed +- **`lib/pregate/rust.ts`** (new, vilano93): a crate is the nearest Cargo.toml with `[package]`. Its `.rs` files under `tests/` or holding a `#[cfg(test)]` are tests, and every `.rs` file of a crate and its Cargo.toml select that crate's tests and those of every crate depending on it. Manifests are parsed as TOML, so renamed, inherited, dotted, table-form and target-specific dependencies all count, and a crate runs from the workspace it is a member of under Cargo's rules (members, exclude, `package.workspace`). Nothing runs cargo to build the graph. +- **`lib/pregate/checks/lanes.ts`** (vilano93): selected Rust tests run per crate as `cargo test -p ` from the workspace root and are judged on the summed `test result:` lines; zero tests is ZERO-RUN and a missing cargo is incomplete. A JS/TS test owned by a nested package.json that declares vitest or jest runs from that package through the package manager its `packageManager` field or nearest lockfile names (`pnpm exec`, `yarn`, `bun x`, else `npx`). +- **`lib/pregate/checks/lanes.ts`**: the crate name comes from the repo's Cargo.toml and reaches the lane's shell. Before, a crate named `evil;touch pwned` ran `touch pwned` in the workspace, and a name containing `{files}` spliced the file list into the command. A name outside Cargo's grammar (letters, digits, `_`, `-`) now gets no runner, so its files are unclaimed and the lane is incomplete, as Cargo would refuse the manifest anyway; a valid name is quoted like every file and cwd. The lane only runs with `--allow-repo-commands`, under which the repo's tests run anyway, but no other repo input reached the shell unquoted. +- **`lib/pregate/rust.ts`**: Cargo `members` globs are matched per path component and by Unicode code point, in one pass. The first cut used the policy glob matcher, which matched `[ab]` literally (a member listed that way lost its inherited workspace dependencies, so its tests were never selected) and backtracked for seconds on a hostile glob. Past 4096 characters of `members` globs, membership is undecided: every crate under the workspace counts as a member for dependency edges (more tests, never fewer), but its own tests are unclaimed and the lane is incomplete, because running `cargo test -p ` from the wrong root can test a same-named member instead. +- **`lib/pregate/rust.ts`**: a plain `#[test]` or `#[tokio::test]` function in `src/` makes the file a test, so a dependent crate tested that way runs when its dependency changes. The `#[cfg(...)]` scan reads each attribute once, up to its closing `]`, so a file of unclosed attributes no longer takes seconds, and an attribute it cannot close counts as a test (more tests selected, never fewer). +- **`lib/pregate/init-draft.ts`** (vilano93): `init` notes that Rust tests run per crate. + +### Tests +- `test/pregate-rust.test.ts` (new, vilano93) covers the crate model, the dependency forms, workspace membership, the cargo summary parser and three end-to-end lane runs. New cases: crates named `evil;touch pwned-by-crate-name` and `evil{files}` never reach the shell (nothing is created, both are unclaimed) while `naïve_crate-2` still runs quoted; `crates/[ab]`, `nested/**/leaf` and `unicode/?` (a 📦 directory) members inherit workspace dependencies; a plain `#[test]` dependent and a `cfg(any(test, feature = "<300 chars>"))` are tests; and a same-named non-member past the size cap is unclaimed rather than run as the member; a hostile members glob, a million unmatched `[` and 100,000 unclosed `#[cfg(` run in a supervised Bun that kills a super-linear version at 30 s. Each failed before its fix. +- The cargo stand-in is hermetic on POSIX: where Bun and cargo share a directory, the lane puts Bun's directory first and the real cargo ran against the fixture, so the shim PATH now uses the platform delimiter and the lane's bash puts the shim first again through `BASH_ENV`. On windows-free-shard (5) the two cases that run the stand-in failed deterministically (`local lane exited 1` with an empty log) and still did after the delimiter and a `Path`-key repair, so they are skipped on Windows (`test.skipIf(process.platform === 'win32')`, as other POSIX-shim pregate fixtures are); the selection, planning and hostile-name cases still run there. +- `test/pregate-selection.test.ts` (vilano93) covers nested pnpm/yarn/vitest/jest packages. Its `pnpm` stand-in gets the same `BASH_ENV` fix: on a host with a real pnpm beside Bun, the end-to-end case installed vitest from the network and came back incomplete. + +### Gate integrity +- One coverage change to review: the two new end-to-end cargo-lane cases in `test/pregate-rust.test.ts` are skipped on Windows (they never passed there); Linux and macOS run them, and the rest of the file runs on Windows. +- No threshold, budget, timeout, trial count or case kind changed. Pregate's lanes now map and run tests for `.rs` files that previously failed closed with "no test lane", and nested vitest/jest packages run under their own runner instead of `bun test`. A `.rs` file outside any crate, a crate with no tests, and a missing cargo or package manager still fail closed or stay incomplete; the tests above cover each. +- No new dependency (Cargo.toml is parsed with Bun's built-in TOML parser), no network call in pregate itself, no workflow change. As with the root runner detection on main (`npx vitest run`), a nested package's detected runner (`npx`, `bun x`) may fetch a missing tool when it runs under `--allow-repo-commands`. + +## [1.91.196.0] - 2026-10-25 + +**`/land-and-deploy` no longer stops parallel PRs with a false "VERSION drift detected".** Step 3.4 compared the branch VERSION with the next free patch slot (`gstack-next-version --bump patch --exclude-pr`) under every ship policy. So it stopped a stamp-at-merge PR whenever a sibling shared its tentative stamp, a claim-at-open PR whenever a sibling had claimed a higher slot, and every MICRO bump even with no other PR open. Reported with a full reproduction by Evgenii Lopatin (@exGeni) in #3318. + +### Fixed +- **`land-and-deploy/SKILL.md.tmpl` Step 3.4**: reads `gstack-ship-policy show-effective` and prints one `VERSION_CHECK:` verdict. Under `stamp-at-merge` it ignores claims and checks the exact head stamp: VERSION is the base's version plus one bump, and the changelog's top two headings are that version and then the base's top entry. Otherwise it stops with `STOP stamp stale` and names the `gstack-restamp --next` command. Under `claim-at-open`, and with no policy file, it stops only on a real collision: another open PR holds the same VERSION, or the base is already at or above it. MICRO bumps and versions below a sibling's claim pass. + +### Documentation +- `docs/ship-policy.md` gains "What `/land-and-deploy` reads from it", which gives Step 3.4's stop and pass conditions for each `queue_mode`. + +### Tests +- `test/land-and-deploy-version-check.test.ts` runs the template's own Step 3.4 block against real git fixtures with the real `gstack-ship-policy` and a stubbed claim list, under bash and, when installed, zsh. It covers the three false stops from #3318 (all pass now) and the real stops: a same-VERSION claim, a base at or above the branch, a stale stamp after a sibling landed, a base that moved by MICRO under a stamp, an `[Unreleased]` entry, and a two-bump stamp. A missing VERSION, an invalid policy and unread claims are `unavailable`. + +### Gate integrity +- Step 3.4 now stops on fewer inputs: a unique VERSION below a sibling's claim, a MICRO bump, and a stamp-at-merge stamp that siblings share no longer stop. Every real collision still stops, and six mutation controls of the block (drop the collision check, the base check, the changelog binding, the one-bump rule, the policy read, or the unread-claims rule) each turn the new tests red. One new `unavailable` path: an invalid ship policy. It mirrors the existing offline path, and a base at or above the branch still stops there. +- Owner-approved budget raise (Garry, 2026-10-24: "do it"): land-and-deploy's exact size pins in `test/fixtures/skill-md-size-allowlist.json` move up by 1,084 to 1,111 characters on 11 hosts, and its parity `maxSizeRatio` in `test/helpers/carve-guards.ts` goes from 1.112 to 1.125 (measured 1.1201). No threshold, timeout, trial count or case kind changed. + +## [1.91.195.0] - 2026-10-25 + +**Four skill prompts stop contradicting themselves or their code.** dgrant's issues #3347, #3350, #3351 and #3352 each found a skill telling the model something wrong or ambiguous: /office-hours read a master-based repo as "no changes", /plan-design-review named a vision model the design binary no longer uses, /health gave two different GBrain scores for the same readings, and /ios-qa's demo mode claimed to override every other rule in the skill. + +### Fixed +- **`office-hours/SKILL.md.tmpl`** (#3347): Phase 1 runs `git diff origin/HEAD --stat`, the remote's default branch, instead of `git diff origin/main --stat 2>/dev/null`. On a clone whose default branch is `master`, the old command exited 128 with its error hidden, so the model read the empty output as "no changes"; the new one shows the diff, and a repo without `origin/HEAD` now shows its error instead of passing for "no diff". The line is the same length, so office-hours stays at its pinned size on every host (the shared `{{BASE_BRANCH_DETECT}}` block would have added 1,727 chars, past the size-guard pins and the parity skeleton cap). +- **`plan-design-review/SKILL.md.tmpl`** (#3350): the design binary's `check` command is described as "OpenAI vision" instead of "GPT-4o vision", so the prose no longer pins a model. The binary defaults to gpt-5.5 (`design/src/models.ts`), `GSTACK_DESIGN_MODEL` overrides it, and gpt-4o cannot drive gpt-image-2 at all. The swap is the same length, so the pinned host sizes don't move. +- **`health/SKILL.md.tmpl`** (#3351): the GBrain score is computed by a `bun -e` snippet from the doctor component (10 for `ok`, 7 for `warnings`, else 0), the queue length and the hours since the last push, like the composite already was. The snippet takes only numeric `` inputs, the placeholder form the generated-bash lint already allows. The rubric row's 10/7/4 bands, which disagreed with the weighted formula (doctor ok, queue 50, pushed 2h: 7 by the rubric, 9.1 by the formula) and overlapped (a queue of 3 matched both 10 and 7), now point at that computation. The bands are half-open (<10, <100 lines; <24, <72 hours), and with sync off the doctor component carries the whole score. History records `gbrain` with one decimal. +- **`ios-qa/SKILL.md.tmpl`** (#3352): "DEMO MODE OVERRIDES ALL OTHER RULES" becomes the narrow rule it meant: in demo mode drive every action through visible UI rather than `POST /state/*` writes, and every other rule in the skill (the device lock, no replay of a lost tap, never hand-editing DebugBridge) still applies. + +### Tests +- `test/qa-health-rubric.test.ts` checks that the GBrain rubric row defers to the computation, then runs the template's own snippet: the issue's two inputs score 9.1 and 8.5, the band edges (9/10/99/100 lines, 23.9/24/71.9/72 hours) land on the right side, a missing doctor reading counts as 0, and sync off gives 10, 7 and 0. All four new tests fail on the old template. + +- `test/helpers/safety-rules.ts`: the `ios-qa-demo-override` row's anchor moves from `/demo mode overrides/i` to `/drive every action through visible UI/i`, and its hash to the reworded paragraph, in the #3352 commit as the registry requires. `test/safety-rule-registry.test.ts` passes, including its unique-anchor and rule-removed-arm checks. + +### Gate integrity +- One gate edit to review: the safety-rule registry row for `safety-ios-demo-ui-only` now anchors on the reworded paragraph. Verdict: not weaker. The paragraph still requires visible UI (`/tap`, `/swipe`, `/type`) over `POST /state/*` writes, and the only text removed is the "overrides all other rules" claim. The case's rule-removed arm deletes the same paragraph as before. No threshold, budget, timeout, trial count or case kind changed. + +## [1.91.194.0] - 2026-10-25 + +**A read-only repo can now opt in to Memorable recall, one remote at a time, and a corrupt policy store no longer turns the recall veto off.** #3359 (exGeni) asked for a way to keep a repo out of the brain while still letting its prompts reach Memorable: today the recall hook skips every `deny` and `read-only` repo, and `read-only` is also the tier people pick just to stop code ingest. Garry approved a per-remote flag on 2026-10-24: off by default, and a `deny` always wins. While building the negative controls we found that the hook's policy lookup went through `get`, which quarantines a corrupt store and answers "unset", so a truncated `gbrain-repo-policy.json` let recall run in every repo, read-only and deny included, and reset the store on the way. + +### Added +- **`bin/gstack-gbrain-repo-policy memorable set `** stores the opt-in under a reserved `_memorable_recall` map in the policy store. It is refused unless the remote is read-only (deny vetoes recall; read-write never needed it). `memorable get []` prints `\t`, and `list` adds a `memorable=allow` column. Changing a remote's tier clears its opt-in, and the two metadata keys (`_schema_version`, `_memorable_recall`) are refused as remotes. +- **`lib/gbrain-repo-policy-client.ts`**: `memorableRecallPolicy(url)` returns the tier and the opt-in in one spawn. Anything but exactly `\t` reads as `unreadable`. + +### Changed +- **`hosts/claude/hooks/memorable-user-prompt-hook.ts`**: a `read-only` remote is recalled only when it is opted in; `deny` is always skipped. The skip line in `hook-errors.log` names the opt-in command. + +### Fixed +- The recall hook's policy lookup is now a pure read that fails closed in either state root: a corrupt, empty or non-object store, a present key with an unknown tier, a path that is not a readable file (a dangling symlink included) and a path whose existence an unsearchable ancestor directory hides are all errors, never "no policy", and nothing is migrated, quarantined or rewritten. The opt-in counts only when it is exactly `"allow"` and every store that makes the remote read-only (including `~/.gstack` under a custom state root) opts it in. +- The hook looks policy up by every configured `remote.origin.url`, as `get` and `/sync-gbrain` read it, instead of the first URL after `url.*.insteadOf` rewriting. A rewrite to another host, or a second origin URL, no longer dodges a deny: any denied or not-opted-in read-only URL skips recall. `hasRepoPolicyStore()` treats only confirmed absence as "no store", so a dangling symlink at or above the store, a file in its path, or an unreadable directory reaches the script's fail-closed checks instead of the no-policy fast path. Origin URLs are read NUL-delimited and used verbatim, so a valid local path containing a newline or edge whitespace is looked up whole. + +### Docs +- `docs/memorable-workflow-memory.md` has a short "Recall in a read-only repo" section and updated refusal and troubleshooting lines. + +### Tests +- `test/memorable-user-prompt-hook.test.ts` drives the real hook: read-only stays blocked by default, the opt-in recalls on that remote and not on a second read-only remote, `deny` with a hand-written opt-in is skipped, and a corrupt store skips recall and is left in place. +- `test/gbrain-repo-policy.test.ts` covers the CLI: per-remote keying (a different remote, a prefix-sharing remote and an unset remote stay off; the SSH spelling of the same remote matches), refusal on deny, read-write and unset, clearing on a tier change, malformed opt-ins reading off, corrupt and non-file stores in both roots failing with exit 2, and the second-store rule. +- `test/gbrain-repo-policy-client.test.ts` covers `memorableRecallPolicy`: fast path, per-remote opt-in, deny, corrupt store, an unknown tier, a dangling-symlink store and a timeout. +- Review hardening, each test red at the pre-review head: empty, whitespace, `null`, array, number and two-document stores and `false`/`null`/unknown tier values fail with exit 2 in either root; dangling symlinks and an unsearchable directory or ancestor fail closed; the lookup leaves a schema-less store byte-for-byte unchanged; and the hook refuses a deny remote whose origin is rewritten to another host or carries a second, unlisted URL in either order, or whose local-path URL contains a newline or edge whitespace; and a dangling symlink above the store or a file in its path skips the no-store fast path. + +### Gate integrity +- One deliberate widening of a privacy veto, approved by Garry: a read-only remote with an explicit opt-in is recalled. Nothing else is loosened, and the corrupt-store path is tightened. Seventeen mutations of the new gate were run against the focused tests. Sixteen turn at least one test red: the hook ignoring the opt-in, the hook dropping deny, the CLI reporting the opt-in on any tier, an unkeyed opt-in, a corrupt store read as no policy, the second store ignored, an opt-in accepted on deny, the hook using the rewritten URL, an existsSync fast path, any JSON document accepted, a non-file path read as absent, the hook checking only the last origin URL, a missing path trusted without checking its ancestors, origin URLs split on newlines or trimmed, and the client fast path ignoring a dangling ancestor symlink. The seventeenth, the client reading unknown script output as no policy, stays green because the script can no longer produce such output. That check is a second layer and is left in place. No threshold, budget, timeout, trial count or case kind changed. +- Known limit, unchanged by this PR: remotes that the store already treats as one key (`host:8443/x` and `host/8443/x`, or paths that differ only in case) share the opt-in exactly as they already share a tier. + +## [1.91.193.0] - 2026-10-25 + +**Windows: the browse terminal agent stops respawning every 60 seconds and stops flashing a console when Bun came from npm (#2295; ported from #3357 by @adammartin0087).** The agent was spawned as a bare `bun`. With Bun installed through `npm install -g bun`, that name resolves to npm's `bun.cmd` shim and runs as `cmd.exe /c bun …`, so `windowsHide` hid only `cmd.exe` while `bun.exe` opened its own visible console, and the recorded pid was `cmd.exe`'s. The agent's startup check never matched its record, it exited after 2 seconds, and the watchdog respawned it. + +### Fixed +- **`browse/src/terminal-agent-control.ts`**: `spawnTerminalAgent` spawns `resolveBunExecutable()`, which on Windows is the real `bun.exe`: the running `bun.exe` itself, then `BUN_INSTALL\bin`, each absolute `PATH` entry (and, next to an npm `bun.cmd` shim, the `node_modules\bun\bin\bun.exe` it forwards to), then `USERPROFILE\.bun\bin`. With none found it falls back to `bun`, as before. macOS and Linux are unchanged. +- **`browse/src/bun-child-config.ts`**, **`browse/src/cookie-import-native.ts`**: that lookup is `findWindowsBunExe()`, and the Windows native cookie-import worker now uses it instead of its own copy, so it also finds an npm-global Bun. It lives in `bun-child-config.ts` because that file is already in the native import's source-bound qualification set (`NATIVE_CODE_INPUTS`). The edit changes the hash of `cookie-import-native.ts` and `bun-child-config.ts`; the qualification list is empty today, so no receipt is invalidated. + +### Tests +- `browse/test/terminal-agent-bun-exe.test.ts` (from the PR) covers POSIX, the running `bun.exe`, a compiled `browse.exe`, the npm shim, lookup order and the fallback, plus a source check that the spawn site never passes a bare `'bun'`. New: the shared lookup returns null when nothing is installed, finds the npm-global `bun.exe` for the cookie worker, and `cookie-import-native.ts` has no candidate list of its own. +- Not verified here: real Windows behaviour. The PR author tested on Windows 11; the `windows-free-tests` lane runs the new unit tests. + +## [1.91.192.0] - 2026-10-25 + +**Merge trains stop refusing correct heads and stop passing damaged CHANGELOGs.** This week's trains hit five defects in the ship-queue tooling. #3331's READY was invisible to the coordinator because its lane's origin was `https://git.capy.ai/...`. #3345's pin replaced the released `## [1.91.179.0]` heading, and three things then failed. The Version Gate passed the pin. `mergeChangelogs` copied 179's body into `[Unreleased]`. `verify --train` refused the correct hand-resolved head at 593e61c. Separately, the train slotted the /cso docs PR (#3337) ahead of the catalog PR it describes (#3343). Each defect now has a regression test built from the real case. + +### Fixed +- **`lib/version-gate-train.ts`**: the unstamped-head check read CHANGELOG.md through `spawnSync`'s default 1 MiB buffer. gstack's CHANGELOG is 1.67 MB, so both reads failed and the check compared two empty strings and passed. That is how #3345's 1eb7092 got through: `classifyTrainHead(base a59b5e4, head 1eb7092)` returned `unstamped`. The buffer is now 64 MiB, a CHANGELOG that can't be read is refused, and a dropped released heading is named in the error ("CHANGELOG.md drops released heading 1.91.179.0"). `gstack-ship-queue ready` runs the same check and refuses with `QUEUE_NOT_READY`. +- **`lib/changelog-check.ts`**: `mergeChangelogs(ours, theirs, base?)` returns null, meaning resolve by hand, when either side damaged released history. Damage means one of two things: a released heading the merge base carries is missing from that side (dropped or renamed), or a section only that side carries holds a paragraph of a released entry the other side has. The train merge, the gate-ahead recompute and `gstack-restamp --after` all pass the merge base. A damaged PR is now `QUEUE_TRAIN_CONFLICT` on CHANGELOG.md with the reason. Before, the damage went into T. +- **`lib/ship-queue-train-verify.ts`**: `verify --train` refused #3345's correct stamped head because T1's CHANGELOG was the damaged mechanical merge. The refusal printed the diff against the pin (main's files), not the diff against T1. A merged-in head is now accepted when it contains main and equals T modulo stamps, with its CHANGELOG judged against main: main's header and sections must be unchanged, with only lines from the pin's own CHANGELOG diff above them. The refusal now names the real difference. +- **`lib/ship-queue-train-record.ts`**: READY receipts, train records and freezes are keyed on origin normalized to `https://github.com//`, whatever URL form the lane uses (github.com, git.capy.ai, scp, ssh, a token URL, with or without `.git`). The canonical key equals the github.com key existing records already use. Readers also accept the keys older versions wrote for the common URL forms, so #3331's `1fb5dd69740a8cf8` receipt is found. The stored `repo` URL never carries credentials. +- **`lib/restamp.ts`**: its git reads use a 64 MiB buffer too. Before, `gstack-restamp --after` read each CHANGELOG conflict stage through the 1 MiB default. + +### Added +- **`gstack-ship-queue train build --order ,...`**: an explicit slot order naming every pinned PR once. The default order (screened first, then READY order) is unchanged, and the train's later `NEXT:` rebuild lines keep the explicit order. + +### Tests +- `test/changelog-merge-damage.test.ts`: the GSTA-64 repro, plus #3345's real CHANGELOGs (pin 1eb7092, base a59b5e4, main 4a98e40, train T1 6755ab8, stamped de23f5d, under `test/fixtures/merge-train-3345/`). Negative controls: a correct hand resolve, a shared tentative stamp, and two `[Unreleased]` blocks are not damage. An invented line, an edited main section, a changed header and a duplicate heading are still refused. +- `test/compare-pr-version.test.ts`: on a CHANGELOG over 1 MiB, #3345's edit is refused naming 1.91.179.0, an additive entry passes, and a corrupt CHANGELOG object is refused. All three failed before the fix. +- `test/ship-queue-train.test.ts`: a git.capy.ai lane writes the owner/repo key, and a legacy-key receipt is admitted. A damaged PR is a CHANGELOG conflict, and ready refuses it. `--order` sets and records the order. A hand-resolved merged-in head verifies, while an invented CHANGELOG line, a head without main and a stray file are refused. `test/ship-queue-train-logic.test.ts`: URL normalization, #3331's real keys, `orderSlots` and `parseOrder`. + +### Gate integrity +- Version Gate unstamped check: **stronger**. It refuses a head it can't read and names dropped headings. Additive heads still pass at full size. +- `mergeChangelogs` and the train CHANGELOG merge: **stronger**. Damage is now a conflict. +- `verify --train` merged-in path: **narrower in one place, stronger elsewhere**. The head must now contain main. A CHANGELOG that differs from T is accepted only as main's CHANGELOG plus lines the pin itself added. Every other file must still equal T modulo stamps, and the paid gate never reads CHANGELOG text. +- READY receipt lookup: **unchanged strength**. The head and CI binding are the same; only the repository key is normalized. + +## [1.91.191.0] - 2026-10-25 + +**`gstack-version-bump write --changelog` moves the branch's `[Unreleased]` entry under the new version, and /ship Step 12 stops treating a provisional queue pick as a verified slot.** Stamping by hand meant writing VERSION with `gstack-version-bump write` and then renaming the `## [Unreleased]` heading yourself, because only `gstack-restamp` re-headed the CHANGELOG. Separately, when `gstack-next-version` could not read some open PRs' VERSION it returned `complete:false` and a provisional pick, and Step 12 printed the warning and then wrote that version as if the queue had been checked (left over from #3258, noted in GSTA-45). + +### Fixed +- **`bin/gstack-version-bump`**: `write` takes an opt-in `--changelog `. It re-heads an existing `## [Unreleased]` section as `## [] - ` at the top, using the same `reheadChangelog` that `gstack-restamp` uses, so the result is byte-for-byte what a restamp produces. Only the heading changes; every entry is kept. A file with no `[Unreleased]` section, or no file, is left alone, so a released heading is never renamed. A move that would duplicate a version heading exits 2 before anything is written. The output gains a `changelog` field only when the flag is passed. `gstack-restamp` doesn't pass the flag, so under stamp-at-merge it still owns the re-heading, and a later restamp moves an entry this command headed as the branch's own stamp. +- **`ship/SKILL.md.tmpl` Step 12**: an incomplete queue (`complete:false`) reruns the queue check once, then asks whether to use the provisional candidate (recommended, because Version Gate CI rereads the queue on every push) or stop, naming the PRs in `unknown_claims`. The write in item 4 passes `--changelog CHANGELOG.md`, so an `[Unreleased]` entry written early sits under the new version before Step 13 extends it. + +### Tests +- `test/gstack-version-bump.test.ts`: eight cases for `--changelog`. They cover the re-heading (checked with `normalizeChangelog` and `checkChangelog`), an entry below the predecessor after a main merge, reruns, a file with no `[Unreleased]`, a missing file, the duplicate-heading refusal, bad paths, and a real `gstack-restamp` run after the write (`already headed`, then `1.2.4.0 -> 1.2.5.0`). Seven of them failed before the fix. +- `test/ship-version-sync.test.ts`: the rendered Step 12 has to handle `complete:false`, using the field names `gstack-next-version` emits. Its write command, run verbatim, has to re-head `[Unreleased]` and leave a released heading alone. Both tests failed before the fix. + +## [1.91.190.0] - 2026-10-25 + +**The weekly model-policy freshness check reads OpenAI's deprecations page again (#3155).** It has been red since the 2026-10-19 run: OpenAI's page now writes some deprecated models as a display name followed by the backticked ids, for example ``GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`)``, and the parser refused any model cell that did not start with a backtick, so every run stopped with `openai-lifecycle:unrecognized-deprecated-model-cell`. Left alone, the stored pinned-model evidence would have gone stale around 2026-11-11. + +### Fixed +- **`lib/model-policy-freshness-sources.ts`**: an OpenAI deprecated-model cell is accepted when it contains backticked model ids, wherever they sit in the cell, and each id becomes its own lifecycle row. A cell with no backticked id still fails with `unrecognized-deprecated-model-cell`. The live page (42,211 bytes, fetched 2026-10-24) now parses to 177 rows, including the five ids from its four display-name rows. + +### Tests +- `test/fixtures/model-policy-freshness/openai-lifecycle.md` gains a display-name row, and `test/model-policy-freshness.test.ts` checks that both of its ids come out with the row's shutdown date. With the old parser, 16 of the file's 21 tests fail on the new fixture. +- Negative controls: the existing cell with its backticks removed, and the new display-name row with unticked ids, both still fail with `unrecognized-deprecated-model-cell`. + +### Gate integrity +- One accepted-input change to review: the OpenAI lifecycle parser accepts a model cell with text before its backticked ids. It is a weekly advisory check, not a required check. The negative controls above still fail closed, and no threshold, budget, timeout, trial count or case kind changed. + ## [1.91.189.0] - 2026-10-25 **A stalled apt mirror can no longer hold a Free Tests shard for 20 minutes, PR runs now see main's branch layout, and gstack's install default moves to Bun 1.4.4.** On 2026-10-21, free-suite (12) in run 39641712129 sat in "Install Playwright Chromium" until the job timeout cancelled it. The browser had already come from cache: `--with-deps` was waiting on apt's fetch of noble-security from archive.ubuntu.com. No tests ran, and a whole merge train had to be rebuilt. Separately, a push to main checks out a local `main` while PR runs check out a detached merge ref, so a fixture that created its own `main` passed its PR and then failed every main push until #3237 (run 38999168852). And Capy images and gbrain now run Bun 1.4.4, which no CI lane tested, while the installer still downloaded 1.4.3. diff --git a/CLAUDE.md b/CLAUDE.md index a3ed96bc2..095a8d7b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -544,7 +544,7 @@ Key routing rules: - Architecture → invoke /plan-eng-review - Design system/plan review → invoke /design-consultation or /plan-design-review - Full review pipeline → invoke /autoplan -- Bugs/errors → invoke /investigate +- Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test) - QA/testing site behavior → invoke /qa or /qa-only - Code review/diff check → invoke /review - Visual polish → invoke /design-review diff --git a/SKILL.md b/SKILL.md index 526670028..7ab37f435 100644 --- a/SKILL.md +++ b/SKILL.md @@ -189,7 +189,7 @@ quality gates that produce better results than answering inline. - User asks to review design of a plan → invoke `/plan-design-review` - User asks about developer experience of a plan, API/CLI/SDK design → invoke `/plan-devex-review` - User wants all reviews done automatically, "review everything" → invoke `/autoplan` -- User reports a bug, error, broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → invoke `/investigate` +- User reports a bug, error, failing test or broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → check the cause by **Failures** below, then fix it or invoke `/investigate` - User asks to test the site, find bugs, QA, "does this work", "check the deploy" → invoke `/qa` - User asks to just report bugs without fixing → invoke `/qa-only` - User asks to review code, check the diff, pre-landing review, "look at my changes" → invoke `/review` @@ -244,6 +244,11 @@ Generic “second opinion”, “outside review”, or “cross-model review” - Voice trigger "quality check", "test the app", "run QA" → invoke `/qa` - Voice trigger "bug report", "just check for bugs" → invoke `/qa-only` +**Failures: check the cause, then route.** A failing test, error or stack trace shows where a failure surfaces, not its root cause. + +- **Cause supplied** (the user names a specific cause and a narrow fix): make one read-only check of the evidence it rests on, such as the named file, test or log. If that confirms the cause, make the fix and run the focused test that covers it. If it does not, invoke `/investigate` before any further tracing or edit. +- **Cause unknown:** make one read-only triage pass over the supplied evidence and the directly implicated test and code. Do not reproduce, test hypotheses or search wider. If that pass establishes the root cause and a narrow fix, make the fix and run the focused test that covers it. If the cause, scope or intended behavior is still unclear, invoke `/investigate` before any broader diagnosis or edit. + **Route only to skills in your available-skills list.** A rule above whose skill is not listed there is turned off on this install; skip it, never try to invoke it. diff --git a/SKILL.md.tmpl b/SKILL.md.tmpl index 3326854f2..c230671ce 100644 --- a/SKILL.md.tmpl +++ b/SKILL.md.tmpl @@ -65,7 +65,7 @@ quality gates that produce better results than answering inline. - User asks to review design of a plan → invoke `/plan-design-review` - User asks about developer experience of a plan, API/CLI/SDK design → invoke `/plan-devex-review` - User wants all reviews done automatically, "review everything" → invoke `/autoplan` -- User reports a bug, error, broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → invoke `/investigate` +- User reports a bug, error, failing test or broken behavior, "why is this broken", "this doesn't work", "wtf", "something's wrong" → check the cause by **Failures** below, then fix it or invoke `/investigate` - User asks to test the site, find bugs, QA, "does this work", "check the deploy" → invoke `/qa` - User asks to just report bugs without fixing → invoke `/qa-only` - User asks to review code, check the diff, pre-landing review, "look at my changes" → invoke `/review` @@ -99,6 +99,11 @@ quality gates that produce better results than answering inline. - User asks to tune question sensitivity, "stop asking me that" → invoke `/plan-tune` - User asks for code quality dashboard, "health check" → invoke `/health`{{VOICE_TRIGGER_ROUTING}} +**Failures: check the cause, then route.** A failing test, error or stack trace shows where a failure surfaces, not its root cause. + +- **Cause supplied** (the user names a specific cause and a narrow fix): make one read-only check of the evidence it rests on, such as the named file, test or log. If that confirms the cause, make the fix and run the focused test that covers it. If it does not, invoke `/investigate` before any further tracing or edit. +- **Cause unknown:** make one read-only triage pass over the supplied evidence and the directly implicated test and code. Do not reproduce, test hypotheses or search wider. If that pass establishes the root cause and a narrow fix, make the fix and run the focused test that covers it. If the cause, scope or intended behavior is still unclear, invoke `/investigate` before any broader diagnosis or edit. + **Route only to skills in your available-skills list.** A rule above whose skill is not listed there is turned off on this install; skip it, never try to invoke it. diff --git a/VERSION b/VERSION index 46844378b..c5d5571bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.189.0 +1.91.207.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index b343b2739..2ce846c8e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.189.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.207.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-gbrain-repo-policy b/bin/gstack-gbrain-repo-policy index 5b23caa9a..f98c39e19 100755 --- a/bin/gstack-gbrain-repo-policy +++ b/bin/gstack-gbrain-repo-policy @@ -15,10 +15,22 @@ # rather than bypass a set policy. # # gstack-gbrain-repo-policy set -# Persist a tier for the given remote. Exits 0 on success. +# Persist a tier for the given remote. Exits 0 on success. Changing a +# remote's tier clears its Memorable recall opt-in. +# +# gstack-gbrain-repo-policy memorable get [] +# Print "\t": the tier (as `get` prints it) and whether +# Memorable recall is opted in for this remote. Read-only lookup for the +# recall hook: a corrupt store is a hard error (exit 2), never quarantined. +# +# gstack-gbrain-repo-policy memorable set +# Opt a read-only remote in to (or back out of) Memorable recall. Off by +# default. Refused unless the remote's tier is read-only: a deny tier +# always vetoes recall, and read-write never needed an opt-in. # # gstack-gbrain-repo-policy list -# Print every entry as "\t", sorted by key. +# Print every entry as "\t", sorted by key, with a third +# column "memorable=allow" on remotes opted in to Memorable recall. # # gstack-gbrain-repo-policy normalize # Print the normalized (canonical) key for a given remote URL. @@ -33,8 +45,11 @@ # { # "_schema_version": 2, # "github.com/foo/bar": "read-write", -# "github.com/baz/qux": "deny" +# "github.com/baz/qux": "deny", +# "_memorable_recall": { "github.com/foo/bar": "allow" } # } +# The keys _schema_version and _memorable_recall are reserved; other keys +# name remotes (including the _unattributed sentinel memory-ingest uses). # # Tier semantics: # read-write — agent may search AND write new pages from this repo. @@ -43,6 +58,11 @@ # decision.) # deny — no gbrain interaction at all. # +# Memorable recall opt-in: the recall hook skips deny and read-only remotes. +# A read-only remote listed in _memorable_recall with exactly "allow" is +# recalled anyway; any other value or shape counts as off. Every store that +# makes the remote read-only (see the merge below) must opt it in. +# # Legacy migration: # On any read of a file missing `_schema_version` (or with version < 2), # legacy `allow` values are atomically rewritten to `read-write`, and @@ -187,6 +207,24 @@ ensure_file() { fi } +# memorable_optin FILE KEY — "allow" when FILE's _memorable_recall map holds +# exactly the string "allow" for KEY; any other value or shape is "off". +memorable_optin() { + jq -r --arg key "$2" '(._memorable_recall | if type == "object" then .[$key] else null end) as $v + | if $v == "allow" then "allow" else "off" end' "$1" +} + +# jq filter that removes $key's opt-in (and an emptied map). +DROP_MEMORABLE_OPTIN='if (._memorable_recall | type) == "object" then (._memorable_recall |= del(.[$key])) | (if ._memorable_recall == {} then del(._memorable_recall) else . end) else . end' + +# _schema_version and _memorable_recall hold store metadata; a remote that +# normalizes to one of them cannot be stored. +reject_reserved_key() { + case "$1" in + _schema_version|_memorable_recall) die "'$1' is a reserved key in the policy store, not a remote" ;; + esac +} + _tier_rank() { case "$1" in deny) echo 0 ;; read-only) echo 1 ;; read-write) echo 2 ;; *) echo 3 ;; esac } @@ -295,13 +333,146 @@ cmd_set() { local key key=$(normalize "$url") [ -z "$key" ] && die "cannot normalize remote URL: $url" + reject_reserved_key "$key" ensure_file - local tmp + local previous opted tmp + previous=$(jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE") + opted=$(memorable_optin "$POLICY_FILE" "$key") tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") - jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp" + if [ "$previous" != "$tier" ]; then + jq --arg key "$key" --arg tier "$tier" ".[\$key] = \$tier | $DROP_MEMORABLE_OPTIN" "$POLICY_FILE" > "$tmp" + else + jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp" + fi mv "$tmp" "$POLICY_FILE" chmod 0600 "$POLICY_FILE" echo "Set $key → $tier" + if [ "$previous" != "$tier" ] && [ "$opted" = "allow" ]; then + echo "Cleared the Memorable recall opt-in for $key (the tier changed; opt in again with: gstack-gbrain-repo-policy memorable set $key allow)" + fi +} + +# memorable_store_answer FILE KEY — " " for KEY in one store, +# read from a single snapshot of the file with no write, migration or +# quarantine (tier is none when the file or the key is absent; a legacy +# "allow" reads as read-write in memory). Dies (exit 2) on anything else: a +# path that is present but not a readable regular file, a path whose +# existence cannot be confirmed (an unsearchable ancestor), content that is not exactly one JSON object, or a +# present key whose value is not a known tier. +memorable_store_answer() { + local store="$1" key="$2" up raw + if ! [ -e "$store" ] && ! [ -L "$store" ]; then + # Absent only if the nearest existing ancestor is a searchable directory: + # an unsearchable one hides an existing store from every test above. + up=$(dirname "$store") + while ! [ -e "$up" ] && ! [ -L "$up" ] && [ "$up" != "/" ] && [ "$up" != "." ]; do up=$(dirname "$up"); done + if [ -d "$up" ] && [ -x "$up" ]; then + printf 'none off\n' + return 0 + fi + die "cannot confirm whether $store exists ($up is not a searchable directory) — refusing the Memorable recall lookup." + fi + if ! [ -f "$store" ] || ! [ -r "$store" ]; then + die "policy store $store exists but is not a readable file — refusing the Memorable recall lookup. Inspect or remove it." + fi + raw=$(cat -- "$store") || die "cannot read policy store $store — refusing the Memorable recall lookup." + printf '%s' "$raw" | jq -e -s -r --arg key "$key" ' + if length == 1 and (.[0] | type) == "object" then .[0] else error("not one JSON object") end + | (if has($key) then + (.[$key] | if . == "allow" then "read-write" + elif . == "deny" or . == "read-only" or . == "read-write" then . + else error("invalid tier") end) + else "none" end) as $tier + | ((._memorable_recall | if type == "object" then .[$key] else null end) == "allow") as $opt + | "\($tier) \(if $opt then "allow" else "off" end)"' 2>/dev/null \ + || die "policy store $store is corrupt (not one JSON object, or an invalid tier for $key) — refusing the Memorable recall lookup. Inspect with: gstack-gbrain-repo-policy list; re-run /setup-gbrain to rebuild the store." +} + +# memorable get — tier plus the effective Memorable recall opt-in, for the +# recall hook. Pure read: each store is read once, never migrated, never +# quarantined, and any doubt about a present store is a hard error (exit 2), +# because reading it as "no policy" would silently lift a deny or read-only +# veto. The tier merges as get does (the ~/.gstack store contributes only deny +# or read-only). The opt-in counts only when the merged tier is read-only and +# every store that restricts the remote (deny or read-only) opts it in, so an +# opt-in can never relax a restriction another store imposed. +cmd_memorable_get() { + local url="${1:-}" + require_jq + if [ -z "$url" ]; then + url=$(git config --get remote.origin.url 2>/dev/null || true) + fi + local key + key=$(normalize "$url") + if [ -z "$key" ]; then + printf 'unset\toff\n' + return 0 + fi + local answer p_tier p_opt l_tier="none" l_opt="off" + answer=$(memorable_store_answer "$POLICY_FILE" "$key") || exit 2 + read -r p_tier p_opt <<<"$answer" + if [ -n "$_gstack_legacy_root" ]; then + answer=$(memorable_store_answer "$_gstack_legacy_root/gbrain-repo-policy.json" "$key") || exit 2 + read -r l_tier l_opt <<<"$answer" + fi + local merged="$p_tier" + case "$l_tier" in + deny|read-only) + if [ "$(_tier_rank "$l_tier")" -lt "$(_tier_rank "$merged")" ]; then merged="$l_tier"; fi ;; + esac + if [ "$merged" != "read-only" ]; then + [ "$merged" = "none" ] && merged="unset" + printf '%s\toff\n' "$merged" + return 0 + fi + local optin="allow" + case "$p_tier" in deny|read-only) [ "$p_opt" = "allow" ] || optin="off" ;; esac + case "$l_tier" in deny|read-only) [ "$l_opt" = "allow" ] || optin="off" ;; esac + printf 'read-only\t%s\n' "$optin" +} + +cmd_memorable_set() { + local url="${1:-}" value="${2:-}" + if [ -z "$url" ] || [ -z "$value" ]; then die "usage: memorable set "; fi + case "$value" in + allow|off) ;; + *) die "invalid value '$value' (must be allow or off)" ;; + esac + local key + key=$(normalize "$url") + [ -z "$key" ] && die "cannot normalize remote URL: $url" + reject_reserved_key "$key" + if [ "$value" = "allow" ]; then + local current + current=$(cmd_memorable_get "$url" | cut -f1) + case "$current" in + read-only) ;; + deny) die "$key is deny: a deny tier always vetoes Memorable recall, so an opt-in is refused. Change the tier first if you mean it: gstack-gbrain-repo-policy set $key read-only" ;; + *) die "$key is $current, not read-only: the opt-in only applies to read-only remotes (read-write is already recalled; an unset remote has no veto)" ;; + esac + fi + ensure_file + local tmp + tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX") + if [ "$value" = "allow" ]; then + jq --arg key "$key" '._memorable_recall = ((._memorable_recall | if type == "object" then . else {} end) + {($key): "allow"})' "$POLICY_FILE" > "$tmp" + else + jq --arg key "$key" "$DROP_MEMORABLE_OPTIN" "$POLICY_FILE" > "$tmp" + fi + mv "$tmp" "$POLICY_FILE" + chmod 0600 "$POLICY_FILE" + echo "Set $key memorable recall → $value" + if [ "$value" = "allow" ] && [ "$(cmd_memorable_get "$url" | cut -f2)" != "allow" ]; then + echo "gstack-gbrain-repo-policy: $LEGACY_POLICY_FILE also makes $key read-only and does not opt it in, so recall stays off. Opt in there too: GSTACK_STATE_ROOT=$_gstack_legacy_root gstack-gbrain-repo-policy memorable set $key allow" >&2 + fi +} + +cmd_memorable() { + case "${1:-}" in + get) shift; cmd_memorable_get "$@" ;; + set) shift; cmd_memorable_set "$@" ;; + *) die "usage: memorable {get []|set }" ;; + esac } cmd_list() { @@ -310,7 +481,9 @@ cmd_list() { return 0 fi ensure_file - jq -r 'to_entries[] | select(.key != "_schema_version") | "\(.key)\t\(.value)"' "$POLICY_FILE" | sort + jq -r '(._memorable_recall | if type == "object" then . else {} end) as $m + | to_entries[] | select(.key != "_schema_version" and .key != "_memorable_recall") + | "\(.key)\t\(.value)" + (if $m[.key] == "allow" then "\tmemorable=allow" else "" end)' "$POLICY_FILE" | sort } cmd_normalize() { @@ -323,8 +496,9 @@ case "${1:-}" in get) shift; cmd_get "$@" ;; set) shift; cmd_set "$@" ;; list) shift; cmd_list "$@" ;; + memorable) shift; cmd_memorable "$@" ;; normalize) shift; cmd_normalize "$@" ;; - --help|-h|help) sed -n '2,54p' "$0" | sed 's/^# \{0,1\}//' ;; - "") die "usage: gstack-gbrain-repo-policy {get|set|list|normalize|--help}" ;; + --help|-h|help) sed -n '2,73p' "$0" | sed 's/^# \{0,1\}//' ;; + "") die "usage: gstack-gbrain-repo-policy {get|set|memorable|list|normalize|--help}" ;; *) die "unknown subcommand: $1" ;; esac diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts index d8d8d0e9f..88cfbfdbe 100644 --- a/bin/gstack-global-discover.ts +++ b/bin/gstack-global-discover.ts @@ -186,11 +186,26 @@ function gitProject(cwd: string): GitProject | null { const up = prefix.split("/").filter(Boolean).map(() => ".."); project = { root: resolve(cwd, ...up), main: basename(common) === ".git" ? dirname(common) : common }; } + } else { + // A merged branch's worktree is removed but its session history stays: + // count those sessions with the repository the worktree was created from. + const parent = parentRepoOfClaudeWorktree(cwd); + if (parent) project = gitProject(parent); } PROJECT_MEMO.set(cwd, project); return project; } +/** + * Claude Code creates worktrees at `/.claude/worktrees/`. Given a + * cwd inside one (the worktree root or any subdirectory), the repository root + * it was created from; null for any other path. + */ +export function parentRepoOfClaudeWorktree(cwd: string): string | null { + const m = cwd.match(/^(.+?)[\\/]\.claude[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/); + return m ? m[1] : null; +} + function getGitRemote(root: string): string | null { const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf-8", diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 9a6db5752..8ce0a2083 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -29,7 +29,7 @@ import * as path from 'node:path'; import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts'; import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config'; import { describeDraft, draftConfig } from '../lib/pregate/init-draft'; -import { buildContext, describeRefusal } from '../lib/pregate/context'; +import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context'; import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index'; import { describeLane, discoverLanes } from '../lib/pregate/workflows'; import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes'; @@ -140,13 +140,14 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; } remote = { spendCapUsd: cap }; } else if (flag(rest, '--spend-cap') !== undefined) { process.stderr.write('gstack-pregate: --spend-cap applies only with --remote\n'); return EXIT.usage; } const base = resolveBaseBranch(root, flag(rest, '--base')); - const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2] }); + const userConfigPath = defaultUserConfigPath(root); + const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2], userConfigPath }); if ('refused' in loaded) { const refusal = describeRefusal(loaded.refused, base); process.stderr.write(refusal.line + '\n'); @@ -281,6 +282,8 @@ async function main(argv: string[]): Promise { if (bad) { process.stderr.write(`gstack-pregate: ${bad}; see gstack-pregate --help\n`); return EXIT.usage; } const root = repoRoot(); if (!root) { process.stderr.write('gstack-pregate: not inside a git repository\n'); return EXIT.usage; } + const dotenv = loadedDotenvFiles(process.cwd(), process.execArgv); + if (dotenv.length) { process.stderr.write(resultLine('gstack-pregate', 'POLICY_SOURCE_UNAVAILABLE', `bun loaded ${dotenv.join(', ')} from the working directory, and a checkout's .env must not choose the state root or slug behind the policy; run gstack-pregate directly (its shebang passes --no-env-file) or as bun --no-env-file`) + '\n'); return EXIT.refused; } if (cmd === 'run') return await cmdRun(root, rest, json); if (cmd === 'verify') return cmdVerify(root, rest, json); if (cmd === 'clear') return cmdClear(root, rest); diff --git a/bin/gstack-review-log b/bin/gstack-review-log index 8a4dd2288..102fb1dc1 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -3,6 +3,10 @@ # Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}' # # Before reading a diff: gstack-review-log --start review +# Fix-range repeat (#3339): gstack-review-log --start review --fix-range +# prints the token, then FIX_BASE=; refuses (exit 1) unless the latest +# pass completed with fixes on unchanged content and its commit is an +# ancestor of HEAD. Its row is never CURRENT; a later full pass must follow. # After that pass: gstack-review-log '{...,"completed":true,"converged":true}' --finish TOKEN # With a findings file: gstack-review-log '{...}' --findings findings.jsonl # status/unresolved/issues_found/critical_gaps are DERIVED from the file (B7); @@ -72,9 +76,11 @@ fi SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive) export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND" if [ "$INPUT" = --start ]; then - GSTACK_REVIEW_SKILL="${2:-}" bun --no-env-file --config=/dev/null -e ' + case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac + GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config=/dev/null -e ' const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB); - console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL)); + try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); } + catch (e) { console.error(e.message); process.exit(1); } ' exit $? fi @@ -86,7 +92,7 @@ if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then exit $? fi usage() { - echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL | --check-shared-libs TOKEN < finding.json' >&2 + echo 'Usage: gstack-review-log JSON [--finish TOKEN] [--findings FILE] | --start SKILL [--fix-range] | --check-shared-libs TOKEN < finding.json' >&2 echo 'Exit codes: 0 written · 1 rejected · 2 usage' >&2 exit 2 } diff --git a/bin/gstack-session-update b/bin/gstack-session-update index 7166abc6d..2ae01297f 100755 --- a/bin/gstack-session-update +++ b/bin/gstack-session-update @@ -53,6 +53,7 @@ LOG_FILE="$STATE_DIR/analytics/session-update.log" PENDING_FILE="$STATE_DIR/session-update-pending" HOOK_CHECK="$(cd "$(dirname "$0")" && pwd)/gstack-hook-check" FF_UPDATE="$(cd "$(dirname "$0")" && pwd)/gstack-ff-update" +LEASE="$(cd "$(dirname "$0")" && pwd)/gstack-lease" RESCUE_FILE="$STATE_DIR/session-update-rescues" # Path spelling (Git Bash /c/x vs native C:/x) for the held-reason prefix strip. . "$(cd "$(dirname "$0")" && pwd)/gstack-path-spelling.sh" 2>/dev/null || { gstack_path_shell() { printf '%s\n' "$1"; }; gstack_path_native() { printf '%s\n' "$1"; }; } @@ -362,6 +363,16 @@ fi ) & HB_PID=$! + # ── Workflow leases (#3363): a live gstack session on this installation + # (bin/gstack-lease) means neither the fast-forward nor a pending setup + # retry runs under it, the rule bin/gstack-upgrade enforces as UPGRADE_BUSY. + # The throttle file is not written, so the next session start tries again. + if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$LEASE" ] \ + && BUSY=$("$LEASE" live --root "$GSTACK_DIR" --exclude-ancestry-of $$ 2>/dev/null); then + log_entry "SKIP leased $(printf '%s\n' "$BUSY" | awk 'NF { printf "%spid=%s/%s", n++ ? " " : "", $1, $2 }')" + exit 0 + fi + # ── Fetch, check the incoming release's Bun floor, then fast-forward (E1) ── OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null) UPDATE_URL=$(git -C "$GSTACK_DIR" remote get-url origin 2>/dev/null || echo "") diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 6592d5746..680a645b9 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -604,7 +604,7 @@ Key routing rules: - Architecture → invoke /plan-eng-review - Design system/plan review → invoke /design-consultation or /plan-design-review - Full review pipeline → invoke /autoplan -- Bugs/errors → invoke /investigate +- Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test) - QA/testing site behavior → invoke /qa or /qa-only - Code review/diff check → invoke /review - Visual polish → invoke /design-review @@ -639,10 +639,21 @@ fi # end interactive-onboarding guard (kind != spawned/unattended) # final gate is written and never approved, nothing is published or synced. # It also carries the host adaptation a harness without a Skill tool, slash # commands or AskUserQuestion needs, printed here so every host's render has it -# without growing any skill's prose. +# without growing any skill's prose. The record sentence is per skill: only +# skills with steps that write GSTACK_RESULT are told to end with it, and the +# rest name the record the parent reads instead (docs/unattended.md, #3281). if [ "$_SESSION_KIND" = "unattended" ]; then + _UA_NONE="This skill writes no run directory and prints no GSTACK_RESULT line, so do not invent either; end at its first pending item and list that item, with every recommended option you took, in your final report." + case "$SKILL_NAME" in + autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=\`." ;; + eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=\`, where is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in ." ;; + ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt in the PR body (\`gstack-ship-receipt read --pr --json\`)." ;; + review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;; + qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;; + *) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;; + esac _emit_block unattended-session < status=gate_pending run=\`; you never approve it, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read. +You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read. EOI fi diff --git a/bin/gstack-specialist-stats b/bin/gstack-specialist-stats index 3162e3033..af0b3d15e 100755 --- a/bin/gstack-specialist-stats +++ b/bin/gstack-specialist-stats @@ -5,6 +5,8 @@ # Reads all *-reviews.jsonl files across branches, parses specialist fields, # and outputs hit rates. Tags specialists as GATE_CANDIDATE (0 findings in 10+ # dispatches) or NEVER_GATE (security, data-migration, state-machines — insurance policy). +# Fix-range passes (scope "fix-range", #3339) are skipped: a narrowed dispatch +# says nothing about a specialist's hit rate on a whole branch. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" @@ -40,7 +42,7 @@ let reviewed = 0; for (const line of lines) { try { const e = JSON.parse(line); - if (!e.specialists) continue; + if (!e.specialists || e.scope === 'fix-range') continue; reviewed++; for (const [name, info] of Object.entries(e.specialists)) { if (!stats[name]) stats[name] = { dispatched: 0, findings: 0 }; diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 0d470c351..ba6a51254 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -25,8 +25,16 @@ // the caller must handle), exit 2 on bad args / unresolvable base / // broken version source. // -// write --version [--version-path

] [--regen-digest] +// write --version [--version-path

] [--regen-digest] [--changelog

] // Validates the 4-digit pattern, writes VERSION + package.json.version. +// --changelog

(opt-in): re-head the branch's `## [Unreleased]` +// entry in

as `## [] - ` at the top, with +// lib/changelog-check's reheadChangelog — the same function +// gstack-restamp uses, so a later restamp finds the heading already in +// place (or moves it again as the branch's own stamp). No [Unreleased] +// section, or an absent file, is a no-op. Duplicate headings refuse with +// exit 2 before anything is written. restamp never passes the flag: under +// stamp-at-merge it stays the only owner of the re-heading. // --regen-digest (opt-in): additionally run the target repo's own // scripts/gen-agents-digest.ts when present, so the version-stamped // committed digest stays freshness-green through the bump. Executes @@ -67,6 +75,7 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { bunIsolationArgs } from "../lib/bun-isolation"; +import { checkChangelog, parseChangelog, reheadChangelog } from "../lib/changelog-check"; import { extractVersion, isJsonVersionPath, @@ -404,6 +413,42 @@ function regenAgentsDigest(versionPath: string, optedIn: boolean): boolean | nul } } +interface ChangelogPlan { rel: string; text: string | null; detail: string } + +/** + * --changelog: computed before any write, so a CHANGELOG the re-heading + * cannot handle (duplicate headings) refuses with nothing written. Only an + * existing [Unreleased] section moves; released sections are never renamed. + */ +function planChangelog(cwd: string, args: string[], version: string): ChangelogPlan | null { + if (!args.includes("--changelog")) return null; + const rel = argVal(args, "--changelog"); + if (!rel || rel.startsWith("--")) fail("write: --changelog needs a path (for example --changelog CHANGELOG.md)", 2); + assertRepoContained(cwd, rel, "--changelog"); + const p = join(cwd, rel); + if (!existsSync(p)) return { rel, text: null, detail: `${rel} absent` }; + const text = readFileSync(p, "utf-8"); + if (!parseChangelog(text).sections.some((s) => s.unreleased)) return { rel, text: null, detail: "no [Unreleased] section" }; + const r = reheadChangelog(text, { from: null, to: version, date: new Date().toISOString().slice(0, 10), file: rel }); + if (r.error) fail(`write: ${r.error.path}: ${r.error.message}. Nothing written.`, 2); + if (!r.moved) return { rel, text: null, detail: `already headed ${version}` }; + const errors = checkChangelog(r.text, version, rel); + if (errors.length) fail(`write: ${errors[0]!.path}: ${errors[0]!.message}. Nothing written.`, 2); + return { rel, text: r.text, detail: `Unreleased -> ${version}` }; +} + +function writeChangelog(cwd: string, plan: ChangelogPlan | null): { changelog: { path: string; moved: boolean; detail: string } } | Record { + if (!plan) return {}; + if (plan.text !== null) { + try { + writeFileSync(join(cwd, plan.rel), plan.text); + } catch { + fail(`write: the version files were written but ${plan.rel} was not. Re-run write with the same --version and --changelog.`, 3); + } + } + return { changelog: { path: plan.rel, moved: plan.text !== null, detail: plan.detail } }; +} + function cmdWrite(args: string[], cwd: string): void { const version = argVal(args, "--version"); if (!version) fail("write requires --version ", 2); @@ -431,6 +476,7 @@ function cmdWrite(args: string[], cwd: string): void { // npm rejects forever — with no drift state to catch it (a JSON source is // self-consistent by construction). const jsonV = npmVersion(version!); + const changelogPlan = planChangelog(cwd, args, jsonV); let manifestWritten = false; let lockSynced: string[] = []; try { @@ -466,6 +512,7 @@ function cmdWrite(args: string[], cwd: string): void { // here: a JSON-manifest version source implies no plain VERSION file // for the digest generator to stamp. agentsDigest: null, + ...writeChangelog(cwd, changelogPlan), }) + "\n", ); return; @@ -473,6 +520,7 @@ function cmdWrite(args: string[], cwd: string): void { const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path")); const hasPkg = existsSync(pkgPath); + const changelogPlan = planChangelog(cwd, args, version!); writeFileSync(versionPath, version + "\n"); const digestRegen = regenAgentsDigest(versionPath, args.includes("--regen-digest")); let lockSynced: string[] = []; @@ -511,6 +559,7 @@ function cmdWrite(args: string[], cwd: string): void { packageLock: lockSynced.length > 0, // null = not the gstack repo (no digest to regen); true/false = regen outcome agentsDigest: digestRegen, + ...writeChangelog(cwd, changelogPlan), }) + "\n", ); } diff --git a/browse/src/bun-child-config.ts b/browse/src/bun-child-config.ts index 045a97571..7c808c02b 100644 --- a/browse/src/bun-child-config.ts +++ b/browse/src/bun-child-config.ts @@ -33,3 +33,41 @@ export function emptyBunConfig(platform: NodeJS.Platform = process.platform): st emptyConfig = file; return file; } + +export interface BunExeLookup { + env?: NodeJS.ProcessEnv; + execPath?: string; + isBun?: boolean; + isFile?: (p: string) => boolean; +} + +/** + * The bun.exe a Windows parent spawns its bun children with, or null when + * none is installed: the running bun.exe itself, then BUN_INSTALL\bin, each + * absolute PATH entry (plus, beside npm's bun.cmd shim, the bun.exe it + * forwards to), then USERPROFILE\.bun\bin. The terminal agent and the native + * cookie-import worker (cookie-import-native.ts) both resolve through it; it + * lives here because this file is part of the native import's source-bound + * qualification (NATIVE_CODE_INPUTS), and terminal-agent-control.ts is not. + */ +export function findWindowsBunExe(opts: BunExeLookup = {}): string | null { + const win = path.win32; + const env = opts.env ?? process.env; + const execPath = opts.execPath ?? process.execPath; + const isBun = opts.isBun ?? !!process.versions.bun; + const isFile = opts.isFile ?? ((p: string) => { try { return fs.statSync(p).isFile(); } catch { return false; } }); + // A compiled browse.exe also reports versions.bun, so check the name too. + if (isBun && /^bun\.exe$/i.test(win.basename(execPath))) return execPath; + const dirs = (env.PATH || env.Path || '').split(';').filter(d => d && win.isAbsolute(d)); + const candidates = [ + ...(env.BUN_INSTALL ? [win.join(env.BUN_INSTALL, 'bin', 'bun.exe')] : []), + ...dirs.flatMap(d => [ + win.join(d, 'bun.exe'), + // npm global install: PATH holds only the bun.cmd shim; the real + // binary it forwards to lives under node_modules. + ...(isFile(win.join(d, 'bun.cmd')) ? [win.join(d, 'node_modules', 'bun', 'bin', 'bun.exe')] : []), + ]), + ...(env.USERPROFILE ? [win.join(env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []), + ]; + return candidates.find(isFile) ?? null; +} diff --git a/browse/src/cookie-import-native.ts b/browse/src/cookie-import-native.ts index 6fc0c41cb..7632300a6 100644 --- a/browse/src/cookie-import-native.ts +++ b/browse/src/cookie-import-native.ts @@ -4,7 +4,7 @@ import { createRequire } from 'node:module'; import { release } from 'node:os'; import path from 'node:path'; import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser'; -import { emptyBunConfig } from './bun-child-config'; +import { emptyBunConfig, findWindowsBunExe } from './bun-child-config'; import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity'; type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge'; @@ -107,14 +107,7 @@ export async function importNativeCookies(options: { } catch (error) { throw new CookieImportError('The native browser build could not be checked safely. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified'); } - const bunCandidates = [ - ...(process.env.BUN_INSTALL ? [path.join(process.env.BUN_INSTALL, 'bin', 'bun.exe')] : []), - ...(process.env.PATH || process.env.Path || '').split(path.delimiter).filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bun.exe')), - ...(process.env.USERPROFILE ? [path.join(process.env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []), - ]; - const bunExecutable = bunCandidates.find(candidate => { - try { return statSync(candidate).isFile(); } catch { return false; } - }); + const bunExecutable = findWindowsBunExe(); if (!bunExecutable || !qualified.some(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion)) { throw new CookieImportError('This browser build or supervisor runtime has not passed native qualification. Sign in manually in the gstack browser.', 'native_unqualified'); } diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts index 1b7247838..bda034539 100644 --- a/browse/src/terminal-agent-control.ts +++ b/browse/src/terminal-agent-control.ts @@ -22,7 +22,7 @@ import { safeUnlink, isProcessAlive } from './error-handling'; import { restrictFilePermissions, mkdirSecure } from './file-permissions'; import { atomicWriteSync } from '../../lib/fs-atomic'; import { readPidCmdline, readPidStartTime } from './xvfb'; -import { emptyBunConfig } from './bun-child-config'; +import { emptyBunConfig, findWindowsBunExe, type BunExeLookup } from './bun-child-config'; function agentProcessInfo(pid: number): { startTime: string; commandLine: string } { if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' }; @@ -149,6 +149,22 @@ export function bunChildFlags(): string[] { return ['--no-env-file', `--config=${emptyBunConfig()}`]; } +/** + * The bun executable to spawn the agent with. On Windows a bare 'bun' under + * the Node polyfill resolves (via cross-spawn) to npm's bun.cmd shim, run as + * `cmd.exe /c bun ...`. That breaks two ways: windowsHide only hides cmd.exe, + * and the detached cmd.exe has no console, so bun.exe allocates a fresh + * visible one (a window that steals focus on every spawn); and proc.pid is + * cmd.exe's, so the agent's startup check (record.pid === process.pid) never + * matches and it exits after 2s, which the 60s watchdog then respawns. + * Spawning bun.exe directly fixes both. Falls back to 'bun' when no .exe is + * found, which is the old behavior. + */ +export function resolveBunExecutable(opts: BunExeLookup & { platform?: string } = {}): string { + if ((opts.platform ?? process.platform) !== 'win32') return 'bun'; + return findWindowsBunExe(opts) ?? 'bun'; +} + export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null { const meta = searchHints.metaDir || __dirname; const exec = searchHints.execPath || process.execPath; @@ -208,7 +224,7 @@ export function spawnTerminalAgent(opts: { writeAgentRecord(stateDir, reservation); let proc: any; try { - proc = (Bun as any).spawn(['bun', 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], { + proc = (Bun as any).spawn([resolveBunExecutable(), 'run', ...bunChildFlags(), script, `--agent-gen=${gen}`], { cwd: opts.cwd || process.cwd(), env: { ...process.env, diff --git a/browse/test/sidebar-tabs.test.ts b/browse/test/sidebar-tabs.test.ts index a8ece8f68..3e0eed513 100644 --- a/browse/test/sidebar-tabs.test.ts +++ b/browse/test/sidebar-tabs.test.ts @@ -233,7 +233,7 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => { 'utf-8', ); expect(CONTROL_SRC).toContain('terminal-agent.ts'); - expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/); + expect(CONTROL_SRC).toMatch(/\.spawn\(\[resolveBunExecutable\(\),\s*'run',\s*\.\.\.bunChildFlags\(\),\s*script,\s*`--agent-gen=\$\{gen\}`\]/); expect(CONTROL_SRC).toContain('BROWSE_OWNER_PID: String(opts.ownerPid)'); }); }); diff --git a/browse/test/terminal-agent-bun-exe.test.ts b/browse/test/terminal-agent-bun-exe.test.ts new file mode 100644 index 000000000..5be1b0abf --- /dev/null +++ b/browse/test/terminal-agent-bun-exe.test.ts @@ -0,0 +1,85 @@ +/** + * Windows terminal-agent console flash: under the Node polyfill a bare 'bun' + * resolved to npm's bun.cmd shim and ran as `cmd.exe /c bun ...`. The detached + * cmd.exe had no console, so bun.exe opened a visible one on every spawn, and + * proc.pid was cmd.exe's, so the agent failed its startup record check and + * exited, and the 60s watchdog respawned it. spawnTerminalAgent now spawns + * bun.exe directly. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { findWindowsBunExe } from '../src/bun-child-config'; +import { resolveBunExecutable } from '../src/terminal-agent-control'; + +const files = (...paths: string[]) => (p: string) => paths.some(f => f.toLowerCase() === p.toLowerCase()); + +describe('resolveBunExecutable', () => { + test('POSIX keeps bare bun', () => { + expect(resolveBunExecutable({ platform: 'linux', isFile: () => true })).toBe('bun'); + expect(resolveBunExecutable({ platform: 'darwin', isFile: () => true })).toBe('bun'); + }); + + test('Windows under bun.exe reuses its own executable', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: true, execPath: 'C:\\Tools\\bun.exe', env: {}, isFile: () => false, + })).toBe('C:\\Tools\\bun.exe'); + }); + + test('a compiled browse.exe is not mistaken for bun', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: true, execPath: 'C:\\gstack\\browse\\dist\\browse.exe', + env: { PATH: 'C:\\bun\\bin' }, isFile: files('C:\\bun\\bin\\bun.exe'), + })).toBe('C:\\bun\\bin\\bun.exe'); + }); + + test('npm global install resolves through the bun.cmd shim to the real bun.exe', () => { + const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm'; + expect(resolveBunExecutable({ + platform: 'win32', isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe', + env: { PATH: `C:\\Windows\\System32;${npm}` }, + isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`), + })).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`); + }); + + test('BUN_INSTALL wins over PATH, and USERPROFILE\\.bun is the last resort', () => { + const isFile = files('C:\\bi\\bin\\bun.exe', 'C:\\p\\bun.exe', 'C:\\Users\\u\\.bun\\bin\\bun.exe'); + const base = { platform: 'win32', isBun: false, execPath: 'node.exe', isFile }; + expect(resolveBunExecutable({ ...base, env: { BUN_INSTALL: 'C:\\bi', PATH: 'C:\\p' } })).toBe('C:\\bi\\bin\\bun.exe'); + expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\p' } })).toBe('C:\\p\\bun.exe'); + expect(resolveBunExecutable({ ...base, env: { PATH: 'C:\\none', USERPROFILE: 'C:\\Users\\u' } })).toBe('C:\\Users\\u\\.bun\\bin\\bun.exe'); + }); + + test('falls back to bare bun when no bun.exe exists', () => { + expect(resolveBunExecutable({ + platform: 'win32', isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false, + })).toBe('bun'); + }); + + test('spawnTerminalAgent spawns through the resolver, not a bare bun', () => { + const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/terminal-agent-control.ts'), 'utf8'); + const spawnSite = source.slice(source.indexOf('export function spawnTerminalAgent')); + expect(spawnSite).toContain('spawn([resolveBunExecutable(), \'run\''); + expect(spawnSite).not.toMatch(/spawn\(\[\s*'bun'/); + }); +}); + +describe('findWindowsBunExe is the one bun.exe lookup', () => { + test('returns null, not a bare bun, when no bun.exe is installed', () => { + expect(findWindowsBunExe({ isBun: false, execPath: 'node.exe', env: { PATH: 'C:\\x;relative' }, isFile: () => false })).toBeNull(); + }); + + test('the native cookie-import worker finds an npm-global bun.exe through the same lookup', () => { + const npm = 'C:\\Users\\u\\AppData\\Roaming\\npm'; + expect(findWindowsBunExe({ + isBun: false, execPath: 'C:\\Program Files\\nodejs\\node.exe', env: { PATH: npm }, + isFile: files(`${npm}\\bun.cmd`, `${npm}\\node_modules\\bun\\bin\\bun.exe`), + })).toBe(`${npm}\\node_modules\\bun\\bin\\bun.exe`); + }); + + test('cookie-import-native has no bun.exe candidate list of its own', () => { + const source = fs.readFileSync(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'), 'utf8'); + expect(source).toContain('const bunExecutable = findWindowsBunExe();'); + expect(source).not.toContain("'bun.exe'"); + }); +}); diff --git a/docs/memorable-workflow-memory.md b/docs/memorable-workflow-memory.md index 0fe7021c8..9cf8ba715 100644 --- a/docs/memorable-workflow-memory.md +++ b/docs/memorable-workflow-memory.md @@ -94,10 +94,11 @@ It refuses the hand-off, with one rate-limited line in - the prompt carries a HIGH-tier credential shape (checked on the raw bytes and on the decoded string values, so a JSON-escaped key does not slip by), or is larger than 1 MiB; -- the repo's per-remote trust policy is `deny` or `read-only` (judged by the - session's working directory, so a session that touches other repositories - is not covered), or that policy could not be looked up at all (git could - not read the repository, the store is unreadable): the lookup fails closed; +- the repo's per-remote trust policy is `deny`, or `read-only` without the + recall opt-in below (judged by the session's working directory, so a + session that touches other repositories is not covered), or that policy + could not be looked up at all (git could not read the repository, the store + is unreadable or corrupt): the lookup fails closed; - the receipt cannot be written, or the hook's 4.5 s budget cannot afford the next step (the secret scan of a very large prompt, or the vendor spawn). @@ -163,6 +164,34 @@ whatever it stored are untouched: `memorable disable` stops capture, The entry also comes out with `gstack-uninstall` (named in its summary) and survives `./setup --no-team`, which only tears down team-mode hooks. +## Recall in a read-only repo (per-remote opt-in) + +`read-only` keeps a repo's content out of the brain, and by default it also +keeps that repo's prompts away from Memorable. If you chose `read-only` only +to stop code ingest, and you have no objection to Memorable seeing prompts +from that repo, opt that one remote in: + +```bash +gstack-gbrain-repo-policy memorable set github.com/acme/client-a allow +gstack-gbrain-repo-policy memorable get github.com/acme/client-a # read-only allow +gstack-gbrain-repo-policy memorable set github.com/acme/client-a off +``` + +The opt-in is off by default and covers only the remote you name (the SSH and +HTTPS spellings of it are one key). It is refused on a `deny` remote, and a +`deny` vetoes recall even if an opt-in was written into the store by hand. +Changing the remote's tier clears it, so moving a repo back to `read-only` +later never silently re-opens recall. When `~/.gstack` also makes the remote +read-only (see [state roots](state-root.md)), that store has to opt it in too. +A corrupt, empty or unreadable policy store (or a dangling symlink in its +place) skips recall for every repo until you fix it; the hook never resets it +to "no policy" and never rewrites it. The lookup checks every configured +`remote.origin.url`, not the URL after `url.*.insteadOf` rewriting, and any +denied or not-opted-in read-only one skips recall. `gstack-gbrain-repo-policy list` shows +opted-in remotes with a `memorable=allow` column. Nothing about gbrain +ingest changes: the repo stays `read-only` for `/sync-gbrain` and every +other gbrain writer. + ## If you run gbrain Memorable can keep its procedures in your own gbrain database instead of its @@ -189,7 +218,7 @@ and `/sync-gbrain` are unrelated to it. (roughly 4 seconds after gstack's own work). That is usually network. - **`hook-errors.log` names a refusal.** `refused:redaction-high` means a credential shape was in the prompt; `trust policy ... deny or read-only` - means the repo is protected; `receipt-unwritable` means + means the repo is protected (see the per-remote opt-in above); `receipt-unwritable` means `~/.gstack/security` is not writable. - **`enable` says the stable install predates this bridge.** Run `./setup` (or `/gstack-upgrade`) so the hook registered at `~/.claude/skills/gstack` diff --git a/docs/pregate.md b/docs/pregate.md index 22a661b98..216baf97c 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -19,7 +19,8 @@ Three bins, all over `lib/`: A pin file names commands. Its presence never authorizes execution: both tools read the committed copy on `origin/` (a PR cannot smuggle a command into -its own gate) and run commands only with `--allow-repo-commands`, which `/ship` +its own gate; `gstack-pregate run` alone also reads a user-owned file outside +the repository when the base has none, see [pregate v1](#pregate-v1)) and run commands only with `--allow-repo-commands`, which `/ship` passes. Standalone, `gstack-regen check` lists the commands and exits 3 (`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes` `incomplete`, never `pass`. `--policy-from head` (a PR that introduces or @@ -59,12 +60,13 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | Key | Meaning | |---|---| -| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH); nothing detected, a Dart SDK missing from PATH, or a `.dart` file with no `pubspec.yaml` above it is `incomplete` (the runnable groups still run, so a real failure still fails) | +| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p ` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) | | `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences) | | `empty_lanes` | extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a `join()` of path segments, an import, or a `dependencies` entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`, and so does licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`, `licenses/*.txt`). Source code and files under a test directory never qualify. A test that reads the file still selects that test | | `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) | | `warn` | check ids downgraded to warn; `secrets` and required lanes never | | `strays.allow` | intentional root files or new top-level directories | +| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane | | `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) | | `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) | | `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) | @@ -72,8 +74,36 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) | | `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) | -Only the `origin/` copy is honoured, so a downgrade is reviewed before it -takes effect. `gstack-pregate validate` / `init` cover the working-tree file. +Only the `origin/` copy or the user-owned file below is honoured, so a +downgrade is reviewed (or is the user's own) before it takes effect. A repo that +keeps `.gstack/` out of git (a shared repo where gstack is one contributor's +tool) can hold the same file at `/projects//pregate.json`. The +state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`, +else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `` is what +`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository +(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none +(never under `--policy-from head`), it lives outside the repository so a PR still +cannot reach it, and the record names it (`policy user `) and fingerprints +its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the +ship receipt treat an edited or deleted user file, or one that appears (or a +link on its path that starts pointing nowhere) after a run under the defaults, +as `PREGATE_STALE`. A path that is relative, resolves inside the repository, or +reaches it through any symlink on the way (a state root linked into the +checkout, or a link hop inside it, even when the last hop points back outside) +is ignored, since a PR could commit it or choose the file; `--explain` says +so, or says `(absent)` when no file is there. The slug and state root come from +the caller's environment, never the checkout's `.env`: the bin's shebang passes +`--no-env-file`, and a bare `bun gstack-pregate` launched beside any `.env` file +refuses (`POLICY_SOURCE_UNAVAILABLE`). `verify` reads both paths from the +record, not from its own environment. +It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the +base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`) +rather than holding a policy nothing reads. A user file that exists but cannot +be read, or a symlink on its path that points nowhere, refuses the same way +instead of falling back to defaults. A base copy git cannot read (and +`git ls-tree` cannot confirm is absent) refuses with `POLICY_SOURCE_UNAVAILABLE` +instead of handing the run to the user file. +`gstack-pregate validate` / `init` cover the working-tree file. `gstack-pregate init` writes a draft built from the repo, not a fixed example. It prints the languages it found and the runner each language's tests would @@ -115,13 +145,15 @@ Two stages with separate clocks, both printed: `TIMING_ASSERTIONS: found= escaped= files=` summary. It warns (`PREGATE_TIMING_ASSERTION`); it does not block. See [timing](#timing). -- `guards`: each guard in `.gstack/pregate.json` whose `when` globs match a +- `guards`: each guard in the active policy (`.gstack/pregate.json`, or the + user-owned file) whose `when` globs match a touched file (renames included) runs its `command` in its own process, under the verified launch Bun, with its own `timeout_s` (default `--timeout`). A non-zero exit fails pregate naming the guard and its `fix` (`PREGATE_GUARD_FAILED`); a timeout is `incomplete`; without `--allow-repo-commands` the selected guards are listed and the check is - `incomplete`. Guards are read from the base branch's copy and cannot be + `incomplete`. Guards are read from the base branch's copy (or the + user-owned file when the base carries none) and cannot be downgraded to warn. See [guards](#guards). **Stage 2, test execution:** @@ -135,7 +167,13 @@ Two stages with separate clocks, both printed: one has no lane until you declare `"**/integration_test/**": []` (and `"**/test_driver/**": []`) under `dependencies` and let CI run them; Dart files, device tests included, are source code, so a `dependencies` `[]` declaration clears them and `empty_lanes` - does not), transitively through + does not); in a Rust crate (the nearest Cargo.toml with a `[package]` name) a `.rs` file is a + test when it sits under `/tests/`, holds a `#[cfg(test)]` (any cfg predicate naming + `test`) or a test attribute (`#[test]`, `#[tokio::test]`), and every `.rs` file of the crate and its Cargo.toml select the crate's tests and + those of every crate whose manifest depends on it, directly or through other crates (cargo + runs a crate's tests, not a file, so the lane is the crate; a crate with no tests still has + no lane, and a workspace-only Cargo.toml or Cargo.lock needs a `dependencies` entry), + transitively through non-test files; deleted and renamed files select the tests that named them. An extensionless JS/TS import (relative, a tsconfig/jsconfig `paths` alias or `baseUrl`) tries the exact path, then `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, @@ -172,9 +210,10 @@ Two stages with separate clocks, both printed: that step's `working-directory`, else the job's or workflow's `defaults.run.working-directory`): local ones run through the runner under `set -o pipefail`, judged on the runner's summary line (zero tests ran is a - failure, a `| tail`-hidden exit is still an exit); a platform lane this + failure, a `| tail`-hidden exit is still an exit; cargo's `test result:` lines, one per + test binary and doc-tests included, are summed); a platform lane this machine cannot run becomes a `requires-remote` obligation naming - `workflow.yml/job`. `--explain` prints each test's provenance and every + `workflow.yml/job`, unless `local` names that lane. `--explain` prints each test's provenance and every uncertain mapping (a planner-computed matrix without a `selection` manifest selects conservatively). Test files matching `serial_patterns` (default `**/*.serial.test.*`) run one process per file after the unit files. The @@ -187,7 +226,7 @@ Two stages with separate clocks, both printed: runner process (the unit-file group, each serial file, each guard without its own `timeout_s`), not their total; the stall timeout catches a hung or silent one sooner. -- Under 8 cores, a base-branch `remote_gate` or a remote `test_backend` +- Under 8 cores, a `remote_gate` in the active policy or a remote `test_backend` (`gstack-config set test_backend ubicloud`) records the local test tier as a `requires-remote` obligation instead of running it: see [remote gate](#remote-gate). @@ -234,7 +273,7 @@ test-tier command: Per-file lines come from a progress adapter where the runner's reporter provides them: `bun test` (its `:` headers) is the only adapter today; a one-file run (each serial file) reports its file from the launch for any - runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test) get heartbeats + runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test, cargo test) get heartbeats only. A heartbeat follows each 60 s with no progress line; - is killed as **stalled** when its log does not grow for the stall timeout (`--stall-timeout `, else `stall_timeout_s`, else 300; `0` disables): the @@ -253,8 +292,8 @@ misses a descendant that was reparented or started detached. ## Small machines and the remote gate -Under 8 cores (`os.availableParallelism()`), when the base branch's -`.gstack/pregate.json` declares `remote_gate` or `gstack-config get +Under 8 cores (`os.availableParallelism()`), when the active policy (the +base branch's `.gstack/pregate.json`, or the user-owned file) declares `remote_gate` or `gstack-config get test_backend` is not `local`, the lanes check runs nothing locally: it records the selected local files as a `requires-remote` obligation with the command to run, `pregate.json/remote_gate` (the declared command) or diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 316018092..7901da94a 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -337,7 +337,9 @@ gstack-ship-queue train build --prs 41@,42@,43@ It prints `ORDER:` (the slot order to announce: screened PRs first in READY order, then unscreened; when `max_size` or more unscreened PRs wait they form -their own train and `HELD:` lists them), one `PREFIX: T +their own train and `HELD:` lists them; `--order ,,...` naming every +pinned PR once replaces that rule, for a PR that must merge before another, +and the train's later `NEXT:` rebuilds keep it), one `PREFIX: T tree=` per prefix and one `SLOT:` per PR. `--dry-run` adds the `ESTIMATE:` line and writes, pushes and dispatches nothing. Otherwise it writes `train.json`, pushes `train/`, dispatches `free_workflow` there @@ -355,7 +357,11 @@ merging #,#; nothing dispatched, no branch pushed; fix: …` line names the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red, missing, cancelled or on another SHA). In a merge, version files take the prefix's side and the CHANGELOG merges by section, since the stamp at merge -rewrites both. +rewrites both; a PR whose CHANGELOG drops or renames a released heading (or +carries a released entry's text in a section of its own) is a CHANGELOG +conflict, never merged with the damage. READY receipts and train records are +keyed on origin's GitHub owner/repo, so lanes whose origin uses another URL +form (git.capy.ai, ssh, a token URL) share the coordinator's queue. **3. Coordinator: status and verdict.** @@ -406,13 +412,13 @@ EVAL_POLICY records it. Bisect results never turn a train green. ```bash git fetch origin main git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only -bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);" +bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3), s(1)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);" git add CHANGELOG.md && git commit --no-edit gstack-restamp --next --base main --pr 42 --expect-base

--expect-order --allow-repo-commands git add CHANGELOG.md VERSION package.json && git commit -m "v stamp" && git push ``` -A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. `verify` accepts the resulting head because its tree equals T modulo stamps; the `head:` line says "T with main merged in". +A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. `verify` accepts the resulting head because it contains main and its tree equals T modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T with main merged in". **5. Coordinator: verify, merge, verify again.** For each slot in order: @@ -423,8 +429,9 @@ gstack-ship-queue verify --pr 41 --train # VERIFY_TRAIN: #41 merged; m ``` Before a merge, main's tree must equal the prefix before the PR modulo stamps -and the PR must be at its pin (stamps aside) or at T modulo stamps (its pin -with main merged in); after it, main's tree +and the PR must be at its pin (stamps aside) or contain main and be T +modulo stamps (its pin with main merged in; its CHANGELOG must be main's with +only lines from the pin's own CHANGELOG diff above it); after it, main's tree must equal the prefix after it. Anything else is `QUEUE_TRAIN_TREE_MISMATCH` (or `QUEUE_TRAIN_MOVED`). When nothing of the train has merged, that is a plain refusal: build a new train on current main. When earlier slots already @@ -471,6 +478,20 @@ which carry the workflow consequences the skill prose would otherwise spell out: The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`), written by the pr-body section of `/ship` after the body is composed. +## What `/land-and-deploy` reads from it + +Step 3.4 runs `show-effective --base ` before the merge and prints one +`VERSION_CHECK:` verdict for the PR head: + +| Policy | Stops when | Passes | +|---|---|---| +| `queue_mode: stamp-at-merge` | the head's VERSION is not the base's version plus one bump, or the changelog's top two headings at the head are not that version and then the base's top entry (`STOP stamp stale`: run `gstack-restamp --next` on the branch, push the stamp, rerun) | an exact stamp; other open PRs' tentative stamps are ignored | +| `queue_mode: claim-at-open` (and no policy file) | another open PR holds the same VERSION, or the base is already at or above it (`VERSION drift detected`: rerun `/ship`) | any unique VERSION above the base, including a MICRO bump or one below a sibling's claim | + +An invalid policy, an unreadable claim list or a missing VERSION is `unavailable`: +the skill reports it and CI's version gate is the backstop. A base at or above the +branch VERSION still stops. + ## `queue_mode: stamp-at-merge` diff --git a/docs/state-root.md b/docs/state-root.md index 5cd662ee5..45c246ec6 100644 --- a/docs/state-root.md +++ b/docs/state-root.md @@ -104,6 +104,9 @@ lives in `projects//`. `bin/gstack-slug` picks the slug from the git and the ssh, https and ssh-with-port spellings of one repository share one. - Without a remote, the project root's directory name is used. +A bucket can also hold a user-owned `pregate.json` policy, for a repo that keeps +`.gstack/` out of git (see [pregate v1](pregate.md#pregate-v1)). + The first slug gstack resolves in a directory sticks (it is cached under `slug-cache/`), so a project keeps its bucket when it adds a remote later. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 48d90fcf2..f9ca4cef2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -170,8 +170,10 @@ the readiness dashboard and the PR body, and never count it as a pass. findings. - **Every review gate.** These rules apply to every gate that summarises a review, including the `execution` gate behind `/codex` consult and challenge. - A reply whose last `Verdict:` line says `not ready` but carries no readable - severity prints `FINDINGS: unspecified` and exits 3 like a blocking finding. + A reply whose last `Verdict:` line says `not ready` is `findings` and exits + 3 even when readable advisory severities (P2 or P3) were parsed; `FINDINGS:` + names the highest one. A not-ready reply with no readable severity still + prints `FINDINGS: unspecified` and exits 3. - **No findings.** A review with no tag and no label is `clean` only when its conclusion says so: the last sentence (or its last ", and" clause), or the `because` clause of its `Recommendation:` line, opens with `NO_FINDINGS`, @@ -2472,7 +2474,13 @@ file is described in [docs/ship-policy.md](ship-policy.md); the queue flow in **Meaning.** The policy is read from the committed copy on `origin/`, and that ref does not resolve here (a shallow or detached gate checkout, or a fetch that never happened). The tool exits 3 and never falls back to the -working-tree file silently. +working-tree file silently. For `gstack-pregate` the same refusal covers a base +`.gstack/pregate.json` git cannot read (a timeout, an oversized blob, a missing +or corrupt object) that `git ls-tree` cannot confirm is absent; it never falls +through to the user-owned file. It also refuses when Bun loaded a `.env` file +from the working directory (a bare `bun gstack-pregate`), since a branch could +then pick the user policy: run `gstack-pregate` directly or as +`bun --no-env-file`. **Fix.** `git fetch origin --depth=1`, then rerun. To run against the branch's own file deliberately, pass `--policy-from head --allow-repo-commands`; @@ -2875,7 +2883,11 @@ or `REALITY: surface-check finding bun upgrade --version absent on 1.3.14 bin/gs Both tools read their pin file (`.gstack/generated.json`, `.gstack/pregate.json`) from `origin/`, never the working tree, and run repo-declared commands only -under `--allow-repo-commands` or inside `/ship`. Details: docs/pregate.md. +under `--allow-repo-commands` or inside `/ship`. When the base carries no +`.gstack/pregate.json`, `gstack-pregate run` reads +`/projects//pregate.json` instead (outside the repository; see +docs/pregate.md for the state root and slug). +Details: docs/pregate.md. ### `REGEN_REGISTRY_INVALID` @@ -2930,10 +2942,16 @@ repository's lockfile), then rerun `gstack-regen check` or the pre-gate. **Meaning.** `.gstack/pregate.json` has an unknown key, a wrong type, a glob that escapes the repository, a runner without `{files}`, or `secrets` in `warn`. +The user-owned `/projects//pregate.json` also refuses when it +sets `sealed` or `price_file` (both are read only from the base branch's copy) +or exists but cannot be read (a dangling symlink on its path counts). **Fix.** `gstack-pregate validate`. `secrets` and required-lane obligations cannot be downgraded; every other check id in `warn` is honoured only from the -base branch's copy. +base branch's copy or the user-owned file. For the user-owned file, edit it +directly at the path the message names. A user file inside the repository, or +reached through a symlink into it, is ignored rather than read (`--explain` +names it). ### `PREGATE_SECRETS` @@ -2978,7 +2996,7 @@ allowlisted tool config no test names, are the assets-and-tool-config empty lane not this error. **Fix.** Add a test that names the file, or declare its lane (an explicit `[]` -for files with no behaviour) under `dependencies` in `.gstack/pregate.json`. +for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or the user-owned file the message names). Other non-Markdown documentation goes in `empty_lanes` on the base branch. Dart files under `integration_test/` or `test_driver/` need a device, so pregate never runs them: declare `"**/integration_test/**": []` (and @@ -3017,7 +3035,7 @@ declare `runner`), raise `--timeout`, or keep the tree still, then rerun. **Meaning.** A test command the lanes check (or a guard, or the `--remote` gate) launched wrote nothing to its log for the stall timeout (default 300 s, -`stall_timeout_s` in the base branch's `.gstack/pregate.json`, or +`stall_timeout_s` in the base branch's `.gstack/pregate.json` or the user-owned file, or `--stall-timeout`). pregate killed its process group and names the last test file started. The check is incomplete, never pass. @@ -3045,7 +3063,10 @@ file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap ### `PREGATE_STALE` **Meaning.** `pregate.json` was recorded against a different working-tree -fingerprint than the one being published (the final stamp, a regen, any edit). +fingerprint than the one being published (the final stamp, a regen, any edit), +or the user-owned policy it ran under was edited or deleted (or one appeared, +or a symlink on its path started pointing nowhere, after a run under the +defaults). **Fix.** Rerun `gstack-pregate` after the final generation and stamp; Step 18 refuses to write the ship receipt otherwise. @@ -3274,8 +3295,9 @@ next `./setup` prints no CRLF warning. ### `PREGATE_GUARD_FAILED` -**Meaning.** A guard from the base branch's `.gstack/pregate.json` (a repo -command such as a serial-test manifest check or a CLI golden test) matched a +**Meaning.** A guard (a repo command such as a serial-test manifest check or a +CLI golden test) from the base branch's `.gstack/pregate.json`, or from the +user-owned file when the base carries none, matched a file the branch touched and exited non-zero. Guards cover what the import graph cannot see: manifests, subprocess goldens, registries. They cannot be downgraded to warn. diff --git a/health/SKILL.md b/health/SKILL.md index 8eb500741..1078ec6d5 100644 --- a/health/SKILL.md +++ b/health/SKILL.md @@ -500,7 +500,7 @@ Score each category on a 0-10 scale using this rubric: | Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass | | Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused | | Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) | -| GBrain | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) | +| GBrain | 10% | Computed in code: see **GBrain sub-score computation** below | — | — | N/A (gbrain not installed) | **Parsing tool output for counts:** Use the complete captured output, not the displayed tail. A zero match count cannot @@ -534,17 +534,28 @@ a numeric composite, and skip numeric history persistence and trend calculation. **GBrain sub-score computation:** -``` -doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok"; - 7 if "warnings"; 0 otherwise (or command times out after 5s). -queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines; - 7 if 10-100; 0 if >=100 (suggests secret-scan rejections - piling up). N/A if artifacts_sync_mode == off. -push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h; - 7 if <72h; 0 if >=72h. N/A if artifacts_sync_mode == off. -gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component - (redistribute 0.3 + 0.2 into doctor when sync_mode is off: - gbrain_score = doctor_component in that case) +Compute the GBrain score in code, not by hand, and pass the printed +`GBRAIN_SCORE` as `gbrain` to the composite above. Gather three readings: + +- **doctor:** 10 when `gbrain doctor --json` reports `status` `ok`, 7 for + `warnings`, 0 for anything else or when the call times out after 5s. +- **queue:** the line count of `~/.gstack/.brain-queue.jsonl` (0 when absent). + A long queue suggests secret-scan rejections piling up. +- **pushHours:** hours since the mtime of `~/.gstack/.brain-last-push`. + +Pass `null` for both queue and pushHours when `artifacts_sync_mode` is `off`; +the doctor component then carries the whole score: + +```bash +bun --no-env-file --config=/dev/null -e ' +const doctor = ; +const queue = ; +const pushHours = ; +const d = doctor ?? 0; +const band = (v, ten, seven) => (v < ten ? 10 : v < seven ? 7 : 0); +const score = queue === null || pushHours === null ? d : 0.5 * d + 0.3 * band(queue, 10, 100) + 0.2 * band(pushHours, 24, 72); +console.log("GBRAIN_SCORE: " + score.toFixed(1)); +' ``` The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung @@ -625,7 +636,8 @@ Fields: - `ts` -- ISO 8601 timestamp - `branch` -- current git branch - `score` -- composite score (one decimal) -- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10) +- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10) +- `gbrain` -- the printed `GBRAIN_SCORE` (0-10, one decimal) - `duration_s` -- total time for all tools in seconds If a category was skipped, set its value to `null`. Treat a history entry diff --git a/health/SKILL.md.tmpl b/health/SKILL.md.tmpl index c16434508..47f83551e 100644 --- a/health/SKILL.md.tmpl +++ b/health/SKILL.md.tmpl @@ -177,7 +177,7 @@ Score each category on a 0-10 scale using this rubric: | Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass | | Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused | | Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) | -| GBrain | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) | +| GBrain | 10% | Computed in code: see **GBrain sub-score computation** below | — | — | N/A (gbrain not installed) | **Parsing tool output for counts:** Use the complete captured output, not the displayed tail. A zero match count cannot @@ -211,17 +211,28 @@ a numeric composite, and skip numeric history persistence and trend calculation. **GBrain sub-score computation:** -``` -doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok"; - 7 if "warnings"; 0 otherwise (or command times out after 5s). -queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines; - 7 if 10-100; 0 if >=100 (suggests secret-scan rejections - piling up). N/A if artifacts_sync_mode == off. -push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h; - 7 if <72h; 0 if >=72h. N/A if artifacts_sync_mode == off. -gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component - (redistribute 0.3 + 0.2 into doctor when sync_mode is off: - gbrain_score = doctor_component in that case) +Compute the GBrain score in code, not by hand, and pass the printed +`GBRAIN_SCORE` as `gbrain` to the composite above. Gather three readings: + +- **doctor:** 10 when `gbrain doctor --json` reports `status` `ok`, 7 for + `warnings`, 0 for anything else or when the call times out after 5s. +- **queue:** the line count of `~/.gstack/.brain-queue.jsonl` (0 when absent). + A long queue suggests secret-scan rejections piling up. +- **pushHours:** hours since the mtime of `~/.gstack/.brain-last-push`. + +Pass `null` for both queue and pushHours when `artifacts_sync_mode` is `off`; +the doctor component then carries the whole score: + +```bash +bun --no-env-file --config=/dev/null -e ' +const doctor = ; +const queue = ; +const pushHours = ; +const d = doctor ?? 0; +const band = (v, ten, seven) => (v < ten ? 10 : v < seven ? 7 : 0); +const score = queue === null || pushHours === null ? d : 0.5 * d + 0.3 * band(queue, 10, 100) + 0.2 * band(pushHours, 24, 72); +console.log("GBRAIN_SCORE: " + score.toFixed(1)); +' ``` The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung @@ -301,7 +312,8 @@ Fields: - `ts` -- ISO 8601 timestamp - `branch` -- current git branch - `score` -- composite score (one decimal) -- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10) +- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10) +- `gbrain` -- the printed `GBRAIN_SCORE` (0-10, one decimal) - `duration_s` -- total time for all tools in seconds If a category was skipped, set its value to `null`. Treat a history entry diff --git a/hosts/claude/hooks/memorable-user-prompt-hook.ts b/hosts/claude/hooks/memorable-user-prompt-hook.ts index 26cd1925a..3f01d7d01 100644 --- a/hosts/claude/hooks/memorable-user-prompt-hook.ts +++ b/hosts/claude/hooks/memorable-user-prompt-hook.ts @@ -10,7 +10,8 @@ * and clean removal. This file is that mediation. * * stdin JSON -> cap 1 MiB -> parse -> MEMORABLE=0? -> gate memorable_recall == on? - * -> win32? -> trust policy (deny / read-only veto, by session cwd; fail-closed) + * -> win32? -> trust policy (deny / read-only veto, by session cwd; fail-closed; + * a read-only remote's explicit per-remote opt-in lifts it, deny never) * -> HIGH-tier secret scan (raw bytes AND decoded string leaves, each admitted by the clock) * -> resolve vendor -> budget >= 500 ms? -> gate re-check * -> receipt (fail-closed: no receipt, no send) @@ -57,7 +58,7 @@ import { } from '../../../lib/egress-receipt'; import { wrapUntrustedTrackerContent } from '../../../lib/tracker-guard'; import { scan } from '../../../lib/redact-engine'; -import { hasRepoPolicyStore, repoPolicyTier } from '../../../lib/gbrain-repo-policy-client'; +import { hasRepoPolicyStore, memorableRecallPolicy } from '../../../lib/gbrain-repo-policy-client'; import { LOG_RATE_LIMIT_MS, logHookError as sharedLogHookError } from './hook-log'; export { LOG_RATE_LIMIT_MS }; @@ -393,13 +394,30 @@ async function policyVeto(cwd: string, timeoutMs: number, remaining: () => numbe if (git.status === GIT_NO_REMOTE) return 'ok'; if (git.status === GIT_FATAL && NOT_A_REPO_RE.test(git.stderrTail.trim())) return 'ok'; if (git.status !== 0) return 'error'; - const url = git.stdout.toString('utf8').trim(); - if (!url) return 'ok'; - const res = repoPolicyTier(url, process.env, Math.max(1, Math.min(STAGE_CAP_MS, remaining()))); - if (res.error) return 'error'; - // `deny` and `read-only` are the tiers a user picks so a repo's content - // never lands in a shared store; a third-party memory service is one. - return res.tier === 'deny' || res.tier === 'read-only' ? 'skip' : 'ok'; + if (!git.stdout.toString('utf8').trim()) return 'ok'; + // The store is keyed by the configured URLs (what `gstack-gbrain-repo-policy + // get` and /sync-gbrain read), not the url.*.insteadOf rewrite that + // `remote get-url` applies, and every configured origin URL is checked: + // neither a rewrite nor an added URL may dodge a deny. + const raw = await runExternal('git', ['config', '--null', '--get-all', 'remote.origin.url'], { + cwd, timeoutMs: Math.max(1, Math.min(timeoutMs, remaining())), maxBuffer: GIT_MAX_BUFFER, env: gitEnv(process.env), + onSpawn: (kill) => { killInflight = kill; }, + }); + killInflight = null; + if (raw.timedOut || raw.error || raw.status !== 0) return 'error'; + const urls = raw.stdout.toString('utf8').split('\0').filter((u) => u !== ''); + if (urls.length === 0) return 'error'; + for (const url of urls) { + const res = memorableRecallPolicy(url, process.env, Math.max(1, Math.min(STAGE_CAP_MS, remaining()))); + if (res.error) return 'error'; + // `deny` and `read-only` are the tiers a user picks so a repo's content + // never lands in a shared store; a third-party memory service is one. + // Only an explicit per-remote opt-in (`gstack-gbrain-repo-policy memorable + // set allow`) lifts the read-only veto; nothing lifts deny. + if (res.tier === 'deny') return 'skip'; + if (res.tier === 'read-only' && !res.memorableOptIn) return 'skip'; + } + return 'ok'; } function writeStdout(text: string): Promise { @@ -446,7 +464,7 @@ export async function main(): Promise { const payloadCwd = (payload as { cwd?: unknown }).cwd; const cwd = typeof payloadCwd === 'string' && isDirectory(payloadCwd) ? payloadCwd : process.cwd(); const veto = await policyVeto(cwd, Math.min(STAGE_CAP_MS, remaining()), remaining); - if (veto === 'skip') { logHookError(`trust policy for ${cwd} is deny or read-only, recall skipped`, Date.now(), 'trust policy skip'); return; } + if (veto === 'skip') { logHookError(`trust policy for ${cwd} is deny or read-only, recall skipped (a read-only remote can opt in: gstack-gbrain-repo-policy memorable set allow)`, Date.now(), 'trust policy skip'); return; } if (veto === 'error') { logHookError('trust policy lookup failed (store or repository unreadable), recall skipped (fail-closed)'); return; } // HIGH-tier pre-scan over the raw text and the decoded string leaves (a JSON diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index dfd43a37a..c985f5975 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -593,10 +593,10 @@ If the user says "demo", "demo mode", "show me", or "I want to see it working", run in **DEMO MODE**. This changes how the agent interacts with the app: -**DEMO MODE OVERRIDES ALL OTHER RULES.** When demo mode is active, the -agent MUST drive every action through visible UI (`/tap`, `/swipe`, `/type`) -and NEVER use `POST /state/*` writes to skip steps. Viewers see the agent -type every key, tap every button. The on-device DebugOverlay attribution +In demo mode, drive every action through visible UI (`/tap`, `/swipe`, +`/type`) rather than `POST /state/*` writes, because viewers watch each +step: they see the agent type every key and tap every button. All other +rules in this skill still apply. The on-device DebugOverlay attribution chip shows "Driven by Claude Code (demo)" or the remote agent identity. In demo mode, the screencap rate is bumped to 4fps so the recording feels diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl index 4c7b4a2c5..3c957a051 100644 --- a/ios-qa/SKILL.md.tmpl +++ b/ios-qa/SKILL.md.tmpl @@ -249,10 +249,10 @@ If the user says "demo", "demo mode", "show me", or "I want to see it working", run in **DEMO MODE**. This changes how the agent interacts with the app: -**DEMO MODE OVERRIDES ALL OTHER RULES.** When demo mode is active, the -agent MUST drive every action through visible UI (`/tap`, `/swipe`, `/type`) -and NEVER use `POST /state/*` writes to skip steps. Viewers see the agent -type every key, tap every button. The on-device DebugOverlay attribution +In demo mode, drive every action through visible UI (`/tap`, `/swipe`, +`/type`) rather than `POST /state/*` writes, because viewers watch each +step: they see the agent type every key and tap every button. All other +rules in this skill still apply. The on-device DebugOverlay attribution chip shows "Driven by Claude Code (demo)" or the remote agent identity. In demo mode, the screencap rate is bumped to 4fps so the recording feels diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 62d201db9..9c6571b01 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -708,35 +708,45 @@ approves each), or C) stop. ## Step 3.4: VERSION drift detection (workspace-aware ship) -Check that another workspace has not claimed this PR's VERSION since `/ship`. - ```bash BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') -QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \ - --base "$BASE_BRANCH" \ - --exclude-pr "$PR_NUMBER" \ - --bump patch \ - --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') -NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty') -OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false') -NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false') +git fetch -q origin "$BASE_BRANCH" +read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json | + jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"') +Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \ + --exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') +Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version + then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not) + then "unavailable: claims unread" else "ok" end') +cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; } +H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl) +V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$" + if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit } + for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit } + s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }') +if [ "$Q" = NO_VERSION ]; then R=$Q +elif [ "$V" = bad ]; then R="unavailable: bad version" +elif [ "$MODE" = stamp-at-merge ]; then + [ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale" +elif [ "$V" = low ]; then R="STOP base is at or above it" +elif [ -z "$MODE" ]; then R="unavailable: no policy" +else R=$Q; fi +echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)" ``` -Use the existing conservative patch-level allocation; compare numeric version -components, not lexical strings. A missing/unparseable version on only one side is -unavailable, not green. - -0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads): - print `VERSION check: not applicable ()` and continue to - Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag. -1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable - with the reason; continue to Step 3.5. CI's version gate is the backstop. -2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue. -3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions - and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path - reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT - auto-bump or merge here: duplicate versions can overwrite another branch's release notes. +- `NO_VERSION`: print `VERSION check: not applicable ()`; never + expect a version-prefixed title, CHANGELOG version header or tag. +- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop). +- `ok`: continue; a higher sibling claim or a MICRO bump is not drift. +- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings + are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base + --pr --expect-base --allow-repo-commands`, push the stamp diff, + wait for CI, rerun. +- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship` + from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json, + CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can + overwrite another branch's release notes. --- diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index 8b292abb2..6243389b1 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -191,35 +191,45 @@ approves each), or C) stop. ## Step 3.4: VERSION drift detection (workspace-aware ship) -Check that another workspace has not claimed this PR's VERSION since `/ship`. - ```bash BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') -QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \ - --base "$BASE_BRANCH" \ - --exclude-pr "$PR_NUMBER" \ - --bump patch \ - --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') -NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty') -OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false') -NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false') +git fetch -q origin "$BASE_BRANCH" +read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json | + jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"') +Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \ + --exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') +Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version + then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not) + then "unavailable: claims unread" else "ok" end') +cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; } +H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl) +V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$" + if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit } + for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit } + s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }') +if [ "$Q" = NO_VERSION ]; then R=$Q +elif [ "$V" = bad ]; then R="unavailable: bad version" +elif [ "$MODE" = stamp-at-merge ]; then + [ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale" +elif [ "$V" = low ]; then R="STOP base is at or above it" +elif [ -z "$MODE" ]; then R="unavailable: no policy" +else R=$Q; fi +echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)" ``` -Use the existing conservative patch-level allocation; compare numeric version -components, not lexical strings. A missing/unparseable version on only one side is -unavailable, not green. - -0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads): - print `VERSION check: not applicable ()` and continue to - Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag. -1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable - with the reason; continue to Step 3.5. CI's version gate is the backstop. -2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue. -3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions - and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path - reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT - auto-bump or merge here: duplicate versions can overwrite another branch's release notes. +- `NO_VERSION`: print `VERSION check: not applicable ()`; never + expect a version-prefixed title, CHANGELOG version header or tag. +- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop). +- `ok`: continue; a higher sibling claim or a MICRO bump is not drift. +- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings + are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base + --pr --expect-base --allow-repo-commands`, push the stamp diff, + wait for CI, rerun. +- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship` + from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json, + CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can + overwrite another branch's release notes. --- diff --git a/lib/changelog-check.ts b/lib/changelog-check.ts index 03ce9a4e9..ae2d81333 100644 --- a/lib/changelog-check.ts +++ b/lib/changelog-check.ts @@ -115,6 +115,76 @@ export function normalizeChangelog(text: string): string { return [parsed.header.join('\n').replace(/\n+$/, ''), ...sections].join('\n\u0000'); } +/** + * A merged-in head's CHANGELOG, however its conflict was resolved: why it is + * not main's CHANGELOG with the PR's own entry above it, or null. Main's + * header and every main section must follow unchanged and in order, and the + * sections above them may hold only lines the PR itself added (`authored`, + * the + lines of its pin's CHANGELOG diff), so a hand resolve can drop + * damage but never add text the train gate did not carry. + */ +export function ownEntryAboveMain(head: string, main: string, authored: ReadonlySet): string | null { + const h = parseChangelog(head); + const m = parseChangelog(main); + const trim = (lines: string[]) => { const out = [...lines]; while (out.length && !out[out.length - 1]!.trim()) out.pop(); return out.join('\n'); }; + if (trim(h.header) !== trim(m.header)) return 'the header differs from main'; + const own = h.sections.length - m.sections.length; + if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`; + const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines)); + if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`; + const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased')); + const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased')); + if (clash) return `${clash.heading.trim()} is headed twice`; + const added = h.sections.slice(0, own).flatMap(s => s.lines.slice(1)).find(l => l.trim() && !authored.has(l)); + return added === undefined ? null : `the PR's entry holds a line its pin never added: ${JSON.stringify(added.slice(0, 80))}`; +} + +const MIN_BLOCK_CHARS = 12; + +/** Paragraph blocks of a section's entry text (heading lines dropped), long enough to identify one entry. */ +function entryBlocks(s: ChangelogSection): string[] { + const blocks: string[] = []; + let run: string[] = []; + const flush = () => { const text = run.join('\n').trim(); if (text.length >= MIN_BLOCK_CHARS) blocks.push(text); run = []; }; + for (const line of s.lines.slice(1)) { + if (!line.trim() || line.startsWith('#')) flush(); + else run.push(line); + } + flush(); + return blocks; +} + +/** + * Why one side of a CHANGELOG merge damaged released history, or null. A + * side damages it when it lacks a released heading the merge base carries + * (dropped, or renamed), or when a section only that side carries holds a + * paragraph of a released entry the other side carries (a released heading + * replaced, so its body reads as part of the section above it; #3345). + */ +export function changelogDamage(ours: string, theirs: string, base?: string): string | null { + const sides = [{ name: 'ours', parsed: parseChangelog(ours) }, { name: 'theirs', parsed: parseChangelog(theirs) }]; + const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!); + if (base !== undefined) { + const released = parseChangelog(base).sections.filter(s => !s.unreleased).map(key); + for (const side of sides) { + const has = new Set(side.parsed.sections.map(key)); + const missing = released.find(v => !has.has(v)); + if (missing) return `${side.name} drops released heading ${missing} that the merge base carries`; + } + } + for (const [i, side] of sides.entries()) { + const other = sides[1 - i]!.parsed.sections; + const otherKeys = new Set(other.map(key)); + const releasedText = new Map(); + for (const s of other.filter(s => !s.unreleased)) for (const b of entryBlocks(s)) releasedText.set(b, s.version!); + for (const s of side.parsed.sections.filter(s => !otherKeys.has(key(s)))) { + const hit = entryBlocks(s).find(b => releasedText.has(b)); + if (hit) return `${side.name}'s ${key(s)} section holds released ${releasedText.get(hit)} text (${JSON.stringify(hit.slice(0, 60))}); a released heading was dropped or renamed`; + } + } + return null; +} + /** * Gate-ahead merge of two CHANGELOGs that conflict textually but not * semantically: the predecessor's header and sections, with the branch's own @@ -122,9 +192,12 @@ export function normalizeChangelog(text: string): string { * branch's top section carries the same version heading as a predecessor * section with a different body (both took the same tentative stamp), the * branch's entry is kept under `[Unreleased]` so the restamp re-heads it. - * Both sides carrying `[Unreleased]` is a real conflict and returns null. + * Both sides carrying `[Unreleased]` is a real conflict and returns null, and + * so is damage to released history (`changelogDamage`): null means a hand + * resolve, never a merge that carries the damage through. */ -export function mergeChangelogs(ours: string, theirs: string): string | null { +export function mergeChangelogs(ours: string, theirs: string, base?: string): string | null { + if (changelogDamage(ours, theirs, base)) return null; const a = parseChangelog(ours); const b = parseChangelog(theirs); const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!); diff --git a/lib/cso/preparation.ts b/lib/cso/preparation.ts index 26a68bc73..35612e9ee 100644 --- a/lib/cso/preparation.ts +++ b/lib/cso/preparation.ts @@ -5,8 +5,8 @@ import { createHash } from 'node:crypto'; import { lstatSync, readFileSync, realpathSync } from 'node:fs'; import { isAbsolute, relative, resolve, sep } from 'node:path'; -/** 'java' (Maven or Gradle) is detected so it is reported as itself; no runtime prepares it. */ -export type CsoStack = 'node' | 'bun' | 'python' | 'rails' | 'java'; +/** 'java' (Maven or Gradle) and 'go' (go.mod) are detected so they are reported as themselves; no runtime prepares them. */ +export type CsoStack = 'node' | 'bun' | 'python' | 'rails' | 'java' | 'go'; export interface PreparationPrerequisite { code: string; message: string; @@ -1205,12 +1205,15 @@ export function inspectPreparation(snapshotPath: string, stack?: CsoStack): Prep detectedStacks.push('node'); if (read(root, 'Gemfile.lock', true) !== undefined || read(root, 'Gemfile', true) !== undefined) detectedStacks.push('rails'); - // A root Maven or Gradle build names the repository's language; Python - // metadata beside it is helper tooling, not a second application stack. + // A root Maven or Gradle build, or a root go.mod with no other supported + // stack, names the repository's language; Python metadata beside it is + // helper tooling, not a second application stack. const jvmBuild = ['pom.xml', 'build.gradle', 'build.gradle.kts'].find( (file) => read(root, file, true) !== undefined, ); if (jvmBuild) detectedStacks.push('java'); + else if (detectedStacks.length === 0 && read(root, 'go.mod', true) !== undefined) + detectedStacks.push('go'); else if ( read(root, 'uv.lock', true) !== undefined || read(root, 'requirements.txt', true) !== undefined || @@ -1231,10 +1234,16 @@ export function inspectPreparation(snapshotPath: string, stack?: CsoStack): Prep `Java (${jvmBuild ?? 'Maven or Gradle'}) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.`, jvmBuild, ); + if (detected === 'go') + fail( + 'UNSUPPORTED_STACK', + 'Go (go.mod) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.', + 'go.mod', + ); if (!['node', 'bun', 'python', 'rails'].includes(detected)) fail('UNSUPPORTED_STACK', 'Supported runtime stacks are Node, Bun, Python, and Rails.'); ({ node: inspectNode, bun: inspectBun, python: inspectPython, rails: inspectRails })[ - detected as Exclude + detected as Exclude ](root, plan); } catch (error) { plan.status = 'prerequisites'; diff --git a/lib/gbrain-guards.ts b/lib/gbrain-guards.ts index 1c46b4ad4..0df4576e7 100644 --- a/lib/gbrain-guards.ts +++ b/lib/gbrain-guards.ts @@ -17,9 +17,12 @@ * subcommand `--help` is generic — so capability detection is best-effort and * defaults to "unsupported". When we can't protect a user-managed source's * files, we FAIL CLOSED (refuse the remove) rather than delete unprotected. - * - The autopilot lock filename isn't documented and (gbrain #1226) ignores - * GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal; - * known lock paths under both the configured home and ~/.gbrain are secondary. + * - The live `gbrain autopilot` process is the PRIMARY signal and it is + * deliberately machine-wide (#3360, Garry 2026-10-24): any autopilot on the + * machine refuses, whichever brain or repo it serves, because two syncs that + * share one brain or working tree can race. The refusal names the matched + * pid and command so the user can tell whose it is. Known lock paths under + * both the configured home and ~/.gbrain are secondary. * - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect * never blocks a normal sync (that would brick the tool). * - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't @@ -69,11 +72,17 @@ export interface AutopilotStatus { signal: string | null; } +/** A running process whose command line matches `gbrain autopilot`. */ +export interface AutopilotProcess { + pid: number; + command: string; +} + export interface AutopilotProbe { /** Override the lock-path list (tests). */ lockPaths?: string[]; - /** Override the live-process check (tests). */ - processRunning?: () => boolean; + /** Override the live-process check (tests): a bare boolean, or the matched processes. */ + processRunning?: () => boolean | AutopilotProcess[]; } /** @@ -109,10 +118,17 @@ export function detectAutopilot( // Stale lock (holder pid is dead): ignore this signal, keep checking. Pure // decision function — we do NOT delete the file here; the caller may clean it. } - // Primary signal: a live `gbrain autopilot` process. - const running = (probe.processRunning ?? defaultProcessRunning)(); - if (running) return { active: true, signal: "process:gbrain autopilot" }; - return { active: false, signal: null }; + // Primary signal: a live `gbrain autopilot` process anywhere on the machine. + const running = (probe.processRunning ?? autopilotProcesses)(); + if (running === true) return { active: true, signal: "process:gbrain autopilot" }; + if (!Array.isArray(running) || running.length === 0) return { active: false, signal: null }; + const listed = running.map(p => `pid ${p.pid} \`${p.command}\``).join(", "); + return { + active: true, + signal: `process:gbrain autopilot ${listed}; this check counts any process on this machine whose command line contains ` + + `"gbrain autopilot", even an autopilot serving another brain or repo. Stop that autopilot (or its service, if ` + + `\`gbrain autopilot --install\` set one up), or end the process that only mentions it`, + }; } /** Read the holder pid from a lock/pid file. Returns null if no integer pid is present. */ @@ -143,11 +159,22 @@ function isPidAlive(pid: number): boolean { } } -function defaultProcessRunning(): boolean { +function autopilotProcesses(): AutopilotProcess[] { // No reliable pgrep on Windows; rely on the lock-file signal there. - if (process.platform === "win32") return false; - const r = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 }); - return r.status === 0 && (r.stdout || "").trim().length > 0; + if (process.platform === "win32") return []; + const found = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 }); + const pids = found.status === 0 ? (found.stdout || "").split(/\s+/).filter(p => /^\d+$/.test(p)) : []; + if (pids.length === 0) return []; + const ps = spawnSync("ps", ["-o", "pid=,args=", "-p", pids.join(",")], { encoding: "utf-8", timeout: 3_000 }); + const commands = new Map(); + for (const line of (ps.stdout || "").split("\n")) { + const m = line.match(/^\s*(\d+)\s+(.*\S)/); + if (m) commands.set(m[1], m[2]); + } + return pids.map(pid => ({ + pid: Number(pid), + command: (commands.get(pid) ?? "command unavailable").replace(/\/\/[^\s/@]+@/g, "//***@").slice(0, 200), + })); } // ── Capability detection (E4 + Codex: per-process memo, no persistent cache) ─ diff --git a/lib/gbrain-repo-policy-client.ts b/lib/gbrain-repo-policy-client.ts index f62d0a163..941a4c9d0 100644 --- a/lib/gbrain-repo-policy-client.ts +++ b/lib/gbrain-repo-policy-client.ts @@ -17,8 +17,8 @@ */ import { spawnSync } from "child_process"; -import { existsSync } from "fs"; -import { join } from "path"; +import { lstatSync, statSync } from "fs"; +import { dirname, join } from "path"; import { mergedStateRoots, resolveStateRoot } from "./state-root"; export type RepoPolicyTierValue = "deny" | "read-only" | "read-write" | "none"; @@ -46,9 +46,36 @@ export function repoPolicyStorePath(env: NodeJS.ProcessEnv = process.env): strin /** * No store on disk = no policy was ever set (the fast path — no subprocess). * The ~/.gstack store counts too: its deny tiers merge into every state root. + * Only confirmed absence is "no store": ENOENT whose nearest existing + * ancestor resolves (so no dangling symlink cut the walk short). A dangling + * symlink at the store or above it, a non-directory component, or a path that + * cannot be checked (an unsearchable directory) counts as a store, so the + * caller spawns the script and its own fail-closed rules apply. */ export function hasRepoPolicyStore(env: NodeJS.ProcessEnv = process.env): boolean { - return mergedStateRoots(env).some((root) => existsSync(join(root, "gbrain-repo-policy.json"))); + return mergedStateRoots(env).some((root) => { + let path = join(root, "gbrain-repo-policy.json"); + try { + lstatSync(path); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") return true; + } + for (let up = dirname(path); up !== path; path = up, up = dirname(up)) { + try { + lstatSync(up); + } catch { + continue; + } + try { + statSync(up); + return false; + } catch { + return true; + } + } + return false; + }); } /** The bash script that owns the store — resolved relative to this file (lib/ → bin/), never cwd. */ @@ -94,6 +121,50 @@ export function repoPolicyTier( return { tier: "none", error: "unreadable" }; // unexpected output — a read failure, not a tier } +export interface MemorableRecallPolicy extends RepoPolicyResult { + /** + * True only when the tier is `read-only` and the store opts this remote in + * to Memorable recall. Never true alongside `error` or any other tier. + */ + memorableOptIn: boolean; +} + +/** + * Tier plus the per-remote Memorable recall opt-in, via + * `gstack-gbrain-repo-policy memorable get ` (one spawn). Unlike + * repoPolicyTier, the script never quarantines a corrupt store on this path, + * so a corrupt store reads as `unreadable` instead of as "no policy". + * Fast paths and failure classification match repoPolicyTier; anything but + * exactly "\t" is `unreadable`. POLARITY IS THE CALLER'S. + */ +export function memorableRecallPolicy( + url: string | null, + env: NodeJS.ProcessEnv = process.env, + timeoutMs: number = 10_000, +): MemorableRecallPolicy { + if (!hasRepoPolicyStore(env)) return { tier: "none", memorableOptIn: false }; + if (!url) return { tier: "none", memorableOptIn: false }; + const [cmd, args]: [string, string[]] = + process.platform === "win32" + ? ["bash", [POLICY_SCRIPT, "memorable", "get", url]] + : [POLICY_SCRIPT, ["memorable", "get", url]]; + const res = spawnSync(cmd, args, { + encoding: "utf-8", + timeout: Math.max(1, timeoutMs), + env: { ...env } as NodeJS.ProcessEnv, + }); + if (res.error) { + const code = (res.error as NodeJS.ErrnoException).code; + return { tier: "none", memorableOptIn: false, error: code === "ENOENT" ? "spawn-failed" : "unreadable" }; + } + const unreadable: MemorableRecallPolicy = { tier: "none", memorableOptIn: false, error: "unreadable" }; + if (res.status !== 0) return unreadable; + const match = /^(deny|read-only|read-write|unset)\t(allow|off)\n?$/.exec(res.stdout || ""); + if (!match) return unreadable; + const tier = match[1] === "unset" ? "none" : (match[1] as RepoPolicyTierValue); + return { tier, memorableOptIn: tier === "read-only" && match[2] === "allow" }; +} + /** * Bulk trust-tier lookup via `gstack-gbrain-repo-policy get --batch` — ONE * spawn total for the whole url list (memory-ingest checks every distinct diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index e46d2336d..4b5cfb408 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -73,6 +73,10 @@ export interface PregateFile { schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number; // C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict. head?: string; base?: string; generated_at?: string; policy?: string; registry?: string; requires_remote?: PregateRemote[]; verdict?: string; + /** sha256 of the user-owned policy when `policy` is `user `; verify re-checks it. */ + policy_digest?: string; + /** Under the defaults, the user-owned policy path that was looked for and absent; verify checks it is still absent. */ + policy_user_path?: string; } export interface ShipReceipt { schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string; @@ -189,7 +193,7 @@ export const ARTIFACT_SCHEMAS = { properties: { schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num, checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList } } }, - head: str, base: str, generated_at: str, policy: str, registry: str, verdict: str, + head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str } } }, }, }, diff --git a/lib/model-policy-freshness-sources.ts b/lib/model-policy-freshness-sources.ts index b90bbecb4..e0dfee345 100644 --- a/lib/model-policy-freshness-sources.ts +++ b/lib/model-policy-freshness-sources.ts @@ -178,7 +178,6 @@ function openaiLifecycle(text: string): ParsedSource { if (!upcoming || !modelTables.length) throw new Error('missing-deprecation-tables'); const lifecycle = modelTables.flatMap(table => table.slice(1).flatMap(row => { if (/^(?:Videos API|Assistants API|OpenAI-Beta: |New fine-tuning training on )/.test(row[1]) || row[1].startsWith('`/')) return []; - if (!row[1].startsWith('`')) throw new Error('unrecognized-deprecated-model-cell'); const floor = row[0].startsWith('at earliest '); const date = lifecycleDate(row[0].replace(/^at earliest /, '')); if (!date) throw new Error('missing-shutdown-date'); diff --git a/lib/outside-review-result.ts b/lib/outside-review-result.ts index 8409c73b2..ad76233ab 100644 --- a/lib/outside-review-result.ts +++ b/lib/outside-review-result.ts @@ -263,7 +263,8 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC } if (!reviewed) return result(blocking ? 'findings' : 'clean'); const unparsed = firstUnparsed(code); - const notReady = !findings.highest && verdictNotReady(plainCode); + // A last explicit not-ready blocks even when the only parsed severities are advisory. + const notReady = verdictNotReady(plainCode); if (blocking || notReady) return { ...result('findings'), ...(unparsed ? { unparsed } : {}) }; if (unparsed) return { ...result('unverified', 'unparsed_severity', unparsed), unparsed }; if ((input.gate === 'structured' || input.gate === 'review') && !findings.highest && !concludesNoFindings(code)) { diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 04c697a37..8d8fdf054 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -16,9 +16,10 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { baseInputs, sha256 } from '../context'; import { PREGATE_CONFIG_PATH } from '../config'; -import { isDeviceDart, readRepoFile } from '../import-graph'; +import { isDeviceDart, listRepoFiles, readRepoFile } from '../import-graph'; import { selectLanes, type Selection } from '../lane-selection'; import { launchRuntime } from '../runtime'; +import { cargoModel, type CargoModel } from '../rust'; import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise'; import { matchesAny } from '../../ship-policy'; import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types'; @@ -42,21 +43,24 @@ export function detectRunner(ctx: Pick, f /** One runner invocation: `files` relative to `cwd` (repo-relative, `''` for the root). */ export interface RunGroup { runner: string; cwd: string; files: string[] } -/** The nearest directory at or above `file` holding a pubspec.yaml, repo-relative; null outside any Dart package. `seen` memoises per directory. */ -function dartRoot(repoRoot: string, file: string, seen: Map): string | null { +/** The nearest directory at or above `file` holding `marker`, repo-relative; null when none does. `seen` memoises per directory (one map per marker). */ +function nearestRoot(repoRoot: string, file: string, marker: string, seen: Map): string | null { const walked: string[] = []; let found: string | null = null; for (let d = path.posix.dirname(file); ; d = path.posix.dirname(d)) { const rel = d === '.' ? '' : d; if (seen.has(rel)) { found = seen.get(rel)!; break; } walked.push(rel); - if (fs.existsSync(path.join(repoRoot, rel, 'pubspec.yaml'))) { found = rel; break; } + if (fs.existsSync(path.join(repoRoot, rel, marker))) { found = rel; break; } if (d === '.' || d === '/' || d === '') break; } for (const w of walked) seen.set(w, found); return found; } +/** The nearest directory at or above `file` holding a pubspec.yaml, repo-relative; null outside any Dart package. */ +function dartRoot(repoRoot: string, file: string, seen: Map): string | null { return nearestRoot(repoRoot, file, 'pubspec.yaml', seen); } + /** An fvm pin (`.fvmrc` or `.fvm/fvm_config.json`) at the package root `root` or any ancestor up to the repo root. */ function fvmPinned(repoRoot: string, root: string): boolean { for (let d = root; ; d = path.posix.dirname(d)) { @@ -72,6 +76,9 @@ function fvmPinnedFor(repoRoot: string, files: readonly string[]): boolean { return files.some(f => { const root = f.endsWith('.dart') ? dartRoot(repoRoot, f, seen) : null; return root !== null && fvmPinned(repoRoot, root); }); } +/** Whether `bin` is on the PATH of `env` (the PATH the runner will get). */ +const onPath = (bin: string, env: NodeJS.ProcessEnv) => !!Bun.which(bin, { PATH: env.PATH ?? '' }); + /** * `flutter test` for a package on the Flutter SDK, else `dart test`; through fvm * when the package or an ancestor up to the repo root pins one and fvm is on PATH. `missing` names the tool when @@ -82,48 +89,129 @@ export function dartRunner(repoRoot: string, root: string, env: NodeJS.ProcessEn const pubspec = readRepoFile(repoRoot, path.posix.join(root, 'pubspec.yaml')) ?? ''; // `sdk: flutter` (the key optionally quoted) under any dependency name (flutter, flutter_test, ...), block style or inside a flow mapping a key opens, comments stripped. const tool = /^[ \t]*(['"]?)sdk\1:[ \t]*['"]?flutter\b|^[ \t]*[\w-]+:[ \t]*\{(?:[^\n]*[{,])?[ \t]*(['"]?)sdk\2:[ \t]*['"]?flutter\b/m.test(pubspec.replace(/(^|\s)#.*$/gm, '$1')) ? 'flutter' : 'dart'; - const onPath = (bin: string) => !!Bun.which(bin, { PATH: env.PATH ?? '' }); - const fvm = fvmPinned(repoRoot, root) && onPath('fvm'); - if (!fvm && !onPath(tool)) return { missing: tool }; + const fvm = fvmPinned(repoRoot, root) && onPath('fvm', env); + if (!fvm && !onPath(tool, env)) return { missing: tool }; return { runner: `${fvm ? 'fvm ' : ''}${tool} test {files}` }; } -/** The run plan: the groups that can run, the files no runner claims, Dart SDKs missing from PATH, and `.dart` files with no pubspec.yaml above. */ +/** + * `cargo test -p ` for a crate (cargo runs a crate's tests, never a file list), or the missing tool when cargo is + * not on PATH. The name comes from the repo's Cargo.toml and reaches a shell and the `{files}` expansion: a name outside + * Cargo's grammar (letters, digits, `_`, `-`) gets no runner (null), and a valid one is still quoted like every file and cwd. + */ +function cargoRunner(name: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } | null { + if (!/^[\p{ID_Continue}-]+$/u.test(name)) return null; + return onPath('cargo', env) ? { runner: `cargo test -p ${quote(name)}` } : { missing: 'cargo' }; +} + +type PackageJson = { packageManager?: string; devDependencies?: Record; dependencies?: Record; scripts?: Record }; +function readPackageJson(repoRoot: string, dir: string): PackageJson | null { + try { return JSON.parse(readRepoFile(repoRoot, path.posix.join(dir, 'package.json')) ?? 'null'); } catch { return null; } +} + +const LOCKFILES: Array<[string, string]> = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm']]; +const PM_EXEC: Record = { pnpm: 'pnpm exec', yarn: 'yarn', bun: 'bun x', npm: 'npx' }; + +/** The package manager for a package: the first `packageManager` field or lockfile from its directory up to the repo root; npm when none. */ +function packageManager(repoRoot: string, dir: string): string { + for (let d = dir; ; d = path.posix.dirname(d)) { + const rel = d === '.' ? '' : d; + const declared = /^(pnpm|yarn|bun|npm)@/.exec(readPackageJson(repoRoot, rel)?.packageManager ?? '')?.[1]; + if (declared) return declared; + const lock = LOCKFILES.find(([f]) => fs.existsSync(path.join(repoRoot, rel, f))); + if (lock) return lock[1]; + if (rel === '') return 'npm'; + } +} + +/** + * The runner a nested package (not the repo root) declares for itself: vitest + * or jest in its dependencies or its `test` script, through the package manager + * its `packageManager` field or nearest lockfile names; null when it declares + * neither, so its files stay with the root runner. `missing` names a package + * manager that is not on PATH. + */ +function packageRunner(repoRoot: string, dir: string, env: NodeJS.ProcessEnv): { runner: string } | { missing: string } | null { + const pkg = readPackageJson(repoRoot, dir); + if (!pkg) return null; + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + const script = pkg.scripts?.test ?? ''; + const tool = deps.vitest || /\bvitest\b/.test(script) ? 'vitest run' : deps.jest || /\bjest\b/.test(script) ? 'jest' : null; + if (!tool) return null; + const pm = packageManager(repoRoot, dir); + const bin = pm === 'npm' ? 'npx' : pm; + if (!onPath(bin, env)) return { missing: bin }; + return { runner: `${PM_EXEC[pm]} ${tool} {files}` }; +} + +/** The run plan: the groups that can run, the files no runner claims, tools missing from PATH, and `.dart` files with no pubspec.yaml above. */ export interface RunPlan { groups: RunGroup[]; unclaimed: string[]; missing: string[]; noPubspec: string[] } /** * The invocations that run `files`. A declared runner takes every file. Otherwise * Dart tests run from their own package root (a sub-package's imports resolve - * against its own pubspec), and the rest go to the detected runner. Files no - * runner claims (a Dart SDK missing from PATH, a `.dart` file outside any - * package, no detected runner) are `unclaimed`: the lane is `incomplete`, never a pass. + * against its own pubspec); Rust tests run per crate as `cargo test -p ` + * from the crate's workspace root; JS/TS tests owned by a nested package that + * declares vitest or jest run from that package; the rest go to the detected + * runner from the repo root. Files no runner claims (a tool missing from PATH, + * a `.dart` file outside any package, a `.rs` file outside any crate or in a + * crate whose name Cargo would reject, no detected runner) are `unclaimed`: the lane is `incomplete`, never a pass. */ export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan { if (ctx.config.runner) return { groups: [{ runner: ctx.config.runner, cwd: '', files: [...files] }], unclaimed: [], missing: [], noPubspec: [] }; const groups: RunGroup[] = []; const dart = new Map(); + const cargo = new Map(); + const packages = new Map(); const seen = new Map(); + const seenPackage = new Map(); + let model: CargoModel | null = null; + const pkgRunners = new Map>(); const rest: string[] = []; const noPubspec: string[] = []; + const unclaimed: string[] = []; + const missing = new Set(); + const add = (map: Map, key: string, value: T) => { let list = map.get(key); if (!list) map.set(key, (list = [])); list.push(value); }; + const under = (root: string, f: string) => (root ? f.slice(root.length + 1) : f); for (const f of files) { + if (f.endsWith('.rs')) { + model ??= cargoModel(listRepoFiles(ctx.repoRoot), rel => readRepoFile(ctx.repoRoot, rel)); + const crate = model.crateOf(f); + const cwd = crate?.workspace ?? null; + if (!crate || cwd === null) { unclaimed.push(f); continue; } + const key = `${cwd}\0${crate.name}`; + if (!cargo.has(key)) cargo.set(key, { name: crate.name, cwd, files: [] }); + cargo.get(key)!.files.push(under(cwd, f)); + continue; + } + const pkg = /\.[cm]?[jt]sx?$/.test(f) ? nearestRoot(ctx.repoRoot, f, 'package.json', seenPackage) : null; + if (pkg) { + if (!pkgRunners.has(pkg)) pkgRunners.set(pkg, packageRunner(ctx.repoRoot, pkg, ctx.env)); + const r = pkgRunners.get(pkg); + if (r && 'missing' in r) { missing.add(r.missing); unclaimed.push(f); continue; } + if (r) { add(packages, pkg, under(pkg, f)); continue; } + } if (!f.endsWith('.dart')) { rest.push(f); continue; } const root = dartRoot(ctx.repoRoot, f, seen); if (root === null) { noPubspec.push(f); continue; } - let list = dart.get(root); - if (!list) dart.set(root, (list = [])); - list.push(root ? f.slice(root.length + 1) : f); + add(dart, root, under(root, f)); } - const unclaimed: string[] = []; - const missing = new Set(); for (const [root, list] of [...dart].sort(([a], [b]) => a.localeCompare(b))) { const r = dartRunner(ctx.repoRoot, root, ctx.env); if ('missing' in r) { missing.add(r.missing); unclaimed.push(...list.map(f => (root ? `${root}/${f}` : f))); } else groups.push({ runner: r.runner, cwd: root, files: list }); } + for (const [, c] of [...cargo].sort(([a], [b]) => a.localeCompare(b))) { + const r = cargoRunner(c.name, ctx.env); + if (r && 'runner' in r) { groups.push({ runner: r.runner, cwd: c.cwd, files: c.files }); continue; } + if (r) missing.add(r.missing); + unclaimed.push(...c.files.map(f => (c.cwd ? `${c.cwd}/${f}` : f))); + } if (rest.length) { const runner = detectRunner(ctx, rest); if (runner) groups.push({ runner, cwd: '', files: rest }); else unclaimed.push(...rest); } + for (const [root, list] of [...packages].sort(([a], [b]) => a.localeCompare(b))) groups.push({ runner: (pkgRunners.get(root) as { runner: string }).runner, cwd: root, files: list }); return { groups, unclaimed: [...unclaimed, ...noPubspec], missing: [...missing].sort(), noPubspec }; } @@ -132,8 +220,8 @@ function planGap(ctx: PregateContext, plan: RunPlan, total: number): Pick 8 ? ' …' : ''}${missing.length ? `; not on PATH: ${missing.join(', ')}` : ''}${noPubspec.length ? `; no pubspec.yaml above ${noPubspec.length} .dart file(s)` : ''}`; const fix = missing.length ? `install ${missing.join(' and ')}${fvmPinnedFor(ctx.repoRoot, unclaimed) ? ' (or fvm, which this repo pins)' : ''} and rerun` - : noPubspec.length ? 'commit the Dart package\'s pubspec.yaml, or declare `runner` (with {files}) in .gstack/pregate.json' - : 'declare `runner` (with {files}) in .gstack/pregate.json'; + : noPubspec.length ? `commit the Dart package's pubspec.yaml, or declare \`runner\` (with {files}) in ${ctx.configFile}` + : `declare \`runner\` (with {files}) in ${ctx.configFile}`; return { code: 'PREGATE_INCOMPLETE', detail, fix }; } @@ -153,9 +241,18 @@ function parseDart(output: string): RunnerSummary | null { return d ? { ran: Number(d[1]) + Number(d[2] ?? 0), failed: Number(d[2] ?? 0) } : null; } -/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test. A bats or dart/flutter runner's summary is tried first; for any other runner those formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */ +/** cargo test: one `test result: ok|FAILED. N passed; M failed; …` line per test binary (unit tests, each integration test, doc-tests), summed; ignored and filtered-out tests did not run. */ +function parseCargo(output: string): RunnerSummary | null { + const lines = [...output.matchAll(/^test result: \w+\. (\d+) passed; (\d+) failed;/gm)]; + if (!lines.length) return null; + const failed = lines.reduce((n, m) => n + Number(m[2]), 0); + return { ran: lines.reduce((n, m) => n + Number(m[1]), 0) + failed, failed }; +} + +/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test, cargo test. A bats, dart/flutter or cargo runner's summary is tried first (cargo's only there: its `N passed` lines would misread as another runner's); for any other runner the bats and dart formats are a last resort. `ran: null` when not recognised. Colour codes are dropped and a carriage return ends a line, so a coloured or redrawn verdict still reads. */ export function parseSummary(raw: string, runner = ''): RunnerSummary { const output = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r/g, '\n'); + if (/(?> const timedOut = r.runs.filter(x => x.outcome === 'timeout'); const rest = r.failed ? `; ${r.failed} failed` : ''; if (stalled.length) { - return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in .gstack/pregate.json; 0 disables) for a test that is quiet for longer` }; + return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that is quiet for longer` }; } if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' }; return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' }; @@ -282,7 +379,7 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string */ async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise { const lines = base.lines ?? []; - if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: 'declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in .gstack/pregate.json on the base branch, or run the tests locally without --remote' }; + if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: `declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in ${ctx.configFile === PREGATE_CONFIG_PATH ? `${PREGATE_CONFIG_PATH} on the base branch` : ctx.configFile}, or run the tests locally without --remote` }; const obligation = remoteObligation(ctx, sel); const cap = ctx.remote!.spendCapUsd; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-remote-')); @@ -314,7 +411,7 @@ async function run(ctx: PregateContext): Promise { const lines = describe(sel); const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines, remote: sel.remote }; if (sel.noLane.length) { - return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(sel.noLane) }; + return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) }; } if (sel.tests.length === 0) { const inert = sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : ''; @@ -352,9 +449,9 @@ async function run(ctx: PregateContext): Promise { return { ...withRun, status: sel.remote.length ? 'requires-remote' : 'pass', detail }; } -function noLaneFix(noLane: readonly string[]): string { +function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string { const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : ''; - return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${PREGATE_CONFIG_PATH}${device}`; + return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`; } export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run }; diff --git a/lib/pregate/checks/strays.ts b/lib/pregate/checks/strays.ts index d46e88b39..4b5d5b572 100644 --- a/lib/pregate/checks/strays.ts +++ b/lib/pregate/checks/strays.ts @@ -31,8 +31,8 @@ function run(ctx: PregateContext): CheckResult { if (!baseDirs.has(top)) warnings.push(`${file}: new top-level directory ${top}/ (not on ${ctx.baseRef})`); } ctx.explain.push(`strays: base directories ${[...baseDirs].join(',')}; allow ${allow.join(',') || '(none)'}`); - if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: 'delete or move the file; an intentional root file goes in .gstack/pregate.json strays.allow' }; - if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: 'confirm the directory is intended, or allow it in .gstack/pregate.json strays.allow' }; + if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: `delete or move the file; an intentional root file goes in ${ctx.configFile} strays.allow` }; + if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: `confirm the directory is intended, or allow it in ${ctx.configFile} strays.allow` }; return { id: 'strays', stage: 'preflight', status: 'pass', inputs, detail: `${ctx.touched.filter(t => t.status === 'A').length} added file(s), none stray` }; } diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index cb9d82a9e..e1f2b3e20 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -16,6 +16,9 @@ * warn check ids downgraded to warn (never `secrets`; `lanes` keeps * its required-lane and requires-remote verdicts) * strays.allow root files or new top-level directories that are intentional + * local `/` lanes a local run satisfies (a + * platform-agnostic toolchain, or a repo whose CI is off): + * their tests run here instead of becoming requires-remote * guards `{ id, when: [globs], command, fix?, timeout_s? }`: a repo * command that must pass when a touched file matches `when` * (manifest checks, CLI goldens); never downgradable @@ -30,10 +33,16 @@ * remote_gate the repo's remote test gate command: under 8 cores the test * tier is a requires-remote obligation naming it, and * `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`) + * + * A repo that keeps `.gstack/` out of git can hold the same file at + * `/projects//pregate.json` (lib/state-root.ts), honoured + * only when the base carries none. It must sit outside the repo, so a PR + * cannot reach it; a path inside the checkout is ignored (pregate/context). */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import type { ResultCodeName } from '../result-codes'; import { sealedErrors } from '../sealed-guard'; import { containedRelative, globToRegExp } from '../ship-policy'; @@ -52,6 +61,7 @@ export interface PregateConfig { selection: Record; warn: string[]; strays: { allow: string[] }; + local: string[]; guards: Guard[]; serial_patterns: string[]; price_file: string | null; @@ -59,12 +69,13 @@ export interface PregateConfig { remote_gate: string | null; } export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number } -export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null }; +export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null }; export interface ConfigError { code: ResultCodeName; path: string; message: string } export const MAX_LIST = 256; const MAX_COMMAND = 512; -const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate']; +const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate']; +const LANE_ID = /^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/; const MAX_GUARDS = 32; const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s']; @@ -153,7 +164,7 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = [] const sel = obj.selection; if (!sel || typeof sel !== 'object' || Array.isArray(sel)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'selection', message: 'must be an object of / → command' }); else for (const [lane, command] of Object.entries(sel as Record)) { - if (!/^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be /' }); + if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be /' }); const why = commandError(command); if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: why }); } @@ -176,6 +187,10 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = [] } } } + if ('local' in obj) { + if (!Array.isArray(obj.local) || obj.local.some(l => typeof l !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'local', message: 'must be an array of /' }); + else (obj.local as string[]).forEach((l, i) => { if (!LANE_ID.test(l)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `local[${i}]`, message: `${JSON.stringify(l)} must be /` }); }); + } return errors; } @@ -186,14 +201,15 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []): const errors = validateConfig(raw, knownChecks); if (errors.length) return { config: DEFAULT_CONFIG, errors }; const o = raw as Partial; - return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] }; + return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] }; } -export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string }; +export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string }; function git(cwd: string, args: string[]): { ok: boolean; out: string } { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); - return { ok: r.status === 0, out: (r.stdout ?? '').trim() }; + // r.error covers ENOBUFS, where stdout is cut at the buffer limit even with status 0. + return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() }; } export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad { @@ -201,7 +217,12 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` }; const show = git(repoRoot, ['show', `${sha.out}:${PREGATE_CONFIG_PATH}`]); - if (!show.ok) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; + if (!show.ok) { + // Absent only when the tree says so: a timeout, an oversized blob or a missing/corrupt object must refuse, not hand the run to the user file. + const entry = git(repoRoot, ['ls-tree', '--name-only', sha.out, '--', PREGATE_CONFIG_PATH]); + if (!entry.ok || entry.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} could not be read and git cannot confirm it is absent` }; + return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; + } return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' }; } @@ -211,6 +232,37 @@ export function loadConfigFromHead(repoRoot: string, knownChecks: readonly strin return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' }; } +/** A symlink to nothing on the way to `file` (a dotfiles link not yet cloned): a policy the user meant to have, not an absent one. */ +function danglingLink(file: string): boolean { + for (let p = file; path.dirname(p) !== p; p = path.dirname(p)) { + try { if (fs.lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() && !fs.existsSync(p)) return true; } catch { return true; } + } + return false; +} + +/** Nothing at `file` and no dangling link on the way to it: the only state that means "no user policy", for the loader and verify alike. */ +export function userPolicyAbsent(file: string): boolean { + try { fs.statSync(file); return false; } catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT' && !danglingLink(file); } +} + +/** The user-owned copy for a repo that keeps `.gstack/` out of git; the caller consults it only when the base has none. */ +export function loadConfigFromUser(file: string, knownChecks: readonly string[] = []): ConfigLoad | null { + let text: string; + try { text = fs.readFileSync(file, 'utf8'); } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT' && userPolicyAbsent(file)) return null; + // An unreadable policy must refuse, never fall back to defaults (its guards would silently vanish). + return { ...parseConfig('{}', knownChecks), errors: [{ code: 'PREGATE_CONFIG_INVALID', path: file, message: `cannot read the user-owned policy: ${(e as Error).message}` }], present: true, label: `user ${file}`, kind: 'user' }; + } + const load = parseConfig(text, knownChecks); + // `sealed` (the pre-push guard) and `price_file` (gstack-eval-plan) read only the git copies, so a non-null value in the user file would look active and do nothing. + let raw: unknown = null; + try { raw = JSON.parse(text); } catch { /* parseConfig reported it */ } + const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file'].filter(k => (raw as Record)[k] != null) : []; + // parseConfig names the repo copy for a JSON error; this file is not it. + const errors = [...load.errors.map(e => e.path === PREGATE_CONFIG_PATH ? { ...e, path: file } : e), ...ignored.map(k => ({ code: 'PREGATE_CONFIG_INVALID' as const, path: k, message: `the user-owned policy covers gstack-pregate run only; ${k} is read from ${PREGATE_CONFIG_PATH} on the base branch` }))]; + return { ...load, errors, present: true, label: `user ${file}`, kind: 'user', digest: createHash('sha256').update(text).digest('hex') }; +} + export function isConfigUnavailable(load: ConfigLoad): load is { unavailable: true; base: string; detail: string } { return (load as any).unavailable === true; } @@ -223,11 +275,13 @@ export function explainConfig(): string { 'selection: / → the runner-owned command that lists that lane’s test files when its matrix is planner-computed', `warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`, 'strays.allow: root files or new top-level directories that are intentional', + 'local: / lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote', 'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable', `serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`, 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)', 'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)', 'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)', 'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree= tests= failed=) (docs/pregate.md#remote-gate)', + 'no .gstack/pregate.json on the base: /projects//pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)', ].join('\n') + '\n'; } diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 4d5584284..ba9b4e532 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -9,13 +9,16 @@ */ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { slugFromEnvironment } from '../bin-context'; import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen'; +import { resolveStateRoot } from '../state-root'; import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes'; import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation'; import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy'; -import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config'; +import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config'; import type { CheckResult, PregateContext, TouchedFile } from './types'; import { discoverLanes } from './workflows'; import type { RegistryError } from '../regen'; @@ -27,6 +30,8 @@ export interface BuildContextOptions { stallTimeoutMs?: number; progress?: (line: string) => void; cores?: number; testBackend?: string; remote?: { spendCapUsd: number } | null; /** How gstack's own bash bins launch (gstack-wtree, gstack-config); tests inject a Windows probe. */ invocation?: InvocationOptions; + /** `/projects//pregate.json`: read only when the base carries no pregate.json, and never from a relative path or one inside the repository. */ + userConfigPath?: string; } export const DEFAULT_STALL_TIMEOUT_S = 300; @@ -123,6 +128,61 @@ export function touchedFiles(repoRoot: string, mergeBase: string): TouchedFile[] return [...out.values()].filter(t => t.path !== 'node_modules' && !t.path.startsWith('node_modules/')).sort((a, b) => a.path.localeCompare(b.path)); } +/** The deepest existing ancestor realpathed (on-disk casing), the rest re-joined: a symlinked state root inside the checkout still counts as inside. */ +function realish(p: string): string { + let head = path.resolve(p); const tail: string[] = []; + while (!fs.existsSync(head) && path.dirname(head) !== head) { tail.unshift(path.basename(head)); head = path.dirname(head); } + try { return path.join(fs.realpathSync.native(head), ...tail); } catch { return path.resolve(p); } +} +/** + * Every location the path to `p` passes through, each symlink followed one hop at a time: a link inside the checkout picks + * the file even when the last hop points back outside it. A missing component ends the walk with the rest joined lexically. + */ +function resolutionChain(p: string): string[] { + const chain: string[] = []; + let cur = path.parse(path.resolve(p)).root; + let rest = path.relative(cur, path.resolve(p)).split(path.sep).filter(Boolean); + for (let hops = 0; rest.length && hops <= 40;) { + const next = path.join(cur, rest.shift()!); + let st: fs.Stats | undefined; + try { st = fs.lstatSync(next, { throwIfNoEntry: false }); } catch { st = undefined; } + chain.push(st ? next : path.join(next, ...rest)); + if (!st) break; + if (!st.isSymbolicLink()) { cur = next; continue; } + hops++; + const target = path.resolve(cur, fs.readlinkSync(next)); + cur = path.parse(target).root; + rest = [...path.relative(cur, target).split(path.sep).filter(Boolean), ...rest]; + } + return chain; +} +const within = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); }; + +/** Dotenv files Bun loads from the launch directory unless started with --no-env-file. */ +const DOTENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local', '.env.production', '.env.production.local', '.env.test', '.env.test.local']; + +/** + * The dotenv files in `cwd` that Bun loaded (no --no-env-file in `execArgv`). Any of them could set the slug or state + * root that picks the user policy, and Bun's dotenv syntax is looser than a key scan can follow, so any one refuses. + */ +export function loadedDotenvFiles(cwd: string, execArgv: readonly string[]): string[] { + if (execArgv.includes('--no-env-file')) return []; + return DOTENV_FILES.filter(f => fs.existsSync(path.join(cwd, f))); +} + +/** `/projects//pregate.json` for the repository at `root`, from the caller's environment. */ +export function defaultUserConfigPath(root: string): string { + return path.join(resolveStateRoot(), 'projects', slugFromEnvironment(undefined, root), 'pregate.json'); +} + +/** Why a user policy path must not be read, or null: a relative path (no HOME) or one a PR could commit is never trusted. */ +export function userConfigRejection(repoRoot: string, file: string): string | null { + if (!path.isAbsolute(file)) return 'not an absolute path'; + const repo = path.resolve(repoRoot), real = realish(repoRoot); + if (within(repo, path.resolve(file)) || [file, ...resolutionChain(file)].some(p => within(real, realish(p)))) return 'inside the repository; a PR could supply it'; + return null; +} + export function buildContext(o: BuildContextOptions): ContextLoad { const base = o.base.replace(/^origin\//, ''); const baseRef = `origin/${base}`; @@ -130,9 +190,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad { if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } }; const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out; const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out; - const configLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks); + let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks); + const explain: string[] = []; + let absentUserPolicy: string | undefined; + if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') { + const rejected = userConfigRejection(o.repoRoot, o.userConfigPath); + const user = rejected ? null : loadConfigFromUser(o.userConfigPath, o.knownChecks); + if (user) configLoad = user; + else explain.push(`user policy: ${o.userConfigPath} ${rejected ? `ignored (${rejected})` : '(absent)'}`); + if (!user && !rejected) absentUserPolicy = o.userConfigPath; + } if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } }; - if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors } }; + if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors, ...(configLoad.kind === 'user' ? { fix: `edit ${o.userConfigPath} directly at each path the message names, then rerun gstack-pregate; it lives outside the repository, so a base-branch change does not reach it` } : {}) } }; const registryLoad = o.policyFrom === 'head' ? loadRegistryFromHead(o.repoRoot) : loadRegistryFromBase(o.repoRoot, base); if (isRegistryUnavailable(registryLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: registryLoad.detail } }; if (registryLoad.errors.length) return { refused: { code: 'REGEN_REGISTRY_INVALID', message: `${registryLoad.source.label}: ${registryLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: registryLoad.errors } }; @@ -142,15 +211,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad { const wtree = fp.tree; const platform = o.platform ?? process.platform; const config = configLoad.config as PregateConfig; + const lanes = discoverLanes(o.repoRoot, platform); + // A lane that exists but is not platform-tier on this host already runs here, so only an unknown id is worth a line. + for (const id of config.local) if (!lanes.some(l => l.id === id)) explain.push(`local: ${id} matches no workflow lane; ignored`); return { ctx: { repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase), - config, configLabel: configLoad.label, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, - lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000, + config, configLabel: configLoad.label, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.kind === 'user' && configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, + lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000, stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000, progress: o.progress ?? (line => process.stderr.write(`${line}\n`)), cores: o.cores ?? os.availableParallelism(), testBackend: o.testBackend ?? readTestBackend(env, o.invocation), policyFrom: o.policyFrom, remote: o.remote ?? null, - env, explain: [], + env, explain, }, }; } @@ -159,7 +231,7 @@ export function sha256(text: string): string { return createHash('sha256').updat /** The common input identity every check records; checks add their own keys. */ export function baseInputs(ctx: PregateContext, extra: Record = {}): Record { - return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...extra }; + return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...(ctx.configDigest ? { policy: ctx.configDigest } : {}), ...extra }; } /** diff --git a/lib/pregate/import-graph.ts b/lib/pregate/import-graph.ts index 52da30f32..22b90e4fb 100644 --- a/lib/pregate/import-graph.ts +++ b/lib/pregate/import-graph.ts @@ -12,7 +12,10 @@ * 3. the `bin/` → `test/*.test.*` convention (with and without * the `gstack-` prefix); * 4. explicit `.gstack/pregate.json` `dependencies` declarations; - * 5. in a Django project, the files Django loads by name (lib/pregate/django.ts). + * 5. in a Django project, the files Django loads by name (lib/pregate/django.ts); + * 6. in a Cargo crate, every `.rs` file and the Cargo.toml select the crate's + * tests and its dependents' (lib/pregate/rust.ts); in-source + * `#[cfg(test)]` files and `/tests/` files are the tests. * Dart directives (`import`/`export`/`part`, conditional URIs included) resolve * `package:/x` through every tracked pubspec.yaml to `/lib/x`. * Uncertainty selects more tests, never fewer: a dependency on a directory @@ -25,6 +28,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { matchesAny } from '../ship-policy'; import { djangoEdges } from './django'; +import { rustEdges } from './rust'; export const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|sh|bash|go|rs|dart)$/; export const TEST_FILE_RE = /(?:^|\/)(?:test|tests|spec|__tests__)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go|rb)$|_spec\.rb$|(?:^|\/)[^/]*\.bats$|(?:^|\/)test\/(?:.*\/)?[^/]*_test\.dart$/; @@ -465,6 +469,9 @@ export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph { for (const { target, via } of referencesIn(files, dirs, rel, source, config, graph.tests.has(rel) ? unresolved : undefined, packages)) addEdge(graph, target, rel, via); if (unresolved.length) graph.unresolved!.set(rel, unresolved); } + const rust = rustEdges(all, rel => readRepoFile(repoRoot, rel, maxBytes)); + for (const t of rust.tests) graph.tests.add(t); + for (const e of rust.edges) addEdge(graph, e.dependency, e.dependent, e.via); const django = djangoEdges({ files: [...files], tests: graph.tests, read: rel => pySources.get(rel) ?? readRepoFile(repoRoot, rel, maxBytes), code: pythonCode, resolveDotted: dotted => pythonPatchTarget(files, dotted) }); for (const e of django) addEdge(graph, e.dependency, e.dependent, 'framework'); for (const test of graph.tests) { diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 74a553be1..d6a52462f 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -22,8 +22,8 @@ import { regenCheck } from './checks/regen'; import { secretsCheck } from './checks/secrets'; import { straysCheck } from './checks/strays'; import { timingCheck } from './checks/timing'; -import { NON_DOWNGRADABLE } from './config'; -import { withFingerprint } from './context'; +import { NON_DOWNGRADABLE, userPolicyAbsent } from './config'; +import { sha256, withFingerprint } from './context'; import type { CheckDef, CheckResult, PregateContext, Stage } from './types'; export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck]; @@ -57,7 +57,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise c.remote ?? []).map(r => ({ ...r })); const file: PregateFile = { schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(), - policy: ctx.configLabel, registry: ctx.registryLabel, run: o.run, + policy: ctx.configLabel, ...(ctx.configDigest ? { policy_digest: ctx.configDigest } : {}), ...(ctx.absentUserPolicy ? { policy_user_path: ctx.absentUserPolicy } : {}), registry: ctx.registryLabel, run: o.run, stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' ? undefined : clocks.tests, checks: checks.map(c => { const { remote: _r, ...rest } = c; return rest; }), requires_remote: remote, @@ -122,12 +122,17 @@ export interface Verification { ok: boolean; code?: 'PREGATE_STALE' | 'PREGATE_R /** * Publication check: the recorded tree must equal the tree now (results * current after final generation and stamping), no check may be fail or - * incomplete, and every requires-remote obligation needs its receipt. + * incomplete, and every requires-remote obligation needs its receipt. The + * user-owned policy behind the run (or its absence, under the defaults) must + * still hold, since it lives outside the tree. */ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: WtreeResult = wtreeFingerprint({ cwd: repoRoot })): Verification { const uncleared = (file.requires_remote ?? []).filter(r => !r.receipt); - const current = 'tree' in treeNow && treeNow.tree === file.tree; - if (!current) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current, uncleared }; + const treeCurrent = 'tree' in treeNow && treeNow.tree === file.tree; + if (!treeCurrent) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current: false, uncleared }; + const policyChange = userPolicyChange(file); + if (policyChange) return { ok: false, code: 'PREGATE_STALE', message: policyChange, current: false, uncleared }; + const current = true; const failed = file.checks.filter(c => c.status === 'fail'); if (failed.length) return { ok: false, code: 'PREGATE_LANE_FAILED', message: `${failed.length} check(s) failed: ${failed.map(c => c.id).join(', ')}`, current, uncleared }; const incomplete = file.checks.filter(c => c.status === 'incomplete' || c.status === 'not_installed'); @@ -136,6 +141,27 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared }; } +/** + * Why the user-owned policy a run recorded no longer holds, or null. Both paths come from the record, never the verifier's + * environment: the policy file and its digest, or under the defaults the path that was looked for and absent. + */ +function userPolicyChange(file: PregateFile): string | null { + const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : null; + if (recorded) { + if (!file.policy_digest) return `pregate.json names the user-owned policy ${recorded} without its digest; rerun gstack-pregate`; + let text: string; + try { text = fs.readFileSync(recorded, 'utf8'); } catch { return `the user-owned policy ${recorded} that pregate.json was recorded under is gone or unreadable; rerun gstack-pregate`; } + return sha256(text) === file.policy_digest ? null : `the user-owned policy ${recorded} changed after pregate.json was recorded; rerun gstack-pregate`; + } + const absent = file.policy_user_path; + if (!absent) return null; + if (userPolicyAbsent(absent)) return null; + let leaf: fs.Stats | undefined; + try { leaf = fs.lstatSync(absent, { throwIfNoEntry: false }); } catch { leaf = undefined; } + if (!leaf) return `cannot confirm ${absent} is still absent (a link on the way to it is dangling or unreadable); rerun gstack-pregate`; + return `${absent} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`; +} + export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string } export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string }; diff --git a/lib/pregate/init-draft.ts b/lib/pregate/init-draft.ts index 974d48169..4be30a1f3 100644 --- a/lib/pregate/init-draft.ts +++ b/lib/pregate/init-draft.ts @@ -72,13 +72,14 @@ export function draftConfig(repoRoot: string): InitDraft { } const groups = new Map(); - for (const t of tests) { const lang = t.endsWith('.dart') ? 'dart' : t.endsWith('.py') ? 'python' : /\.[cm]?[jt]sx?$/.test(t) ? 'javascript' : 'other'; groups.set(lang, [...(groups.get(lang) ?? []), t]); } + for (const t of tests) { const lang = t.endsWith('.dart') ? 'dart' : t.endsWith('.rs') ? 'rust' : t.endsWith('.py') ? 'python' : /\.[cm]?[jt]sx?$/.test(t) ? 'javascript' : 'other'; groups.set(lang, [...(groups.get(lang) ?? []), t]); } const notes = [...groups].map(([lang, list]) => { if (lang === 'dart') return `${list.length} Dart test file(s) run per package with flutter test or dart test`; + if (lang === 'rust') return `${list.length} Rust test file(s) run per crate with cargo test -p`; const runner = detectRunner({ repoRoot, config: DEFAULT_CONFIG }, list); return runner ? `${lang} tests detected at run time: ${runner}` : `no runner detected for ${list.length} ${lang} test file(s); set runner (with {files})`; }); - const mixed = [...groups.keys()].filter(l => l !== 'dart').length > 1 ? '; a selection mixing these languages needs runner set' : ''; + const mixed = [...groups.keys()].filter(l => l !== 'dart' && l !== 'rust').length > 1 ? '; a selection mixing these languages needs runner set' : ''; const runnerNote = notes.length ? notes.join('; ') + mixed : 'no test files found'; const selectionTodo = discoverLanes(repoRoot).filter(l => l.runsTests && l.matrixExpression && l.testFiles.length === 0).map(l => l.id); diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 044e27312..529227cd5 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -130,12 +130,13 @@ export function selectLanes(ctx: PregateContext): Selection { const set = laneFileSet(ctx, lane); if (set !== 'all') laneSets[lane.id] = set; const inLane = set === 'all' ? selected : set === '?' ? selected : selected.filter(t => set.includes(t)); - if (lane.tier === 'platform') { + if (lane.tier === 'platform' && ctx.config.local.includes(lane.id)) ctx.explain.push(`lane ${lane.id}: ${lane.platform} lane satisfied locally (pregate.json local)`); + else if (lane.tier === 'platform') { if (inLane.length) remote.push({ lane: lane.id, platform: lane.platform, workflow: lane.workflow, job: lane.job, tests: inLane, receipt: null }); continue; } if (set === 'all') anyLocalAll = true; - if (inLane.length) { tiers.add(lane.tier); for (const t of inLane) local.add(t); } + if (inLane.length) { tiers.add(lane.tier === 'platform' ? 'unit' : lane.tier); for (const t of inLane) local.add(t); } } if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t); for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t); diff --git a/lib/pregate/rust.ts b/lib/pregate/rust.ts new file mode 100644 index 000000000..6e0028229 --- /dev/null +++ b/lib/pregate/rust.ts @@ -0,0 +1,258 @@ +/** + * pregate/rust — Cargo crates for the import graph and the `lanes` runner. + * A crate is the nearest directory holding a Cargo.toml with a `[package]` + * name. Its `.rs` files are tests when they sit under `/tests/` or hold + * a `#[cfg(test)]` (in-source unit tests). Cargo runs tests per crate, never + * per file, so the graph maps at crate level: every `.rs` file of a crate, + * and its Cargo.toml, select that crate's tests and the tests of every crate + * that depends on it, transitively. + * Manifests are parsed as TOML (Bun.TOML, as lib/model-policy.ts does), so + * dotted keys, `[dependencies.]` tables, target-specific tables and + * `{ workspace = true }` inheritance read as Cargo reads them. A dependency + * resolves by `path` (relative to the manifest declaring it) to a tracked + * crate, else by package name (`package = ` or the key); a name match only + * widens. The workspace a crate runs in follows Cargo's membership rules: + * `package.workspace`, else the nearest `[workspace]` above it, when that + * workspace is the crate's own manifest, lists it in `members` or reaches it + * by a path dependency, and does not `exclude` it; otherwise the crate + * directory. Nothing here runs cargo: pregate reads files only. + */ +import * as path from 'node:path'; + +/** Reads a repo-relative file's text, or null when it is absent or unreadable. */ +export type ReadFile = (rel: string) => string | null; + +type Table = Record; +const isTable = (v: unknown): v is Table => typeof v === 'object' && v !== null && !Array.isArray(v); +const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []); +const DEP_TABLES = ['dependencies', 'dev-dependencies', 'build-dependencies'] as const; + +/** One dependency: the crate directory its `path` names (repo-relative, normalized) and the package name it asks for. */ +interface DepSpec { path: string | null; name: string } + +export interface Crate { + root: string; + name: string; + /** Where `cargo test -p ` runs: the root of the workspace it is a member of, else the crate directory; null when membership is undecided. */ + workspace: string | null; + /** Roots of the tracked crates it depends on. */ + deps: string[]; +} + +export interface CargoModel { crates: Map; crateOf(file: string): Crate | null } + +interface Manifest { dir: string; toml: Table } + +const dirOf = (manifest: string) => (path.posix.dirname(manifest) === '.' ? '' : path.posix.dirname(manifest)); +/** `dir` joined with a manifest-relative path, normalized; null when it leaves the repository. */ +function joinRel(dir: string, rel: string): string | null { + const joined = path.posix.normalize(path.posix.join(dir || '.', rel.replace(/\\/g, '/'))).replace(/\/+$/, ''); + if (joined === '..' || joined.startsWith('../') || path.posix.isAbsolute(joined)) return null; + return joined === '.' ? '' : joined; +} +/** One glob component (`*`, `?`, `[...]` with `!`/`^` negation and ranges, else literal) against one path component, by Unicode code point and dynamic programming: O(pattern × name), no regex backtracking. */ +function componentMatch(glob: string, name: string): boolean { + const g = Array.from(glob); + const s = Array.from(name); + const nextClose = new Array(g.length + 1).fill(-1); + for (let i = g.length - 1; i >= 0; i--) nextClose[i] = g[i] === ']' ? i : nextClose[i + 1]!; + const tokens: Array<'*' | ((c: string) => boolean)> = []; + for (let i = 0; i < g.length; i++) { + const ch = g[i]!; + const negate = ch === '[' && /[!^]/.test(g[i + 1] ?? ''); + const close = ch === '[' ? nextClose[i + (negate ? 3 : 2)] ?? -1 : -1; + if (ch === '*') tokens.push('*'); + else if (ch === '?') tokens.push(() => true); + else if (close > 0) { + const body = g.slice(i + (negate ? 2 : 1), close).map(c => c.codePointAt(0)!); + tokens.push(c => { + const p = c.codePointAt(0)!; + let hit = false; + for (let j = 0; j < body.length; j++) { + if (body[j + 1] === 0x2d && j + 2 < body.length) { hit ||= p >= body[j]! && p <= body[j + 2]!; j += 2; } else hit ||= p === body[j]; + } + return hit !== negate; + }); + i = close; + } else tokens.push(c => c === ch); + } + let row = s.map(() => false).concat(false); + row[0] = true; + for (const t of tokens) { + const next = row.map(() => false); + next[0] = t === '*' && row[0]!; + for (let j = 1; j <= s.length; j++) next[j] = t === '*' ? row[j]! || next[j - 1]! : row[j - 1]! && t(s[j - 1]!); + row = next; + } + return row[s.length]!; +} +/** A Cargo `members` glob against a workspace-relative crate directory, component by component; `**` spans any number of components. */ +function memberMatch(glob: string, rel: string): boolean { + const p = rel.split('/').filter(Boolean); + let row = p.map(() => false).concat(false); + row[0] = true; + for (const part of glob.split('/').filter(Boolean)) { + const next = row.map(() => false); + next[0] = part === '**' && row[0]!; + for (let j = 1; j <= p.length; j++) next[j] = part === '**' ? row[j]! || next[j - 1]! : row[j - 1]! && componentMatch(part, p[j - 1]!); + row = next; + } + return row[p.length]!; +} +/** + * Past this many characters of `members` globs a workspace's membership is undecided: every crate under it counts as a + * member for dependency edges (more tests, never fewer), but where its tests run is unknown, so they are unclaimed and the + * lane is incomplete (running from the wrong root can test a same-named member instead). + */ +const MAX_MEMBER_GLOB_CHARS = 4096; +const inside = (dir: string, root: string) => root === '' || dir === root || dir.startsWith(`${root}/`); +const relTo = (dir: string, root: string) => (root === '' ? dir : dir === root ? '' : dir.slice(root.length + 1)); + +/** Every dependency of a manifest: `[*dependencies]` and `[target..*dependencies]`, `{ workspace = true }` read from `ws`'s `[workspace.dependencies]` (its paths relative to the workspace root). */ +function depSpecs(m: Manifest, ws: Manifest | null): DepSpec[] { + const tables: Table[] = []; + for (const key of DEP_TABLES) if (isTable(m.toml[key])) tables.push(m.toml[key] as Table); + if (isTable(m.toml.target)) for (const cfg of Object.values(m.toml.target)) if (isTable(cfg)) for (const key of DEP_TABLES) if (isTable(cfg[key])) tables.push(cfg[key] as Table); + const inherited = ws && isTable(ws.toml.workspace) && isTable(ws.toml.workspace.dependencies) ? ws.toml.workspace.dependencies : {}; + const out: DepSpec[] = []; + for (const table of tables) { + for (const [key, raw] of Object.entries(table)) { + const fromWorkspace = isTable(raw) && raw.workspace === true; + const spec = fromWorkspace ? inherited[key] : raw; + const renamed = [spec, raw].find(s => isTable(s) && typeof s.package === 'string') as Table | undefined; + const p = isTable(spec) && typeof spec.path === 'string' ? joinRel(fromWorkspace ? ws!.dir : m.dir, spec.path) : null; + out.push({ path: p, name: (renamed?.package as string | undefined) ?? key }); + } + } + return out; +} + +/** The tracked crates among `files` (the paths `git ls-files` reports), what each depends on, and the workspace each runs in. */ +export function cargoModel(files: readonly string[], read: ReadFile): CargoModel { + const manifests = new Map(); + for (const f of files) { + if (path.posix.basename(f) !== 'Cargo.toml') continue; + const text = read(f); + if (text === null) continue; + try { const toml = Bun.TOML.parse(text); if (isTable(toml)) manifests.set(dirOf(f), { dir: dirOf(f), toml }); } catch { /* an unparsable manifest names no crate */ } + } + const nameOf = (dir: string) => { const pkg = manifests.get(dir)?.toml.package; return isTable(pkg) && typeof pkg.name === 'string' ? pkg.name : null; }; + const roots = [...manifests.keys()].filter(d => nameOf(d) !== null); + const workspaces = [...manifests.values()].filter(m => isTable(m.toml.workspace)); + const byName = new Map(); + for (const r of roots) byName.set(nameOf(r)!, [...(byName.get(nameOf(r)!) ?? []), r]); + + // `package.workspace` names the workspace; otherwise Cargo consults only the nearest [workspace] at or above the crate. + const candidate = (root: string): Manifest | null => { + const pkg = manifests.get(root)!.toml.package as Table; + if (typeof pkg.workspace === 'string') { const w = manifests.get(joinRel(root, pkg.workspace) ?? '\0'); return w && isTable(w.toml.workspace) ? w : null; } + return workspaces.filter(w => inside(root, w.dir)).sort((a, b) => b.dir.length - a.dir.length)[0] ?? null; + }; + const memberCache = new Map>(); + const undecided = new Set(); + const membersOf = (ws: Manifest): Set => { + const cached = memberCache.get(ws.dir); + if (cached) return cached; + const conf = ws.toml.workspace as Table; + const norm = (g: string) => joinRel('', g) ?? '\0'; + const globs = strings(conf.members).map(norm); + const exclude = strings(conf.exclude).map(norm); + const own = new Set(roots.filter(r => candidate(r)?.dir === ws.dir)); + const excluded = (r: string) => exclude.some(e => { const rel = relTo(r, ws.dir); return rel === e || rel.startsWith(`${e}/`); }); + // Cargo's rules: the root package and a literally listed member always belong; a glob match or a path dependency of a + // member inside the workspace belongs unless `exclude` names it or a directory above it. + const literal = (r: string) => globs.some(g => !/[*?[]/.test(g) && relTo(r, ws.dir) === g); + const oversized = globs.join('').length > MAX_MEMBER_GLOB_CHARS; + const members = new Set([...own].filter(r => r === ws.dir || literal(r) || ((oversized || globs.some(g => memberMatch(g, relTo(r, ws.dir)))) && !excluded(r)))); + if (oversized) for (const r of members) if (r !== ws.dir && !literal(r)) undecided.add(r); + memberCache.set(ws.dir, members); + const queue = [...members]; + while (queue.length) { + for (const dep of depSpecs(manifests.get(queue.shift()!)!, ws)) { + if (dep.path === null || members.has(dep.path) || !own.has(dep.path) || !inside(dep.path, ws.dir) || excluded(dep.path)) continue; + members.add(dep.path); + queue.push(dep.path); + } + } + return members; + }; + + // A path names one crate; without a path (or when it names no tracked crate) every crate of that package name is a candidate. + const resolve = (specs: DepSpec[]) => [...new Set(specs.flatMap(s => (s.path !== null && nameOf(s.path) !== null ? [s.path] : byName.get(s.name) ?? [])))]; + const crates = new Map(); + for (const root of roots) { + const ws = candidate(root); + const member = ws !== null && membersOf(ws).has(root) ? ws : null; + crates.set(root, { root, name: nameOf(root)!, workspace: undecided.has(root) ? null : member?.dir ?? root, deps: resolve(depSpecs(manifests.get(root)!, member)).filter(d => d !== root) }); + } + const crateOf = (file: string): Crate | null => { + for (let d = path.posix.dirname(file); ; d = path.posix.dirname(d)) { + const c = crates.get(d === '.' ? '' : d); + if (c) return c; + if (d === '.' || d === '/' || d === '') return null; + } + }; + return { crates, crateOf }; +} + +/** + * Whether Rust source declares tests: a `#[test]` or `#[::test]` attribute (a plain test function, `#[tokio::test]`), + * or a `#[cfg(...)]` whose predicate names `test` outside a string (`cfg(test)`, `cfg(all(test, unix))`; never + * `cfg(feature = "test")`). One forward scan: each attribute is read once to its `]`, and one this scan cannot close + * (an unterminated string, no `]` before the end) counts as a test, which selects more, never fewer. + */ +export function hasCfgTest(source: string): boolean { + if (/#\s*\[\s*(?:[A-Za-z_]\w*\s*::\s*)*test\s*[\]()]/.test(source)) return true; + const open = /#\s*\[\s*cfg\s*\(/g; + for (let m = open.exec(source); m; m = open.exec(source)) { + let predicate = ''; + let i = open.lastIndex; + for (; i < source.length && source[i] !== ']'; i++) { + if (source[i] !== '"') { predicate += source[i]; continue; } + const close = source.indexOf('"', i + 1); + if (close < 0) return true; + predicate += '""'; + i = close; + } + if (i >= source.length) return true; + const body = predicate.trimEnd(); + if (body.endsWith(')') && /\btest\b/.test(body.slice(0, -1))) return true; + open.lastIndex = i + 1; + } + return false; +} + +export interface RustGraph { + tests: string[]; + edges: Array<{ dependency: string; dependent: string; via: 'import' | 'convention' }>; +} + +/** + * The Rust part of the reverse graph over the tracked `files`: each crate's + * test files, an edge from every `.rs` file of a crate to its Cargo.toml (the + * crate's node), and an edge from each Cargo.toml to the tests of its crate + * (`convention`) and of every crate depending on it, transitively (`import`). + */ +export function rustEdges(files: readonly string[], read: ReadFile): RustGraph { + const { crates, crateOf } = cargoModel(files, read); + const out: RustGraph = { tests: [], edges: [] }; + if (!crates.size) return out; + const testsOf = new Map([...crates.keys()].map(k => [k, []])); + const manifestOf = (root: string) => (root ? `${root}/Cargo.toml` : 'Cargo.toml'); + for (const f of files) { + if (!f.endsWith('.rs')) continue; + const crate = crateOf(f); + if (!crate) continue; + out.edges.push({ dependency: f, dependent: manifestOf(crate.root), via: 'convention' }); + if (f.startsWith(crate.root ? `${crate.root}/tests/` : 'tests/') || hasCfgTest(read(f) ?? '')) { out.tests.push(f); testsOf.get(crate.root)!.push(f); } + } + const dependents = new Map>(); + for (const c of crates.values()) for (const d of c.deps) dependents.set(d, (dependents.get(d) ?? new Set()).add(c.root)); + for (const root of crates.keys()) { + const reach = new Set([root]); + const queue = [root]; + while (queue.length) for (const next of dependents.get(queue.shift()!) ?? []) if (!reach.has(next)) { reach.add(next); queue.push(next); } + for (const r of reach) for (const t of testsOf.get(r)!) out.edges.push({ dependency: manifestOf(root), dependent: t, via: r === root ? 'convention' : 'import' }); + } + return out; +} diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index 37512dee4..b93fbe140 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -33,6 +33,12 @@ export interface PregateContext { touched: TouchedFile[]; config: PregateConfig; configLabel: string; + /** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */ + configFile: string; + /** sha256 of the user-owned policy's bytes when it is active; recorded as `inputs.policy` (a base copy is pinned by `inputs.base`). */ + configDigest?: string; + /** The user-owned policy path looked for and absent under the defaults; recorded so verify can tell when one appears. */ + absentUserPolicy?: string; registry: Registry; registryLabel: string; lanes: Lane[]; diff --git a/lib/restamp.ts b/lib/restamp.ts index 4824b3059..8980d5d27 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -68,7 +68,7 @@ export interface RestampResult { function sha256(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex'); } function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; out: string; err: string; status: number | null } { - const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env }); + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 }); return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, ''), err: (r.stderr ?? '').trim(), status: r.status }; } class Refusal extends Error { constructor(public code: ResultCodeName, message: string, public extra: Partial = {}) { super(message); } } @@ -578,7 +578,9 @@ function gateAhead(o: RestampOptions, plan: Plan, stages: StageRecord[], common: if (resolvable) { for (const c of conflicts) { if (c === plan.policy.changelog) { - const merged = mergeChangelogs(git(dir, ['show', `:2:${c}`]).out + '\n', git(dir, ['show', `:3:${c}`]).out + '\n'); + const stage = (n: number) => git(dir, ['show', `:${n}:${c}`]); + const [base, ours, theirs] = [stage(1), stage(2), stage(3)]; + const merged = ours.ok && theirs.ok ? mergeChangelogs(ours.out + '\n', theirs.out + '\n', base.ok ? base.out + '\n' : undefined) : null; if (merged === null) { unresolved = [c]; break; } fs.writeFileSync(path.join(dir, c), merged); } else git(dir, ['checkout', '--theirs', '--', c]); diff --git a/lib/result-codes.ts b/lib/result-codes.ts index cd5a5f74e..d1e09b56f 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -166,7 +166,7 @@ export const RESULT_CODES = { // C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt. POLICY_SOURCE_UNAVAILABLE: { anchor: 'policy-source-unavailable', - summary: 'policy source unavailable: origin/ is not a resolvable commit, so the reviewed .gstack/ship-policy.json cannot be read', + summary: 'policy source unavailable: origin/ is not a resolvable commit, or a pin file committed on it cannot be read, so the reviewed .gstack/ship-policy.json cannot be read', fix: 'git fetch origin --depth=1 (the working-tree copy is never used silently)', }, POLICY_INVALID: { @@ -364,7 +364,7 @@ export const RESULT_CODES = { }, PREGATE_CONFIG_INVALID: { anchor: 'pregate-config-invalid', - summary: '.gstack/pregate.json has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob', + summary: 'the pregate policy (.gstack/pregate.json, or the user-owned copy) has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob', fix: 'run `gstack-pregate validate` and fix the file on the base branch (secrets and required lanes cannot be downgraded)', }, PREGATE_SECRETS: { diff --git a/lib/review-evidence.ts b/lib/review-evidence.ts index 52586833d..418d08ab5 100644 --- a/lib/review-evidence.ts +++ b/lib/review-evidence.ts @@ -7,6 +7,7 @@ import { resultLine, type ResultCodeName } from './result-codes'; const DIFF_REVIEWS = new Set(['review', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']); const SHARED_LIBS_COVERAGE_VERSION = 1; +const HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; function record(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); @@ -163,18 +164,54 @@ export function checkSharedLibsReuse(finding: unknown, token: string, env = proc return result; } -export function captureReviewStart(skill: string, env = process.env): string { +/** + * #3339: a fix-range repeat reviews only what changed since the latest pass of + * the same skill, and only when that pass completed and made fixes on this + * branch, no Step 11 review followed it, and the content it started from is + * exactly its commit's tree. That commit, held in the review log rather than + * the working tree, is the base; it must still be an ancestor of HEAD, so + * `git diff ` holds every fix. Anything else needs a full pass. + */ +function fixRangeBase(skill: string, env: NodeJS.ProcessEnv): string { + const refuse = (reason: string): never => { + throw new Error(`gstack-review-log: fix-range refused: ${reason}; run a full pass (gstack-review-log --start ${skill})`); + }; + let rows: Record[] = []; + try { + rows = readFileSync(env.GSTACK_REVIEW_LOG!, 'utf8').split('\n').filter(Boolean).flatMap(line => { + try { const row = JSON.parse(line); return record(row) ? [row] : []; } catch { return []; } + }); + } catch {} + const at = rows.findLastIndex(row => row.skill === skill); + if (at < 0) refuse('no earlier pass on this branch'); + if (rows.slice(at + 1).some(row => row.skill === 'adversarial-review' || row.skill === 'codex-review')) refuse('a Step 11 review followed the latest pass'); + const prior = rows[at]!, binding = prior.review_binding; + if (prior.completed !== true) refuse('the latest pass did not complete'); + if (prior.converged !== false) refuse('the latest pass made no fixes'); + if (!record(binding) || (binding.state !== 'changed' && binding.state !== 'incomplete') || typeof binding.start_wtree !== 'string') refuse('the latest pass has no captured start'); + if (binding.branch_id !== sha256(env.GSTACK_REVIEW_BRANCH ?? '')) refuse('the latest pass is from another branch'); + const base = prior.commit_full; + const git = (...args: string[]) => spawnSync('git', args, { cwd: env.GSTACK_REVIEW_REPO, encoding: 'utf8', timeout: 10_000 }); + if (typeof base !== 'string' || !HASH.test(base) || git('cat-file', '-e', `${base}^{commit}`).status !== 0) refuse('the latest pass has no resolvable commit'); + if (git('rev-parse', `${base}^{tree}`).stdout.trim() !== binding.start_wtree) refuse('the latest pass started from uncommitted content, so no commit marks what it read'); + if (git('merge-base', '--is-ancestor', base, 'HEAD').status !== 0) refuse(`${base} is not an ancestor of HEAD`); + return base; +} + +export function captureReviewStart(skill: string, env = process.env, fixRange = false): string { if (!DIFF_REVIEWS.has(skill) || !env.GSTACK_STAMP_WTREE || !env.GSTACK_REVIEW_REPO) { throw new Error('cannot capture a diff review without a working-tree fingerprint'); } + const fixBase = fixRange ? fixRangeBase(skill, env) : undefined; const dir = join(env.GSTACK_REVIEW_DIR!, '.review-starts'); mkdirSync(dir, { recursive: true, mode: 0o700 }); const token = crypto.randomUUID(); writeFileSync(join(dir, `${token}.json`), JSON.stringify({ skill, repo: env.GSTACK_REVIEW_REPO, branch: env.GSTACK_REVIEW_BRANCH, wtree: env.GSTACK_STAMP_WTREE, started_at: new Date().toISOString(), + ...(fixBase ? { scope: 'fix-range', fix_base: fixBase } : {}), }), { mode: 0o600, flag: 'wx' }); - return token; + return fixBase ? `${token}\nFIX_BASE=${fixBase}` : token; } const GATE_ACTIONS = new Set(['kept', 'restored', 'open']); @@ -226,7 +263,7 @@ export function deriveFromFindings(rec: Record, findingsPath: strin } export function bindReview(rec: Record, token: string, env = process.env, findingsPath?: string): Record { - for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file']) delete rec[key]; + for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key]; // The findings file is validated BEFORE the start token is consumed below: // a rejected row must leave the token for the retry. rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' }; @@ -256,11 +293,19 @@ export function bindReview(rec: Record, token: string, env = proces const state = !start || !end ? 'uncaptured' : start.wtree !== end ? 'changed' : rec.completed !== true || rec.converged !== true ? 'incomplete' : 'verified'; - rec.review_binding = { - state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at, - ...(typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {}), - }; - if (state === 'verified') rec.wtree = end; + const branchId = typeof start?.branch === 'string' && start.branch.length > 0 ? { branch_id: sha256(start.branch) } : {}; + const narrowed = start?.scope === 'fix-range' || start?.fix_base !== undefined; + if (narrowed) { + rec.scope = 'fix-range'; + if (typeof start.fix_base === 'string' && HASH.test(start.fix_base)) rec.fix_base = start.fix_base; + } + // A narrowed pass on unchanged content never carries start_wtree/end_wtree, + // so Step 11.5 cannot bind it and it never becomes CURRENT; only a later full pass can. + rec.review_binding = narrowed && (state === 'incomplete' || state === 'verified') + ? { state: 'fix-range', fix_base: rec.fix_base, range_start_wtree: start.wtree, range_end_wtree: end, started_at: start.started_at, ...branchId } + : { state, start_wtree: start?.wtree, end_wtree: end, started_at: start?.started_at, ...branchId }; + const verified = rec.review_binding.state === 'verified'; + if (verified) rec.wtree = end; if (rec.skill === 'review' && Array.isArray(rec.findings)) { rec.shared_libs_coverage_version = SHARED_LIBS_COVERAGE_VERSION; for (const finding of rec.findings) { @@ -270,7 +315,7 @@ export function bindReview(rec: Record, token: string, env = proces const fingerprint = sharedLibsFingerprint(finding); if (!fingerprint) continue; finding.fingerprint = fingerprint; - finding.snapshot_covered_paths = state === 'verified' && finding.action === 'skipped' + finding.snapshot_covered_paths = verified && finding.action === 'skipped' ? sharedLibsSnapshotCoverage(env.GSTACK_REVIEW_REPO!, end!, finding.evidence_paths, env) : []; } } @@ -282,6 +327,7 @@ export function reviewFreshness(rec: Record, currentWtree: string): if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' }; const binding = rec.review_binding; if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' }; + if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' }; if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true || !rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) { return { status: 'UNVERIFIED', reason: 'missing start capture or incomplete/nonconverged pass' }; diff --git a/lib/ship-queue-gate.ts b/lib/ship-queue-gate.ts index 67c6e51cb..9e42bcf4d 100644 --- a/lib/ship-queue-gate.ts +++ b/lib/ship-queue-gate.ts @@ -99,7 +99,8 @@ export function recomputeMergeTree(repoRoot: string, branchHead: string, predece if (!ours || !theirs) return { error: `${file} conflicts beyond a content merge (added or deleted on one side)` }; if (file === policy.changelog) { const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n'; - const merged = mergeChangelogs(text(ours), text(theirs)); + const base = entry.get(1); + const merged = mergeChangelogs(text(ours), text(theirs), base ? text(base) : undefined); if (merged === null) return { error: `${file} conflicts beyond a heading move` }; const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged); if (!blob.ok) return { error: `cannot write the merged ${file}: ${blob.err}` }; diff --git a/lib/ship-queue-train-cli.ts b/lib/ship-queue-train-cli.ts index 03c83c780..cc41f5988 100644 --- a/lib/ship-queue-train-cli.ts +++ b/lib/ship-queue-train-cli.ts @@ -15,7 +15,9 @@ import { trainBisect, trainRebuild, trainVerdict } from './ship-queue-train-verd import { verifyTrain } from './ship-queue-train-verify'; import { redactCause } from './ship-queue-train-record'; -export const TRAIN_HELP = ` train build --prs @[,...] [--urgent | --repair] [--dry-run] [--free-timeout ] +export const TRAIN_HELP = ` train build --prs @[,...] [--order ,...] [--urgent | --repair] [--dry-run] [--free-timeout ] + --order: explicit slot order, every PR in --prs once (default: screened + first, then READY order) train build --train resume a train (push, free tests, dispatch) from its record merge pinned unstamped heads in slot order onto main, free tests on train/, then one full gate through eval-bg's nonce @@ -77,8 +79,9 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise,... orders a new train from --prs; a resumed train keeps its recorded order'); return guarded('train build', () => buildTrain(ctx, { - pins: flag(rest, '--prs'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec, + pins: flag(rest, '--prs'), order: flag(rest, '--order'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, })); } diff --git a/lib/ship-queue-train-ready.ts b/lib/ship-queue-train-ready.ts index 12102394e..43f24fc29 100644 --- a/lib/ship-queue-train-ready.ts +++ b/lib/ship-queue-train-ready.ts @@ -14,6 +14,7 @@ import { randomBytes } from 'node:crypto'; import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg'; import { EXIT } from './headless-artifacts'; import { ready, type QueueContext } from './ship-queue'; +import { classifyTrainHead } from './version-gate-train'; import { git } from './ship-queue-gate'; import { execFor, repoSlug } from './ship-queue-train-github'; import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record'; @@ -100,6 +101,8 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise; superseded_by: string | null; effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string; } @@ -87,11 +87,34 @@ const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json'); const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`); export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`); -/** The repository's identity: origin's URL, keyed so records of different repositories never mix. */ -export function repoIdentity(repoRoot: string): { key: string; url: string } { - const url = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot; - const norm = url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, ''); - return { key: sha256(norm).slice(0, 16), url: norm }; +/** Hosts that serve GitHub repositories at // (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */ +const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai']; + +/** Before 1.91.187.0 the key hashed origin's URL with only .git, git@github.com: and trailing slashes normalized. */ +const legacyNorm = (url: string) => url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, ''); + +/** A remote URL as https://github.com// when it names a GitHub repository in any form (scp, ssh, http(s), credentials, proxy host); otherwise the legacy form. */ +export function normalizeRemoteUrl(url: string): string { + const m = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/*$/i.exec(url.trim()); + if (m && GITHUB_HOSTS.includes(m[1]!.toLowerCase())) return `https://github.com/${m[2]!.toLowerCase()}/${m[3]!.toLowerCase()}`; + return legacyNorm(url.trim().replace(/^([a-z+]+:\/\/)[^@/]+@/i, '$1')); +} + +/** + * The repository's identity, keyed so records of different repositories + * never mix: a hash of origin's URL normalized to owner/repo, so lanes whose + * origin names the same GitHub repository in another form share one key. + * `keys` adds the keys earlier versions wrote for this URL and for the + * repository's common URL forms; readers accept all of them, writers use `key`. + */ +export function repoIdentity(repoRoot: string): { key: string; url: string; keys: string[] } { + const raw = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot; + const url = normalizeRemoteUrl(raw); + const key = sha256(url).slice(0, 16); + const gh = /^https:\/\/github\.com\/(.+)$/.exec(url)?.[1]; + const forms = gh ? GITHUB_HOSTS.flatMap(h => ['https', 'http', 'ssh://git@'].map(s => `${s.includes('://') ? s : `${s}://`}${h}/${gh}`)) : []; + const keys = [...new Set([key, ...[raw, ...forms].map(u => sha256(legacyNorm(u)).slice(0, 16))])]; + return { key, url, keys }; } function readJson(file: string, kind: string): T | null { @@ -101,19 +124,25 @@ function readJson(file: string, kind: string): T | null { export const loadTrain = (nonce: string) => (/^[A-Za-z0-9._-]{1,128}$/.test(nonce) ? readJson(trainFile(nonce), 'train') : null); export const loadAttempt = (nonce: string, id: string) => (/^[A-Za-z0-9._-]{1,64}$/.test(id) ? readJson(attemptPath(nonce, id), 'train-attempt') : null); export const loadBisect = (nonce: string) => readJson(bisectPath(nonce), 'train-bisect'); -export const loadFreeze = (repoKey: string) => readJson(freezePath(repoKey), 'freeze'); -export const loadReady = (repoKey: string, pr: string, head: string) => readJson(readyPath(repoKey, pr, head), 'ready'); -export const activeFreeze = (repoKey: string): FreezeRecord | null => { const f = loadFreeze(repoKey); return f && !f.cleared_at ? f : null; }; +/** A reader's repo keys: the current key first, then the legacy keys (repoIdentity().keys). */ +type RepoKeys = string | readonly string[]; +const keyList = (k: RepoKeys): readonly string[] => (typeof k === 'string' ? [k] : k); +const firstOf = (k: RepoKeys, read: (key: string) => T | null): T | null => { for (const key of keyList(k)) { const r = read(key); if (r) return r; } return null; }; + +export const loadFreeze = (repoKeys: RepoKeys) => firstOf(repoKeys, key => readJson(freezePath(key), 'freeze')); +export const loadReady = (repoKeys: RepoKeys, pr: string, head: string) => firstOf(repoKeys, key => readJson(readyPath(key, pr, head), 'ready')); +export const activeFreeze = (repoKeys: RepoKeys): FreezeRecord | null => firstOf(repoKeys, key => { const f = readJson(freezePath(key), 'freeze'); return f && !f.cleared_at ? f : null; }); export function attemptsOf(train: TrainRecord): AttemptRecord[] { return train.attempts.map(id => loadAttempt(train.nonce, id)).filter((a): a is AttemptRecord => !!a); } /** Every train of this repository, newest first. */ -export function listTrains(repoKey: string): TrainRecord[] { +export function listTrains(repoKeys: RepoKeys): TrainRecord[] { const dir = path.join(queueRoot(), 'trains'); if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && t.repo_key === repoKey) + const keys = keyList(repoKeys); + return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && keys.includes(t.repo_key)) .sort((a, b) => b.created_at.localeCompare(a.created_at) || b.nonce.localeCompare(a.nonce)); } diff --git a/lib/ship-queue-train-verdict.ts b/lib/ship-queue-train-verdict.ts index 5ee480ce9..9d27bafae 100644 --- a/lib/ship-queue-train-verdict.ts +++ b/lib/ship-queue-train-verdict.ts @@ -23,7 +23,7 @@ import { type AttemptRecord, type BisectRecord, type CasePanel, type GateOutcome, type PrefixResult, type TrainRecord, } from './ship-queue-train-record'; import { - dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, pinList, save, trainEnv, + dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, prsArgs, save, trainEnv, type TrainEnv, type TrainOut, } from './ship-queue-train'; @@ -309,7 +309,7 @@ export async function trainRebuild(ctx: QueueContext, o: { train: string; infra: const run = trainFile(t.nonce); const gates = gateAttempts(t); const last = gates.at(-1); - const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`; + const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`; const refuse = (reason: string) => outRefused({ code: 'QUEUE_TRAIN_REBUILD_REFUSED', message: `rebuild refused: ${reason}` }, gates.map(attemptLine), repair, run); if (o.infra) { if (!last || last.outcome !== 'INCOMPLETE' || !last.infra_eligible) return refuse(`no INFRA/INCOMPLETE-only record (the last gate is ${last?.outcome ?? 'missing'})`); @@ -321,7 +321,7 @@ export async function trainRebuild(ctx: QueueContext, o: { train: string; infra: if (b.outcome.kind !== 'unattributed' || !b.outcome.all_green) return refuse(`the bisect was not all green (${Object.entries(b.outcome.patterns).map(([c, s]) => `${c}=${s}`).join(' ')})`); } const tip = remoteBranchSha(args => git(ctx.repoRoot, args), env.remote, t.branch); - if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build --prs ${pinList(t.slots)}`, run); + if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run); const slug = repoSlug(ctx); if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train rebuild --train ${t.nonce}${o.infra ? ' --infra' : ''}`, run); const a = await dispatchAttempt(env, slug, t, o.infra ? 'infra-redispatch' : 'rebuild', gateDispatch(env, t, `${t.nonce}.${gates.length + 1}`)); diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 8c797eba3..7f2db14fb 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -20,8 +20,9 @@ import { git } from './ship-queue-gate'; import { checkRun, execFor, repoSlug } from './ship-queue-train-github'; import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record'; import { - effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, save, trainEnv, type TrainEnv, type TrainOut, + effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prsArgs, save, trainEnv, type TrainEnv, type TrainOut, } from './ship-queue-train'; +import { ownEntryAboveMain } from './changelog-check'; import { buildReceipt } from './tree-receipt'; const Q = 'gstack-ship-queue'; @@ -32,6 +33,37 @@ function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` }; } +/** + * A slot head with main merged in (from slot 2 on the lane merges main into + * its pin so GitHub can merge it): it must contain main, and its tree must be + * the slot's prefix T modulo stamps. The CHANGELOG is judged against main + * instead of T's mechanical merge, so a hand resolve is accepted when it + * is main's CHANGELOG plus the PR's own entry (ownEntryAboveMain); any other + * difference is refused. + */ +function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: string): { same: boolean; detail: string } { + const after = t.prefixes[i + 1]!; + const root = env.ctx.repoRoot; + if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` }; + const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label }); + if ('error' in r) return { same: false, detail: r.error }; + const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`; + if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` }; + const cl = env.policy.policy.changelog; + const beyond = r.residual.filter(f => f !== cl); + if (beyond.length || !cl) return { same: false, detail: `differs from T${after.index}: ${r.residual.slice(0, 8).join(', ')}` }; + const pin = t.slots[i]!.head; + const forkPoint = git(root, ['merge-base', pin, t.prefixes[0]!.commit]).out; + const diff = git(root, ['diff', '--unified=0', '--no-color', '--no-ext-diff', forkPoint, pin, '--', cl]); + const show = (rev: string) => git(root, ['show', `${rev}:${cl}`]); + const [headCl, mainCl] = [show(head), show(main)]; + if (!forkPoint || !diff.ok || !headCl.ok || !mainCl.ok) return { same: false, detail: `cannot read ${cl} at the pin, the head or main` }; + const authored = new Set(diff.out.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1))); + const why = ownEntryAboveMain(headCl.out, mainCl.out, authored); + if (why) return { same: false, detail: `differs from T${after.index} in ${cl}, which is not main's plus #${t.slots[i]!.pr}'s own entry: ${why}` }; + return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps}) except ${cl}, resolved as main's ${cl} plus #${t.slots[i]!.pr}'s own entry` }; +} + /** * Freeze the queue: the record goes to local state and the notes ref before * anything remote, then the backstop gate is dispatched on main's sha. @@ -61,8 +93,8 @@ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSh /** Interrupted publication: freeze when earlier slots merged; otherwise a plain refusal (nothing was published). */ async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[]): Promise { const run = trainFile(t.nonce); - if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build --prs ${pinList(t.slots)} (a new train on current main)`, run); - const f = activeFreeze(env.repo.key) ?? await freezeQueue(env, t, message, mainSha); + if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run); + const f = activeFreeze(env.repo.keys) ?? await freezeQueue(env, t, message, mainSha); return outFailed({ code, message: `${message}; the queue is frozen` }, [...lines, ...frozenLines(f)], `${Q} train build --repair --prs @`, run); } @@ -84,7 +116,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str if (check?.conclusion !== 'success') return outRefused({ code: 'QUEUE_GATE_MISSING', message: `no successful ${env.train.full_gate_check} check run on the train head ${t.head.slice(0, 12)}` }, [], `${Q} train verdict --train ${t.nonce}`, run); if (!fetchTrainBranch(env, t)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the train's prefix commits are not here and ${t.branch} could not be fetched` }, [], `${Q} train status --train ${t.nonce}`, run); const main = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out; - const freeze = activeFreeze(env.repo.key); + const freeze = activeFreeze(env.repo.keys); if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs @`, run); const after = t.prefixes[i + 1]!; const before = t.prefixes[i]!; @@ -98,9 +130,8 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str const headRef = o.noFetch ? '' : (git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/pull/${o.pr}/head:refs/gstack/queue/pr-${o.pr}`]).ok ? `refs/gstack/queue/pr-${o.pr}` : ''); const head = headRef ? git(ctx.repoRoot, ['rev-parse', headRef]).out : t.slots[i]!.head; const pinned = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head); - const asPrefix = pinned.same ? null : sameModuloStamps(env, after.commit, head); - const stampOnly = asPrefix?.same ? { same: true, detail: `T${after.index} with main merged in, ${asPrefix.detail}` } : pinned; - if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${pinned.detail})`, main, lines); + const stampOnly = pinned.same ? pinned : mergedIn(env, t, i, head, main); + if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${stampOnly.detail}; against the pin it ${pinned.detail})`, main, lines); return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`, `VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`], `merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run); @@ -113,7 +144,7 @@ function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, effect(t, `verified #${pr} merged as T${index}`); save(env, t); lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${t.merged.length}/${t.slots.length} merged)`); - const freeze = activeFreeze(env.repo.key); + const freeze = activeFreeze(env.repo.keys); if (freeze && freeze.repair?.train === t.nonce && t.state === 'merged') { freeze.cleared_at = nowIso(); freeze.cleared_by = `repair #${pr} via train ${t.nonce}; main ${main.slice(0, 12)} carries a green record`; diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index 69bfd195b..60e7be402 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -16,7 +16,7 @@ */ import { randomBytes } from 'node:crypto'; import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg'; -import { mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check'; +import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check'; import type { ResultStatus } from './headless-artifacts'; import { versionAtRef, versionFiles } from './restamp'; import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy'; @@ -34,7 +34,7 @@ export const outRefused = (failure: QueueFailure, lines: string[], next: string, export const outFailed = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 1, status: 'refused', lines, next, failure, run }); export const outPending = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 3, status: 'incomplete', lines, next, failure, run }); -export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string }; remote: string } +export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string; keys: string[] }; remote: string } const HEX = /^[0-9a-f]{40,64}$/; const Q = 'gstack-ship-queue'; @@ -92,9 +92,20 @@ export function parsePins(spec: string | undefined): Array<{ pr: string; sha: st } export const pinList = (slots: Array<{ pr: string; head: string }>) => slots.map(s => `${s.pr}@${s.head}`).join(','); +/** `--prs ` for a rebuild of these slots, plus `--order` when the train was built with an explicit order. */ +export const prsArgs = (slots: Array<{ pr: string; head: string }>, explicit?: boolean) => `--prs ${pinList(slots)}${explicit ? ` --order ${slots.map(s => s.pr).join(',')}` : ''}`; -/** Slot order: screened PRs first, then unscreened, each in READY order; enough unscreened PRs form their own train. */ -export function orderSlots(slots: TrainSlot[], max: number): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } { +/** + * Slot order: screened PRs first, then unscreened, each in READY order; + * enough unscreened PRs form their own train. An explicit order (`--order`, + * every admitted PR once) replaces that rule: slots follow it, the first + * `max` ride and the rest are held. + */ +export function orderSlots(slots: TrainSlot[], max: number, explicit?: readonly string[]): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } { + if (explicit) { + const all = explicit.map(pr => slots.find(s => s.pr === pr)!); + return { train: all.slice(0, max), held: all.slice(max), split: false }; + } const byReady = (a: TrainSlot, b: TrainSlot) => a.ready_at.localeCompare(b.ready_at) || Number(a.pr) - Number(b.pr); const screened = slots.filter(s => s.screen.choice === 'screen').sort(byReady); const unscreened = slots.filter(s => s.screen.choice !== 'screen').sort(byReady); @@ -130,7 +141,9 @@ function unionChangelog(ours: string, theirs: string): string { * One train merge, in object storage: the PR head onto the previous prefix. * Version files take the prefix's side; the CHANGELOG merges by section (the * gate-ahead rule, else a union of sections, since each heading is restamped - * at merge). Any other conflict is reported with every conflicting path. + * at merge), unless a side damaged released history (changelogDamage), which + * is a conflict the lane resolves. Any other conflict is reported with every + * conflicting path. */ export function mergeStep(repoRoot: string, pred: string, head: string, policy: ShipPolicy): { tree: string } | { conflicts: string[] } { const r = git(repoRoot, ['merge-tree', '--write-tree', head, pred]); @@ -157,6 +170,9 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy: if (!ours || !theirs) conflicts.push(file); else if (file === policy.changelog) { const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n'; + const base = entry.get(1); + const damage = changelogDamage(text(ours), text(theirs), base ? text(base) : undefined); + if (damage) { conflicts.push(`${file} (${damage})`); continue; } const merged = mergeChangelogs(text(ours), text(theirs)) ?? unionChangelog(text(ours), text(theirs)); const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged); if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file); @@ -207,12 +223,12 @@ export function nextFor(t: TrainRecord): string { case 'attributed': { const drop = loadBisect(n)?.outcome?.prs ?? []; const keep = t.slots.filter(s => !drop.includes(s.pr)); - return keep.length ? `${Q} train build --prs ${pinList(keep)}` : 'none (every PR was dropped)'; + return keep.length ? `${Q} train build ${prsArgs(keep, t.explicit_order)}` : 'none (every PR was dropped)'; } - case 'red-on-main': return `fix forward on main first, then ${Q} train build --prs ${pinList(t.slots)}`; + case 'red-on-main': return `fix forward on main first, then ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`; case 'unattributed': return loadBisect(n)?.outcome?.all_green && !attemptsOf(t).some(a => a.purpose === 'rebuild') ? `${Q} train rebuild --train ${n}` - : `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`; + : `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`; default: return `${Q} train prune --train ${n}`; } } @@ -249,7 +265,17 @@ export function gateDispatch(env: TrainEnv, train: TrainRecord, nonce: string): return { workflow: env.train.gate_workflow, inputs: { [DISPATCH_INPUTS.evalsAll]: 'true', [DISPATCH_INPUTS.expectedSha]: train.head, [DISPATCH_INPUTS.nonce]: nonce } }; } -export interface BuildOptions { pins?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number } +export interface BuildOptions { pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number } + +/** `--order ,,...`: every pinned PR exactly once (a leading # allowed). */ +export function parseOrder(spec: string | undefined, pins: Array<{ pr: string }>): string[] | undefined | { error: string } { + if (spec === undefined) return undefined; + const order = spec.split(',').map(s => s.trim().replace(/^#/, '')).filter(Boolean); + const want = pins.map(p => p.pr).sort((a, b) => Number(a) - Number(b)); + const got = [...order].sort((a, b) => Number(a) - Number(b)); + if (order.some(pr => !/^\d+$/.test(pr)) || got.join(',') !== want.join(',')) return { error: `--order must list every PR in --prs exactly once (--prs has ${want.map(p => '#' + p).join(',')}; got ${spec})` }; + return order; +} /** The slots a new train would carry: trusted, unmoved, READY PRs, in slot order. */ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): { slots: TrainSlot[]; lines: string[] } | TrainOut { @@ -267,7 +293,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): { if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) { return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs `, 'none'); } - const ready = loadReady(env.repo.key, p.pr, info.headRefOid); + const ready = loadReady(env.repo.keys, p.pr, info.headRefOid); if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none'); slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen }); } @@ -280,28 +306,30 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor if (typeof pins === 'string') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: pins }, [], 'none', 'none'); if ((o.urgent || o.repair) && pins.length !== 1) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${o.repair ? '--repair' : '--urgent'} admits exactly one PR` }, [], 'none', 'none'); rebuildFromNotes(env.ctx.repoRoot, env.remote); - const freeze = activeFreeze(env.repo.key); + const freeze = activeFreeze(env.repo.keys); if (freeze && !o.repair) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen since ${freeze.created_at}: train ${freeze.train} stopped after merging ${freeze.merged.map(p => '#' + p).join(',')} (${freeze.reason})` }, frozenLines(freeze), `${Q} train build --repair --prs @`, 'none'); + const explicit = parseOrder(o.order, pins); + if (explicit && 'error' in explicit) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: explicit.error }, [], 'none', 'none'); const admitted = admitSlots(env, pins); if ('exit' in admitted) return admitted; - const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size); + const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit); const lines = [ - `ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}`, + `ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`, ...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []), ]; const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair); - if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build --prs ${pinList(order.train)} (after ${due}, or with more PRs, or --urgent for one)`, 'none'); + if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none'); const baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out; const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) })); - const live = listTrains(env.repo.key).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed'); + const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed'); if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines }; const chain = buildChain(env.ctx.repoRoot, baseSha, order.train, env.policy.policy); if ('stoppedAt' in chain) { const without = order.train.filter(s => s.pr !== chain.stoppedAt); return outFailed({ code: 'QUEUE_TRAIN_CONFLICT', message: `#${chain.stoppedAt} conflicts with the prefix before it` }, [...lines, - `QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build --prs ${pinList(without)}`], - without.length ? `${Q} train build --prs ${pinList(without)}` : 'none', 'none'); + `QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build ${prsArgs(without, !!explicit)}`], + without.length ? `${Q} train build ${prsArgs(without, !!explicit)}` : 'none', 'none'); } const head = chain.prefixes.at(-1)!; const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' }); @@ -309,7 +337,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor return { lines, t: { schema: 1, kind: 'train', nonce, key, repo_key: env.repo.key, repo: env.repo.url, base: { ref: env.ctx.base, sha: baseSha }, policy: env.policy.source.label, slots: order.train, held: order.held.map(s => s.pr), prefixes: chain.prefixes, head: head.commit, tree: head.tree, branch: `train/${nonce}`, - urgent: o.urgent, repair: o.repair, state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null, + urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null, created_at: now, updated_at: now, } }; } @@ -342,7 +370,7 @@ async function freePhase(env: TrainEnv, slug: string, t: TrainRecord, o: BuildOp t.state = 'free-red'; effect(t, `${env.train.free_workflow} dispatch refused by gh`); save(env, t); - return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build --prs ${pinList(t.slots)}`, run); + return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run); } t.state = 'free-pending'; effect(t, `${env.train.free_workflow} dispatched on ${t.branch}`); @@ -422,12 +450,12 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise t.slots.some((n: TrainSlot) => n.pr === s.pr))) continue; old.state = 'superseded'; @@ -435,7 +463,7 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise !!t) : listTrains(env.repo.key).filter(t => !TERMINAL_STATES.includes(t.state)); + const trains = o.train ? [loadTrain(o.train)].filter((t): t is TrainRecord => !!t) : listTrains(env.repo.keys).filter(t => !TERMINAL_STATES.includes(t.state)); if (o.train && !trains.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, lines, `${Q} train status`, 'none'); const slug = trains.length ? repoSlug(ctx) : null; const exec = execFor(ctx); @@ -482,7 +510,7 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut lines.push(`NEXT(${t.nonce}): ${nextFor(t)}`); } if (!trains.length) lines.push('TRAIN: none live'); - const freeze = activeFreeze(env.repo.key); + const freeze = activeFreeze(env.repo.keys); if (freeze) lines.push(...frozenLines(freeze)); const single = trains.length === 1 ? trains[0]! : null; const next = freeze && !freeze.repair ? `${Q} train build --repair --prs @` : single ? nextFor(single) : trains.length ? 'see each NEXT() line' : 'none'; @@ -496,7 +524,7 @@ export function pruneTrains(ctx: QueueContext, o: { dryRun: boolean; train?: str rebuildFromNotes(ctx.repoRoot, env.remote); const lines: string[] = []; const rgit = (args: string[]) => git(ctx.repoRoot, args); - for (const t of listTrains(env.repo.key).filter(x => !o.train || x.nonce === o.train)) { + for (const t of listTrains(env.repo.keys).filter(x => !o.train || x.nonce === o.train)) { if (!TERMINAL_STATES.includes(t.state)) { lines.push(`KEEP: ${t.branch} (${t.state})`); continue; } if (!remoteBranchSha(rgit, env.remote, t.branch)) { lines.push(`GONE: ${t.branch} (${t.state})`); continue; } if (o.dryRun) { lines.push(`WOULD-DELETE: ${t.branch} (${t.state})`); continue; } diff --git a/lib/version-gate-train.ts b/lib/version-gate-train.ts index 327cdfa1e..3574542f9 100644 --- a/lib/version-gate-train.ts +++ b/lib/version-gate-train.ts @@ -18,7 +18,7 @@ export type TrainHead = | { kind: 'invalid'; reason: string }; function git(repoRoot: string, args: string[]): string | null { - const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); + const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; } @@ -50,11 +50,18 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: } const file = typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md'; - const before = parseChangelog(show(mergeBase, file) ?? ''); - const after = parseChangelog(show(o.head, file) ?? ''); + const exists = (rev: string) => git(o.repoRoot, ['cat-file', '-e', `${rev}:${file}`]) !== null; + const read = (rev: string) => (exists(rev) ? show(rev, file) : ''); + const [beforeText, afterText] = [read(mergeBase), read(o.head)]; + if (beforeText === null || afterText === null) return { kind: 'invalid', reason: `cannot read ${file} at ${beforeText === null ? `the merge base ${mergeBase.slice(0, 12)}` : o.head}` }; + const before = parseChangelog(beforeText); + const after = parseChangelog(afterText); const unreleased = after.sections.filter(s => s.unreleased); if (unreleased.length > 1) return { kind: 'invalid', reason: `${file} has ${unreleased.length} [Unreleased] sections` }; if (unreleased.length === 1 && after.sections[0] !== unreleased[0]) return { kind: 'invalid', reason: `${file}: [Unreleased] must be the top section` }; + const kept = new Set(after.sections.filter(s => !s.unreleased).map(s => s.version)); + const dropped = before.sections.filter(s => !s.unreleased && !kept.has(s.version)).map(s => s.version); + if (dropped.length) return { kind: 'invalid', reason: `${file} drops released heading ${dropped.slice(0, 3).join(', ')}${dropped.length > 3 ? ` (+${dropped.length - 3})` : ''}; an unstamped head may only add its [Unreleased] section above the latest release` }; const released = (c: typeof after) => [c.header.join('\n'), ...c.sections.filter(s => !s.unreleased).map(s => s.lines.join('\n'))]; const was = released(before); const now = released(after); diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index c22b3db49..7e925ebf7 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -509,7 +509,7 @@ SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null) ``` 1. Read `CLAUDE.md`, `TODOS.md` (if they exist). -2. Run `git log --oneline -30` and `git diff origin/main --stat 2>/dev/null` to understand recent context. +2. Run `git log --oneline -30` and `git diff origin/HEAD --stat` (default branch) to grasp recent context. 3. Use Grep/Glob to map the codebase areas most relevant to the user's request. 4. **List existing design docs for this project:** ```bash diff --git a/office-hours/SKILL.md.tmpl b/office-hours/SKILL.md.tmpl index 868a7fe73..881daa3f9 100644 --- a/office-hours/SKILL.md.tmpl +++ b/office-hours/SKILL.md.tmpl @@ -81,7 +81,7 @@ Understand the project and the area the user wants to change. ``` 1. Read `CLAUDE.md`, `TODOS.md` (if they exist). -2. Run `git log --oneline -30` and `git diff origin/main --stat 2>/dev/null` to understand recent context. +2. Run `git log --oneline -30` and `git diff origin/HEAD --stat` (default branch) to grasp recent context. 3. Use Grep/Glob to map the codebase areas most relevant to the user's request. 4. **List existing design docs for this project:** ```bash diff --git a/package.json b/package.json index a38735809..53b6f1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.189", + "version": "1.91.207", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index 8ab0c3fd8..049e21ddc 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -467,7 +467,7 @@ only under the Step 0.5 conditions (no UI scope, designer unavailable, or the us Commands: `generate` (single mockup), `variants` (multiple directions), `compare` (side-by-side review board), `iterate` (refine with feedback), `check` (cross-model -quality gate via GPT-4o vision), `evolve` (improve from screenshot). +quality gate via OpenAI vision), `evolve` (improve from screenshot). Setup is handled by the DESIGN SETUP section below. If `DESIGN_READY` is printed, the designer is available and you should use it. diff --git a/plan-design-review/SKILL.md.tmpl b/plan-design-review/SKILL.md.tmpl index c30f8d0a5..923bbeeed 100644 --- a/plan-design-review/SKILL.md.tmpl +++ b/plan-design-review/SKILL.md.tmpl @@ -85,7 +85,7 @@ only under the Step 0.5 conditions (no UI scope, designer unavailable, or the us Commands: `generate` (single mockup), `variants` (multiple directions), `compare` (side-by-side review board), `iterate` (refine with feedback), `check` (cross-model -quality gate via GPT-4o vision), `evolve` (improve from screenshot). +quality gate via OpenAI vision), `evolve` (improve from screenshot). Setup is handled by the DESIGN SETUP section below. If `DESIGN_READY` is printed, the designer is available and you should use it. diff --git a/plan-eng-review/sections/review-sections.md b/plan-eng-review/sections/review-sections.md index 0f43c741c..399ee8401 100644 --- a/plan-eng-review/sections/review-sections.md +++ b/plan-eng-review/sections/review-sections.md @@ -125,7 +125,8 @@ building proposed code. Keep suppressed findings for the output appendix. ## Confidence Calibration -Every finding MUST include a confidence score (1-10): +Every finding MUST include a severity, CRITICAL (blocks shipping) or INFORMATIONAL +(lower severity, still actioned), and a confidence score (1-10): | Score | Meaning | Display rule | |-------|---------|-------------| @@ -133,20 +134,18 @@ Every finding MUST include a confidence score (1-10): | 7-8 | High confidence pattern match. Very likely correct. | Show normally | | 5-6 | Moderate. Could be a false positive. | Show with caveat: "Medium confidence, verify this is actually an issue" | | 3-4 | Low confidence. Pattern is suspicious but may be fine. | Suppress from main report. Include in appendix only. | -| 1-2 | Speculation. | Only report if severity would be P0. | +| 1-2 | Speculation. | Only report a suspected release-blocking catastrophe (widespread data loss, total outage or system-wide compromise), at CRITICAL severity and marked explicitly speculative. | **Finding format:** -`[SEVERITY] (confidence: N/10) file:line — description` +`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description` Example: -`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause` -`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs` +`[CRITICAL] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause` +`[INFORMATIONAL] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs` ### Pre-emit verification gate -Before any finding is promoted to the report, the gate requires: - 1. **Quote the specific code line that motivates the finding** — file:line plus the verbatim text of the line(s) that triggered it. If the finding is "field X doesn't exist on model Y", quote the lines of class Y where the field @@ -156,9 +155,7 @@ Before any finding is promoted to the report, the gate requires: 2. **If you cannot quote the motivating line(s), the finding is unverified.** Force its confidence to 4-5. Use 4 when it should be suppressed from the main report; use 5 only when it belongs in the report with the medium-confidence - caveat. Keep suppressed items in the appendix so reviewers can audit - calibration. Do not work around this by inventing - speculative confidence 7+ — that defeats the gate. + caveat. Never invent speculative confidence 7+. **Framework-meta nudge:** When the symbol is generated by a framework metaclass, descriptor, ORM Meta inner-class, or migration history (Django @@ -178,10 +175,8 @@ False-positive classes the gate catches: | "save() might lose fields" | Requires quoting the ORM signature or model definition | | "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident | -**Calibration learning:** If you report a finding with confidence < 7 and the user -confirms it IS a real issue, that is a calibration event. Your initial confidence was -too low. Log the corrected pattern as a learning so future reviews catch it with -higher confidence. +**Calibration learning:** If the user confirms a reported finding scored < 7 is +real, log the corrected pattern as a learning. ## Decision procedure diff --git a/scripts/harness-version.json b/scripts/harness-version.json index d95e24d5a..bcf395dd3 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -34,7 +34,7 @@ "test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6}, "test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6}, "test/helpers/pty/binary.ts": {"blob":"404d7515076d5c00804f89e23ea69296312c00e1","since":6}, - "test/helpers/pty/boundaries.ts": {"blob":"8c87100c9dd0cfc28c02598f250726fd93bfbdea","since":6,"nonBehavioral":"Removes the plan-design 0D focus picker from the shared harness; floor.ts gains an optional pickSetupChoice hook that no other caller passes, so no other case's input or verdict can change. plan-design-finding-floor, the only case whose seed raises that menu, passes the picker from its owned fixture test/fixtures/design-floor-focus-picker.ts, so its owned bytes start its own new series."}, + "test/helpers/pty/boundaries.ts": {"blob":"c5520118da36f96f60a3c9720751bb57a669865c","since":6,"nonBehavioral":"Widens the plan-eng-review first-review severity annotation from [P0-3] to [P0-3|CRITICAL|INFORMATIONAL] (#3349). Only a question carrying the new labels can change verdict, and only the changed plan-eng-review prompt asks for them; every case that runs it owns that generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."}, "test/helpers/pty/classify.ts": {"blob":"3f2daa12ff6484f96b8d28d910a50231c4bb3ae8","since":6}, "test/helpers/pty/fake-session.ts": {"blob":"554e064ef259b8444280eddfe9b0db93c66a8202","since":6}, "test/helpers/pty/judge.ts": {"blob":"81c5bb4c48ab928aca2f186a1ade9df7169d7fbe","since":6}, diff --git a/scripts/resolvers/confidence.ts b/scripts/resolvers/confidence.ts index f10d15a79..747bdc225 100644 --- a/scripts/resolvers/confidence.ts +++ b/scripts/resolvers/confidence.ts @@ -53,7 +53,8 @@ real, log the corrected pattern as a learning.`; const result = `## Confidence Calibration -Every finding MUST include a confidence score (1-10): +Every finding MUST include a severity, CRITICAL (blocks shipping) or INFORMATIONAL +(lower severity, still actioned), and a confidence score (1-10): | Score | Meaning | Display rule | |-------|---------|-------------| @@ -61,20 +62,18 @@ Every finding MUST include a confidence score (1-10): | 7-8 | High confidence pattern match. Very likely correct. | Show normally | | 5-6 | Moderate. Could be a false positive. | Show with caveat: "Medium confidence, verify this is actually an issue" | | 3-4 | Low confidence. Pattern is suspicious but may be fine. | Suppress from main report. Include in appendix only. | -| 1-2 | Speculation. | Only report if severity would be P0. | +| 1-2 | Speculation. | Only report a suspected release-blocking catastrophe (widespread data loss, total outage or system-wide compromise), at CRITICAL severity and marked explicitly speculative. | **Finding format:** -\\\`[SEVERITY] (confidence: N/10) file:line — description\\\` +\\\`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description\\\` Example: -\\\`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\\\` -\\\`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\\\` +\\\`[CRITICAL] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\\\` +\\\`[INFORMATIONAL] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\\\` ### Pre-emit verification gate -Before any finding is promoted to the report, the gate requires: - 1. **Quote the specific code line that motivates the finding** — file:line plus the verbatim text of the line(s) that triggered it. If the finding is "field X doesn't exist on model Y", quote the lines of class Y where the field @@ -84,9 +83,7 @@ Before any finding is promoted to the report, the gate requires: 2. **If you cannot quote the motivating line(s), the finding is unverified.** Force its confidence to 4-5. Use 4 when it should be suppressed from the main report; use 5 only when it belongs in the report with the medium-confidence - caveat. Keep suppressed items in the appendix so reviewers can audit - calibration. Do not work around this by inventing - speculative confidence 7+ — that defeats the gate. + caveat. Never invent speculative confidence 7+. **Framework-meta nudge:** When the symbol is generated by a framework metaclass, descriptor, ORM Meta inner-class, or migration history (Django @@ -106,9 +103,7 @@ False-positive classes the gate catches: | "save() might lose fields" | Requires quoting the ORM signature or model definition | | "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident | -**Calibration learning:** If you report a finding with confidence < 7 and the user -confirms it IS a real issue, that is a calibration event. Your initial confidence was -too low. Log the corrected pattern as a learning so future reviews catch it with -higher confidence.`; +**Calibration learning:** If the user confirms a reported finding scored < 7 is +real, log the corrected pattern as a learning.`; return _ctx.skillName === 'plan-eng-review' ? result.replaceAll('\\`', '`') : result; } diff --git a/scripts/resolvers/preamble/generate-test-failure-triage.ts b/scripts/resolvers/preamble/generate-test-failure-triage.ts index 9b1b55f19..d54f975e5 100644 --- a/scripts/resolvers/preamble/generate-test-failure-triage.ts +++ b/scripts/resolvers/preamble/generate-test-failure-triage.ts @@ -1,6 +1,13 @@ import { FREE_TEXT_WRITE_RULE, freeTextFileBash, freeTextFileUse } from '../free-text-file'; +export function generateFailureRouting(): string { + return `**Failures: check the cause, then route.** A failing test, error or stack trace shows where a failure surfaces, not its root cause. + +- **Cause supplied** (the user names a specific cause and a narrow fix): make one read-only check of the evidence it rests on, such as the named file, test or log. If that confirms the cause, make the fix and run the focused test that covers it. If it does not, invoke \`/investigate\` before any further tracing or edit. +- **Cause unknown:** make one read-only triage pass over the supplied evidence and the directly implicated test and code. Do not reproduce, test hypotheses or search wider. If that pass establishes the root cause and a narrow fix, make the fix and run the focused test that covers it. If the cause, scope or intended behavior is still unclear, invoke \`/investigate\` before any broader diagnosis or edit.`; +} + export function generateTestFailureTriage(): string { return `## Test Failure Ownership Triage @@ -48,7 +55,7 @@ Ask with AskUserQuestion in the AskUserQuestion Format. List each failure the sa ### Step T4: Execute the chosen action **If "Investigate and fix now":** -- Switch to /investigate mindset: root cause first, then minimal fix. +- One read-only root-cause pass, then \`/investigate\` if unclear. - Fix the pre-existing failure. - Commit the fix separately from the branch's changes: \`git commit -m "fix: pre-existing test failure in "\` - Continue with the workflow. diff --git a/ship/SKILL.md b/ship/SKILL.md index 924983ab4..24d4cfafb 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -793,7 +793,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. -3. **Queue-aware pick** (workspace-aware ship): +3. **Queue-aware pick:** ```bash QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty') @@ -803,6 +803,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Offline output without that fallback, failure, malformed output or an empty version is unusable, even when it contains a version-looking string. + **Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may + collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended; + Version Gate CI rereads the queue) or stop. + - **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`. ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump (refresh CHANGELOG/PR title) or keep current (CI rejects a collision). @@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest + bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md ``` - The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes - VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` → - `./package.json`) and existing npm lockfiles, never creating one, with the 3-digit - translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write: - reclassify and `repair` DRIFT_STALE_PKG. + It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION + for Step 13. Exit 2 wrote nothing: show stderr, stop. + Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG. - `--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges - only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is + `--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before push, verify the committed digest matches generation for the selected VERSION. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 6adf2d0ae..fcff6bc33 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -265,7 +265,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. -3. **Queue-aware pick** (workspace-aware ship): +3. **Queue-aware pick:** ```bash QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty') @@ -275,6 +275,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Offline output without that fallback, failure, malformed output or an empty version is unusable, even when it contains a version-looking string. + **Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may + collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended; + Version Gate CI rereads the queue) or stop. + - **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`. ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump (refresh CHANGELOG/PR title) or keep current (CI rejects a collision). @@ -286,16 +290,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest + bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md ``` - The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes - VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` → - `./package.json`) and existing npm lockfiles, never creating one, with the 3-digit - translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write: - reclassify and `repair` DRIFT_STALE_PKG. + It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION + for Step 13. Exit 2 wrote nothing: show stderr, stop. + Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG. - `--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges - only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is + `--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before push, verify the committed digest matches generation for the selected VERSION. diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index 348458c08..53c6f44c8 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -3,8 +3,8 @@ ## Step 9: Pre-Landing Review Set CYCLES to 0 on first entry only. Keep existing approvals; changed finding scope -needs a new decision. Run checklist/design, specialists (9.1), merge/Red Team (9.2), -exploratory QA (9.2.1), dedup (9.3), then fixes and logging (9.4). +needs a new decision. Order: checklist/design, specialists (9.1), Red Team (9.2), +QA (9.2.1), dedup (9.3), fixes and logging (9.4). Gated/unsupported specialists skip only their dispatch, never QA or Step 11. Steps 10–11 queue findings without editing; include those findings in this pass. Every repeat starts before the checklist read and captures a fresh REVIEW_START. @@ -12,7 +12,8 @@ Finish the complete review and QA before applying any fix in Step 9.4. ## Confidence Calibration -Every finding MUST include a confidence score (1-10): +Every finding MUST include a severity, CRITICAL (blocks shipping) or INFORMATIONAL +(lower severity, still actioned), and a confidence score (1-10): | Score | Meaning | Display rule | |-------|---------|-------------| @@ -20,20 +21,18 @@ Every finding MUST include a confidence score (1-10): | 7-8 | High confidence pattern match. Very likely correct. | Show normally | | 5-6 | Moderate. Could be a false positive. | Show with caveat: "Medium confidence, verify this is actually an issue" | | 3-4 | Low confidence. Pattern is suspicious but may be fine. | Suppress from main report. Include in appendix only. | -| 1-2 | Speculation. | Only report if severity would be P0. | +| 1-2 | Speculation. | Only report a suspected release-blocking catastrophe (widespread data loss, total outage or system-wide compromise), at CRITICAL severity and marked explicitly speculative. | **Finding format:** -\`[SEVERITY] (confidence: N/10) file:line — description\` +\`[CRITICAL|INFORMATIONAL] (confidence: N/10) file:line — description\` Example: -\`[P1] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\` -\`[P2] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\` +\`[CRITICAL] (confidence: 9/10) app/models/user.rb:42 — SQL injection via string interpolation in where clause\` +\`[INFORMATIONAL] (confidence: 5/10) app/controllers/api/v1/users_controller.rb:18 — Possible N+1 query, verify with production logs\` ### Pre-emit verification gate -Before any finding is promoted to the report, the gate requires: - 1. **Quote the specific code line that motivates the finding** — file:line plus the verbatim text of the line(s) that triggered it. If the finding is "field X doesn't exist on model Y", quote the lines of class Y where the field @@ -43,9 +42,7 @@ Before any finding is promoted to the report, the gate requires: 2. **If you cannot quote the motivating line(s), the finding is unverified.** Force its confidence to 4-5. Use 4 when it should be suppressed from the main report; use 5 only when it belongs in the report with the medium-confidence - caveat. Keep suppressed items in the appendix so reviewers can audit - calibration. Do not work around this by inventing - speculative confidence 7+ — that defeats the gate. + caveat. Never invent speculative confidence 7+. **Framework-meta nudge:** When the symbol is generated by a framework metaclass, descriptor, ORM Meta inner-class, or migration history (Django @@ -65,10 +62,8 @@ False-positive classes the gate catches: | "save() might lose fields" | Requires quoting the ORM signature or model definition | | "update_fields might miss X" | Requires quoting the field set; if X doesn't exist, the FP is self-evident | -**Calibration learning:** If you report a finding with confidence < 7 and the user -confirms it IS a real issue, that is a calibration event. Your initial confidence was -too low. Log the corrected pattern as a learning so future reviews catch it with -higher confidence. +**Calibration learning:** If the user confirms a reported finding scored < 7 is +real, log the corrected pattern as a learning. ### Core checklist @@ -78,6 +73,10 @@ This pass is static; defer product probes to Step 9.2.1. 2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them. + **Fix-range repeat** (decision 3): start with `--start review --fix-range`. It prints + REVIEW_START, then `FIX_BASE=`, which stands in for `origin/` and `` in this pass. + If it refuses, start a full pass. + 2.5. **Gate scan.** Before applying the checklist, run the gate scan on the same candidate: List every gate edit in the candidate (test, CI, runner/lint-config, snapshot and @@ -121,9 +120,9 @@ fi ### Design-lite checklist -Its numbering is local to this checklist. When frontend review applies, `/ship` -automatically attempts this optional design check; `enabled` expresses that choice, -not a new user question. Step 11 has its own outside-review switch and required native pass. +Its numbering is local. `/ship` attempts this design check automatically when frontend +review applies; `enabled` is that choice, not a user question. Step 11 has its own +outside-review switch and required native pass. ## Design Review (conditional, diff-scoped) @@ -257,10 +256,9 @@ Use the original DESIGN_START token. COMPLETED is true only when the native chec Substitute: TIMESTAMP = ISO 8601 datetime, STATUS = "clean" if 0 findings or "issues_found", N = total findings, M = auto-fixed count, D = counted detector findings from step 0 (0 when the detector did not run), COMMIT = output of `git rev-parse --short HEAD`. -The parent owns design-lite; the Design specialist is an independent read. -Before final counting/Fix-First, merge the same evidenced design defect at the same path/line -into one item with both sources and stricter ASK. Retain actual specialist stats; -distinct defects stay separate and neither pass substitutes for the other. +Design-lite (parent) and the Design specialist are independent reads. Before counting +and Fix-First, merge one evidenced design defect at the same path/line into one item +with both sources and the stricter ASK. Keep actual specialist stats and distinct defects. ## Step 9.1: Review Army — Specialist Dispatch @@ -623,11 +621,9 @@ but missing dispatched output still blocks continuation, even with a QA exceptio 2. **Auto-fix all AUTO-FIX items.** Apply each fix. Output one line per fix: `[AUTO-FIXED] [file:line] Problem → what you did` -3. **If ASK items remain,** present them in ONE AskUserQuestion: - - List each with number, severity, problem, recommended fix - - Per-item options: A) Fix B) Skip - - Overall RECOMMENDATION - - If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead +3. **If ASK items remain,** present them in ONE AskUserQuestion (or one per item for 3 or + fewer): each item's number, severity, problem and recommended fix with A) Fix B) Skip, + plus an overall RECOMMENDATION. Save each explicit Skip immediately in the invocation action list with its identity, scope and supporting source evidence; keep it across repeats. @@ -674,10 +670,10 @@ but missing dispatched output still blocks continuation, even with a QA exceptio ```bash ~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship","completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES}' --finish REVIEW_START ``` -- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output; - otherwise `clean` only for completed coverage with no - unresolved non-advisory defects; otherwise `issues_found`. N counts current - unresolved defects, not original totals. Missing coverage is not a defect. +- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output, + else `clean` only for completed coverage with no unresolved non-advisory defects, + else `issues_found`. N counts unresolved defects now, not original totals; missing + coverage is not a defect. - `REVIEW_START`: this pass's Step 9 token captured before reading the diff; never recapture at persistence to certify unreviewed fixes. - `COMPLETED`: checklist and dispatched specialists/Red Team finish, and all required probes pass. @@ -699,13 +695,13 @@ but missing dispatched output still blocks continuation, even with a QA exceptio `open` counts in `issues_found` and by severity. The logger stamps `actor` from `gstack-session-kind` (a supplied `actor` is discarded) and forces `"open"` on gate findings in a non-interactive session. -Save the review output and the `Gate edits:` line — they go into the PR body in Step 19. -Any `open` gate finding also decides Step 19's draft state. +Save the review output and `Gate edits:` line for Step 19's PR body; an `open` gate +finding makes that PR a draft. ### Decide whether to repeat Step 9 -After persistence, record missing dispatched output, CYCLES and applied fixes in -the invocation record. Apply these decisions in order: +After persistence, record missing output, CYCLES and fixes in the invocation record. +Apply these decisions in order: 1. **Dispatched reviewer output missing:** STOP and name each failed specialist or Red Team. Retain queued fixes and restore coverage. If this pass made edits, @@ -715,10 +711,10 @@ the invocation record. Apply these decisions in order: `converged:false`; do not run a fourth fixing cycle. 3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing - failures and scope. Keep CYCLES and scoped approvals across this repeat. + failures and scope. Keep CYCLES and scoped approvals across this repeat; it may be a fix-range repeat. 4. **No edits in this pass:** Resolve the required-probe gate below. Only after it - clears may you continue to Step 10. Undispatched gated/unsupported specialists - do not block independently, but never replace QA or required native review. + clears may you continue to Step 10, and only from a full pass: after a fix-range + pass, run one full Step 9 first. **Required-probe parent gate:** With completed checklist and dispatched reviewers, failed/unavailable required probes block continuation. diff --git a/ship/sections/review-army.md.tmpl b/ship/sections/review-army.md.tmpl index 9d6413919..f6d3a7b2a 100644 --- a/ship/sections/review-army.md.tmpl +++ b/ship/sections/review-army.md.tmpl @@ -1,8 +1,8 @@ ## Step 9: Pre-Landing Review Set CYCLES to 0 on first entry only. Keep existing approvals; changed finding scope -needs a new decision. Run checklist/design, specialists (9.1), merge/Red Team (9.2), -exploratory QA (9.2.1), dedup (9.3), then fixes and logging (9.4). +needs a new decision. Order: checklist/design, specialists (9.1), Red Team (9.2), +QA (9.2.1), dedup (9.3), fixes and logging (9.4). Gated/unsupported specialists skip only their dispatch, never QA or Step 11. Steps 10–11 queue findings without editing; include those findings in this pass. Every repeat starts before the checklist read and captures a fresh REVIEW_START. @@ -18,6 +18,10 @@ This pass is static; defer product probes to Step 9.2.1. 2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them. + **Fix-range repeat** (decision 3): start with `--start review --fix-range`. It prints + REVIEW_START, then `FIX_BASE=`, which stands in for `origin/` and `` in this pass. + If it refuses, start a full pass. + 2.5. **Gate scan.** Before applying the checklist, run the gate scan on the same candidate: {{GATE_SCAN_BLOCK}} @@ -33,16 +37,15 @@ This pass is static; defer product probes to Step 9.2.1. ### Design-lite checklist -Its numbering is local to this checklist. When frontend review applies, `/ship` -automatically attempts this optional design check; `enabled` expresses that choice, -not a new user question. Step 11 has its own outside-review switch and required native pass. +Its numbering is local. `/ship` attempts this design check automatically when frontend +review applies; `enabled` is that choice, not a user question. Step 11 has its own +outside-review switch and required native pass. {{DESIGN_REVIEW_LITE}} -The parent owns design-lite; the Design specialist is an independent read. -Before final counting/Fix-First, merge the same evidenced design defect at the same path/line -into one item with both sources and stricter ASK. Retain actual specialist stats; -distinct defects stay separate and neither pass substitutes for the other. +Design-lite (parent) and the Design specialist are independent reads. Before counting +and Fix-First, merge one evidenced design defect at the same path/line into one item +with both sources and the stricter ASK. Keep actual specialist stats and distinct defects. {{REVIEW_ARMY}} @@ -63,11 +66,9 @@ but missing dispatched output still blocks continuation, even with a QA exceptio 2. **Auto-fix all AUTO-FIX items.** Apply each fix. Output one line per fix: `[AUTO-FIXED] [file:line] Problem → what you did` -3. **If ASK items remain,** present them in ONE AskUserQuestion: - - List each with number, severity, problem, recommended fix - - Per-item options: A) Fix B) Skip - - Overall RECOMMENDATION - - If 3 or fewer ASK items, you may use individual AskUserQuestion calls instead +3. **If ASK items remain,** present them in ONE AskUserQuestion (or one per item for 3 or + fewer): each item's number, severity, problem and recommended fix with A) Fix B) Skip, + plus an overall RECOMMENDATION. Save each explicit Skip immediately in the invocation action list with its identity, scope and supporting source evidence; keep it across repeats. @@ -114,10 +115,10 @@ but missing dispatched output still blocks continuation, even with a QA exceptio ```bash ~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship","completed":COMPLETED,"converged":CONVERGED,"cycles":CYCLES}' --finish REVIEW_START ``` -- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output; - otherwise `clean` only for completed coverage with no - unresolved non-advisory defects; otherwise `issues_found`. N counts current - unresolved defects, not original totals. Missing coverage is not a defect. +- `TIMESTAMP`: ISO 8601. `STATUS`: `unavailable` for missing dispatched reviewer output, + else `clean` only for completed coverage with no unresolved non-advisory defects, + else `issues_found`. N counts unresolved defects now, not original totals; missing + coverage is not a defect. - `REVIEW_START`: this pass's Step 9 token captured before reading the diff; never recapture at persistence to certify unreviewed fixes. - `COMPLETED`: checklist and dispatched specialists/Red Team finish, and all required probes pass. @@ -139,13 +140,13 @@ but missing dispatched output still blocks continuation, even with a QA exceptio `open` counts in `issues_found` and by severity. The logger stamps `actor` from `gstack-session-kind` (a supplied `actor` is discarded) and forces `"open"` on gate findings in a non-interactive session. -Save the review output and the `Gate edits:` line — they go into the PR body in Step 19. -Any `open` gate finding also decides Step 19's draft state. +Save the review output and `Gate edits:` line for Step 19's PR body; an `open` gate +finding makes that PR a draft. ### Decide whether to repeat Step 9 -After persistence, record missing dispatched output, CYCLES and applied fixes in -the invocation record. Apply these decisions in order: +After persistence, record missing output, CYCLES and fixes in the invocation record. +Apply these decisions in order: 1. **Dispatched reviewer output missing:** STOP and name each failed specialist or Red Team. Retain queued fixes and restore coverage. If this pass made edits, @@ -155,10 +156,10 @@ the invocation record. Apply these decisions in order: `converged:false`; do not run a fourth fixing cycle. 3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing - failures and scope. Keep CYCLES and scoped approvals across this repeat. + failures and scope. Keep CYCLES and scoped approvals across this repeat; it may be a fix-range repeat. 4. **No edits in this pass:** Resolve the required-probe gate below. Only after it - clears may you continue to Step 10. Undispatched gated/unsupported specialists - do not block independently, but never replace QA or required native review. + clears may you continue to Step 10, and only from a full pass: after a fix-range + pass, run one full Step 9 first. **Required-probe parent gate:** With completed checklist and dispatched reviewers, failed/unavailable required probes block continuation. diff --git a/ship/sections/tests.md b/ship/sections/tests.md index 280834f6d..d083d3f19 100644 --- a/ship/sections/tests.md +++ b/ship/sections/tests.md @@ -319,7 +319,7 @@ Ask with AskUserQuestion in the AskUserQuestion Format. List each failure the sa ### Step T4: Execute the chosen action **If "Investigate and fix now":** -- Switch to /investigate mindset: root cause first, then minimal fix. +- One read-only root-cause pass, then `/investigate` if unclear. - Fix the pre-existing failure. - Commit the fix separately from the branch's changes: `git commit -m "fix: pre-existing test failure in "` - Continue with the workflow. diff --git a/test/changelog-merge-damage.test.ts b/test/changelog-merge-damage.test.ts new file mode 100644 index 000000000..5fe107512 --- /dev/null +++ b/test/changelog-merge-damage.test.ts @@ -0,0 +1,82 @@ +/** + * CHANGELOG merges that would carry damage to released history (GSTA-67, + * from #3345 on 2026-10-22). #3345's pin 1eb7092 replaced main's + * `## [1.91.179.0]` heading with `## [Unreleased]` instead of inserting + * above it, and mergeChangelogs returned non-null output with 179's body + * duplicated into [Unreleased]; the train's T1 6755ab8 carried it. The + * fixtures under test/fixtures/merge-train-3345/ are the real CHANGELOGs at + * those commits, cut below 1.91.177.0. A null merge means a hand resolve. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { changelogDamage, mergeChangelogs, ownEntryAboveMain, parseChangelog } from '../lib/changelog-check'; + +const fixture = (name: string) => fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'merge-train-3345', name), 'utf8'); +const base = fixture('base-a59b5e4.md'); +const pin = fixture('pin-1eb7092.md'); +const main = fixture('main-4a98e40.md'); +const keys = (text: string) => parseChangelog(text).sections.map(s => s.version ?? 'Unreleased'); + +describe('mergeChangelogs refuses damage to released history', () => { + test('GSTA-64 repro: a released heading replaced by [Unreleased] is null, not a merge with the 1.0.1 body duplicated', () => { + const ours = '# Changelog\n\n## [Unreleased]\n\nNew entry.\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n'; + const theirs = '# Changelog\n\n## [1.0.2] - d\n\nMain body.\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n'; + expect(changelogDamage(ours, theirs)).toContain("ours's Unreleased section holds released 1.0.1 text"); + expect(mergeChangelogs(ours, theirs)).toBeNull(); + const baseText = '# Changelog\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n'; + expect(changelogDamage(ours, theirs, baseText)).toBe('ours drops released heading 1.0.1 that the merge base carries'); + }); + + test('#3345: pin 1eb7092 against main 4a98e40 is null with or without the merge base; the train T1 it produced duplicated 179', () => { + expect(changelogDamage(pin, main, base)).toBe('ours drops released heading 1.91.179.0 that the merge base carries'); + expect(changelogDamage(pin, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/); + expect(mergeChangelogs(pin, main, base)).toBeNull(); + expect(mergeChangelogs(pin, main)).toBeNull(); + const t1 = fixture('train-T1-6755ab8.md'); + expect(keys(t1)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']); + expect(changelogDamage(t1, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/); + }); + + test('either side, and a renamed heading, count as damage', () => { + expect(changelogDamage(main, pin, base)).toBe('theirs drops released heading 1.91.179.0 that the merge base carries'); + const renamed = main.replace('## [1.91.178.0]', '## [1.91.178.9]'); + expect(changelogDamage(renamed, main, base)).toBe('ours drops released heading 1.91.178.0 that the merge base carries'); + expect(changelogDamage(renamed, main)).toMatch(/^ours's 1\.91\.178\.9 section holds released 1\.91\.178\.0 text/); + }); + + test('negative controls: the real hand resolve, an entry above a newer main, a shared tentative stamp and two [Unreleased] blocks are not damage', () => { + const fixed = pin.replace(/\n\n\*\*docsync-fault-interface/, '\n\n## [1.91.179.0] - 2026-10-22\n\n**docsync-fault-interface'); + expect(keys(fixed)).toEqual(['Unreleased', '1.91.179.0', '1.91.178.0', '1.91.177.0']); + expect(changelogDamage(fixed, main, base)).toBeNull(); + const merged = mergeChangelogs(fixed, main, base)!; + expect(keys(merged)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']); + expect(merged.split('**docsync-fault-interface fits its budgets').length).toBe(2); + const b = '## [1.2.3.0] - d\n\n- base release entry\n'; + expect(mergeChangelogs(`# C\n\n## [1.2.4.0] - d\n\n- mine, a new entry\n\n${b}`, `# C\n\n## [1.2.4.0] - e\n\n- theirs, a new entry\n\n${b}`, `# C\n\n${b}`)).not.toBeNull(); + expect(changelogDamage(`# C\n\n## [Unreleased]\n\n- #2 change, its entry\n\n${b}`, `# C\n\n## [Unreleased]\n\n- #1 change, its entry\n\n${b}`, `# C\n\n${b}`)).toBeNull(); + }); +}); + +describe("ownEntryAboveMain: a merged-in head's CHANGELOG however it was resolved", () => { + const dir = path.join(import.meta.dir, 'fixtures', 'merge-train-3345'); + const diff = spawnSync('git', ['diff', '--no-index', '--unified=0', '--no-color', path.join(dir, 'base-a59b5e4.md'), path.join(dir, 'pin-1eb7092.md')], { encoding: 'utf8', timeout: 30_000 }).stdout; + const authored = new Set(diff.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1))); + const stamped = fixture('stamped-de23f5d.md'); + + test("#3345's stamped head de23f5d is main 4a98e40's CHANGELOG plus its own entry; the damaged train T1 is not", () => { + expect(authored.has('### Fixed')).toBe(true); + expect(authored.has('## [1.91.179.0] - 2026-10-22')).toBe(false); + expect(ownEntryAboveMain(stamped, main, authored)).toBeNull(); + expect(ownEntryAboveMain(fixture('train-T1-6755ab8.md'), main, authored)).toMatch(/^the PR's entry holds a line its pin never added: "\*\*docsync-fault-interface/); + }); + + test('negative controls: an invented line, a dropped or edited main section, a changed header and a duplicate heading are refused', () => { + expect(ownEntryAboveMain(stamped.replace('### Fixed\n', '### Fixed\n- added after the gate\n'), main, authored)).toBe('the PR\'s entry holds a line its pin never added: "- added after the gate"'); + expect(ownEntryAboveMain(stamped.replace(/## \[1\.91\.180\.0\][^\n]*\n/, ''), main, authored)).toBe("main's ## [1.91.181.0] - 2026-10-23 is not carried unchanged below the PR's entry"); + expect(ownEntryAboveMain(stamped.replace('## [1.91.178.0]', '## [1.91.178.0]\n\n- smuggled'), main, authored)).toBe("main's ## [1.91.178.0] - 2026-10-22 is not carried unchanged below the PR's entry"); + expect(ownEntryAboveMain(stamped.replace('# Changelog', '# Change log'), main, authored)).toBe('the header differs from main'); + expect(ownEntryAboveMain(stamped.replace('## [1.91.182.0]', '## [1.91.181.0]'), main, authored)).toMatch(/is headed twice$/); + }); +}); diff --git a/test/codex-host.test.ts b/test/codex-host.test.ts index 0976a72e6..d6dda8b79 100644 --- a/test/codex-host.test.ts +++ b/test/codex-host.test.ts @@ -128,13 +128,13 @@ describe('per-case host metadata', () => { for (const id of Object.keys(E2E_HOSTS)) expect(Object.hasOwn(E2E_TOUCHFILES, id), id).toBe(true); }); - test('classifies 52 adapter-only, 29 Claude-specific, 26 rework and 1 host-neutral case', () => { + test('classifies 52 adapter-only, 33 Claude-specific, 26 rework and 1 host-neutral case', () => { const counts: Record = {}; for (const support of Object.values(E2E_HOSTS)) counts[support] = (counts[support] ?? 0) + 1; expect(counts.both).toBe(52); expect(counts.rework).toBe(26); expect(counts['host-neutral']).toBe(1); - expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(29); + expect(['pty', 'sdk', 'subagents', 'skill-tool', 'hermetic', 'self-host'].reduce((sum, reason) => sum + (counts[reason] ?? 0), 0)).toBe(33); }); }); diff --git a/test/compare-pr-version.test.ts b/test/compare-pr-version.test.ts index b7355f28e..8a38963db 100644 --- a/test/compare-pr-version.test.ts +++ b/test/compare-pr-version.test.ts @@ -185,6 +185,52 @@ describe('version gate: unstamped heads under a train policy', () => { } }); + describe('a CHANGELOG over 1 MiB (GSTA-67: #3345 head 1eb7092 passed because the 1.67 MB CHANGELOG overflowed the read)', () => { + const excerpt = (name: string) => fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'merge-train-3345', name), 'utf8'); + const baseTop = excerpt('base-a59b5e4.md'); + const padding = Array.from({ length: 2400 }, (_, i) => `## [1.0.${2400 - i}.0] - 2026-01-01\n\nOlder release ${i}: ${'x'.repeat(440)}\n`).join('\n'); + const big = `${baseTop}\n${padding}`; + const bigFixture = () => { + const r = fixture(); + write(r, 'CHANGELOG.md', big); + commit(r, 'main with a full-size CHANGELOG'); + r.git('push', '-q', 'origin', 'main'); + r.git('fetch', '-q', 'origin'); + branch(r); + expect(Buffer.byteLength(big)).toBeGreaterThan(1.05 * 1024 * 1024); + return r; + }; + + test("#3345's change (the 1.91.179.0 heading replaced by [Unreleased]) is refused, naming the dropped heading", () => { + const r = bigFixture(); + write(r, 'CHANGELOG.md', big.replace(baseTop, excerpt('pin-1eb7092.md'))); + const res = run(r, commit(r, 'the 1eb7092 CHANGELOG edit')); + expect(res.code).toBe(1); + expect(res.out).toContain('::error::PR #3320: CHANGELOG.md drops released heading 1.91.179.0; an unstamped head may only add its [Unreleased] section above the latest release.'); + }); + + test('control: an additive [Unreleased] entry above the latest release still passes at full size', () => { + const r = bigFixture(); + write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]')); + const res = run(r, commit(r, 'additive entry')); + expect(res.code).toBe(0); + expect(res.out).toContain('is unstamped under the base'); + }); + + test('a CHANGELOG that cannot be read is refused, never passed', () => { + const r = bigFixture(); + write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]')); + const head = commit(r, 'additive entry, then a damaged object'); + const blob = r.git('rev-parse', `${head}:CHANGELOG.md`); + const loose = path.join(r.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2)); + fs.chmodSync(loose, 0o644); + fs.writeFileSync(loose, fs.readFileSync(loose).subarray(0, 2000)); + const res = run(r, head); + expect(res.code).toBe(1); + expect(res.out).toContain(`::error::PR #3320: cannot read CHANGELOG.md at ${head}.`); + }); + }); + test('the workflow runs the gate from the merge commit with the base ref and head sha', () => { const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any; const steps = workflow.jobs.check.steps; diff --git a/test/cso-preparation.test.ts b/test/cso-preparation.test.ts index 6addab47f..174ebd8b3 100644 --- a/test/cso-preparation.test.ts +++ b/test/cso-preparation.test.ts @@ -170,6 +170,32 @@ describe('CSO inert Python preparation', () => { }); }); +describe('CSO preparation names a Go module as Go (#3263)', () => { + const goFiles = { + 'go.mod': 'module example.com/app\n\ngo 1.23\n\nrequire github.com/google/uuid v1.6.0\n', + 'go.sum': 'github.com/google/uuid v1.6.0 h1:abc=\n', + 'main.go': 'package main\n\nfunc main() {}\n', + }; + const GO_UNSUPPORTED = { code: 'UNSUPPORTED_STACK', message: 'Go (go.mod) is not a supported runtime stack yet; supported runtime stacks are Node, Bun, Python, and Rails.', path: 'go.mod' }; + test('a go.mod repository is Go, unsupported, and never asks for requirements.txt', () => { + const plan = inspectPreparation(fixture(goFiles)); + expect([plan.stack, plan.runtimeProfile, plan.status]).toEqual(['go', 'go', 'prerequisites']); + expect(plan.prerequisites).toEqual([GO_UNSUPPORTED]); + expect(JSON.stringify(plan)).not.toContain('requirements.txt'); + }); + test('Python helper metadata beside go.mod stays Go', () => { + const plan = inspectPreparation(fixture({ ...goFiles, 'requirements.txt': `mkdocs==1.6.1 --hash=sha256:${hash}\n` })); + expect(plan.stack).toBe('go'); + expect(plan.prerequisites).toEqual([GO_UNSUPPORTED]); + }); + test('controls: go.mod beside a Node lockfile stays Node; Python without go.mod stays Python', () => { + const node = inspectPreparation(fixture({ ...goFiles, ...nodeFiles() })); + expect([node.stack, node.status]).toEqual(['node', 'ready']); + expect(inspectPreparation(fixture({ 'requirements.txt': `Flask==3.1.0 --hash=sha256:${hash}\n` })).stack).toBe('python'); + expect(inspectPreparation(fixture({ 'go.sum': '', 'main.go': '' })).stack).toBe('python'); + }); +}); + describe('CSO inert Rails preparation', () => { test('does not silently choose Node in a multi-stack repository',()=>{const plan=inspectPreparation(fixture({...nodeFiles(),'Gemfile.lock':gemLock,'Gemfile':''}));expect(plan.status).toBe('prerequisites');expect(plan.prerequisites[0].code).toBe('MULTIPLE_STACKS');expect(inspectPreparation(fixture({...nodeFiles(),'Gemfile.lock':gemLock,'Gemfile':''}),'rails').stack).toBe('rails');}); test('fetches exact gems without evaluating Gemfiles or building native extensions', () => { diff --git a/test/eng-finding-retry-budget.test.ts b/test/eng-finding-retry-budget.test.ts index 9d35a6f0a..0320c653d 100644 --- a/test/eng-finding-retry-budget.test.ts +++ b/test/eng-finding-retry-budget.test.ts @@ -141,7 +141,7 @@ test('live periodic census fits the declared CI wall including setup', () => { test('registered allocation is deterministic and preserves every discovered file', () => { const files = collectPaidTestFiles(); - expect(files).toHaveLength(109); // + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0); + expect(files).toHaveLength(110); // + skill-e2e-failure-routing (#3157/#3158); + skill-e2e-dx-error-contract (#3226); + skill-e2e-gate-integrity (HONEST_WORK_GATE_INTEGRITY W8); + skill-e2e-autoplan-schema-canary, -guard-pty and -long-session (Oct 7 wave, CEO-13, ENG-18, CEO-15); + skill-e2e-ship-measure-seeded-flake (v1.91.32.0); W5a: 15 carve wrappers became one case-sharded file; + codex-e2e-multiblock-live and skill-e2e-design-model-smoke (v1.91.30.0); expect(files).toContain('test/skill-e2e-ship-skip.test.ts'); const m = livePlan(files); expect(livePlan([...files].reverse())).toEqual(m); diff --git a/test/eng-first-review.test.ts b/test/eng-first-review.test.ts index 26d19c8bc..26e74de94 100644 --- a/test/eng-first-review.test.ts +++ b/test/eng-first-review.test.ts @@ -1467,3 +1467,19 @@ describe('Y whole-plan complexity setup decision', () => { }); }); }); + +describe('plan finding severity labels (#3349)', () => { +const relabel = (captured: { call: unknown }, label: string) => { + const c = structuredClone(captured.call) as NativePlanQuestionCall, q = c.questions[0]!, picked = c.answers![q.question]!; + q.question = label ? q.question.replace('[P1] (confidence', `${label} (confidence`) : q.question.replace('[P1] ', ''); + c.answers = { [q.question]: picked }; + return engFirstReviewAUQ(nativePlanCallFingerprint(c, Date.parse(c.answeredAt!), true)); +}; +const captures = [['eng-annotated-cache-au', captured_eng_annotated_cache_au], ['eng-declarative-as', captured_eng_declarative_as]] as const; +test.each(captures)('%s opens review under either severity scale', (_, captured) => { + for (const label of ['[P1]', '[CRITICAL]', '[INFORMATIONAL]']) expect(relabel(captured, label)).toBe(true); +}); +test.each(captures)('%s rejects labels outside both scales', (_, captured) => { + for (const label of ['[HIGH]', '[critical]', '[Critical]', '[P4]', '[CRITICAL|INFORMATIONAL]', '']) expect(relabel(captured, label)).toBe(false); +}); +}); diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 3be910710..6cc30d0b4 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -788,7 +788,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. -3. **Queue-aware pick** (workspace-aware ship): +3. **Queue-aware pick:** ```bash QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-next-version --base --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty') @@ -798,6 +798,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Offline output without that fallback, failure, malformed output or an empty version is unusable, even when it contains a version-looking string. + **Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may + collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended; + Version Gate CI rereads the queue) or stop. + - **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`. ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump (refresh CHANGELOG/PR title) or keep current (CI rejects a collision). @@ -809,16 +813,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest + bun --no-env-file --config=/dev/null run ~/.gemini/antigravity-cli/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md ``` - The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes - VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` → - `./package.json`) and existing npm lockfiles, never creating one, with the 3-digit - translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write: - reclassify and `repair` DRIFT_STALE_PKG. + It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION + for Step 13. Exit 2 wrote nothing: show stderr, stop. + Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG. - `--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges - only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is + `--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before push, verify the committed digest matches generation for the selected VERSION. diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 924983ab4..24d4cfafb 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -793,7 +793,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. -3. **Queue-aware pick** (workspace-aware ship): +3. **Queue-aware pick:** ```bash QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') CANDIDATE_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty') @@ -803,6 +803,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Offline output without that fallback, failure, malformed output or an empty version is unusable, even when it contains a version-looking string. + **Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may + collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended; + Version Gate CI rereads the queue) or stop. + - **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`. ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump (refresh CHANGELOG/PR title) or keep current (CI rejects a collision). @@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. 4. **Write the bump** (FRESH, or an approved rebump): ```bash - bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest + bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md ``` - The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes - VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` → - `./package.json`) and existing npm lockfiles, never creating one, with the 3-digit - translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write: - reclassify and `repair` DRIFT_STALE_PKG. + It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION + for Step 13. Exit 2 wrote nothing: show stderr, stop. + Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG. - `--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges - only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is + `--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before push, verify the committed digest matches generation for the selected VERSION. diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 3106f86c4..bbadb86ec 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -810,7 +810,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. -3. **Queue-aware pick** (workspace-aware ship): +3. **Queue-aware pick:** ```bash [ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;} QUEUE_JSON=$(bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-next-version --base --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') @@ -821,6 +821,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Offline output without that fallback, failure, malformed output or an empty version is unusable, even when it contains a version-looking string. + **Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may + collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended; + Version Gate CI rereads the queue) or stop. + - **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`. ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump (refresh CHANGELOG/PR title) or keep current (CI rejects a collision). @@ -833,16 +837,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. 4. **Write the bump** (FRESH, or an approved rebump): ```bash [ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.agents/skills/gstack;[ -d "$_r/bin" ]||_r=${CODEX_HOME:-~/.codex}/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host codex from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;} - bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest + bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md ``` - The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes - VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` → - `./package.json`) and existing npm lockfiles, never creating one, with the 3-digit - translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write: - reclassify and `repair` DRIFT_STALE_PKG. + It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION + for Step 13. Exit 2 wrote nothing: show stderr, stop. + Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG. - `--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges - only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is + `--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before push, verify the committed digest matches generation for the selected VERSION. diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 04255582c..a59827a30 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -791,7 +791,7 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level. -3. **Queue-aware pick** (workspace-aware ship): +3. **Queue-aware pick:** ```bash [ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;} QUEUE_JSON=$(bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-next-version --base --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') @@ -802,6 +802,10 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. Offline output without that fallback, failure, malformed output or an empty version is unusable, even when it contains a version-looking string. + **Incomplete** (`complete:false`): PRs in `unknown_claims` went unread; the pick may + collide. Rerun item 3 once; still incomplete, AskUserQuestion: use it (recommended; + Version Gate CI rereads the queue) or stop. + - **Usable candidate:** print warnings and claimed queue. FRESH sets `NEW_VERSION=CANDIDATE_VERSION`. ALREADY_BUMPED compares it with `currentVersion`: if different, ask to rebump (refresh CHANGELOG/PR title) or keep current (CI rejects a collision). @@ -814,16 +818,13 @@ chooses it in item 2 and ALREADY_BUMPED derives it in item 1. 4. **Write the bump** (FRESH, or an approved rebump): ```bash [ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/.factory/skills/gstack;[ -d "$_r/bin" ]||_r=~/.factory/skills/gstack;[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). Fix: ./setup --host factory from your gstack checkout; ./setup --status shows it.">&2;exit 1;};GSTACK_ROOT=$_r;} - bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest + bun --no-env-file --config=/dev/null run $GSTACK_ROOT/bin/gstack-version-bump write --version "$NEW_VERSION" --regen-digest --changelog CHANGELOG.md ``` - The CLI validates `MAJOR.MINOR.PATCH.MICRO` (or pinned 3-digit semver), writes - VERSION, the manifest (`--package-json-path` → `.gstack/package-json-path` → - `./package.json`) and existing npm lockfiles, never creating one, with the 3-digit - translation (`1.67.0.0` → `1.67.0`); VERSION is authoritative. Exit 3 is a half-write: - reclassify and `repair` DRIFT_STALE_PKG. + It writes VERSION, manifest and lockfiles, and moves `## [Unreleased]` under NEW_VERSION + for Step 13. Exit 2 wrote nothing: show stderr, stop. + Exit 3 is a half-write: reclassify and `repair` DRIFT_STALE_PKG. - `--regen-digest` runs repo code (`scripts/gen-agents-digest.ts`) with Step 5's privileges - only when it and committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is + `--regen-digest` runs `scripts/gen-agents-digest.ts` only when it and a committed `agents-digest/gstack-AGENTS.md` exist; if `agentsDigest` is false, run `bun --no-env-file --config=/dev/null scripts/gen-agents-digest.ts` and stage the digest with the bump. Before push, verify the committed digest matches generation for the selected VERSION. diff --git a/test/fixtures/merge-train-3345/base-a59b5e4.md b/test/fixtures/merge-train-3345/base-a59b5e4.md new file mode 100644 index 000000000..108ca40f1 --- /dev/null +++ b/test/fixtures/merge-train-3345/base-a59b5e4.md @@ -0,0 +1,30 @@ +# Changelog + +## [1.91.179.0] - 2026-10-22 + +**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed. + +### Fixed +- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms. +- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls. +- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical. +- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint. + +## [1.91.178.0] - 2026-10-22 + +**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged. + +### Fixed +- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root. +- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it. + +### Gate integrity +- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root. + +## [1.91.177.0] - 2026-10-22 + +**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything. + +### Fixed +- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`. +- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token. diff --git a/test/fixtures/merge-train-3345/main-4a98e40.md b/test/fixtures/merge-train-3345/main-4a98e40.md new file mode 100644 index 000000000..bfe62ca2b --- /dev/null +++ b/test/fixtures/merge-train-3345/main-4a98e40.md @@ -0,0 +1,47 @@ +# Changelog + +## [1.91.181.0] - 2026-10-23 + +**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd. + +### Added +- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`). + +### For contributors +- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion. + +## [1.91.180.0] - 2026-10-22 + +**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one. + +### Changed +- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches. + +## [1.91.179.0] - 2026-10-22 + +**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed. + +### Fixed +- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms. +- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls. +- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical. +- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint. + +## [1.91.178.0] - 2026-10-22 + +**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged. + +### Fixed +- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root. +- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it. + +### Gate integrity +- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root. + +## [1.91.177.0] - 2026-10-22 + +**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything. + +### Fixed +- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`. +- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token. diff --git a/test/fixtures/merge-train-3345/pin-1eb7092.md b/test/fixtures/merge-train-3345/pin-1eb7092.md new file mode 100644 index 000000000..650f6e746 --- /dev/null +++ b/test/fixtures/merge-train-3345/pin-1eb7092.md @@ -0,0 +1,37 @@ +# Changelog + +## [Unreleased] + +**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`. + +### Fixed +- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move. +- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4. + + +**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed. + +### Fixed +- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms. +- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls. +- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical. +- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint. + +## [1.91.178.0] - 2026-10-22 + +**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged. + +### Fixed +- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root. +- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it. + +### Gate integrity +- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root. + +## [1.91.177.0] - 2026-10-22 + +**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything. + +### Fixed +- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`. +- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token. diff --git a/test/fixtures/merge-train-3345/stamped-de23f5d.md b/test/fixtures/merge-train-3345/stamped-de23f5d.md new file mode 100644 index 000000000..7beef11e1 --- /dev/null +++ b/test/fixtures/merge-train-3345/stamped-de23f5d.md @@ -0,0 +1,55 @@ +# Changelog + +## [1.91.182.0] - 2026-10-23 + +**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`. + +### Fixed +- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move. +- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4. + +## [1.91.181.0] - 2026-10-23 + +**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd. + +### Added +- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`). + +### For contributors +- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion. + +## [1.91.180.0] - 2026-10-22 + +**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one. + +### Changed +- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches. + +## [1.91.179.0] - 2026-10-22 + +**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed. + +### Fixed +- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms. +- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls. +- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical. +- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint. + +## [1.91.178.0] - 2026-10-22 + +**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged. + +### Fixed +- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root. +- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it. + +### Gate integrity +- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root. + +## [1.91.177.0] - 2026-10-22 + +**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything. + +### Fixed +- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`. +- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token. diff --git a/test/fixtures/merge-train-3345/train-T1-6755ab8.md b/test/fixtures/merge-train-3345/train-T1-6755ab8.md new file mode 100644 index 000000000..d5f97c098 --- /dev/null +++ b/test/fixtures/merge-train-3345/train-T1-6755ab8.md @@ -0,0 +1,64 @@ +# Changelog + +## [Unreleased] + +**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`. + +### Fixed +- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move. +- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4. + + +**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed. + +### Fixed +- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms. +- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls. +- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical. +- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint. + +## [1.91.181.0] - 2026-10-23 + +**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd. + +### Added +- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`). + +### For contributors +- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion. + +## [1.91.180.0] - 2026-10-22 + +**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one. + +### Changed +- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches. + +## [1.91.179.0] - 2026-10-22 + +**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed. + +### Fixed +- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms. +- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls. +- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical. +- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint. + +## [1.91.178.0] - 2026-10-22 + +**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged. + +### Fixed +- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root. +- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it. + +### Gate integrity +- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root. + +## [1.91.177.0] - 2026-10-22 + +**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything. + +### Fixed +- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`. +- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token. diff --git a/test/fixtures/model-policy-freshness/openai-lifecycle.md b/test/fixtures/model-policy-freshness/openai-lifecycle.md index 4cffbb87f..e57d5e36b 100644 --- a/test/fixtures/model-policy-freshness/openai-lifecycle.md +++ b/test/fixtures/model-policy-freshness/openai-lifecycle.md @@ -9,6 +9,7 @@ This page lists all API deprecations, along with recommended replacements. | Shutdown date | Model / system | Recommended replacement | | --- | --- | --- | | Apr 1, 2027 | `gpt-5.3-codex` | `gpt-6-sol` | +| Feb 16, 2027 | GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`) | `gpt-6-sol` | ## Past deprecations diff --git a/test/fixtures/skill-md-size-allowlist.json b/test/fixtures/skill-md-size-allowlist.json index 9083336de..948752968 100644 --- a/test/fixtures/skill-md-size-allowlist.json +++ b/test/fixtures/skill-md-size-allowlist.json @@ -5,91 +5,91 @@ "agy/gstack-autoplan": 130504, "agy/gstack-design-consultation": 108184, "agy/gstack-design-review": 143528, - "agy/gstack-land-and-deploy": 116611, + "agy/gstack-land-and-deploy": 117722, "agy/gstack-plan-design-review": 140013, "agy/gstack-plan-devex-review": 135760, - "agy/gstack-plan-eng-review": 152701, + "agy/gstack-plan-eng-review": 152626, "agy/gstack-review": 124218, "claude/design-review": 136531, "codex/gstack-autoplan": 130628, "codex/gstack-design-consultation": 109452, "codex/gstack-design-review": 145028, - "codex/gstack-land-and-deploy": 121188, + "codex/gstack-land-and-deploy": 122272, "codex/gstack-office-hours": 102339, "codex/gstack-plan-design-review": 142466, "codex/gstack-plan-devex-review": 138649, - "codex/gstack-plan-eng-review": 155247, + "codex/gstack-plan-eng-review": 155172, "codex/gstack-review": 125122, "copilot/gstack-autoplan": 131637, "copilot/gstack-codex": 100235, "copilot/gstack-design-consultation": 109536, "copilot/gstack-design-review": 144833, - "copilot/gstack-land-and-deploy": 117881, + "copilot/gstack-land-and-deploy": 118977, "copilot/gstack-office-hours": 100495, "copilot/gstack-plan-design-review": 140884, "copilot/gstack-plan-devex-review": 136912, - "copilot/gstack-plan-eng-review": 153426, + "copilot/gstack-plan-eng-review": 153351, "copilot/gstack-review": 125489, "cursor/gstack-autoplan": 129278, "cursor/gstack-design-consultation": 107222, "cursor/gstack-design-review": 142507, - "cursor/gstack-land-and-deploy": 115550, + "cursor/gstack-land-and-deploy": 116645, "cursor/gstack-plan-design-review": 138968, "cursor/gstack-plan-devex-review": 134591, - "cursor/gstack-plan-eng-review": 151555, + "cursor/gstack-plan-eng-review": 151480, "cursor/gstack-review": 140750, "factory/gstack-autoplan": 131562, "factory/gstack-codex": 100655, "factory/gstack-design-consultation": 108670, "factory/gstack-design-review": 144297, - "factory/gstack-land-and-deploy": 119733, + "factory/gstack-land-and-deploy": 120817, "factory/gstack-office-hours": 101374, "factory/gstack-plan-design-review": 141749, "factory/gstack-plan-devex-review": 137960, - "factory/gstack-plan-eng-review": 154669, + "factory/gstack-plan-eng-review": 154594, "factory/gstack-review": 144529, "gbrain/gstack-autoplan": 129815, "gbrain/gstack-design-review": 132842, - "gbrain/gstack-land-and-deploy": 116072, + "gbrain/gstack-land-and-deploy": 117167, "gbrain/gstack-plan-design-review": 127855, "gbrain/gstack-plan-devex-review": 115129, - "gbrain/gstack-plan-eng-review": 134287, + "gbrain/gstack-plan-eng-review": 134212, "hermes/gstack-autoplan": 129905, "hermes/gstack-design-review": 132949, - "hermes/gstack-land-and-deploy": 116174, + "hermes/gstack-land-and-deploy": 117269, "hermes/gstack-plan-design-review": 127964, "hermes/gstack-plan-devex-review": 115215, - "hermes/gstack-plan-eng-review": 134372, + "hermes/gstack-plan-eng-review": 134297, "kiro/gstack-autoplan": 129040, "kiro/gstack-design-consultation": 107064, "kiro/gstack-design-review": 142321, - "kiro/gstack-land-and-deploy": 115440, + "kiro/gstack-land-and-deploy": 116533, "kiro/gstack-plan-design-review": 138808, "kiro/gstack-plan-devex-review": 134431, - "kiro/gstack-plan-eng-review": 151405, + "kiro/gstack-plan-eng-review": 151330, "kiro/gstack-review": 140562, "openclaw/gstack-autoplan": 129520, "openclaw/gstack-design-review": 131647, - "openclaw/gstack-land-and-deploy": 115660, + "openclaw/gstack-land-and-deploy": 116757, "openclaw/gstack-plan-design-review": 126706, "openclaw/gstack-plan-devex-review": 113968, - "openclaw/gstack-plan-eng-review": 132684, + "openclaw/gstack-plan-eng-review": 132609, "opencode/gstack-autoplan": 131640, "opencode/gstack-design-consultation": 109357, "opencode/gstack-design-review": 144705, - "opencode/gstack-land-and-deploy": 117658, + "opencode/gstack-land-and-deploy": 118762, "opencode/gstack-office-hours": 100329, "opencode/gstack-plan-design-review": 140738, "opencode/gstack-plan-devex-review": 136812, - "opencode/gstack-plan-eng-review": 153307, + "opencode/gstack-plan-eng-review": 153232, "opencode/gstack-review": 142985, "slate/gstack-autoplan": 129159, "slate/gstack-design-consultation": 107143, "slate/gstack-design-review": 142414, - "slate/gstack-land-and-deploy": 115495, + "slate/gstack-land-and-deploy": 116589, "slate/gstack-plan-design-review": 138888, "slate/gstack-plan-devex-review": 134511, - "slate/gstack-plan-eng-review": 151480, + "slate/gstack-plan-eng-review": 151405, "slate/gstack-review": 140656 } } diff --git a/test/gbrain-guards.test.ts b/test/gbrain-guards.test.ts index 4ba388f6d..6ebe940a3 100644 --- a/test/gbrain-guards.test.ts +++ b/test/gbrain-guards.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, afterEach } from "bun:test"; +import { spawn } from "child_process"; import * as fs from "fs"; import * as os from "os"; import { join } from "path"; @@ -78,6 +79,52 @@ describe("detectAutopilot", () => { expect(r.signal).toBe("process:gbrain autopilot"); }); + // #3360: the probe stays machine-wide; the refusal names whose autopilot it is. + test("a matched autopilot process is named by pid and command, with how to proceed", () => { + const r = detectAutopilot(process.env, { + lockPaths: [], + processRunning: () => [{ pid: 4242, command: "gbrain autopilot --repo /src/other-brain" }], + }); + expect(r.active).toBe(true); + expect(r.signal).toBe( + "process:gbrain autopilot pid 4242 `gbrain autopilot --repo /src/other-brain`; this check counts any process on this machine " + + "whose command line contains \"gbrain autopilot\", even an autopilot serving another brain or repo. Stop that autopilot " + + "(or its service, if `gbrain autopilot --install` set one up), or end the process that only mentions it", + ); + }); + + test("every matched autopilot process is listed", () => { + const r = detectAutopilot(process.env, { + lockPaths: [], + processRunning: () => [ + { pid: 4242, command: "gbrain autopilot --repo /a" }, + { pid: 4343, command: "gbrain autopilot --repo /b" }, + ], + }); + expect(r.signal).toContain("pid 4242 `gbrain autopilot --repo /a`, pid 4343 `gbrain autopilot --repo /b`;"); + }); + + test("no matched process is inactive", () => { + const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => [] }); + expect(r).toEqual({ active: false, signal: null }); + }); + + test.skipIf(process.platform === "win32")("the real probe names a live process anywhere on the machine, credentials masked", async () => { + const url = ["postgres://u", "hunter2@h/db"].join(":"); + const child = spawn(process.execPath, ["-e", "console.log('ready'); setTimeout(() => {}, 30000)", `gbrain autopilot --repo /tmp/gstack-3360 --db ${url}`], { stdio: ["ignore", "pipe", "ignore"] }); + try { + await new Promise(resolve => child.stdout!.once("data", resolve)); + const r = detectAutopilot(process.env, { lockPaths: [] }); + expect(r.active).toBe(true); + expect(r.signal).toContain(`pid ${child.pid} \``); + expect(r.signal).toContain("gbrain autopilot --repo /tmp/gstack-3360 --db postgres://***@h/db"); + expect(r.signal).not.toContain("hunter2"); + expect(r.signal).toContain("Stop that autopilot"); + } finally { + child.kill("SIGKILL"); + } + }); + test("a lock with no parseable pid stays conservative (active, no pid in signal)", () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-")); const lock = join(tmp, "autopilot.lock"); diff --git a/test/gbrain-repo-policy-client.test.ts b/test/gbrain-repo-policy-client.test.ts index 89a6b1247..0988a5ecb 100644 --- a/test/gbrain-repo-policy-client.test.ts +++ b/test/gbrain-repo-policy-client.test.ts @@ -19,7 +19,7 @@ import * as path from "path"; import * as os from "os"; import { spawnSync } from "child_process"; -import { repoPolicyTier, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; +import { hasRepoPolicyStore, memorableRecallPolicy, repoPolicyTier, repoPolicyTierBatch } from "../lib/gbrain-repo-policy-client"; import { canonicalizeRemote } from "../lib/gstack-memory-helpers"; const ROOT = path.resolve(import.meta.dir, ".."); @@ -219,3 +219,54 @@ describe("repoPolicyTier timeoutMs (hook deadline seam)", () => { expect(repoPolicyTier(url, env(), 1)).toEqual({ tier: "none", error: "unreadable" }); }); }); + +describe("memorableRecallPolicy (#3359: per-remote Memorable recall opt-in)", () => { + const A = "https://github.com/acme/client-a.git"; + test("no store: tier none, opt-in false, no error (fast path)", () => { + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false }); + expect(fs.existsSync(policyFile())).toBe(false); + }); + test("read-only defaults to off; the explicit opt-in turns it on for that remote only", () => { + expect(run(["set", A, "read-only"]).status).toBe(0); + expect(run(["set", "https://github.com/acme/client-b", "read-only"]).status).toBe(0); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "read-only", memorableOptIn: false }); + expect(run(["memorable", "set", A, "allow"]).status).toBe(0); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "read-only", memorableOptIn: true }); + expect(memorableRecallPolicy("https://github.com/acme/client-b", env())).toEqual({ tier: "read-only", memorableOptIn: false }); + expect(memorableRecallPolicy("https://github.com/acme/other", env())).toEqual({ tier: "none", memorableOptIn: false }); + }); + test("deny with a hand-written opt-in: opt-in false", () => { + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, "github.com/acme/client-a": "deny", _memorable_recall: { "github.com/acme/client-a": "allow" } })); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "deny", memorableOptIn: false }); + }); + test("corrupt store: unreadable, never read as no policy, and not quarantined", () => { + fs.writeFileSync(policyFile(), "{not json", { mode: 0o600 }); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + expect(fs.readFileSync(policyFile(), "utf-8")).toBe("{not json"); + }); + test("a tier the client does not know (hand-edited store) is unreadable, never no-policy", () => { + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, "github.com/acme/client-a": 7, _memorable_recall: { "github.com/acme/client-a": "allow" } })); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + }); + test("a dangling symlink as the only store is not the no-store fast path: unreadable", () => { + fs.symlinkSync(path.join(tmpHome, "missing.json"), policyFile()); + expect(hasRepoPolicyStore(env())).toBe(true); + expect(memorableRecallPolicy(A, env())).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + }); + test("a dangling symlink above the store, or a file in its path, is not the no-store fast path", () => { + fs.symlinkSync(path.join(tmpHome, "missing-dir"), path.join(tmpHome, "link")); + const viaLink = { ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "link", "child") }; + expect(hasRepoPolicyStore(viaLink)).toBe(true); + expect(memorableRecallPolicy(A, viaLink)).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + fs.writeFileSync(path.join(tmpHome, "plain-file"), "x"); + expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "plain-file", "child") })).toBe(true); + fs.mkdirSync(path.join(tmpHome, "real-dir")); + fs.symlinkSync(path.join(tmpHome, "real-dir"), path.join(tmpHome, "good-link")); + expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "good-link", "child") })).toBe(false); + expect(hasRepoPolicyStore({ ...env(), GSTACK_TEST_LEGACY_ROOT: path.join(tmpHome, "no", "such", "root") })).toBe(false); + }); + test("a timed-out spawn classifies as unreadable", () => { + expect(run(["set", A, "read-only"]).status).toBe(0); + expect(memorableRecallPolicy(A, env(), 1)).toEqual({ tier: "none", memorableOptIn: false, error: "unreadable" }); + }); +}); diff --git a/test/gbrain-repo-policy.test.ts b/test/gbrain-repo-policy.test.ts index b95964646..60436c450 100644 --- a/test/gbrain-repo-policy.test.ts +++ b/test/gbrain-repo-policy.test.ts @@ -418,3 +418,181 @@ describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path) expect(r.text).not.toContain('refused'); }); }); + +describe('memorable recall opt-in (#3359)', () => { + const A = 'https://github.com/acme/client-a.git'; + const memo = (url: string, extra: Record = {}) => run(['memorable', 'get', url], { env: extra }); + + test('off by default: a read-only remote reports off until it is opted in', () => { + expect(run(['set', A, 'read-only']).status).toBe(0); + expect(memo(A).stdout).toBe('read-only\toff'); + const r = run(['memorable', 'set', A, 'allow']); + expect(r.status).toBe(0); + expect(memo(A).stdout).toBe('read-only\tallow'); + expect(readPolicy()._memorable_recall).toEqual({ 'github.com/acme/client-a': 'allow' }); + expect(run(['list']).stdout).toBe('github.com/acme/client-a\tread-only\tmemorable=allow'); + }); + + test('the opt-in is per remote: another remote, a prefix-sharing remote and an unset remote stay off; the SSH spelling of the same remote is the same key', () => { + for (const u of [A, 'https://github.com/acme/client-b', 'https://github.com/acme/client-a-fork', 'https://github.com/acme/client']) { + expect(run(['set', u, 'read-only']).status).toBe(0); + } + expect(run(['memorable', 'set', A, 'allow']).status).toBe(0); + expect(memo('https://github.com/acme/client-b').stdout).toBe('read-only\toff'); + expect(memo('https://github.com/acme/client-a-fork').stdout).toBe('read-only\toff'); + expect(memo('https://github.com/acme/client').stdout).toBe('read-only\toff'); + expect(memo('https://gitlab.com/acme/client-a').stdout).toBe('unset\toff'); + expect(memo('git@github.com:acme/client-a.git').stdout).toBe('read-only\tallow'); + }); + + test('deny always wins: the opt-in is refused on deny, and a hand-written opt-in on a deny remote reads off', () => { + expect(run(['set', A, 'deny']).status).toBe(0); + const before = fs.readFileSync(policyFile(), 'utf-8'); + const r = run(['memorable', 'set', A, 'allow']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('deny tier always vetoes'); + expect(fs.readFileSync(policyFile(), 'utf-8')).toBe(before); + fs.writeFileSync(policyFile(), JSON.stringify({ ...readPolicy(), _memorable_recall: { 'github.com/acme/client-a': 'allow' } })); + expect(memo(A).stdout).toBe('deny\toff'); + }); + + test('the opt-in is refused on read-write and unset remotes (nothing to lift)', () => { + expect(run(['set', A, 'read-write']).status).toBe(0); + expect(run(['memorable', 'set', A, 'allow']).status).toBe(2); + expect(run(['memorable', 'set', 'https://github.com/acme/never-set', 'allow']).status).toBe(2); + expect(readPolicy()._memorable_recall).toBeUndefined(); + }); + + test('changing the tier clears the opt-in, re-setting the same tier keeps it, and off removes it', () => { + run(['set', A, 'read-only']); + run(['memorable', 'set', A, 'allow']); + expect(run(['set', A, 'read-only']).stdout).not.toContain('Cleared'); + expect(memo(A).stdout).toBe('read-only\tallow'); + const flip = run(['set', A, 'read-write']); + expect(flip.stdout).toContain('Cleared the Memorable recall opt-in'); + run(['set', A, 'read-only']); + expect(memo(A).stdout).toBe('read-only\toff'); + run(['memorable', 'set', A, 'allow']); + expect(run(['memorable', 'set', A, 'off']).status).toBe(0); + expect(memo(A).stdout).toBe('read-only\toff'); + expect(readPolicy()._memorable_recall).toBeUndefined(); + }); + + test('a malformed opt-in reads off: any value but "allow", or a map that is not an object', () => { + run(['set', A, 'read-only']); + for (const bad of [{ 'github.com/acme/client-a': true }, { 'github.com/acme/client-a': 'ALLOW' }, 'allow', ['github.com/acme/client-a'], null]) { + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only', _memorable_recall: bad })); + expect(memo(A).stdout).toBe('read-only\toff'); + } + }); + + test('a corrupt store fails closed: exit 2, never quarantined, store left as is', () => { + fs.writeFileSync(policyFile(), '{"_schema_version":2,"github.com/acme/client-a":"read-only",', { mode: 0o600 }); + const r = memo(A); + expect(r.status).toBe(2); + expect(r.stdout).toBe(''); + expect(r.stderr).toContain('corrupt'); + expect(fs.readFileSync(policyFile(), 'utf-8')).toContain('"read-only",'); + expect(fs.readdirSync(tmpHome).some((f) => f.includes('.corrupt-'))).toBe(false); + }); + + test('a second store that makes the remote read-only must opt it in too; its deny still wins; a corrupt second store fails closed', () => { + const legacy = path.join(tmpHome, 'legacy'); + fs.mkdirSync(legacy); + const extra = { GSTACK_TEST_LEGACY_ROOT: legacy }; + const legacyStore = path.join(legacy, 'gbrain-repo-policy.json'); + run(['set', A, 'read-only'], { env: extra }); + expect(run(['memorable', 'set', A, 'allow'], { env: extra }).status).toBe(0); + expect(memo(A, extra).stdout).toBe('read-only\tallow'); + fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only' })); + expect(memo(A, extra).stdout).toBe('read-only\toff'); + expect(run(['memorable', 'set', A, 'allow'], { env: extra }).stderr).toContain('Opt in there too'); + fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'read-only', _memorable_recall: { 'github.com/acme/client-a': 'allow' } })); + expect(memo(A, extra).stdout).toBe('read-only\tallow'); + fs.writeFileSync(legacyStore, JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': 'deny', _memorable_recall: { 'github.com/acme/client-a': 'allow' } })); + expect(memo(A, extra).stdout).toBe('deny\toff'); + fs.writeFileSync(legacyStore, '{'); + expect(memo(A, extra).status).toBe(2); + }); + + test('a store path that is not a readable file fails closed, in either root', () => { + fs.mkdirSync(policyFile()); + expect(memo(A).status).toBe(2); + fs.rmdirSync(policyFile()); + const legacy = path.join(tmpHome, 'legacy'); + fs.mkdirSync(path.join(legacy, 'gbrain-repo-policy.json'), { recursive: true }); + const r = memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy }); + expect(r.status).toBe(2); + expect(r.stderr).toContain('not a readable file'); + }); + + test('review hardening: empty, non-object or multi-document stores, and invalid tier values, fail closed in either root', () => { + const legacy = path.join(tmpHome, 'legacy'); + fs.mkdirSync(legacy); + const extra = { GSTACK_TEST_LEGACY_ROOT: legacy }; + const legacyStore = path.join(legacy, 'gbrain-repo-policy.json'); + run(['set', A, 'read-only']); + for (const bad of ['', ' \n', 'null', '[]', '7', '{}{}']) { + fs.writeFileSync(legacyStore, bad); + const r = memo(A, extra); + expect([bad, r.status, r.stdout]).toEqual([bad, 2, '']); + } + fs.rmSync(legacyStore); + for (const value of [false, null, 7, 'none', 'Read-Only']) { + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': value })); + expect([value, memo(A).status]).toEqual([value, 2]); + } + fs.writeFileSync(policyFile(), JSON.stringify({ _schema_version: 2, 'github.com/acme/client-a': false })); + fs.writeFileSync(legacyStore, JSON.stringify({ 'github.com/acme/client-a': 'read-only', _memorable_recall: { 'github.com/acme/client-a': 'allow' } })); + expect(memo(A, extra).status).toBe(2); + }); + + test('review hardening: a dangling symlink or an unsearchable directory is never read as no store', () => { + fs.symlinkSync(path.join(tmpHome, 'missing.json'), policyFile()); + expect(memo(A).status).toBe(2); + fs.rmSync(policyFile()); + const legacy = path.join(tmpHome, 'legacy'); + fs.mkdirSync(legacy); + fs.symlinkSync(path.join(tmpHome, 'missing.json'), path.join(legacy, 'gbrain-repo-policy.json')); + expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy }).status).toBe(2); + fs.rmSync(path.join(legacy, 'gbrain-repo-policy.json')); + if (!canRevokeReads()) return; + fs.writeFileSync(path.join(legacy, 'gbrain-repo-policy.json'), JSON.stringify({ 'github.com/acme/client-a': 'deny' })); + fs.chmodSync(legacy, 0o600); + try { + expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: legacy }).status).toBe(2); + } finally { + fs.chmodSync(legacy, 0o700); + } + const nested = path.join(tmpHome, 'ancestor', 'nested'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'gbrain-repo-policy.json'), JSON.stringify({ 'github.com/acme/client-a': 'deny' })); + fs.chmodSync(path.join(tmpHome, 'ancestor'), 0o600); + try { + const r = memo(A, { GSTACK_TEST_LEGACY_ROOT: nested }); + expect([r.status, r.stdout]).toEqual([2, '']); + expect(r.stderr).toContain('not a searchable directory'); + } finally { + fs.chmodSync(path.join(tmpHome, 'ancestor'), 0o700); + } + const absentUnder = path.join(tmpHome, 'no', 'such', 'root'); + expect(memo(A, { GSTACK_TEST_LEGACY_ROOT: absentUnder }).stdout).toBe('unset\toff'); + }); + + test('review hardening: the lookup never writes: a schema-less store is not migrated or rewritten', () => { + const legacyShape = JSON.stringify({ 'github.com/acme/client-a': 'read-only', 'github.com/acme/old': 'allow', _memorable_recall: { 'github.com/acme/client-a': 'allow' } }); + fs.writeFileSync(policyFile(), legacyShape); + expect(memo(A).stdout).toBe('read-only\tallow'); + expect(memo('https://github.com/acme/old').stdout).toBe('read-write\toff'); + expect(fs.readFileSync(policyFile(), 'utf-8')).toBe(legacyShape); + }); + + test('the two metadata keys are never stored as remotes; the _unattributed sentinel still is', () => { + const r = run(['set', '_memorable_recall', 'read-write']); + expect(r.status).toBe(2); + expect(r.stderr).toContain('reserved key'); + expect(run(['set', '_schema_version', 'deny']).status).toBe(2); + expect(run(['set', '_unattributed', 'deny']).status).toBe(0); + expect(run(['list']).stdout).toBe('_unattributed\tdeny'); + }); +}); diff --git a/test/global-discover.test.ts b/test/global-discover.test.ts index fd0c80631..8e56a6b42 100644 --- a/test/global-discover.test.ts +++ b/test/global-discover.test.ts @@ -644,5 +644,104 @@ describe("gstack-global-discover", () => { expect(json.repos).toHaveLength(1); expect(json.repos[0].sessions.claude_code).toBe(1); }); + + // Claude Code worktrees live at /.claude/worktrees/ and are removed once + // their branch merges; the session history under ~/.claude/projects stays behind. + function removedWorktree(main: string, name = "feat-x"): string { + const wt = join(main, ".claude", "worktrees", name); + git(main, "worktree", "add", "-q", wt); + return wt; + } + + test("sessions of a removed Claude worktree count with the repository it was created from", () => { + const main = repo("work/ai-blogger-lab", "https://github.com/acme/ai-blogger-lab.git"); + const wt = removedWorktree(main); + session(main, "main-1"); + session(main, "main-2"); + session(wt, "wt-1"); + session(wt, "wt-2"); + session(wt, "wt-3"); + git(main, "worktree", "remove", "--force", wt); + expect(existsSync(wt)).toBe(false); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(5); + expect(json.repos).toHaveLength(1); + expect(json.repos[0].sessions.claude_code).toBe(5); + expect(json.repos[0].paths).toEqual([main]); + }); + + test("a session started in a subdirectory of a removed Claude worktree counts too", () => { + const main = repo("work/app", "git@github.com:acme/app.git"); + const wt = removedWorktree(main); + mkdirSync(join(wt, "packages", "web"), { recursive: true }); + session(join(wt, "packages", "web"), "sub-1"); + git(main, "worktree", "remove", "--force", wt); + const r = discover().repos.find((x) => x.remote === "https://github.com/acme/app")!; + expect(r.sessions.claude_code).toBe(1); + expect(r.paths).toEqual([main]); + }); + + test("a removed Claude worktree of a repository with no remote counts with its repository", () => { + const main = repo("work/local-only"); + const wt = removedWorktree(main); + session(main, "l-1"); + session(wt, "l-2"); + git(main, "worktree", "remove", "--force", wt); + const local = discover().repos.filter((x) => x.remote.startsWith("local:")); + expect(local).toHaveLength(1); + expect(local[0].sessions.claude_code).toBe(2); + }); + + test("a live Claude worktree keeps its own path next to its repository's", () => { + const main = repo("work/live", "https://github.com/acme/live.git"); + const wt = removedWorktree(main); + session(main, "m-1"); + session(wt, "w-1"); + const r = discover().repos.find((x) => x.remote === "https://github.com/acme/live")!; + expect(r.sessions.claude_code).toBe(2); + expect(r.paths.sort()).toEqual([main, wt].sort()); + }); + + test("a removed Claude worktree whose repository is also gone is not counted as a repository", () => { + const gone = join(home, "work", "gone-repo"); + session(join(gone, ".claude", "worktrees", "feat-x"), "g-1"); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(1); + expect(json.repos).toHaveLength(0); + }); + + test("a removed directory that is not a Claude worktree is not counted as a repository", () => { + const main = repo("work/real", "https://github.com/acme/real.git"); + session(join(main, "some", "deleted-dir"), "d-1"); + session(join(main, ".claude", "worktrees"), "d-2"); + session(main, "r-1"); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(3); + expect(json.repos).toHaveLength(1); + expect(json.repos[0].sessions.claude_code).toBe(1); + }); + + describe("parentRepoOfClaudeWorktree", () => { + let parentRepoOfClaudeWorktree: (cwd: string) => string | null; + beforeEach(async () => { + parentRepoOfClaudeWorktree = (await import("../bin/gstack-global-discover.ts")).parentRepoOfClaudeWorktree; + }); + + test("returns the repository root for a worktree path, with or without a subdirectory", () => { + expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x")).toBe("/Users/me/code/app"); + expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x/src/lib")).toBe("/Users/me/code/app"); + expect(parentRepoOfClaudeWorktree("C:\\code\\app\\.claude\\worktrees\\feat-x")).toBe("C:\\code\\app"); + }); + + test("returns null for any other path", () => { + for (const p of [ + "/Users/me/code/app", + "/Users/me/code/app/.claude/worktrees", + "/Users/me/code/app/.claude/worktrees/", + "/Users/me/code/not.claude/worktrees/x", + "/.claude/worktrees/x", + ]) expect(parentRepoOfClaudeWorktree(p)).toBeNull(); + }); + }); }); }); diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index df6e4ea5d..17d0528b8 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -348,7 +348,7 @@ describe('gstack-skill-start behavior', () => { expect(out.match(/GUARD_NOT_INSTALLED/g)).toHaveLength(1); const ids = (out.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, '')); expect(ids).toEqual(['unattended-session']); - expect(out).toContain('GSTACK_RESULT: skill= status=gate_pending run='); + expect(out).toContain('GSTACK_RESULT: skill=autoplan status=gate_pending run='); expect(out).toMatch(/never approve/i); expect(out).toMatch(/^FIRST_TASK: $/m); expect(out).not.toContain('.telemetry-prompted'); @@ -378,6 +378,46 @@ describe('gstack-skill-start behavior', () => { } }); + test('unattended block promises GSTACK_RESULT only to skills that write it; the rest name their record (#3281)', () => { + const block = (skill: string) => { + const out = runStart(['--skill', skill], { GSTACK_SESSION_KIND: 'unattended' }); + const m = out.match(/^GSTACK_INSTRUCTION_BEGIN: unattended-session .*\n([\s\S]*?)\nGSTACK_INSTRUCTION_END$/m); + expect(m).not.toBeNull(); + return m![1]; + }; + const blocks = Object.fromEntries(['autoplan', 'eval-plan', 'ship', 'review', 'qa', 'investigate'].map((s) => [s, block(s)])); + for (const text of Object.values(blocks)) { + expect(text).not.toContain('skill='); + expect(text).toMatch(/never choose them/); + expect(text).toMatch(/never approve a gate/); + expect(text).toMatch(/never publish, push, sync or send data off this machine/); + } + const autoplan = blocks.autoplan; + expect(autoplan).toContain('`GSTACK_RESULT: skill=autoplan status=gate_pending run=`'); + expect(autoplan).toContain('gate list (gstack-gate render)'); + expect(autoplan).toMatch(/decisions\.jsonl with kind "auto"/); + expect(autoplan).toContain('gate.json'); + const evalPlan = blocks['eval-plan']; + expect(evalPlan).toContain('`GSTACK_RESULT: skill=eval-plan status=gate_pending run=`'); + expect(evalPlan).toContain('No helper writes a gate.json'); + expect(evalPlan).not.toContain('gate list'); + const records: Record = { + ship: 'gstack-ship-receipt read --pr --json', + review: 'gstack-review-read', + qa: 'qa-report-*.md', + investigate: "this skill's own record (docs/unattended.md)", + }; + for (const [skill, record] of Object.entries(records)) { + const text = blocks[skill]; + expect(text).toContain('writes no run directory and prints no GSTACK_RESULT line'); + expect(text).toContain(record); + expect(text).not.toContain('GSTACK_RESULT: skill='); + expect(text).not.toContain('gate list'); + expect(text).not.toContain('decisions.jsonl'); + expect(text).not.toContain('run.json'); + } + }, 60_000); + test('unattended session makes no egress call even with telemetry and artifacts sync enabled and a queue pending (plan B1)', () => { // Shadow bin: every helper is the real one except the egress sinks, which // record their invocation. Bins resolve siblings through dirname "$0", so a diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index 74fcbdc31..b90077dac 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -9,8 +9,11 @@ import { describe, test, expect, afterAll } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { execFileSync } from 'child_process'; +import { execFileSync, spawnSync } from 'child_process'; import { classifyState, VERSION_RE } from '../bin/gstack-version-bump'; +import { checkChangelog, normalizeChangelog, parseChangelog } from '../lib/changelog-check'; +import { EXIT } from '../lib/headless-artifacts'; +import { cleanup, makeFixtureRepo, makePr, readFile, restamp, runBin } from './helpers/restamp-fixture'; const BIN = path.join(import.meta.dir, '..', 'bin', 'gstack-version-bump'); @@ -962,3 +965,115 @@ describe('G1: version source outcomes (absent / valid / ambiguous / broken)', () }); } }); + +describe('write --changelog re-heads [Unreleased] (opt-in, restamp-compatible)', () => { + const dirs: string[] = []; + afterAll(() => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); }); + const RELEASED = '## [1.0.0.0] - 2026-10-01\n\n- first release\n'; + const ENTRY = '## [Unreleased]\n\n### Fixed\n- the branch change\n'; + const repo = (changelog: string | null) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-changelog-')); + dirs.push(dir); + fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n'); + if (changelog !== null) fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog); + return dir; + }; + const run = (dir: string, args: string[]) => { + const r = spawnSync('bun', [BIN, 'write', ...args], { cwd: dir, encoding: 'utf8', timeout: 30_000 }); + return { code: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; + }; + const today = () => new Date().toISOString().slice(0, 10); + const readCl = (dir: string) => fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + + test('without the flag write leaves [Unreleased] and the output shape unchanged', () => { + const before = `# Changelog\n\n${ENTRY}\n${RELEASED}`; + const dir = repo(before); + const r = run(dir, ['--version', '1.0.1.0']); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout)).not.toHaveProperty('changelog'); + expect(readCl(dir)).toBe(before); + }); + + test('re-heads [Unreleased] to the written version, dated today (UTC), keeping every other byte', () => { + const before = `# Changelog\n\n${ENTRY}\n${RELEASED}`; + const dir = repo(before); + const d0 = today(); + const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']); + const d1 = today(); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: true, detail: 'Unreleased -> 1.0.1.0' }); + const after = readCl(dir); + expect([d0, d1].map(d => `# Changelog\n\n## [1.0.1.0] - ${d}\n\n### Fixed\n- the branch change\n\n${RELEASED}`)).toContain(after); + expect(normalizeChangelog(after)).toBe(normalizeChangelog(before)); + expect(checkChangelog(after, '1.0.1.0')).toEqual([]); + }); + + test('an [Unreleased] entry below the predecessor (after a main merge) moves to the top', () => { + const dir = repo(`# Changelog\n\n${RELEASED}\n${ENTRY}`); + expect(run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0); + const sections = parseChangelog(readCl(dir)).sections; + expect(sections.map(s => s.version)).toEqual(['1.0.1.0', '1.0.0.0']); + expect(sections[1]!.lines.join('\n').trimEnd()).toBe(RELEASED.trimEnd()); + }); + + test('a rerun is a no-op, and a CHANGELOG with no [Unreleased] never has a released heading renamed', () => { + const dir = repo(`# Changelog\n\n${ENTRY}\n${RELEASED}`); + expect(run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0); + const once = readCl(dir); + const again = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']); + expect(JSON.parse(again.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: false, detail: 'no [Unreleased] section' }); + expect(readCl(dir)).toBe(once); + const released = repo(`# Changelog\n\n${RELEASED}`); + expect(run(released, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']).code).toBe(0); + expect(readCl(released)).toBe(`# Changelog\n\n${RELEASED}`); + }); + + test('an absent CHANGELOG is a no-op that creates nothing', () => { + const dir = repo(null); + const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout).changelog).toEqual({ path: 'CHANGELOG.md', moved: false, detail: 'CHANGELOG.md absent' }); + expect(fs.existsSync(path.join(dir, 'CHANGELOG.md'))).toBe(false); + }); + + test('a heading the move would duplicate refuses with exit 2 before VERSION is written', () => { + const before = `# Changelog\n\n${ENTRY}\n## [1.0.1.0] - 2026-10-02\n\n- taken\n\n${RELEASED}`; + const dir = repo(before); + const r = run(dir, ['--version', '1.0.1.0', '--changelog', 'CHANGELOG.md']); + expect(r.code).toBe(2); + expect(r.stderr).toContain('1.0.1.0 already headed'); + expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf8')).toBe('1.0.0.0\n'); + expect(readCl(dir)).toBe(before); + }); + + test('a missing value or an escaping path refuses with exit 2 and writes nothing', () => { + const dir = repo(`# Changelog\n\n${ENTRY}`); + for (const args of [['--changelog'], ['--changelog', '--regen-digest'], ['--changelog', '../CHANGELOG.md']]) { + const r = run(dir, ['--version', '1.0.1.0', ...args]); + expect(r.code).toBe(2); + expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf8')).toBe('1.0.0.0\n'); + } + }); + + test('gstack-restamp composes: an already re-headed entry is kept, and a later stamp moves it as the branch stamp', () => { + const fx = makeFixtureRepo({ prefix: 'vbump-restamp-' }); + try { + makePr(fx, 'pr-a'); + const w = runBin(fx, 'gstack-version-bump', ['write', '--version', '1.2.4.0', '--changelog', 'CHANGELOG.md']); + expect(w.status).toBe(0); + fx.git('commit', '-qam', 'bump 1.2.4.0'); + const headed = readFile(fx, 'CHANGELOG.md'); + const same = restamp(fx, ['--version', '1.2.4.0']); + expect(same.status).toBe(EXIT.ok); + expect(same.out).toContain('already headed 1.2.4.0'); + expect(readFile(fx, 'CHANGELOG.md')).toBe(headed); + const next = restamp(fx, ['--version', '1.2.5.0']); + expect(next.status).toBe(EXIT.ok); + expect(next.out).toContain('1.2.4.0 -> 1.2.5.0'); + expect(parseChangelog(readFile(fx, 'CHANGELOG.md')).sections.map(s => s.version)).toEqual(['1.2.5.0', '1.2.3.0', '1.2.2.0']); + } finally { + cleanup(fx); + } + }); +}); + diff --git a/test/helpers/autoplan-preconfigured-fixture.ts b/test/helpers/autoplan-preconfigured-fixture.ts index fcb79d784..a57b10059 100644 --- a/test/helpers/autoplan-preconfigured-fixture.ts +++ b/test/helpers/autoplan-preconfigured-fixture.ts @@ -12,7 +12,7 @@ Key routing rules: - Architecture → invoke /plan-eng-review - Design system/plan review → invoke /design-consultation or /plan-design-review - Full review pipeline → invoke /autoplan -- Bugs/errors → invoke /investigate +- Bugs/errors → invoke /investigate, unless one read-only check confirms the cause and a narrow fix (then fix it and run the focused test) - QA/testing site behavior → invoke /qa or /qa-only - Code review/diff check → invoke /review - Visual polish → invoke /design-review diff --git a/test/helpers/carve-guards.ts b/test/helpers/carve-guards.ts index 4c250a708..ff3ff4203 100644 --- a/test/helpers/carve-guards.ts +++ b/test/helpers/carve-guards.ts @@ -539,7 +539,7 @@ do not launch the downstream skill or open a browser.`, }, behavioral: 'prompt', maxSkeletonBytes: 74_500, // + Aside browser contract for Step 7 canary ({{ASIDE_SETUP}}); measured 73_523 - maxSizeRatio: 1.1120, // + v1.81 Aside contract + gstack-browser fallback block; measured 1.077. + both untrusted-content marker formats in the browser fallback (W7f) + the readiness gate's project test command and live eval-store paths (s06 H6/H7); measured 1.1076 (2026-10-02). Re-measured 1.0943 (2026-10-03); cap = measured + 0.005 headroom. + Oct 7 wave B6: Aside rules 1, 4, 6 and 7 (offer a target-origin tab before openTab, a second sign-in wall means a tab- or URL-bound session, no early-return hint, fast [ok without the sentinel is an abort); measured 115,226 / 104,578 = 1.1018 (2026-10-07). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + maxSizeRatio: 1.125, // + #3318 Step 3.4 reads the ship policy and prints one VERSION_CHECK verdict (owner-approved raise, Garry 2026-10-24: "do it"); measured 1.1201 (2026-10-24). Before: 1.1120. + v1.81 Aside contract + gstack-browser fallback block; measured 1.077. + both untrusted-content marker formats in the browser fallback (W7f) + the readiness gate's project test command and live eval-store paths (s06 H6/H7); measured 1.1076 (2026-10-02). Re-measured 1.0943 (2026-10-03); cap = measured + 0.005 headroom. + Oct 7 wave B6: Aside rules 1, 4, 6 and 7 (offer a target-origin tab before openTab, a second sign-in wall means a tab- or URL-bound session, no early-return hint, fast [ok without the sentinel is an abort); measured 115,226 / 104,578 = 1.1018 (2026-10-07). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). minUnionBytes: 91_000, // Phase 4 wave 1; estimated union ~94.9KB mustContain: ['readiness', 'merge', 'canary', 'revert', 'staging'], }, diff --git a/test/helpers/cso-runtime-catalog.ts b/test/helpers/cso-runtime-catalog.ts index 1e55877d1..10c5a414d 100644 --- a/test/helpers/cso-runtime-catalog.ts +++ b/test/helpers/cso-runtime-catalog.ts @@ -1,6 +1,6 @@ import type { CsoStack } from '../../lib/cso/preparation'; -type RuntimeStack = Exclude; +type RuntimeStack = Exclude; import { canonical, sha256 } from '../../lib/cso/contracts'; import { ISOLATION_POLICY_HASH } from '../../lib/cso/docker'; import { diff --git a/test/helpers/e2e-hosts.ts b/test/helpers/e2e-hosts.ts index 622c3756d..90ca0344f 100644 --- a/test/helpers/e2e-hosts.ts +++ b/test/helpers/e2e-hosts.ts @@ -98,7 +98,8 @@ const CLAUDE_ONLY: Readonly> = { 'shared-libs-review-prior-coverage', 'shared-libs-review-revalidation', 'ship-local-hook-preservation', 'ship-managed-hook-refresh', 'ship-skipped-queued-finding', 'ship-unmanaged-hook-consent'], subagents: ['docsync-spawned', 'review-army-perf-n-plus-one'], - 'skill-tool': ['skillify-approval-reject', 'skillify-happy-path', 'skillify-provenance-refusal'], + 'skill-tool': ['router-failure-contradicted-cause', 'router-failure-triage-resolves', 'router-failure-triage-unclear', + 'router-failure-verified-cause', 'skillify-approval-reject', 'skillify-happy-path', 'skillify-provenance-refusal'], hermetic: ['hermetic-canary', 'hermetic-sentinel'], 'self-host': ['safety-codex-consult-embed'], rework: ['qa-functional-cli-fix', 'qa-functional-cli-report', 'qa-functional-webhook-fix', 'qa-functional-webhook-report', diff --git a/test/helpers/eng-cache-writer-decision.ts b/test/helpers/eng-cache-writer-decision.ts index a39ecbc9d..50686b8d5 100644 --- a/test/helpers/eng-cache-writer-decision.ts +++ b/test/helpers/eng-cache-writer-decision.ts @@ -6,7 +6,7 @@ export function engCacheWriterDecision(q: NativePlanQuestion): boolean { const lines = q.question.split('\n'); // Severity, confidence and source citations annotate an owned issue; they // never replace its current defect, assessment or opposed choices. - const annotated = /^D([1-9]\d*) [—–:-] Issue ([1-9]\d*) \[P[0-3]\] \(confidence (?:10|[1-9])\/10\) [A-Za-z][\w./-]*:[1-9]\d*(?:-[1-9]\d*)?(?: \+ :[1-9]\d*(?:-[1-9]\d*)?)? [—–:-] ([A-Za-z_$][\w$]*) and ([A-Za-z_$][\w$]*) both mutate one module-level ([A-Za-z_$][\w$]*) that does not serialize mutations\. How should the shared cache be wired\?$/.exec(lines[0] ?? ''); + const annotated = /^D([1-9]\d*) [—–:-] Issue ([1-9]\d*) \[(?:P[0-3]|CRITICAL|INFORMATIONAL)\] \(confidence (?:10|[1-9])\/10\) [A-Za-z][\w./-]*:[1-9]\d*(?:-[1-9]\d*)?(?: \+ :[1-9]\d*(?:-[1-9]\d*)?)? [—–:-] ([A-Za-z_$][\w$]*) and ([A-Za-z_$][\w$]*) both mutate one module-level ([A-Za-z_$][\w$]*) that does not serialize mutations\. How should the shared cache be wired\?$/.exec(lines[0] ?? ''); const architecture = /^D([1-9]\d*) [—–:-] Architecture issue ([1-9]\d*): global mutable ([A-Za-z_$][\w$]*) shared by two services with unserialized mutations\.?$/.exec(lines[0] ?? ''); const declared = annotated || architecture; const ordinal = declared?.[1] ?? /^D([1-9]\d*) [—–:-] Who is allowed to write to the auth cache\?$/.exec(lines[0] ?? '')?.[1]; diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 4fcd60246..d8b7c8440 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -138,6 +138,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/investigate-owned-question-39195396438.json': ['test/workflow-boundaries-fixture.test.ts'], 'test/fixtures/judge-calibration/**': ['scripts/judge-calibration.ts', 'test/judge-calibration.test.ts'], 'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'], + 'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'], 'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'], 'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'], 'test/fixtures/multi-agent-wave/**': ['test/headless-artifacts.test.ts', 'test/helpers/autoplan-run-fixture.ts', 'test/plan-reality.test.ts'], diff --git a/test/helpers/pty/boundaries.ts b/test/helpers/pty/boundaries.ts index 8c87100c9..c5520118d 100644 --- a/test/helpers/pty/boundaries.ts +++ b/test/helpers/pty/boundaries.ts @@ -234,7 +234,7 @@ function engNumberedFindingAUQ(fp: AskUserQuestionFingerprint): boolean { // A declarative severity-labelled issue can own the same cache repair as // an interrogative title. Require its native decision, current assessment, // and opposed implementation options; metadata alone never opens review. - const declaredCache = /^(?:Issue|Finding) ([1-9]\d*) \[P[0-3]\] \(confidence (?:10|[1-9])\/10\) [A-Za-z][\w./-]*:[1-9]\d*(?:-[1-9]\d*)?: ([A-Za-z_$][\w$]*) and ([A-Za-z_$][\w$]*) share a global mutable ([A-Za-z_$][\w$]*) via module-level export, and both mutate it\.$/.exec(title); + const declaredCache = /^(?:Issue|Finding) ([1-9]\d*) \[(?:P[0-3]|CRITICAL|INFORMATIONAL)\] \(confidence (?:10|[1-9])\/10\) [A-Za-z][\w./-]*:[1-9]\d*(?:-[1-9]\d*)?: ([A-Za-z_$][\w$]*) and ([A-Za-z_$][\w$]*) share a global mutable ([A-Za-z_$][\w$]*) via module-level export, and both mutate it\.$/.exec(title); if (declaredCache) return engDeclaredCacheFinding(q, declaredCache); if (engCacheWriterDecision(q)) return true; const libraryHooks = engLibraryHooksFinding(q, title); diff --git a/test/helpers/safety-rules.ts b/test/helpers/safety-rules.ts index 08a3e1b44..4972d0090 100644 --- a/test/helpers/safety-rules.ts +++ b/test/helpers/safety-rules.ts @@ -68,8 +68,8 @@ export const SAFETY_RULES: readonly SafetyRule[] = [ evalId: 'safety-ios-demo-ui-only', source: 'ios-qa/SKILL.md.tmpl', section: '## Demo mode', - match: /demo mode overrides/i, - sha256: '8d42208c888d76af49ebe6e4d58e85537c979f53ecb32fff7e85e95e6859182a', + match: /drive every action through visible UI/i, + sha256: '0ba3230bff9de32f03719daec35c6cef762e15c550264fcffd342dde52291e21', }, ]; diff --git a/test/helpers/ship-queue-train-fixture.ts b/test/helpers/ship-queue-train-fixture.ts index 7c6928771..9769d9419 100644 --- a/test/helpers/ship-queue-train-fixture.ts +++ b/test/helpers/ship-queue-train-fixture.ts @@ -9,6 +9,7 @@ * artifact downloads and check-run reads. Every call is logged, so tests can * prove what was (and was not) dispatched. */ +import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -58,7 +59,7 @@ export function repoKey(fx: TrainFixture): string { } /** A same-repo PR: a branch from origin/main touching `file`, published as refs/pull//head (and /merge), known to the gh shim. */ -export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean; cross?: boolean; from?: string } = {}): string { +export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean | ((text: string) => string); cross?: boolean; from?: string } = {}): string { const { repo } = fx; repo.git('checkout', '-q', opts.from ?? 'origin/main', '-B', `pr-${pr}`); const file = opts.file ?? `pr-${pr}.txt`; @@ -66,7 +67,7 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text fs.writeFileSync(path.join(repo.work, file), opts.text ?? `change from #${pr}\n`); if (opts.changelog !== false) { const cl = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8'); - fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl.replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- #${pr} change\n`)); + fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), typeof opts.changelog === 'function' ? opts.changelog(cl) : cl.replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- #${pr} change\n`)); } repo.git('add', '-A'); repo.git('commit', '-q', '-m', `feat #${pr}`); @@ -76,9 +77,19 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text return head; } +/** The repo key of a GitHub-form origin URL: sha256 of the URL with .git and trailing slashes dropped. */ +export const urlKey = (url: string) => createHash('sha256').update(url.replace(/\.git$/, '').replace(/\/+$/, '')).digest('hex').slice(0, 16); + +/** Point the fixture clone's origin at a GitHub-form URL that git rewrites to the local bare origin (url.insteadOf). */ +export function githubOrigin(fx: TrainFixture, url: string, cwd = fx.repo.work): void { + const git = (...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }); + git('config', `url.${fx.repo.origin}.insteadOf`, url); + git('remote', 'set-url', 'origin', url); +} + /** A READY receipt as `ready --screen|--no-screen` writes it (the lane side is tested separately). */ -export function readyReceipt(fx: TrainFixture, pr: string, head: string, opts: { screen?: boolean; at?: string } = {}): void { - const key = repoKey(fx); +export function readyReceipt(fx: TrainFixture, pr: string, head: string, opts: { screen?: boolean; at?: string; key?: string } = {}): void { + const key = opts.key ?? repoKey(fx); const file = path.join(fx.state, 'ship-queue', 'ready', key, `${pr}-${head}.json`); fs.mkdirSync(path.dirname(file), { recursive: true }); const at = opts.at ?? '2026-10-18T08:00:00.000Z'; diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index e48a40bdd..1e9b5f62d 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -1125,6 +1125,10 @@ export const E2E_TOUCHFILES: Record = { "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" ], + 'router-failure-verified-cause': ['SKILL.md.tmpl', 'SKILL.md', 'scripts/resolvers/preamble/generate-test-failure-triage.ts', 'investigate/SKILL.md.tmpl', 'lib/bun-isolation.ts', 'test/skill-e2e-failure-routing.test.ts'], + 'router-failure-contradicted-cause': ['SKILL.md.tmpl', 'SKILL.md', 'scripts/resolvers/preamble/generate-test-failure-triage.ts', 'investigate/SKILL.md.tmpl', 'lib/bun-isolation.ts', 'test/skill-e2e-failure-routing.test.ts'], + 'router-failure-triage-resolves': ['SKILL.md.tmpl', 'SKILL.md', 'scripts/resolvers/preamble/generate-test-failure-triage.ts', 'investigate/SKILL.md.tmpl', 'lib/bun-isolation.ts', 'test/skill-e2e-failure-routing.test.ts'], + 'router-failure-triage-unclear': ['SKILL.md.tmpl', 'SKILL.md', 'scripts/resolvers/preamble/generate-test-failure-triage.ts', 'investigate/SKILL.md.tmpl', 'lib/bun-isolation.ts', 'test/skill-e2e-failure-routing.test.ts'], }; /** @@ -1525,6 +1529,10 @@ export const E2E_TIERS: Record = { 'overlay-harness-claude-dedicated-tools-vs-bash-sonnet': 'periodic', 'overlay-harness-claude-dedicated-tools-vs-bash-opus-5-5': 'periodic', 'journey-negatives': 'periodic', + 'router-failure-verified-cause': 'gate', + 'router-failure-contradicted-cause': 'gate', + 'router-failure-triage-resolves': 'gate', + 'router-failure-triage-unclear': 'gate', }; /** @@ -1890,6 +1898,10 @@ export const E2E_KINDS: Record = { 'overlay-harness-claude-dedicated-tools-vs-bash-sonnet': 'rule', 'overlay-harness-claude-dedicated-tools-vs-bash-opus-5-5': 'rule', 'journey-negatives': 'rule', + 'router-failure-verified-cause': 'rule', + 'router-failure-contradicted-cause': 'rule', + 'router-failure-triage-resolves': 'rule', + 'router-failure-triage-unclear': 'rule', 'review/SKILL.md workflow': 'judge', 'setup-browser-cookies/SKILL.md workflow': 'judge', 'browse/SKILL.md reference': 'judge', diff --git a/test/land-and-deploy-version-check.test.ts b/test/land-and-deploy-version-check.test.ts new file mode 100644 index 000000000..687bbc686 --- /dev/null +++ b/test/land-and-deploy-version-check.test.ts @@ -0,0 +1,193 @@ +/** + * /land-and-deploy Step 3.4 VERSION check (#3318). The old step compared the + * branch VERSION with the next free patch slot, so it stopped stamp-at-merge + * PRs that share a tentative stamp, a claim-at-open PR whose sibling claimed a + * higher slot, and every MICRO bump. These tests run the template's own block + * against a real git fixture and the real gstack-ship-policy, with a stubbed + * gstack-next-version reply, and pin each verdict, including the real stops. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const ROOT = join(import.meta.dir, '..'); +const STUB = `process.stdout.write(process.env.FAKE_QUEUE_JSON ?? ''); +process.exit(Number(process.env.FAKE_QUEUE_CODE ?? 0)); +`; + +function step34(): string { + const tmpl = readFileSync(join(ROOT, 'land-and-deploy', 'SKILL.md.tmpl'), 'utf8'); + const step = tmpl.slice(tmpl.indexOf('## Step 3.4:'), tmpl.indexOf('{{SECTION:readiness-gate}}')); + const blocks = [...step.matchAll(/```bash\n([\s\S]*?)\n```/g)].map(m => m[1]); + expect(blocks).toHaveLength(1); + return blocks[0]; +} + +let root: string; +let dir: string; +let repo: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'lad-version-')); + writeFileSync(join(root, 'next-version.ts'), STUB); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function git(...args: string[]): string { + const r = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], { cwd: repo, encoding: 'utf8', timeout: 15_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); +} + +const changelog = (...versions: string[]) => `# Changelog\n\n${versions.map(v => `## [${v}] - 2026-10-24\n\n- entry\n`).join('\n')}`; + +interface Tree { version?: string; changelog?: string; policy?: string } +function commit(tree: Tree, message: string): string { + for (const [file, body] of [['VERSION', tree.version], ['CHANGELOG.md', tree.changelog], ['.gstack/ship-policy.json', tree.policy]] as const) { + if (body === undefined) continue; + mkdirSync(join(repo, file, '..'), { recursive: true }); + writeFileSync(join(repo, file), body.endsWith('\n') ? body : `${body}\n`); + } + git('add', '-A'); + git('commit', '-q', '--allow-empty', '-m', message); + return git('rev-parse', 'HEAD'); +} + +interface Case { + mode?: 'stamp-at-merge' | 'claim-at-open' | 'none' | 'invalid'; + base: Tree; + head: Tree; + queue?: object | string; + queueCode?: number; + shell?: 'bash' | 'zsh'; +} +function verdict(c: Case): string { + dir = mkdtempSync(join(root, 'case-')); + repo = join(dir, 'repo'); + mkdirSync(repo); + git('init', '-q', '-b', 'main'); + const policy = c.mode === 'none' || c.mode === undefined ? undefined + : c.mode === 'invalid' ? '{"queue_mode": ' + : JSON.stringify({ queue_mode: c.mode, bump: 'patch' }); + const baseSha = commit({ ...c.base, policy }, 'base'); + spawnSync('git', ['init', '-q', '--bare', join(dir, 'origin.git')], { timeout: 15_000 }); + git('remote', 'add', 'origin', join(dir, 'origin.git')); + git('push', '-q', 'origin', 'main'); + git('checkout', '-q', '-b', 'feature'); + const head = commit(c.head, 'head'); + const queue = c.queue ?? { offline: false, claimed: [], complete: true }; + const block = step34() + .replaceAll('~/.claude/skills/gstack/bin/gstack-ship-policy', join(ROOT, 'bin', 'gstack-ship-policy')) + .replaceAll('~/.claude/skills/gstack/bin/gstack-next-version', join(root, 'next-version.ts')); + const r = spawnSync(c.shell ?? 'bash', ['-c', block], { + cwd: repo, + encoding: 'utf8', + timeout: 60_000, + env: { + ...process.env, + PR_HEAD: head, BASE_SHA: baseSha, BASE_BRANCH: 'main', PR_NUMBER: '7', + FAKE_QUEUE_JSON: typeof queue === 'string' ? queue : JSON.stringify(queue), + FAKE_QUEUE_CODE: String(c.queueCode ?? 0), + }, + }); + const line = r.stdout.split('\n').find(l => l.startsWith('VERSION_CHECK: ')); + if (!line) throw new Error(`no VERSION_CHECK line (exit ${r.status}):\n${r.stdout}\n${r.stderr}`); + return line; +} + +const claim = (pr: number, version: string) => ({ pr, branch: `b${pr}`, version }); +const BASE: Tree = { version: '1.91.186.0', changelog: changelog('1.91.186.0') }; +const stamped = (version: string, below = '1.91.186.0'): Tree => ({ version, changelog: changelog(version, below) }); + +describe('stamp-at-merge: verify the exact head stamp, ignore claims', () => { + test('a sibling sharing the tentative stamp no longer stops the PR', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })) + .toBe('VERSION_CHECK: ok (stamp-at-merge: 1.91.187.0, base 1.91.186.0)'); + }); + + test('a numeric one-bump stamp passes (9 -> 10)', () => { + expect(verdict({ mode: 'stamp-at-merge', base: { version: '1.91.9.0', changelog: changelog('1.91.9.0') }, head: { version: '1.91.10.0', changelog: changelog('1.91.10.0', '1.91.9.0') } })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('a MICRO stamp is one bump and passes', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.186.1') })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('claims offline do not matter under stamp-at-merge', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: '', queueCode: 1 })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('stops when the base already reached the stamp (a sibling landed first)', () => { + expect(verdict({ mode: 'stamp-at-merge', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })) + .toBe('VERSION_CHECK: STOP stamp stale (stamp-at-merge: 1.91.187.0, base 1.91.187.0)'); + }); + + test('stops when the base moved under a stamp that is still one bump above it', () => { + expect(verdict({ mode: 'stamp-at-merge', base: stamped('1.91.186.1'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + }); + + test('stops on an unstamped [Unreleased] entry and on a stamp more than one bump up', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: { version: '1.91.187.0', changelog: changelog('Unreleased', '1.91.186.0') } })).toStartWith('VERSION_CHECK: STOP stamp stale'); + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.188.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + }); +}); + +describe('claim-at-open: stop only on a real collision', () => { + test('versions compare numerically, and 3-digit semver works', () => { + expect(verdict({ mode: 'claim-at-open', base: { version: '1.91.9.0' }, head: { version: '1.91.10.0' } })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + expect(verdict({ mode: 'claim-at-open', base: { version: '1.91.10.0' }, head: { version: '1.91.9.0' } })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + expect(verdict({ mode: 'claim-at-open', base: { version: '2.14.9' }, head: { version: '2.14.10' } })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + expect(verdict({ mode: 'claim-at-open', base: { version: '2.14.9' }, head: { version: '2.14.9.1' } })).toStartWith('VERSION_CHECK: unavailable: bad version'); + }); + + test('a sibling holding a higher claim no longer stops the PR', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.188.0')], complete: true } })) + .toBe('VERSION_CHECK: ok (claim-at-open: 1.91.187.0, base 1.91.186.0)'); + }); + + test('a MICRO bump passes', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.186.1') })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + }); + + test('stops when another open PR holds the same VERSION', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0'), claim(9, '1.91.188.0')], complete: true } })) + .toBe('VERSION_CHECK: STOP also claimed by #8 (claim-at-open: 1.91.187.0, base 1.91.186.0)'); + }); + + test('stops when the base is at or above the branch VERSION', () => { + expect(verdict({ mode: 'claim-at-open', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + expect(verdict({ mode: 'claim-at-open', base: stamped('1.91.188.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + }); + + test('a repo with no policy file gets the claim-at-open default', () => { + expect(verdict({ mode: 'none', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); + + test('offline claims and unreadable claims are unavailable, but a known collision still stops', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: true } })).toStartWith('VERSION_CHECK: unavailable: claims unread'); + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [], unknown_claims: [9], complete: false } })).toStartWith('VERSION_CHECK: unavailable: claims unread'); + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], unknown_claims: [9], complete: false } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); +}); + +describe('either mode', () => { + test.skipIf(!Bun.which('zsh'))('the block gives the same verdicts under zsh', () => { + expect(verdict({ shell: 'zsh', mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + expect(verdict({ shell: 'zsh', mode: 'stamp-at-merge', base: stamped('1.91.186.1'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + expect(verdict({ shell: 'zsh', mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); + + test('no configured version source is not applicable', () => { + expect(verdict({ mode: 'stamp-at-merge', base: {}, head: {}, queue: { no_version: true, version: null } })).toStartWith('VERSION_CHECK: NO_VERSION'); + }); + + test('a VERSION missing on one side is unavailable, not green', () => { + expect(verdict({ mode: 'claim-at-open', base: { changelog: changelog('1.91.186.0') }, head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: unavailable: bad version'); + }); + + test('an invalid policy is unavailable, but a base at or above the branch still stops', () => { + expect(verdict({ mode: 'invalid', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: unavailable: no policy'); + expect(verdict({ mode: 'invalid', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + }); +}); diff --git a/test/memorable-user-prompt-hook.test.ts b/test/memorable-user-prompt-hook.test.ts index f307b5c1c..ef2506b99 100644 --- a/test/memorable-user-prompt-hook.test.ts +++ b/test/memorable-user-prompt-hook.test.ts @@ -243,6 +243,121 @@ describe('gate on: the mediated hand-off', () => { } }); + test('#3359 per-remote opt-in: lifts read-only for that remote only; default, other remotes, deny and a corrupt store stay blocked', () => { + gateOn(); + const policy = (...args: string[]) => spawnSync('bash', [POLICY, ...args], { env, encoding: 'utf8', timeout: 20_000 }); + const store = path.join(home, '.gstack', 'gbrain-repo-policy.json'); + const repos: Record = {}; + try { + for (const [name, url] of [['a', 'https://github.com/example/client-a.git'], ['b', 'https://github.com/example/client-b.git']]) { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), `gstack-memo-optin-${name}-`)); + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['remote', 'add', 'origin', url], { cwd: repo, timeout: 10_000 }); + isolateGitRemote(repo, url); + expect(policy('set', url, 'read-only').status).toBe(0); + repos[name] = repo; + } + const hook = (name: string) => { + fs.rmSync(path.join(home, 'calls.log'), { force: true }); + return runHook(JSON.stringify({ prompt: 'hello', cwd: repos[name] }), {}, repos[name]); + }; + // default: read-only stays blocked + expect(hook('a').stdout).toBe(''); + expect(calls()).toBe(''); + expect(errLog()).toContain('memorable set allow'); + // opt-in on client-a: recall runs there, and does not leak to client-b + expect(policy('memorable', 'set', 'https://github.com/example/client-a.git', 'allow').status).toBe(0); + expect(hook('a').stdout).toContain('remembered'); + expect(calls()).toBe('hook user-prompt\n'); + expect(hook('b').stdout).toBe(''); + expect(calls()).toBe(''); + // deny beats an opt-in, even one written into the store by hand + expect(policy('set', 'https://github.com/example/client-a.git', 'deny').status).toBe(0); + const s = JSON.parse(fs.readFileSync(store, 'utf8')); + fs.writeFileSync(store, JSON.stringify({ ...s, _memorable_recall: { 'github.com/example/client-a': 'allow' } })); + expect(hook('a').stdout).toBe(''); + expect(calls()).toBe(''); + // a corrupt store fails closed and is left in place (never quarantined into "no policy") + fs.writeFileSync(store, '{"_schema_version":2,"github.com/example/client-b":"read-only",'); + expect(hook('b')).toEqual({ status: 0, stdout: '', stderr: '' }); + expect(calls()).toBe(''); + expect(errLog()).toContain('trust policy lookup failed'); + expect(fs.readFileSync(store, 'utf8')).toContain('"read-only",'); + expect(hook('b').stdout).toBe(''); + expect(calls()).toBe(''); + } finally { + for (const repo of Object.values(repos)) fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('#3359 review: a url.insteadOf rewrite to another host does not dodge a deny, and a dangling-symlink store fails closed', () => { + gateOn(); + const url = 'https://fixture.invalid/org/denied.git'; + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-rewrite-')); + try { + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['remote', 'add', 'origin', url], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['config', '--local', 'url.https://mirror.invalid/.insteadOf', 'https://fixture.invalid/'], { cwd: repo, timeout: 10_000 }); + expect(spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: repo, encoding: 'utf8', timeout: 10_000 }).stdout.trim()).toBe('https://mirror.invalid/org/denied.git'); + expect(spawnSync('bash', [POLICY, 'set', url, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0); + const prompt = JSON.stringify({ prompt: 'hello', cwd: repo }); + expect(runHook(prompt, {}, repo).stdout).toBe(''); + expect(calls()).toBe(''); + const store = path.join(home, '.gstack', 'gbrain-repo-policy.json'); + fs.rmSync(store); + fs.symlinkSync(path.join(home, 'missing.json'), store); + expect(runHook(prompt, {}, repo)).toEqual({ status: 0, stdout: '', stderr: '' }); + expect(calls()).toBe(''); + expect(errLog()).toContain('trust policy lookup failed'); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('#3359 review: a second configured origin URL cannot dodge the first URL\'s deny, in either order', () => { + gateOn(); + const denied = 'https://fixture.invalid/org/denied.git'; + const other = 'https://fixture.invalid/org/unlisted.git'; + expect(spawnSync('bash', [POLICY, 'set', denied, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0); + for (const order of [[denied, other], [other, denied]]) { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-multi-')); + try { + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['remote', 'add', 'origin', order[0]], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['config', '--local', '--add', 'remote.origin.url', order[1]], { cwd: repo, timeout: 10_000 }); + for (const u of order) spawnSync('git', ['config', '--local', `url.${u}.insteadOf`, u], { cwd: repo, timeout: 10_000 }); + fs.rmSync(path.join(home, 'calls.log'), { force: true }); + expect(runHook(JSON.stringify({ prompt: 'hello', cwd: repo }), {}, repo).stdout).toBe(''); + expect(calls()).toBe(''); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + } + }); + + test('#3359 review: an origin URL with a newline or edge whitespace (valid local paths) is looked up verbatim, so its deny holds', () => { + gateOn(); + for (const name of ['remote\nname.git', 'remote.git ', ' remote.git']) { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-nl-')); + try { + const remote = path.join(parent, name); + fs.mkdirSync(remote); + expect(spawnSync('git', ['init', '-q', '--bare', remote], { timeout: 10_000 }).status).toBe(0); + const repo = path.join(parent, 'work'); + fs.mkdirSync(repo); + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 10_000 }); + spawnSync('git', ['remote', 'add', 'origin', remote], { cwd: repo, timeout: 10_000 }); + expect(spawnSync('bash', [POLICY, 'set', remote, 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0); + expect(spawnSync('bash', [POLICY, 'memorable', 'get', remote], { env, encoding: 'utf8', timeout: 20_000 }).stdout).toBe('deny\toff\n'); + fs.rmSync(path.join(home, 'calls.log'), { force: true }); + expect([name, runHook(JSON.stringify({ prompt: 'hello', cwd: repo }), {}, repo).stdout]).toEqual([name, '']); + expect(calls()).toBe(''); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } + } + }); + test('gate flipped off between two runs: the second run spawns nothing (the mid-flight re-check itself cannot be interleaved from outside)', () => { // The pre-spawn re-check reads the same store; a deterministic mid-flight flip would need a // seam inside main(). This pins the observable contract only: once off, no spawn. diff --git a/test/model-policy-freshness.test.ts b/test/model-policy-freshness.test.ts index ac88d2a02..5f51b04bf 100644 --- a/test/model-policy-freshness.test.ts +++ b/test/model-policy-freshness.test.ts @@ -11,7 +11,7 @@ describe('official recommendation and lifecycle structures', () => { expect(anthropic.recommendations).toEqual({ frontier: 'claude-fable-5-1', smart: 'claude-opus-5-5' }); expect(anthropic.lineup).not.toContain('claude-mythos-5-1'); const openai = parseOfficialSource('openai-lifecycle', freshnessFixture('openai-lifecycle')); - expect(openai.lifecycle?.map(row => row.modelId)).toEqual(['gpt-5.3-codex', 'gpt-old', 'gpt-unicode-date', 'gpt-iso-date', 'gpt-date-floor']); + expect(openai.lifecycle?.map(row => row.modelId)).toEqual(['gpt-5.3-codex', 'gpt-4o-2024-08-06', 'gpt-4o-2024-11-20', 'gpt-old', 'gpt-unicode-date', 'gpt-iso-date', 'gpt-date-floor']); expect(openai.lifecycle?.find(row => row.modelId === 'gpt-date-floor')).toMatchObject({ deadline: null, supportFloor: '2024-06-13' }); }); @@ -57,6 +57,16 @@ describe('official recommendation and lifecycle structures', () => { expect(report.state.lifecycle[0]).toMatchObject({ modelId: 'gpt-6-astra', deadline: '2027-04-01' }); }); + test('OpenAI deprecated cells with a display name before the backticked IDs yield every ID', () => { + const openai = parseOfficialSource('openai-lifecycle', freshnessFixture('openai-lifecycle')); + expect(openai.lifecycle?.filter(row => row.modelId.startsWith('gpt-4o-'))).toEqual([ + { modelId: 'gpt-4o-2024-08-06', state: 'deprecated', deadline: '2027-02-16', supportFloor: null }, + { modelId: 'gpt-4o-2024-11-20', state: 'deprecated', deadline: '2027-02-16', supportFloor: null }, + ]); + const unticked = freshnessFixture('openai-lifecycle').replace('GPT-4o (`gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`)', 'GPT-4o (gpt-4o-2024-08-06, gpt-4o-2024-11-20)'); + expect(() => parseOfficialSource('openai-lifecycle', unticked)).toThrow('unrecognized-deprecated-model-cell'); + }); + test('missing required structures and semantic drift fail closed', () => { expect(() => parseOfficialSource('anthropic-models', freshnessFixture('anthropic-models').replace('Claude API ID', 'Google Cloud ID'))).toThrow('missing-api-id-row'); expect(() => parseOfficialSource('anthropic-models', freshnessFixture('anthropic-models').replace('for demanding reasoning', 'for unexplained goals'))).toThrow('unrecognized-recommendation'); diff --git a/test/outside-review-unparsed-severity.test.ts b/test/outside-review-unparsed-severity.test.ts index bca28a2cc..82e7d0f1a 100644 --- a/test/outside-review-unparsed-severity.test.ts +++ b/test/outside-review-unparsed-severity.test.ts @@ -5,12 +5,14 @@ * FINDINGS: none` for all of them. The fixtures in * test/fixtures/outside-review-severity/codex-3105/ are synthesized from the * forms the issue documents (it describes 13 replies but does not include them). + * #3329: that same gate then printed `clean` for a parsed advisory finding + * whose last `Verdict:` line says `not ready`. `bare-dash-p2.txt` is unchanged. */ import { describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { classifyOutsideReview, type OutsideGate } from '../lib/outside-review-result'; +import { classifyOutsideReview, validateOutsideReview, type OutsideGate } from '../lib/outside-review-result'; import { GATE_OUTCOMES } from '../lib/gate-outcomes'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -26,7 +28,7 @@ describe('#3105 Codex reply shapes under the execution gate', () => { for (const [name, status, stdout] of [ ['plan-bold-dash.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'], ['diff-numbered-dash.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'], - ['bare-dash-p2.txt', 0, 'VERDICT: clean\nFINDINGS: P2\n'], + ['bare-dash-p2.txt', 3, 'VERDICT: findings\nFINDINGS: P2\n'], ['ready-one-p3.txt', 0, 'VERDICT: clean\nFINDINGS: P3\n'], ['table-p1.txt', 3, 'VERDICT: findings\nFINDINGS: P1\n'], ['not-ready-unlabeled.txt', 3, 'VERDICT: findings\nFINDINGS: unspecified\n'], @@ -112,3 +114,73 @@ describe('#3105 severity forms are read by every gate that summarises a review', expect(classifyOutsideReview({ text: proposal, gate: 'proposal' }).verdict).toBe('clean'); }); }); + +describe('#3329 a last not-ready Verdict blocks even with advisory findings', () => { + const ISSUE = '1. **P2 — The docs lag the new flag.**\n2. **P3 — Rename the helper.**\nVerdict: not ready (1 P1/P2)'; + const P2 = '1. **P2 — The docs lag the new flag.**'; + const textFor = (gate: OutsideGate, text: string) => gate === 'review' ? `${text}\n${REC}` : text; + const see = (text: string, gate: OutsideGate) => { + const r = classifyOutsideReview({ text: textFor(gate, text), gate }); + return { verdict: r.verdict as string, highest: r.findings.highest as string | null, reason: (r.reason ?? null) as string | null, unparsed: r.unparsed ?? null }; + }; + + test('numbered bold P2 plus P3 with not ready is findings/P2 on execution, structured and review', () => { + for (const gate of ['execution', 'structured', 'review'] as const) + expect({ gate, ...see(ISSUE, gate) }).toEqual({ gate, verdict: 'findings', highest: 'P2', reason: null, unparsed: null }); + expect(validateOutsideReview(ISSUE, 'structured')).toEqual({ completed: true, gate: 'fail' }); + }); + + test('P3-only and unlabeled not-ready replies block; P0/P1 block even when the last Verdict says ready', () => { + for (const gate of ['execution', 'structured', 'review'] as const) { + expect({ gate, ...see('1. **P3 — Rename the helper.**\nVerdict: not ready', gate) }).toEqual({ gate, verdict: 'findings', highest: 'P3', reason: null, unparsed: null }); + expect({ gate, ...see('The retry path can double-send.\nVerdict: not ready', gate) }).toEqual({ gate, verdict: 'findings', highest: null, reason: null, unparsed: null }); + } + expect(see('1. **P1 — The cap is ignored.**\nVerdict: ready', 'review')).toMatchObject({ verdict: 'findings', highest: 'P1' }); + expect(see('**P0 — Secrets reach the log.**\nVerdict: ready', 'execution')).toMatchObject({ verdict: 'findings', highest: 'P0' }); + }); + + test('advisory P2/P3 with ready or no Verdict stay clean', () => { + for (const text of [ + `${P2}\n2. **P3 — Rename the helper.**\nVerdict: ready`, + `${P2}\n2. **P3 — Rename the helper.**`, + '1. **P3 — Rename the helper.**\nVerdict: ready (1 P3)', + ]) { + const r = see(text, 'execution'); + expect({ text, verdict: r.verdict, highest: r.highest }).toEqual({ text, verdict: 'clean', highest: text.includes('P2') ? 'P2' : 'P3' }); + } + expect(see(`${P2}\nVerdict: ready`, 'review').verdict).toBe('clean'); + expect(validateOutsideReview(`${P2}\nVerdict: ready`, 'structured')).toEqual({ completed: true, gate: 'pass' }); + }); + + test('the last recognized Verdict wins, including supported formatting; quotes do not', () => { + expect(see(`${P2}\nVerdict: ready\nVerdict: not ready (1 P1/P2)`, 'execution')).toMatchObject({ verdict: 'findings', highest: 'P2' }); + expect(see(`${P2}\nVerdict: not ready (1 P1/P2)\nVerdict: ready`, 'execution').verdict).toBe('clean'); + for (const verdictLine of ['**Verdict:** not ready', '- Verdict: not ready', '### Verdict: not ready']) + expect({ verdictLine, ...see(`${P2}\n${verdictLine}`, 'execution') }).toMatchObject({ verdict: 'findings', highest: 'P2' }); + expect(see(`${P2}\nVerdict: not ready\n### Verdict: ready`, 'execution').verdict).toBe('clean'); + const fence = ['```', 'Verdict: not ready (2 P2)', '```'].join('\n'); + expect(see(`${P2}\nVerdict: ready\n${fence}`, 'execution').verdict).toBe('clean'); + expect(see(`${fence}\n${P2}\nVerdict: not ready`, 'execution')).toMatchObject({ verdict: 'findings', highest: 'P2' }); + expect(see(`${P2}\nVerdict: ready\nThe rubric says \`Verdict: not ready\`.`, 'execution').verdict).toBe('clean'); + expect(see(`${P2}\nThe rubric says \`Verdict: not ready\`.`, 'execution').verdict).toBe('clean'); + expect(see(`No issues found.\n${fence}`, 'review').verdict).toBe('clean'); + }); + + test('parsed P2 plus an unparsed label and not-ready still blocks and keeps the diagnostic', () => { + const text = `${P2}\n## P1/P2 findings\nVerdict: not ready`; + expect(see(text, 'execution')).toEqual({ verdict: 'findings', highest: 'P2', reason: null, unparsed: 'line 2: "## P1/P2 findings"' }); + expect(see(`${P2}\n## P1/P2 findings`, 'structured')).toMatchObject({ verdict: 'unverified', reason: 'unparsed_severity', unparsed: 'line 2: "## P1/P2 findings"' }); + }); + + test('missing markers and failed execution stay unavailable; proposal and spec ignore the verdict', () => { + expect(classifyOutsideReview({ text: `${P2}\nVerdict: not ready`, gate: 'review' }).reason).toBe('missing_markers'); + const failed = classifyOutsideReview({ text: textFor('review', `${P2}\nVerdict: not ready`), gate: 'review', exit: 1, stderr: 'fatal: provider exploded' }); + expect(failed.verdict).toBe('unavailable'); + expect(failed.reason).toBe('execution_failed'); + expect(classifyOutsideReview({ text: `${P2}\nVerdict: not ready\n${REC}`, gate: 'proposal' }).verdict).toBe('clean'); + const passing = classifyOutsideReview({ text: `SCORE: 8\nAMBIGUITIES: NONE\n${P2}\nVerdict: not ready`, gate: 'spec' }); + expect([passing.verdict, passing.score]).toEqual(['clean', 8]); + const failing = classifyOutsideReview({ text: 'SCORE: 6\nAMBIGUITIES: NONE\nVerdict: ready', gate: 'spec' }); + expect([failing.verdict, failing.score]).toEqual(['findings', 6]); + }); +}); diff --git a/test/pregate-rust.test.ts b/test/pregate-rust.test.ts new file mode 100644 index 000000000..cec5aeab6 --- /dev/null +++ b/test/pregate-rust.test.ts @@ -0,0 +1,371 @@ +/** + * C8 `lanes` for a Rust repo: a `.rs` file of a crate (the nearest Cargo.toml + * with `[package]`) is a test when it sits under `/tests/` or holds a + * `#[cfg(test)]`; every `.rs` file of a crate and its Cargo.toml select that + * crate's tests and the tests of every crate depending on it; selected tests + * run as `cargo test -p ` from the workspace root, judged on the summed + * `test result:` lines. The end-to-end fixture carries no workflows, so every + * selected test is a local lane on any host platform; `cargo` is a PATH shim. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; +import { EXIT } from '../lib/headless-artifacts'; +import { parseSummary, planRuns } from '../lib/pregate/checks/lanes'; +import { DEFAULT_CONFIG } from '../lib/pregate/config'; +import { buildGraph, testsFor } from '../lib/pregate/import-graph'; +import { draftConfig } from '../lib/pregate/init-draft'; +import { cargoModel, hasCfgTest, rustEdges } from '../lib/pregate/rust'; +import type { PregateContext } from '../lib/pregate/types'; +import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const RUST_FILES: Record = { + 'engine/Cargo.toml': '[workspace]\nresolver = "3"\nmembers = ["crates/*"]\n\n[workspace.dependencies]\nalpha = { path = "crates/alpha" }\n', + 'engine/crates/alpha/Cargo.toml': '[package]\nname = "alpha"\nversion = "0.1.0"\nedition = "2024"\n', + 'engine/crates/alpha/src/lib.rs': 'pub mod util;\npub fn one() -> u32 { 1 }\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn one_is_one() { assert_eq!(super::one(), 1); }\n}\n', + 'engine/crates/alpha/src/util.rs': 'pub fn two() -> u32 { 2 }\n', + 'engine/crates/alpha/src/flags.rs': '#[cfg(feature = "test")]\npub fn only_with_the_test_feature() {}\n', + 'engine/crates/alpha/tests/it.rs': '#[test]\nfn it() { assert_eq!(alpha::one(), 1); }\n', + 'engine/crates/beta/Cargo.toml': '[package]\nname = "beta-core"\nversion = "0.1.0"\n\n[dependencies]\nalpha = { workspace = true } # the shared base\n', + 'engine/crates/beta/src/lib.rs': 'pub fn b() -> u32 { alpha::one() }\n\n#[cfg(all(test, unix))]\nmod tests {\n #[test]\n fn b() {}\n}\n', + 'engine/crates/gamma/Cargo.toml': '[package]\nname = "gamma"\nversion = "0.1.0"\n\n[target.\'cfg(unix)\'.dev-dependencies.base]\npackage = "beta-core"\npath = "../beta"\n', + 'engine/crates/gamma/src/lib.rs': 'pub fn g() {}\n', + 'engine/crates/gamma/tests/g.rs': '#[test]\nfn g() {}\n', + 'engine/crates/lonely/Cargo.toml': '[package]\nname = "lonely"\nversion = "0.1.0"\n', + 'engine/crates/lonely/src/lib.rs': 'pub fn alone() {}\n', + 'standalone/Cargo.toml': '[package]\nname = "standalone"\nversion = "0.1.0"\n', + 'standalone/src/main.rs': 'fn main() {}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn t() {}\n}\n', + 'tools/tests/loose.rs': 'fn main() {}\n', +}; + +const ALPHA_TESTS = ['engine/crates/alpha/src/lib.rs', 'engine/crates/alpha/tests/it.rs']; + +/** The fixture plus RUST_FILES on main, with no base policy and no workflows, so every selected test runs locally. */ +function rustRepo(): FixtureRepo { + const repo = makeFixtureRepo({ pregate: null }); + repos.push(repo); + commit(repo, { ...RUST_FILES, '.github/workflows/free-tests.yml': null, '.github/workflows/windows.yml': null }, 'rust'); + repo.git('push', '-q', 'origin', 'HEAD:main'); + repo.git('fetch', '-q', 'origin'); + return repo; +} + +const CARGO_OK = [ + 'echo "ran in $(basename "$PWD"): $*"', + 'echo "running 2 tests"', + 'echo "test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"', + 'echo " Doc-tests $3"', + 'echo "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"', +].join('\n'); + +/** + * A `cargo` on PATH running `body` (default: reports its cwd and argv, then two passed tests and an empty doc-test binary). + * The lane puts its Bun's directory first on PATH, and that directory can hold a real cargo (/usr/local/bin, + * /opt/homebrew/bin), so on POSIX the lane's bash puts the shim first again through BASH_ENV. + */ +function cargoShim(repo: FixtureRepo, body = CARGO_OK): Record { + const dir = path.join(repo.root, 'shim'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'cargo'), `#!/usr/bin/env bash\n${body}\n`, { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'path.sh'), `PATH='${dir}':"$PATH"\n`); + const PATH = [dir, process.env.PATH ?? ''].join(path.delimiter); + return process.platform === 'win32' ? { PATH } : { PATH, BASH_ENV: path.join(dir, 'path.sh') }; +} + +const rustTests = (tests: Iterable) => [...tests].filter(t => t.endsWith('.rs')).sort(); + +describe('Rust import graph', () => { + // Value: protects=in-source `#[cfg(test)]` modules and `/tests/` files are tests only inside a crate, and a cfg naming a + // feature called "test" is not; fails_when=Rust tests are never recognised (every touched .rs is PREGATE_NO_LANE) or any + // tests/*.rs counts without a Cargo.toml; why_new=no Rust support existed; seam=none; no_claim=a Rust parser (a cfg attribute scan) + test('cfg(test) files and /tests/ files are tests; a loose tests/*.rs and a cfg(feature = "test") file are not', () => { + const g = buildGraph(rustRepo().work); + expect(rustTests(g.tests)).toEqual([...ALPHA_TESTS, 'engine/crates/beta/src/lib.rs', 'engine/crates/gamma/tests/g.rs', 'standalone/src/main.rs']); + }); + + // Value: protects=a src file without its own tests selects its crate's tests and, transitively, the tests of crates depending on it + // (workspace = true, a renamed target-specific dev-dependency); an in-source test selects itself; a crate with no tests stays + // no-lane; fails_when=the mapping narrows to the file, misses dependents, or invents a lane; why_new=no Rust mapping existed; + // seam=none; no_claim=cargo's own resolver (manifest dependency keys are read) + test('a crate file selects its crate and every crate depending on it; a crate without tests has no lane', () => { + const g = buildGraph(rustRepo().work); + const everyAlphaDependent = [...ALPHA_TESTS, 'engine/crates/beta/src/lib.rs', 'engine/crates/gamma/tests/g.rs']; + expect(testsFor(g, 'engine/crates/alpha/src/util.rs').tests).toEqual(everyAlphaDependent); + expect(testsFor(g, 'engine/crates/alpha/src/lib.rs').tests).toEqual(everyAlphaDependent); + expect(testsFor(g, 'engine/crates/alpha/Cargo.toml').tests).toEqual(everyAlphaDependent); + expect(testsFor(g, 'engine/crates/beta/src/lib.rs').tests).toEqual(['engine/crates/beta/src/lib.rs', 'engine/crates/gamma/tests/g.rs']); + expect(testsFor(g, 'engine/crates/gamma/src/lib.rs').tests).toEqual(['engine/crates/gamma/tests/g.rs']); + expect(testsFor(g, 'standalone/src/main.rs').tests).toEqual(['standalone/src/main.rs']); + expect(testsFor(g, 'engine/crates/lonely/src/lib.rs').tests).toEqual([]); + expect(testsFor(g, 'tools/tests/loose.rs').tests).toEqual([]); + }); + + // Value: protects=`gstack-pregate init` tells a Rust repo its tests run per crate (no runner to declare), and Rust does not make a + // JS selection "mixed"; fails_when=init tells the user to set a runner for Rust tests, which would override the per-crate plan; + // why_new=init grouped .rs tests as "other"; seam=none; no_claim=none beyond protects + test('init notes that Rust tests run per crate with cargo test, not that a runner must be set', () => { + const note = draftConfig(rustRepo().work).runnerNote; + expect(note).toContain('5 Rust test file(s) run per crate with cargo test -p'); + expect(note).not.toContain('needs runner set'); + expect(note).not.toMatch(/no runner detected for \d+ (?:other|rust)/); + }); +}); + +describe('Rust runner and summary (pure)', () => { + // Value: protects=cargo prints one `test result:` line per test binary (unit, each integration test, doc-tests); all are summed, + // ignored and filtered tests are not runs, and an all-zero run is ZERO-RUN; fails_when=only the first or last line counts, or the + // generic `N passed` fallback reads cargo output; why_new=cargo output was unparsed (ran: null); seam=none; no_claim=a real cargo + test('cargo test: every test result line counts, doc-tests included; all zeros are a zero run', () => { + const cargo = 'cargo test -p alpha'; + const ok = [ + ' Compiling alpha v0.1.0', ' Running unittests src/lib.rs (target/debug/deps/alpha-1)', '', 'running 2 tests', 'test tests::a ... ok', 'test tests::b ... ok', '', + 'test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s', '', + ' Running tests/it.rs (target/debug/deps/it-2)', '', 'running 2 tests', 'test it ... ok', 'test slow ... ignored', '', + 'test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s', '', + ' Doc-tests alpha', '', 'running 3 tests', 'test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.31s', + ].join('\n'); + expect(parseSummary(ok, cargo)).toEqual({ ran: 6, failed: 0 }); + const failed = 'test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s\n\nfailures:\n tests::b\n\ntest result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s\n\nerror: test failed, to rerun pass `-p alpha --lib`'; + expect(parseSummary(failed, cargo)).toEqual({ ran: 7, failed: 2 }); + const zero = 'running 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s\n\n Doc-tests alpha\n\nrunning 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s'; + expect(parseSummary(zero, cargo)).toEqual({ ran: 0, failed: 0 }); + expect(parseSummary('\x1b[1m\x1b[32mtest result: ok\x1b[0m. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s', cargo)).toEqual({ ran: 5, failed: 0 }); + expect(parseSummary('error[E0425]: cannot find value `x` in this scope\nerror: could not compile `alpha`', cargo)).toEqual({ ran: null, failed: null }); + }); + + // Value: protects=each selected crate runs once as `cargo test -p ` from its workspace root (else its own directory), crates + // group apart, a .rs file outside any crate is unclaimed, and a missing cargo is incomplete; fails_when=.rs files reach the JS + // runner, run from the repo root, merge across crates, or a missing cargo is judged as a test failure (exit 127); why_new=no cargo + // runner existed; seam=none; no_claim=the runners themselves (plan only) + test('without a declared runner, Rust tests group by crate from the workspace root; a declared runner takes every file', () => { + const repo = rustRepo(); + const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env: cargoShim(repo) } as unknown as PregateContext; + const files = [...ALPHA_TESTS, 'engine/crates/beta/src/lib.rs', 'standalone/src/main.rs', 'tools/tests/loose.rs']; + expect(planRuns(ctx, files)).toEqual({ groups: [ + { runner: "cargo test -p 'alpha'", cwd: 'engine', files: ['crates/alpha/src/lib.rs', 'crates/alpha/tests/it.rs'] }, + { runner: "cargo test -p 'beta-core'", cwd: 'engine', files: ['crates/beta/src/lib.rs'] }, + { runner: "cargo test -p 'standalone'", cwd: 'standalone', files: ['src/main.rs'] }, + ], unclaimed: ['tools/tests/loose.rs'], missing: [], noPubspec: [] }); + const noCargo = { ...ctx, env: { PATH: path.join(repo.root, 'empty-path') } } as PregateContext; + expect(planRuns(noCargo, ALPHA_TESTS)).toEqual({ groups: [], unclaimed: ALPHA_TESTS, missing: ['cargo'], noPubspec: [] }); + const declared = { ...ctx, config: { ...DEFAULT_CONFIG, runner: 'bash scripts/test.sh {files}' } } as PregateContext; + expect(planRuns(declared, files).groups).toEqual([{ runner: 'bash scripts/test.sh {files}', cwd: '', files }]); + }); +}); + +/** + * Manifest shapes a line scan misreads (review of the first cut): a renamed + * dependency inherited from `[workspace.dependencies]`, TOML dotted keys, the + * `[dependencies.]` table form, target-specific tables, and a crate the + * workspace excludes. Dependencies resolve by path first, then by package name. + */ +const MANIFEST_FILES: Record = { + 'Cargo.toml': '[workspace]\nmembers = ["crates/*"]\nexclude = ["crates/helper"]\n\n[workspace.dependencies]\nbase = { package = "alpha", path = "crates/a" }\n', + 'crates/a/Cargo.toml': '[package]\nname = "alpha"\nversion = "0.1.0"\n', + 'crates/a/src/lib.rs': 'pub fn a() {}\n', + 'crates/a/tests/a.rs': '#[test]\nfn a() {}\n', + 'crates/beta/Cargo.toml': '[package]\nname = "beta"\nversion = "0.1.0"\n\n[dependencies]\nbase = { workspace = true }\n', + 'crates/beta/tests/b.rs': '#[test]\nfn b() {}\n', + 'crates/dotted/Cargo.toml': '[package]\nname = "dotted"\nversion = "0.1.0"\n\n[dependencies]\nfoo.path = "../a"\n', + 'crates/dotted/tests/d.rs': '#[test]\nfn d() {}\n', + 'crates/table/Cargo.toml': '[package]\nname = "table"\nversion = "0.1.0"\n\n[dev-dependencies.whatever]\npath = "../a"\n', + 'crates/table/tests/t.rs': '#[test]\nfn t() {}\n', + 'crates/targeted/Cargo.toml': '[package]\nname = "targeted"\nversion = "0.1.0"\n\n[target.\'cfg(unix)\'.build-dependencies]\nrenamed = { path = "../a" }\n', + 'crates/targeted/tests/x.rs': '#[test]\nfn x() {}\n', + 'crates/plain/Cargo.toml': '[package]\nname = "plain"\nversion = "0.1.0"\n', + 'crates/plain/tests/p.rs': '#[test]\nfn p() {}\n', + 'crates/helper/Cargo.toml': '[package]\nname = "helper"\nversion = "0.1.0"\n', + 'crates/helper/src/lib.rs': '#[cfg(test)]\nmod tests {\n #[test]\n fn h() {}\n}\n', +}; + +function manifestRepo(): FixtureRepo { + const repo = makeFixtureRepo({ pregate: null }); + repos.push(repo); + commit(repo, MANIFEST_FILES, 'cargo manifests'); + return repo; +} + +describe('Cargo manifests are parsed as TOML', () => { + // Value: protects=every dependency spelling Cargo accepts links a crate to its dependents: an inherited renamed workspace dependency, + // a dotted `foo.path` key, a `[dev-dependencies.]` table and a target-specific build dependency, all resolved by path; + // fails_when=the manifest is read by a line scan again and alpha's change misses these dependents (a false green); + // why_new=the first cut matched dependency keys against package names only; seam=none; no_claim=cargo's own resolver + test('renamed, inherited, dotted, table-form and target-specific dependencies all select the dependent crates', () => { + const g = buildGraph(manifestRepo().work); + expect(testsFor(g, 'crates/a/src/lib.rs').tests).toEqual(['crates/a/tests/a.rs', 'crates/beta/tests/b.rs', 'crates/dotted/tests/d.rs', 'crates/table/tests/t.rs', 'crates/targeted/tests/x.rs']); + }); + + // Value: protects=a crate the workspace excludes runs from its own directory, a member from the workspace root; fails_when=the + // nearest [workspace] is taken without checking members/exclude and `cargo test -p helper` runs where helper is no member + // (cargo fails, or tests a same-named member); why_new=membership was not read; seam=none; no_claim=a real cargo (plan only) + test('an excluded crate runs from its own directory; a member runs from the workspace root', () => { + const repo = manifestRepo(); + const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env: cargoShim(repo) } as unknown as PregateContext; + expect(planRuns(ctx, ['crates/beta/tests/b.rs', 'crates/helper/src/lib.rs']).groups).toEqual([ + { runner: "cargo test -p 'beta'", cwd: '', files: ['crates/beta/tests/b.rs'] }, + { runner: "cargo test -p 'helper'", cwd: 'crates/helper', files: ['src/lib.rs'] }, + ]); + }); +}); + +describe('Cargo membership and test detection (port review)', () => { + const inMemory = (files: Record) => [Object.keys(files), (f: string) => files[f] ?? null] as const; + const pkg = (name: string, deps = '') => `[package]\nname = "${name}"\nversion = "0.1.0"\n${deps}`; + const dependentsOf = (files: Record, manifest: string) => rustEdges(...inMemory(files)).edges.filter(e => e.dependency === manifest).map(e => e.dependent).sort(); + + // Value: protects=Cargo's member globs ([ab] classes, ** spans) admit the crates Cargo admits, so a member inherits + // [workspace.dependencies] and its tests are selected; fails_when=a character class is matched literally, the crate is + // no member, the inherited path dependency is dropped and alpha's change misses beta's tests (a false green); + // why_new=port review found classes escaped; seam=none; no_claim=cargo's own glob crate + test('a members glob with a character class or ** admits the crate, so an inherited dependency links its dependent', () => { + const files = { + 'Cargo.toml': '[workspace]\nmembers = ["crates/[ab]", "nested/**/leaf", "unicode/?"]\n\n[workspace.dependencies]\nbase = { package = "alpha", path = "crates/a" }\n', + 'crates/a/Cargo.toml': pkg('alpha'), 'crates/a/tests/a.rs': '#[test]\nfn a() {}\n', + 'crates/b/Cargo.toml': pkg('beta', '\n[dependencies]\nbase = { workspace = true }\n'), 'crates/b/tests/b.rs': '#[test]\nfn b() {}\n', + 'crates/c/Cargo.toml': pkg('gamma'), + 'nested/x/y/leaf/Cargo.toml': pkg('leaf'), + 'unicode/📦/Cargo.toml': pkg('boxed'), + }; + const { crates } = cargoModel(...inMemory(files)); + expect(['crates/a', 'crates/b', 'crates/c', 'nested/x/y/leaf', 'unicode/📦'].map(r => crates.get(r)!.workspace)).toEqual(['', '', 'crates/c', '', '']); + expect(dependentsOf(files, 'crates/a/Cargo.toml')).toEqual(['crates/a/tests/a.rs', 'crates/b/tests/b.rs']); + }); + + // Value: protects=a plain #[test] function or a #[tokio::test] in src is a Rust test, so a dependent crate tested that way + // runs when its dependency changes; fails_when=only cfg(test) modules and tests/ count and the dependent's failing test + // never runs (a false green); why_new=port review; seam=none; no_claim=custom [[test]] targets outside tests/ + test('a plain #[test] or #[tokio::test] in src is a test; cfg(feature = "test") and #[testing] are not', () => { + expect(['#[test]\nfn a() {}', '#[tokio::test(flavor = "multi_thread")]\nasync fn a() {}', '#[ test ]\nfn a() {}'].map(hasCfgTest)).toEqual([true, true, true]); + expect(['#[cfg(feature = "test")]\nfn a() {}', '#[testing]\nfn a() {}', '// attests', '#[cfg(feature = "a]b")]\nfn a() {}'].map(hasCfgTest)).toEqual([false, false, false, false]); + expect(hasCfgTest(`#[cfg(any(test, feature = "${'f'.repeat(300)}"))]\nmod t {}`)).toBe(true); + const files = { + 'a/Cargo.toml': pkg('a'), 'a/src/lib.rs': 'pub fn one() -> u32 { 1 }\n#[cfg(test)]\nmod t { #[test] fn one() {} }\n', + 'b/Cargo.toml': pkg('b', '\n[dependencies]\na = { path = "../a" }\n'), 'b/src/lib.rs': 'pub fn two() -> u32 { a::one() + 1 }\n#[test]\nfn two() { assert_eq!(super::two(), 2); }\n', + }; + expect(dependentsOf(files, 'a/Cargo.toml')).toEqual(['a/src/lib.rs', 'b/src/lib.rs']); + }); + + // Value: protects=past the members-glob size cap, where a crate's tests run is undecided, so its files are unclaimed (the + // lane incomplete) instead of running `cargo test -p ` from a root where a same-named member answers; fails_when=an + // oversized workspace admits every crate as runnable and the orphan's failing test is replaced by the member's passing + // ones (a false green); why_new=third port review round; seam=none; no_claim=a real cargo (plan only) + test('past the members size cap a same-named non-member is unclaimed, not run as the member', () => { + const repo = makeFixtureRepo({ pregate: null }); + repos.push(repo); + commit(repo, { + 'Cargo.toml': `[workspace]\nmembers = ["[${'m'.repeat(4100)}]ember"]\n`, + 'member/Cargo.toml': pkg('same'), 'member/tests/m.rs': '#[test]\nfn m() {}\n', + 'orphan/Cargo.toml': pkg('same'), 'orphan/tests/t.rs': '#[test]\nfn t() { panic!() }\n', + }, 'oversized members'); + const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env: cargoShim(repo) } as unknown as PregateContext; + expect(planRuns(ctx, ['orphan/tests/t.rs'])).toEqual({ groups: [], unclaimed: ['orphan/tests/t.rs'], missing: [], noPubspec: [] }); + }); + + // Value: protects=a hostile members glob or a file of unclosed cfg attributes cannot stall lane selection, which runs before + // any runner deadline; each probe runs in its own supervised Bun so a regression is killed and fails instead of hanging the + // file; fails_when=member globs backtrack again (an exponential '*a*a…b', or a rescan per unmatched '[') or the cfg scan + // rescans from every start (quadratic: about 80 s on 600 KB, against tens of ms now); why_new=port review; seam=none; + // no_claim=a wall-clock budget for selection (the 30 s kill only separates linear from super-linear) + const supervised = (code: string) => spawnSync(process.execPath, [...bunIsolationArgs(), '-e', `import { cargoModel, hasCfgTest } from ${JSON.stringify(path.join(import.meta.dir, '..', 'lib', 'pregate', 'rust.ts'))};\n${code}`], { encoding: 'utf8', timeout: 30_000 }); + test('a hostile members glob is matched without backtracking', () => { + const r = supervised(` + const pkg = n => '[package]\\nname = "' + n + '"\\nversion = "0.1.0"\\n'; + const run = (members, dirs) => { const files = { 'Cargo.toml': '[workspace]\\nmembers = ' + JSON.stringify(members) + '\\n' }; dirs.forEach((d, i) => { files[d + '/Cargo.toml'] = pkg('k' + i); }); return cargoModel(Object.keys(files), f => files[f] ?? null); }; + const exp = run(['*a'.repeat(20) + 'b'], ['a'.repeat(40)]).crates.get('a'.repeat(40)).workspace; + const brackets = run(['['.repeat(1000000)], Array.from({ length: 10 }, (_, i) => 'c' + i)).crates.get('c0').workspace; + console.log(JSON.stringify([exp, brackets]));`); + expect(r.error).toBeUndefined(); + expect(r.stdout.trim()).toBe(JSON.stringify(['a'.repeat(40), null])); + }, 60_000); + + test('a run of unclosed cfg attributes is scanned once and counts as a test', () => { + const r = supervised(`console.log(JSON.stringify([hasCfgTest('#[cfg('.repeat(100000)), hasCfgTest('#[cfg(unix)] fn a() {}\\n'.repeat(50000))]));`); + expect(r.error).toBeUndefined(); + expect(r.stdout.trim()).toBe('[true,false]'); + }, 60_000); +}); + +describe('gstack-pregate on a Rust workspace', () => { + const run = (repo: FixtureRepo, env: Record) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'], env); + // The cargo stand-in is a bash script the lane's Git Bash must find before the runner's real cargo; on windows-latest it + // never did (three PATH repairs, the lane still exited 1 with an empty log), so the two cases that run it are POSIX-only, + // like test/pregate-dart.test.ts's stand-ins. Selection, planning and the hostile-name case still run on Windows. + const posixShim = test.skipIf(process.platform === 'win32'); + const log = (out: string) => fs.readFileSync(/log: (\S+)/.exec(out)![1]!, 'utf8'); + + // Value: protects=the reported case end to end: a touched src file of a workspace crate is not PREGATE_NO_LANE, its crate and the + // dependent crates run with `cargo test -p` from the workspace root, and the summed count is reported; fails_when=any link of + // graph, plan or summary regresses; why_new=no Rust run existed; seam=none; no_claim=a real cargo (PATH shim reports cwd and argv) + // TODO(#3380): Windows gap. This case is skipped on win32 until the cargo stand-in runs under the lane's Git Bash there; https://github.com/garrytan/gstack/pull/3380 + posixShim('a touched src file runs its crate and the crates depending on it from the workspace root', () => { + const repo = rustRepo(); + commit(repo, { 'engine/crates/alpha/src/util.rs': 'pub fn two() -> u32 { 1 + 1 }\n' }); + const r = run(repo, cargoShim(repo)); + expect(r.out).not.toContain('has no test lane'); + expect(r.out).toMatch(/^tests {6}lanes {5}pass .*6 tests ran in 4 file\(s\), exit 0/m); + expect(r.out).toContain("ran: (cd 'engine' && cargo test -p 'alpha') && (cd 'engine' && cargo test -p 'beta-core') && (cd 'engine' && cargo test -p 'gamma')"); + expect(log(r.out)).toContain('ran in engine: test -p alpha'); + expect(r.status).toBe(EXIT.ok); + }, 60_000); + + // Value: protects=a crate whose test binaries all report 0 tests is ZERO-RUN (exit 0 is not a pass), and a failing cargo run is + // PREGATE_LANE_FAILED with its counts; fails_when=cargo's summary is unparsed (count unavailable passes) or the exit is ignored; + // why_new=the pure parser test does not run the lane; seam=none; no_claim=a real cargo (PATH shim) + // TODO(#3380): Windows gap. This case is skipped on win32 until the cargo stand-in runs under the lane's Git Bash there; https://github.com/garrytan/gstack/pull/3380 + posixShim('zero tests is ZERO-RUN; a failing cargo run is PREGATE_LANE_FAILED', () => { + const repo = rustRepo(); + commit(repo, { 'engine/crates/gamma/src/lib.rs': 'pub fn g() { }\n' }); + const zero = run(repo, cargoShim(repo, 'echo "test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"')); + expect(zero.status).not.toBe(EXIT.ok); + expect(zero.out).toMatch(/^tests {6}lanes {5}fail .*ZERO-RUN: the runner reported 0 tests in \(cd 'engine' && cargo test -p 'gamma'\)/m); + const red = run(repo, cargoShim(repo, 'echo "test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s"\nexit 101')); + expect(red.status).not.toBe(EXIT.ok); + expect(red.out).toMatch(/^tests {6}lanes {5}fail .*local lane exited 101 \(1 failed of 2 ran; 1 files; pipefail\)/m); + }, 60_000); + + // Value: protects=a crate name is data from the repo's Cargo.toml that reaches the lane's shell and the {files} expansion, + // so a name Cargo would reject never runs (its files are unclaimed, the lane incomplete) and a valid one, Unicode + // included, still runs quoted; fails_when=the name is interpolated unchecked (`;touch` runs a command, `{files}` splices + // the quoted file list into the name); why_new=the crate runner was new; seam=none; no_claim=a real cargo (PATH shim) + test('a crate name with shell syntax or {files} never reaches the shell; a valid Unicode name still runs', () => { + const repo = rustRepo(); + const lib = '#[cfg(test)]\nmod tests {\n #[test]\n fn t() {}\n}\n'; + commit(repo, { + 'engine/crates/evil/Cargo.toml': '[package]\nname = "evil;touch pwned-by-crate-name"\nversion = "0.1.0"\n', + 'engine/crates/evil/src/lib.rs': lib, + 'engine/crates/splice/Cargo.toml': '[package]\nname = "evil{files}"\nversion = "0.1.0"\n', + 'engine/crates/splice/src/lib.rs': lib, + 'engine/crates/uni/Cargo.toml': '[package]\nname = "naïve_crate-2"\nversion = "0.1.0"\n', + 'engine/crates/uni/src/lib.rs': lib, + }, 'odd crate names'); + repo.git('push', '-q', 'origin', 'HEAD:main'); + repo.git('fetch', '-q', 'origin'); + const env = cargoShim(repo); + const ctx = { repoRoot: repo.work, config: DEFAULT_CONFIG, env } as unknown as PregateContext; + expect(planRuns(ctx, ['engine/crates/evil/src/lib.rs', 'engine/crates/splice/src/lib.rs', 'engine/crates/uni/src/lib.rs'])).toEqual({ + groups: [{ runner: "cargo test -p 'naïve_crate-2'", cwd: 'engine', files: ['crates/uni/src/lib.rs'] }], + unclaimed: ['engine/crates/evil/src/lib.rs', 'engine/crates/splice/src/lib.rs'], missing: [], noPubspec: [], + }); + commit(repo, { 'engine/crates/evil/src/lib.rs': lib.replace('fn t() {}', 'fn t() { }') }); + const r = run(repo, env); + expect(fs.existsSync(path.join(repo.work, 'engine', 'pwned-by-crate-name'))).toBe(false); + expect(r.out).not.toContain('cargo test -p'); + expect(r.out).toContain('engine/crates/evil/src/lib.rs'); + expect(r.status).not.toBe(EXIT.ok); + }, 60_000); + + // Value: protects=a crate with no tests still fails closed (no lane), so the crate mapping never invents coverage; + // fails_when=crate membership alone counts as a lane; why_new=negative control for the new mapping; seam=none; no_claim=none + test('a touched file of a crate without tests still has no test lane', () => { + const repo = rustRepo(); + commit(repo, { 'engine/crates/lonely/src/lib.rs': 'pub fn alone() { }\n' }); + const r = run(repo, cargoShim(repo)); + expect(r.status).not.toBe(EXIT.ok); + expect(r.out).toContain('FAIL lanes — engine/crates/lonely/src/lib.rs has no test lane'); + }, 60_000); +}); diff --git a/test/pregate-selection.test.ts b/test/pregate-selection.test.ts index 3891b2ef8..a5d5ec86a 100644 --- a/test/pregate-selection.test.ts +++ b/test/pregate-selection.test.ts @@ -14,11 +14,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { changedLiterals, constantsIn } from '../lib/pregate/checks/literals'; -import { parseSummary } from '../lib/pregate/checks/lanes'; -import { validateConfig } from '../lib/pregate/config'; +import { parseSummary, planRuns } from '../lib/pregate/checks/lanes'; +import { DEFAULT_CONFIG, validateConfig } from '../lib/pregate/config'; import { isDocsEmptyLane } from '../lib/pregate/docs-lane'; import { buildGraph, testsFor } from '../lib/pregate/import-graph'; import { CHECK_IDS } from '../lib/pregate/index'; +import type { PregateContext } from '../lib/pregate/types'; import { parseWorkflow } from '../lib/pregate/workflows'; import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; @@ -564,3 +565,85 @@ describe('gstack-pregate (bin)', () => { expect(runBin(repo, 'gstack-pregate', ['validate']).status).toBe(EXIT.ok); }); }); + +describe('nested JS packages run their own runner from their own directory', () => { + const APP = { + 'app/package.json': JSON.stringify({ name: 'app', scripts: { test: 'vitest run' }, devDependencies: { vitest: '^3.2.0' } }) + '\n', + 'app/pnpm-lock.yaml': "lockfileVersion: '9.0'\n", + 'app/src/sum.ts': 'export const sum = (a: number, b: number) => a + b;\n', + 'app/src/sum.test.ts': "import { expect, test } from 'vitest';\nimport { sum } from './sum';\ntest('sum', () => expect(sum(1, 2)).toBe(3));\n", + }; + /** + * A `pnpm` (and `yarn`) on PATH that reports its cwd and argv and prints a vitest summary for the files it is given. + * The lane puts its Bun's directory first on PATH, and that directory can hold a real pnpm (/usr/local/bin), which + * would install from the network; the lane's bash puts the shim first again through BASH_ENV. + */ + const pmShim = (repo: FixtureRepo): { PATH: string; BASH_ENV: string } => { + const dir = path.join(repo.root, 'shim'); + fs.mkdirSync(dir, { recursive: true }); + for (const tool of ['pnpm', 'yarn']) fs.writeFileSync(path.join(dir, tool), '#!/usr/bin/env bash\necho "ran in $(basename "$PWD"): $*"\necho " Test Files 1 passed (1)"\necho " Tests 3 passed (3)"\n', { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'path.sh'), `PATH='${dir}':"$PATH"\n`); + return { PATH: `${dir}:${process.env.PATH ?? ''}`, BASH_ENV: path.join(dir, 'path.sh') }; + }; + const ctxFor = (repo: FixtureRepo, env: Record) => ({ repoRoot: repo.work, config: DEFAULT_CONFIG, env }) as unknown as PregateContext; + + // Value: protects=a test owned by a nested package that declares vitest runs from that package with its lockfile's package manager + // and package-relative paths, while root-owned tests keep the root runner; fails_when=the root package.json alone decides again + // (the reported `bun test` from the repo root: vi.stubGlobal missing); why_new=only root package.json detection was tested; + // seam=none; no_claim=the runners themselves (plan only) + test('a nested vitest package plans its own group with the lockfile\'s package manager; root tests keep the root runner', () => { + const repo = fixture({ pregate: null, baseFiles: APP }); + const ctx = ctxFor(repo, pmShim(repo)); + expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js'])).toEqual({ groups: [ + { runner: 'node --test {files}', cwd: '', files: ['test/util.test.js'] }, + { runner: 'pnpm exec vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }, + ], unclaimed: [], missing: [], noPubspec: [] }); + // The lockfile is found walking up, and a packageManager field outranks it. + fs.rmSync(path.join(repo.work, 'app/pnpm-lock.yaml')); + write(repo.work, 'yarn.lock', '# yarn lockfile v1\n'); + expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'yarn vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }]); + write(repo.work, 'app/package.json', JSON.stringify({ name: 'app', packageManager: 'pnpm@9.15.0', devDependencies: { jest: '^29.0.0' } }) + '\n'); + expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'pnpm exec jest {files}', cwd: 'app', files: ['src/sum.test.ts'] }]); + // Value: protects=a package manager missing from PATH is incomplete naming it, never exit 127 judged as a test failure; + // fails_when=the PATH check is dropped; why_new=new detection path; seam=none; no_claim=a real pnpm install + expect(planRuns(ctxFor(repo, { PATH: path.join(repo.root, 'empty-path') }), ['app/src/sum.test.ts'])).toEqual({ groups: [], unclaimed: ['app/src/sum.test.ts'], missing: ['pnpm'], noPubspec: [] }); + fs.rmSync(path.join(repo.work, 'yarn.lock')); + write(repo.work, 'app/package.json', JSON.stringify({ name: 'app', devDependencies: { vitest: '^3.2.0' } }) + '\n'); + expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }]); + }); + + // Value: protects=repos where root detection works today are unchanged: a nested package that declares no test runner stays with the + // root runner from the root, a root vitest stays `npx vitest run` from the root, and a declared runner takes every file; + // fails_when=every nested package.json splits a group or the root command changes; why_new=negative controls for the new + // nested detection; seam=none; no_claim=the runners themselves (plan only) + test('negative controls: a nested package without a test runner, a root vitest repo and a declared runner keep today\'s plan', () => { + const repo = fixture({ pregate: null, baseFiles: { ...APP, 'app/package.json': JSON.stringify({ name: 'app', scripts: { build: 'tsc' } }) + '\n' } }); + const ctx = ctxFor(repo, pmShim(repo)); + write(repo.work, 'bun.lock', '{}\n'); + expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js']).groups).toEqual([{ runner: 'bun test {files}', cwd: '', files: ['app/src/sum.test.ts', 'test/util.test.js'] }]); + fs.rmSync(path.join(repo.work, 'bun.lock')); + write(repo.work, 'package.json', JSON.stringify({ name: 'fixture', devDependencies: { vitest: '^3.2.0' } }) + '\n'); + write(repo.work, 'pnpm-lock.yaml', "lockfileVersion: '9.0'\n"); + expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: '', files: ['app/src/sum.test.ts', 'test/util.test.js'] }]); + const declared = { ...ctx, config: { ...DEFAULT_CONFIG, runner: 'bun test {files}' } } as PregateContext; + write(repo.work, 'app/package.json', APP['app/package.json']); + expect(planRuns(declared, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'bun test {files}', cwd: '', files: ['app/src/sum.test.ts'] }]); + }); + + // Value: protects=the reported case end to end: a touched source file in a nested pnpm+vitest package runs `pnpm exec vitest run` + // in that package with the package-relative path and passes on vitest's summary; fails_when=the lane runs from the repo root or + // with the root runner; why_new=no nested package run existed; seam=none; no_claim=a real pnpm or vitest (PATH shim) + test('a touched file in a nested pnpm+vitest package runs vitest there', () => { + const repo = fixture({ pregate: null, baseFiles: APP }); + // No workflows, so the Windows lane's unknown matrix cannot turn the selection into a requires-remote obligation. + commit(repo, { '.github/workflows/free-tests.yml': null, '.github/workflows/windows.yml': null }, 'no workflows'); + repo.git('push', '-q', 'origin', 'HEAD:main'); + repo.git('fetch', '-q', 'origin'); + commit(repo, { 'app/src/sum.ts': 'export const sum = (a: number, b: number) => b + a;\n' }); + const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'], pmShim(repo)); + expect(r.out).toMatch(/^tests {6}lanes {5}pass .*3 tests ran in 1 file\(s\), exit 0/m); + expect(r.out).toContain("ran: (cd 'app' && pnpm exec vitest run 'src/sum.test.ts')"); + expect(fs.readFileSync(/log: (\S+)/.exec(r.out)![1]!, 'utf8')).toContain('ran in app: exec vitest run src/sum.test.ts'); + expect(r.status).toBe(EXIT.ok); + }, 60_000); +}); diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts new file mode 100644 index 000000000..b56a32032 --- /dev/null +++ b/test/pregate-user-policy.test.ts @@ -0,0 +1,407 @@ +/** + * The user-owned pregate policy and `local` lanes. A repo that keeps `.gstack/` + * out of git declares its policy at `/projects//pregate.json`; + * the base branch's copy always wins, and `--policy-from head` never reads the + * user file. A platform lane named in `local` runs here instead of becoming + * requires-remote. Every assertion holds on any host platform: each lane the + * fixture selects is either named in `local` or asserted only by label. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { loadConfigFromUser, validateConfig } from '../lib/pregate/config'; +import { buildContext, sha256, userConfigRejection } from '../lib/pregate/context'; +import { runPregate, verifyPregate } from '../lib/pregate/index'; +import { BASE_PREGATE, BIN, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; +import { runScriptSync } from '../lib/script-invocation'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const SLUG = 'userpolicy'; +const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard']; + +/** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */ +function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType } { + const repo = makeFixtureRepo({ pregate: null }); + repos.push(repo); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); + const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json'); + fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); + const run = (extra: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...extra], { GSTACK_PROJECT_SLUG: SLUG }); + return { repo, userPolicy, run }; +} +/** setup() with a base that commits `.gstack/pregate.json` (raw text, or a policy object). */ +function setupWithBase(base: string | Record): { repo: FixtureRepo; userPolicy: string } { + const repo = makeFixtureRepo({ pregate: null, baseFiles: { '.gstack/pregate.json': typeof base === 'string' ? base : JSON.stringify(base) } }); + repos.push(repo); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); + const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json'); + fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); + return { repo, userPolicy }; +} +const writePolicy = (file: string, policy: Record) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy })); + +describe('pregate.json local', () => { + // Value: protects=`local` accepts only / lane ids, shared with selection keys; fails_when=a bare job name or a + // string is accepted and silently matches no lane; why_new=the key is new; seam=none; no_claim=none beyond protects + test('takes / ids only', () => { + expect(validateConfig({ local: ['test.yml/unit'] })).toEqual([]); + expect(validateConfig({ local: ['unit'] }).map(e => e.path)).toEqual(['local[0]']); + expect(validateConfig({ local: 'test.yml/unit' }).map(e => e.path)).toEqual(['local']); + expect(validateConfig({ local: [7] }).map(e => e.path)).toEqual(['local']); + expect(validateConfig({ selection: { unit: 'true' } }).map(e => e.path)).toEqual(['selection["unit"]']); + }); +}); + +describe('loadConfigFromUser', () => { + // Value: protects=a missing user file falls back (null) while any other read error refuses at the file's path, never defaults; + // fails_when=every read error is treated as absent and the user's guards silently vanish; why_new=the loader is new; seam=none; + // no_claim=EACCES specifically (a directory, and a dangling symlink, stand in for an unreadable file) + test('ENOENT is null; an unreadable file refuses at its path', () => { + const { userPolicy } = setup(); + expect(loadConfigFromUser(userPolicy)).toBeNull(); + fs.mkdirSync(userPolicy); + const load = loadConfigFromUser(userPolicy); + expect(load && 'errors' in load ? load.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]); + expect(load && 'kind' in load ? [load.kind, load.label] : null).toEqual(['user', `user ${userPolicy}`]); + fs.rmSync(userPolicy, { recursive: true }); + fs.symlinkSync(path.join(path.dirname(userPolicy), 'not-yet-cloned.json'), userPolicy); + const dangling = loadConfigFromUser(userPolicy); + expect(dangling && 'errors' in dangling ? dangling.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]); + fs.unlinkSync(userPolicy); + // A dangling link one level up (the project directory) is the same intent. + const dir = path.dirname(userPolicy); + fs.rmSync(dir, { recursive: true }); + fs.symlinkSync(path.join(path.dirname(dir), 'not-yet-cloned'), dir); + expect(loadConfigFromUser(userPolicy)).not.toBeNull(); + fs.unlinkSync(dir); + fs.mkdirSync(dir); + // Malformed JSON reaches the sealed/price_file probe too; it must still come back as a refusal, never a throw. + fs.writeFileSync(userPolicy, '{"local": ['); + const bad = loadConfigFromUser(userPolicy); + expect(bad && 'errors' in bad ? bad.errors.map(e => e.message.startsWith('not valid JSON')) : null).toEqual([true]); + // The JSON error names this file, not the repo copy parseConfig assumes. + expect(bad && 'errors' in bad ? bad.errors.map(e => e.path) : null).toEqual([userPolicy]); + }); +}); + +describe('gstack-pregate with a user-owned policy', () => { + // Value: protects=with no base copy the user file is the policy and its `local` lanes run here, while an unnamed platform lane stays + // requires-remote; fails_when=the user fallback or the `local` branch in selectLanes is dropped; why_new=both are new; + // seam=none; no_claim=a real CI host (lanes are judged by the fixture's workflows) + test('the user file is read when the base has none, and a local lane runs here instead of requires-remote', () => { + const { repo, userPolicy, run } = setup(); + expect(run().out).toContain('(no .gstack/pregate.json)'); + + // The lane left off `local` must be foreign to this host: on Windows the windows lane is native and runs anyway. + const [named, foreign] = process.platform === 'win32' + ? [['windows.yml/windows-shard', 'windows'], ['free-tests.yml/unit', 'linux']] + : [['free-tests.yml/unit', 'linux'], ['windows.yml/windows-shard', 'windows']]; + writePolicy(userPolicy, { local: [named[0]] }); + const partial = run(); + expect(partial.out).toContain(`policy user ${userPolicy}`); + expect(partial.out).toContain(`requires-remote ${foreign[0]} (${foreign[1]})`); + expect(partial.out).not.toContain(`requires-remote ${named[0]}`); + + writePolicy(userPolicy, { local: LOCAL_ALL }); + const all = run(['--explain']); + expect(all.status).toBe(EXIT.ok); + // The record pins the user file's bytes, since no commit does. + const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes'); + expect(lanes.inputs.policy).toBe(sha256(fs.readFileSync(userPolicy, 'utf8'))); + expect(all.out).toContain('Tiers ran: unit; requires-remote: none'); + expect(all.out).toContain(`lane ${foreign[0]}: ${foreign[1]} lane satisfied locally (pregate.json local)`); + expect(all.out).toMatch(/^tests {6}lanes {5}pass /m); + }, 60_000); + + // Value: protects=a fix that says where to declare policy names the user file when it is the active policy, since an edit to the + // repo's .gstack/pregate.json is never read on a base that carries none; fails_when=the fix hardcodes .gstack/pregate.json; + // why_new=the user source is new; seam=none; no_claim=every fix string (strays, runner, remote_gate share ctx.configFile) + test('the no-lane fix names the user file when it is the active policy', () => { + const { repo, userPolicy, run } = setup(); + writePolicy(userPolicy, { local: LOCAL_ALL }); + commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' }); + expect(run().status).not.toBe(EXIT.ok); + const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes'); + expect(lanes.code).toBe('PREGATE_NO_LANE'); + expect(lanes.fix).toContain(`under dependencies — or empty_lanes for other documentation — in ${userPolicy}`); + }, 60_000); + + // Value: protects=an invalid, sealed-bearing or unreadable user file refuses with PREGATE_CONFIG_INVALID naming the file, with a fix + // that points at the file rather than the base branch; fails_when=user-file errors run on defaults, or sealed/price_file are accepted + // and look active while nothing reads them; why_new=the user source is new; seam=none; no_claim=the sealed guard itself + test('an invalid, sealed or unreadable user file refuses and names the file', () => { + const { userPolicy, run } = setup(); + writePolicy(userPolicy, { local: ['not-a-lane'] }); + const invalid = run(); + expect(invalid.status).toBe(EXIT.refused); + expect(invalid.err).toContain('PREGATE_CONFIG_INVALID'); + expect(invalid.err).toContain(`user ${userPolicy}`); + expect(invalid.err).toContain(`fix: edit ${userPolicy} directly`); + expect(invalid.err).not.toContain('.gstack/pregate.json has'); + + for (const [key, value] of [['sealed', { paths: ['evals/**'] }], ['price_file', 'evals/calls.json']] as const) { + writePolicy(userPolicy, { [key]: value }); + const r = run(); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toContain(`covers gstack-pregate run only; ${key}`); + } + // A null price_file is the documented default, not a policy, so it is accepted. + writePolicy(userPolicy, { price_file: null, local: LOCAL_ALL }); + expect(run().err).not.toContain('PREGATE_CONFIG_INVALID'); + + fs.rmSync(userPolicy); + fs.mkdirSync(userPolicy); + const unreadable = run(); + expect(unreadable.status).toBe(EXIT.refused); + expect(unreadable.err).toContain('cannot read the user-owned policy'); + }, 60_000); + + // Value: protects=the user file is consulted only for --policy-from base with no base copy: head mode and a committed base copy never + // read it; fails_when=either guard on the fallback is dropped and an unreviewed user file overrides reviewed policy; why_new=the + // fallback is new; seam=none; no_claim=none beyond protects + test('--policy-from head and a base copy never read the user file', () => { + const { repo, userPolicy, run } = setup(); + writePolicy(userPolicy, { local: ['not-a-lane'] }); + + const head = run(['--policy-from', 'head']); + expect(head.err).not.toContain('PREGATE_CONFIG_INVALID'); + expect(head.out).not.toContain('policy user'); + + commit(repo, { '.gstack/pregate.json': JSON.stringify(BASE_PREGATE) + '\n' }); + repo.git('push', '-q', 'origin', 'HEAD:main'); + repo.git('fetch', '-q', 'origin'); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }); + const based = run(); + expect(based.err).not.toContain('PREGATE_CONFIG_INVALID'); + expect(based.out).toContain('origin/main@'); + expect(based.out).not.toContain('policy user'); + // A base copy is pinned by inputs.base; no user digest is recorded. + const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes'); + expect(lanes.inputs.base).toContain('origin/main@'); + expect('policy' in lanes.inputs).toBe(false); + }, 60_000); + + // Value: protects=the --remote refusal for a policy with no remote_gate tells the user where to declare it: the repo copy "on the + // base branch", the user file by its path (never "on the base branch", which the user file is not on); fails_when=the + // configFile ternary is dropped or inverted and the user is sent to edit a file nothing reads; why_new=no test reaches the + // no-remote_gate branch and the user source is new; seam=none (userConfigPath and remote are existing buildContext options); + // no_claim=the remote gate run itself + test('--remote with no remote_gate names the file to declare it in', async () => { + const lanesFix = async (repo: FixtureRepo, userConfigPath?: string) => { + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', remote: { spendCapUsd: 1 }, userConfigPath }); + if (!('ctx' in loaded)) throw new Error('fixture context refused'); + const lanes = (await runPregate(loaded.ctx, { stage: 'tests' })).checks.find(c => c.id === 'lanes')!; + expect([lanes.status, lanes.code]).toEqual(['incomplete', 'PREGATE_INCOMPLETE']); + return lanes.fix!; + }; + const { repo, userPolicy } = setup(); + writePolicy(userPolicy, { local: LOCAL_ALL }); + const user = await lanesFix(repo, userPolicy); + expect(user).toContain(`in ${userPolicy}, or run the tests locally`); + expect(user).not.toContain('on the base branch'); + + const based = makeFixtureRepo({ pregate: { ...BASE_PREGATE, local: LOCAL_ALL } }); + repos.push(based); + commit(based, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); + expect(await lanesFix(based)).toContain('in .gstack/pregate.json on the base branch, or run the tests locally'); + }, 60_000); + + // Value: protects=the strays fail fix, the strays warn fix and the PREGATE_STALLED fix each name the user file when it is the active + // policy, so the user edits the file that is read; fails_when=any of the three reverts to a hardcoded .gstack/pregate.json, which a + // base carrying none never reads; why_new=existing strays and stall tests assert detail and lines, never the fix, and only with a + // repo copy; seam=none; no_claim=the stall timing itself (pregate-supervise covers it) + test('strays (fail and warn) and stall fixes name the user file', () => { + const { repo, userPolicy, run } = setup(); + writePolicy(userPolicy, { local: LOCAL_ALL }); + const check = (id: string) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === id); + const strays = (files: Record) => { + commit(repo, files); + runBin(repo, 'gstack-pregate', ['--base', 'main', '--stage', 'preflight', '--only', 'strays'], { GSTACK_PROJECT_SLUG: SLUG }); + return check('strays'); + }; + + const fail = strays({ 'notes.txt': 'x\n', 'newtop/thing.js': 'module.exports = 1;\n' }); + expect([fail.status, fail.code]).toEqual(['fail', 'PREGATE_STRAYS']); + expect(fail.fix).toContain(`goes in ${userPolicy} strays.allow`); + + const warn = strays({ 'notes.txt': null }); + expect([warn.status, warn.code]).toEqual(['warn', 'PREGATE_STRAYS']); + expect(warn.fix).toContain(`allow it in ${userPolicy} strays.allow`); + + commit(repo, { 'newtop/thing.js': null, 'test/hang.test.js': 'setInterval(() => {}, 1000);\n' }); + run(['--stall-timeout', '2']); + const stalled = check('lanes'); + expect(stalled.code).toBe('PREGATE_STALLED'); + expect(stalled.fix).toContain(`stall_timeout_s in ${userPolicy}; 0 disables`); + }, 60_000); + + // Value: protects=a user path inside the checkout (GSTACK_HOME under the repo, or a relative state root when HOME is unset) is + // never read, so a PR cannot commit its own "user" policy; fails_when=userConfigRejection is dropped and the in-repo file + // becomes the policy; why_new=the trust check is new; seam=none (userConfigPath is an existing buildContext option); + // no_claim=a symlinked state root (the next test) + test('a user path inside the repository or relative is ignored, with an explain line', () => { + const { repo } = setup(); + const inside = path.join(repo.work, '.gstack', 'projects', 'x', 'pregate.json'); + fs.mkdirSync(path.dirname(inside), { recursive: true }); + fs.writeFileSync(inside, JSON.stringify({ local: ['not-a-lane'] })); + for (const [file, why] of [[inside, 'inside the repository'], [path.join('.gstack', 'projects', 'x', 'pregate.json'), 'not an absolute path']] as const) { + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: file }); + if (!('ctx' in loaded)) throw new Error(`refused: ${loaded.refused.message}`); + expect(loaded.ctx.configLabel).not.toStartWith('user '); + expect(loaded.ctx.explain).toContain(`user policy: ${file} ignored (${why}${why === 'inside the repository' ? '; a PR could supply it' : ''})`); + } + }, 60_000); + + // Value: protects=a base copy that exists but git show cannot read (over spawnSync's 1 MB buffer, or its blob missing) + // refuses instead of letting the user file replace it; fails_when=a git show failure is read as "no base copy" and the + // unreviewed user file governs a repo that commits its policy; why_new=the user-file fallback is new; seam=none; + // no_claim=timeouts (they take the same failure branch) + test('a base copy git show cannot read refuses rather than reading the user file', () => { + const { repo, userPolicy } = setupWithBase(`{"runner": "node --test {files}"${' '.repeat(1_200_000)}}\n`); + writePolicy(userPolicy, { local: LOCAL_ALL }); + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy }); + expect('refused' in loaded ? [loaded.refused.code, loaded.refused.message] : null).toEqual(['POLICY_SOURCE_UNAVAILABLE', 'origin/main:.gstack/pregate.json could not be read and git cannot confirm it is absent']); + }, 60_000); + + // Value: protects=absence is decided from the tree, not the blob; fails_when=cat-file -e (a blob check) stands in for absence + // and a missing or corrupt blob lets the user file govern; why_new=the fallback is new; seam=none; no_claim=partial clones + test('a base copy whose blob is missing refuses rather than reading the user file', () => { + const { repo, userPolicy } = setupWithBase({ runner: 'node --test {files}' }); + const blob = repo.git('rev-parse', 'origin/main:.gstack/pregate.json'); + fs.rmSync(path.join(repo.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2))); + writePolicy(userPolicy, { local: LOCAL_ALL }); + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy }); + expect('refused' in loaded ? loaded.refused.code : null).toBe('POLICY_SOURCE_UNAVAILABLE'); + }, 60_000); + + // Value: protects=the containment check sees through a symlinked state root and does not mistake an in-repo `..name` + // directory for a parent; fails_when=the realpath half or the `..`-segment test regresses and a file a PR committed is read + // as the user policy; why_new=both edges are new; seam=none; no_claim=case-only path differences (realpathSync.native) + test('a state root symlinked into the checkout, or under an in-repo ..name directory, is inside', () => { + const { repo } = setup(); + fs.mkdirSync(path.join(repo.work, '.st')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-sr-')); + try { + const link = path.join(outside, 'root'); + fs.symlinkSync(path.join(repo.work, '.st'), link); + expect(userConfigRejection(repo.work, path.join(link, 'projects', 'x', 'pregate.json'))).toContain('inside the repository'); + expect(userConfigRejection(repo.work, path.join(repo.work, '..st', 'projects', 'x', 'pregate.json'))).toContain('inside the repository'); + expect(userConfigRejection(repo.work, path.join(outside, 'projects', 'x', 'pregate.json'))).toBeNull(); + } finally { fs.rmSync(outside, { recursive: true, force: true }); } + }); + + // Value: protects=a policy reached through the checkout is refused even when the last link points back outside it, so a + // branch cannot steer which existing out-of-repo policy governs it; fails_when=containment checks only the lexical path and + // the final target, and a state root or a link hop inside the checkout picks the file; why_new=security review of #3286; + // seam=none; no_claim=case-only path differences + test('a policy whose link chain passes through the checkout is inside, even when its final target is outside', () => { + const { repo } = setup(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-chain-')); + try { + const permissive = path.join(outside, 'permissive.json'); + fs.writeFileSync(permissive, '{}'); + fs.mkdirSync(path.join(repo.work, '.st', 'projects', 'x'), { recursive: true }); + fs.symlinkSync(permissive, path.join(repo.work, '.st', 'projects', 'x', 'pregate.json')); + fs.symlinkSync(path.join(repo.work, '.st'), path.join(outside, 'root')); + expect(userConfigRejection(repo.work, path.join(outside, 'root', 'projects', 'x', 'pregate.json'))).toContain('inside the repository'); + fs.symlinkSync(permissive, path.join(repo.work, '.st', 'hop.json')); + fs.symlinkSync(path.join(repo.work, '.st', 'hop.json'), path.join(outside, 'leaf.json')); + expect(userConfigRejection(repo.work, path.join(outside, 'leaf.json'))).toContain('inside the repository'); + fs.symlinkSync(permissive, path.join(outside, 'direct.json')); + expect(userConfigRejection(repo.work, path.join(outside, 'direct.json'))).toBeNull(); + } finally { fs.rmSync(outside, { recursive: true, force: true }); } + }); + + // Value: protects=a defaults pass stops verifying once a link on the way to the absent user path dangles, the state a fresh + // run refuses; fails_when=verify checks only the leaf and reads the broken parent as continued absence; why_new=security + // review of #3286; seam=verifyPregate with the recorded tree passed in, so only the policy check differs; no_claim=the + // bin's verify wiring (the stale-policy test covers it) + test('verify goes stale when a parent of the absent user path becomes a dangling link', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-dangle-')); + try { + const userPolicy = path.join(dir, 'projects', 's', 'pregate.json'); + const record = { schema_version: 1, tree: 'T', checks: [], policy: 'defaults', policy_user_path: userPolicy, verdict: 'pass' } as any; + expect(verifyPregate(dir, record, { tree: 'T' }).ok).toBe(true); + fs.symlinkSync(path.join(dir, 'not-yet-cloned'), path.join(dir, 'projects')); + expect((loadConfigFromUser(userPolicy) as { errors: { code: string }[] } | null)?.errors.map(e => e.code)).toEqual(['PREGATE_CONFIG_INVALID']); + const v = verifyPregate(dir, record, { tree: 'T' }); + expect([v.ok, v.code]).toEqual([false, 'PREGATE_STALE']); + expect(v.message).toContain(`cannot confirm ${userPolicy} is still absent`); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + // Value: protects=--explain says when the user file was looked for and absent, and names a `local` id that matches no workflow lane; + // fails_when=either line is dropped and a typo'd slug or lane id silently does nothing; why_new=both lines are new; seam=none; + // no_claim=the line wording beyond its path and id + test('--explain names an absent user file and a local id that matches no workflow lane', () => { + const { userPolicy, run } = setup(); + expect(run(['--explain']).out).toContain(`user policy: ${userPolicy} (absent)`); + writePolicy(userPolicy, { local: [...LOCAL_ALL, 'ci.yml/nope'] }); + const r = run(['--explain']); + expect(r.out).toContain('local: ci.yml/nope matches no workflow lane; ignored'); + expect(r.out).not.toContain('local: free-tests.yml/unit matches'); + }, 60_000); + + // Value: protects=with no GSTACK_PROJECT_SLUG the bin finds the user file under the slug gstack-slug resolves for the repo; + // fails_when=the bin computes the slug some other way (a different cwd or state root) and the documented path is never read; + // why_new=every other test pins GSTACK_PROJECT_SLUG; seam=none; no_claim=slug rules themselves (state-root tests cover them) + test('the default slug path is the one gstack-slug prints', () => { + const { repo } = setup(); + const noSlug = { GSTACK_PROJECT_SLUG: '' }; + const slug = /^SLUG=(.+)$/m.exec(runBin(repo, 'gstack-slug', [], noSlug).out)?.[1]; + expect(slug).toBeTruthy(); + const userPolicy = path.join(repo.home, 'projects', slug!, 'pregate.json'); + fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); + writePolicy(userPolicy, { local: LOCAL_ALL }); + expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'], noSlug).out).toContain(`policy user ${userPolicy}`); + }, 60_000); + + // Value: protects=a branch's committed .env cannot choose the slug or state root behind the user policy, whether pregate is + // launched through scriptInvocation, by its shebang, or as a bare `bun ` (which refuses on any loaded dotenv file, + // since Bun's syntax admits `KEY: value` and bare-CR lines a key scan misses); fails_when=Bun's dotenv + // loading reaches slugFromEnvironment and a branch picks which pregate.json governs it; why_new=maintainer review of #3286; + // seam=none; no_claim=a .env in a parent of the working directory (Bun reads the launch directory only) + test('a committed .env cannot redirect the user policy', () => { + const { repo } = setup(); + const env: Record = { ...process.env, ...repo.env }; + delete env.GSTACK_PROJECT_SLUG; + const slug = /^SLUG=(.+)$/m.exec(runScriptSync(BIN('gstack-slug'), [], { cwd: repo.work, encoding: 'utf8', env }).stdout)?.[1]; + expect(slug).toBeTruthy(); + const userPolicy = path.join(repo.home, 'projects', slug!, 'pregate.json'); + fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); + writePolicy(userPolicy, { local: LOCAL_ALL }); + commit(repo, { '.env': 'GSTACK_PROJECT_SLUG=elsewhere\n' }); + const args = ['--base', 'main', '--allow-repo-commands', '--stage', 'tests']; + expect(runScriptSync(BIN('gstack-pregate'), args, { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }).stdout).toContain(`policy user ${userPolicy}`); + if (process.platform !== 'win32') expect(spawnSync(BIN('gstack-pregate'), args, { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }).stdout).toContain(`policy user ${userPolicy}`); + const bare = spawnSync(process.execPath, [BIN('gstack-pregate'), ...args], { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }); + expect(bare.status).toBe(EXIT.refused); + expect(bare.stderr).toContain('POLICY_SOURCE_UNAVAILABLE'); + expect(bare.stderr).toContain('bun loaded .env from the working directory'); + }, 120_000); + + // Value: protects=a recorded pass stops verifying once the user-owned policy behind it is edited or deleted, or once a user + // file appears where the run used the defaults; fails_when=verify checks only the tree and a weakened or removed policy + // keeps an old pass publishable; why_new=maintainer review of #3286; seam=none; no_claim=a base-branch copy that changes + // (pinned by the recorded base, not re-read); both paths come from the record, so the verifier's environment is not read + test('verify goes stale when the user-owned policy changes, disappears, or appears after a defaults run', () => { + const { repo, userPolicy, run } = setup(); + const verify = () => JSON.parse(runBin(repo, 'gstack-pregate', ['verify', '--json'], { GSTACK_PROJECT_SLUG: SLUG }).out); + writePolicy(userPolicy, { local: LOCAL_ALL }); + expect(run().status).toBe(EXIT.ok); + expect(verify().ok).toBe(true); + writePolicy(userPolicy, { local: ['free-tests.yml/unit'] }); + expect([verify().code, verify().message]).toEqual(['PREGATE_STALE', `the user-owned policy ${userPolicy} changed after pregate.json was recorded; rerun gstack-pregate`]); + fs.rmSync(userPolicy); + expect(verify().message).toContain('is gone or unreadable'); + run(); + expect(verify().code).toBe('PREGATE_REMOTE_UNCLEARED'); + writePolicy(userPolicy, { local: LOCAL_ALL }); + expect([verify().code, verify().message]).toEqual(['PREGATE_STALE', `${userPolicy} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`]); + }, 120_000); +}); diff --git a/test/qa-health-rubric.test.ts b/test/qa-health-rubric.test.ts index 8f8d9dee0..842f97128 100644 --- a/test/qa-health-rubric.test.ts +++ b/test/qa-health-rubric.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; import { generateQAMethodology } from '../scripts/resolvers/utility'; import { HOST_PATHS } from '../scripts/resolvers/types'; @@ -31,3 +35,47 @@ describe('QA health rubric scoring contract', () => { expect(rubric).toContain('Round only the final score'); }); }); + +const healthTmpl = fs.readFileSync(path.join(import.meta.dir, '..', 'health', 'SKILL.md.tmpl'), 'utf-8'); +const gbrainSection = healthTmpl.split('**GBrain sub-score computation:**')[1] ?? ''; +const gbrainScript = gbrainSection.match(/```bash\nbun --no-env-file --config=\/dev\/null -e '\n([\s\S]*?)\n'\n```/)?.[1] ?? ''; + +function gbrainScore(doctor: number | null, queue: number | null, pushHours: number | null): string { + const script = gbrainScript + .replace('', String(doctor)) + .replace('', String(queue)) + .replace('', String(pushHours)); + const run = spawnSync(process.execPath, [...bunIsolationArgs(), '-e', script], { encoding: 'utf-8', timeout: 30_000 }); + expect(run.status).toBe(0); + return run.stdout.trim(); +} + +describe('/health GBrain score has one rule, computed in code (#3351)', () => { + test('the rubric row defers to the computation instead of scoring bands', () => { + const row = healthTmpl.split('\n').find(line => line.startsWith('| GBrain |')) ?? ''; + expect(row).toContain('GBrain sub-score computation'); + expect(row).not.toMatch(/queue\s* { + expect(gbrainScript).not.toBe(''); + expect(gbrainScore(10, 50, 2)).toBe('GBRAIN_SCORE: 9.1'); + expect(gbrainScore(7, 3, 2)).toBe('GBRAIN_SCORE: 8.5'); + }); + + test('bands are half-open at 10/100 queue lines and 24/72 hours', () => { + expect(gbrainScore(10, 9, 23.9)).toBe('GBRAIN_SCORE: 10.0'); + expect(gbrainScore(10, 10, 24)).toBe('GBRAIN_SCORE: 8.5'); + expect(gbrainScore(10, 99, 71.9)).toBe('GBRAIN_SCORE: 8.5'); + expect(gbrainScore(10, 100, 72)).toBe('GBRAIN_SCORE: 5.0'); + expect(gbrainScore(0, 100, 72)).toBe('GBRAIN_SCORE: 0.0'); + expect(gbrainScore(null, 0, 1)).toBe('GBRAIN_SCORE: 5.0'); + }); + + test('sync off gives the doctor component the whole weight', () => { + expect(gbrainScore(10, null, null)).toBe('GBRAIN_SCORE: 10.0'); + expect(gbrainScore(7, null, null)).toBe('GBRAIN_SCORE: 7.0'); + expect(gbrainScore(0, null, null)).toBe('GBRAIN_SCORE: 0.0'); + }); +}); diff --git a/test/review-fix-range.test.ts b/test/review-fix-range.test.ts new file mode 100644 index 000000000..defb04c03 --- /dev/null +++ b/test/review-fix-range.test.ts @@ -0,0 +1,216 @@ +/** + * #3339: /ship Step 9 may re-review only the fix range, but a fix-range pass + * can never be the last review. The fix base comes from the review log (never + * the working tree or the caller) and is the commit whose tree the fixing pass + * started from, a narrowed row is never CURRENT and carries no + * start_wtree/end_wtree for Step 11.5 to bind, and narrowed dispatches stay out + * of specialist hit rates. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { canReuseSharedLibsAdvisory, reviewFreshness, sharedLibsFingerprint } from '../lib/review-evidence'; +import { gitIn } from './helpers/scratch-repo'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin'); +const W = 'a'.repeat(40); + +let root: string, repo: string, env: NodeJS.ProcessEnv; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fix-range-')); + repo = path.join(root, 'repo'); + fs.mkdirSync(repo); + env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state') }; + delete env.GSTACK_HOME; + gitIn(repo, 'init -q -b feature'); + fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n'); + gitIn(repo, 'add a.ts'); + gitIn(repo, 'commit -q -m init'); +}); +afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); + +function bin(name: string, args: string[]) { + const r = spawnSync(path.join(BIN, name), args, { cwd: repo, encoding: 'utf8', timeout: 60_000, env }); + return { code: r.status, out: r.stdout.trim(), err: r.stderr }; +} +const head = () => gitIn(repo, 'rev-parse HEAD').trim(); +const edit = (text: string) => fs.writeFileSync(path.join(repo, 'a.ts'), text); +const commit = () => gitIn(repo, 'commit -q -am fix'); +const row = (fields: Record) => JSON.stringify({ skill: 'review', via: 'ship', status: 'clean', issues_found: 0, critical: 0, informational: 0, ...fields }); +const fixing = { status: 'issues_found', issues_found: 1, completed: true, converged: false }; +const clean = { completed: true, converged: true }; + +// Step 9.4's order: start, review, apply fixes, log with the original token, then commit the fixes. +function pass(fields: Record, start: string[] = ['--start', 'review'], fixes?: string, commitFixes = true) { + const started = bin('gstack-review-log', start); + expect(started.code).toBe(0); + const [token, base] = started.out.split('\n'); + if (fixes) edit(fixes); + expect(bin('gstack-review-log', [row(fields), '--finish', token!]).code).toBe(0); + if (fixes && commitFixes) commit(); + return base; +} +function rows(): any[] { + return bin('gstack-review-read', []).out.split('\n---CONFIG---')[0]!.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); +} +const narrowStart = () => bin('gstack-review-log', ['--start', 'review', '--fix-range']); + +describe('fix-range start', () => { + test('after a completed fixing pass it prints the token and the reviewed commit as FIX_BASE', () => { + const reviewed = head(); + pass(fixing, undefined, 'export const a = 2;\n'); + expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${reviewed}`); + expect(gitIn(repo, `diff --name-only ${reviewed}`).trim()).toBe('a.ts'); + }); + + test('fixes still uncommitted at the repeat are inside the range', () => { + const reviewed = head(); + pass(fixing, undefined, 'export const a = 2;\n', false); + expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${reviewed}`); + expect(gitIn(repo, `diff --name-only ${reviewed}`).trim()).toBe('a.ts'); + }); + + test('chained fix-range passes take the latest fixing pass as base', () => { + pass(fixing, undefined, 'export const a = 2;\n'); + const second = head(); + pass(fixing, ['--start', 'review', '--fix-range'], 'export const a = 3;\n'); + expect(pass(clean, ['--start', 'review', '--fix-range'])).toBe(`FIX_BASE=${second}`); + }); + + test.each([ + ['no earlier pass', () => {}, 'no earlier pass on this branch'], + ['a clean latest pass', () => pass(clean), 'the latest pass made no fixes'], + ['an incomplete latest pass (coverage restoration stays full)', () => pass({ ...fixing, status: 'unavailable', completed: false }, undefined, 'export const a = 2;\n'), 'the latest pass did not complete'], + ['a Step 11 review after the latest pass', () => { + pass(fixing, undefined, 'export const a = 2;\n'); + pass({ skill: 'adversarial-review', ...clean }, ['--start', 'adversarial-review']); + }, 'a Step 11 review followed the latest pass'], + ['a latest pass logged without a start capture', () => { + edit('export const a = 2;\n'); + expect(bin('gstack-review-log', [row(fixing)]).code).toBe(0); + }, 'the latest pass has no captured start'], + ['a pass that started from uncommitted content (a reverted dirty hunk would fall outside the range)', () => { + edit('export const a = 1; // dirty\n'); + pass(fixing, undefined, 'export const a = 1;\n', false); + }, 'started from uncommitted content'], + ['a pass that started with an untracked file', () => { + fs.writeFileSync(path.join(repo, 'b.ts'), 'export const b = 1;\n'); + pass(fixing, undefined, 'export const a = 2;\n'); + }, 'started from uncommitted content'], + ['a base that is no longer an ancestor of HEAD', () => { + edit('export const a = 2;\n'); + commit(); + pass(fixing, undefined, 'export const a = 3;\n'); + gitIn(repo, 'reset -q --hard HEAD~2'); + edit('export const a = 4;\n'); + commit(); + }, 'is not an ancestor of HEAD'], + ])('refuses %s', (_, setup, reason) => { + setup(); + const r = narrowStart(); + expect(r.code).toBe(1); + expect(r.out).toBe(''); + expect(r.err).toContain('fix-range refused: '); + expect(r.err).toContain(reason); + expect(r.err).toContain('run a full pass (gstack-review-log --start review)'); + }); + + test('rejects an unknown start flag with usage', () => { + expect(bin('gstack-review-log', ['--start', 'review', '--narrow']).code).toBe(2); + }); +}); + +describe('a fix-range pass is never the last review', () => { + test('its row is fix-range scoped whatever the caller claims, unbindable at Step 11.5 and never CURRENT', () => { + const reviewed = head(); + pass(fixing, undefined, 'export const a = 2;\n'); + pass({ ...clean, scope: 'full', fix_base: 'f'.repeat(40), review_binding: { state: 'verified', start_wtree: W, end_wtree: W }, wtree: W }, + ['--start', 'review', '--fix-range']); + const last = rows().at(-1); + expect(last.scope).toBe('fix-range'); + expect(last.fix_base).toBe(reviewed); + expect(last.review_binding.state).toBe('fix-range'); + expect(last.review_binding.range_start_wtree).toBe(last.review_binding.range_end_wtree); + expect(last.review_binding).not.toHaveProperty('start_wtree'); + expect(last.review_binding).not.toHaveProperty('end_wtree'); + expect(last).not.toHaveProperty('wtree'); + expect(last.review_freshness).toEqual({ status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' }); + expect(narrowStart().err).toContain('the latest pass made no fixes'); + pass(clean); + expect(rows().at(-1).review_freshness.status).toBe('CURRENT'); + }); + + test('a fixing fix-range pass is not CURRENT either', () => { + pass(fixing, undefined, 'export const a = 2;\n'); + pass(fixing, ['--start', 'review', '--fix-range'], 'export const a = 3;\n'); + const last = rows().at(-1); + expect(last.scope).toBe('fix-range'); + expect(last.review_freshness.status).not.toBe('CURRENT'); + }); + + test('a caller cannot narrow a full pass by claiming fix-range', () => { + pass({ ...clean, scope: 'fix-range', fix_base: W }); + const last = rows().at(-1); + expect(last).not.toHaveProperty('scope'); + expect(last).not.toHaveProperty('fix_base'); + expect(last.review_freshness.status).toBe('CURRENT'); + }); + + test('a start token that still names a fix base stays narrowed even if its scope is rewritten', () => { + pass(fixing, undefined, 'export const a = 2;\n'); + const [token] = narrowStart().out.split('\n'); + const slug = bin('gstack-slug', []).out.match(/^SLUG=(.+)$/m)![1]!; + const file = path.join(root, 'state', 'projects', slug, '.review-starts', `${token}.json`); + fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), scope: 'full', fix_base: 'not-a-commit' })); + expect(bin('gstack-review-log', [row(clean), '--finish', token!]).code).toBe(0); + const last = rows().at(-1); + expect(last.scope).toBe('fix-range'); + expect(last).not.toHaveProperty('fix_base'); + expect(last.review_freshness.status).toBe('UNVERIFIED'); + }); + + test('freshness rejects a fix-range row even with a verified-looking binding', () => { + const rec = { skill: 'review', scope: 'fix-range', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, wtree: W, + review_binding: { state: 'verified', start_wtree: W, end_wtree: W } }; + expect(reviewFreshness(rec, W)?.status).toBe('UNVERIFIED'); + expect(reviewFreshness({ ...rec, scope: undefined }, W)?.status).toBe('CURRENT'); + }); + + test('shared-libs advisory reuse never trusts a fix-range row', () => { + const finding = { severity: 'INFORMATIONAL', advisory: true, action: 'skipped', evidence_paths: ['a.ts'], helper_target: { path: 'b.ts', symbol: 'b' } }; + const prior = { ...finding, fingerprint: sharedLibsFingerprint(finding), snapshot_covered_paths: ['a.ts'] }; + const snapshot = { wtree: W, branch_id: 'd'.repeat(64), covered_paths: ['a.ts'] }; + const full = { skill: 'review', completed: true, converged: true, wtree: W, review_binding: { state: 'verified', start_wtree: W, end_wtree: W, branch_id: 'd'.repeat(64) } }; + expect(canReuseSharedLibsAdvisory(prior, finding, full, snapshot)).toBe(true); + const narrowed = { ...full, scope: 'fix-range', review_binding: { state: 'fix-range', range_start_wtree: W, range_end_wtree: W, branch_id: 'd'.repeat(64) } }; + expect(canReuseSharedLibsAdvisory(prior, finding, narrowed, snapshot)).toBe(false); + }); +}); + +describe('gstack-specialist-stats', () => { + test('fix-range passes do not count toward hit rates or gate candidacy', () => { + const entry = (scope?: string) => JSON.stringify({ ...(scope ? { scope } : {}), specialists: { testing: { dispatched: true, findings: 0 } } }); + const slug = bin('gstack-slug', []).out.match(/^SLUG=(.+)$/m)![1]!; + const dir = path.join(root, 'state', 'projects', slug); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'feature-reviews.jsonl'), [...Array(9).fill(entry()), ...Array(5).fill(entry('fix-range'))].join('\n') + '\n'); + const out = bin('gstack-specialist-stats', []).out; + expect(out).toContain('SPECIALIST_STATS: 9 reviews analyzed'); + expect(out).toContain('testing: 9/9 dispatched, 0 findings (0%)'); + expect(out).not.toContain('GATE_CANDIDATE'); + }); +}); + +describe('ship Step 9 instructions', () => { + const army = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8').replace(/\s+/g, ' '); + test('the fix-range start, its base substitution and the mandatory full pass are stated', () => { + expect(army).toContain('start with `--start review --fix-range`'); + expect(army).toContain('`FIX_BASE=`, which stands in for `origin/` and `` in this pass'); + expect(army).toContain('If it refuses, start a full pass.'); + expect(army).toContain('it may be a fix-range repeat'); + expect(army).toContain('continue to Step 10, and only from a full pass: after a fix-range pass, run one full Step 9 first.'); + }); +}); diff --git a/test/session-update-stages.test.ts b/test/session-update-stages.test.ts index ef0c1d581..7bace11a9 100644 --- a/test/session-update-stages.test.ts +++ b/test/session-update-stages.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, test, expect } from 'bun:test'; -import { execFileSync, spawnSync } from 'child_process'; +import { execFileSync, spawn, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -97,13 +97,13 @@ function bunStub(fx: Fx, version: string): string { return dir; } -function runHook(fx: Fx, opts: { noBun?: boolean; bun?: string } = {}) { +function runHook(fx: Fx, opts: { noBun?: boolean; bun?: string; env?: Record } = {}) { const PATH = opts.noBun ? pathWithoutBun(fx.base) : opts.bun ? `${bunStub(fx, opts.bun)}:${pathWithoutBun(fx.base)}` : `${path.dirname(process.execPath)}:${process.env.PATH}`; return spawnSync('bash', [SCRIPT], { encoding: 'utf8', - env: { PATH, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CONTROL: fx.control, SETUP_CALLS: fx.calls, TMPDIR: fx.base }, + env: { PATH, HOME: fx.home, GSTACK_DIR: fx.install, GSTACK_STATE_ROOT: fx.state, SETUP_CONTROL: fx.control, SETUP_CALLS: fx.calls, TMPDIR: fx.base, ...opts.env }, timeout: 20_000, }); } @@ -287,3 +287,82 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update Bun floor ( expect(banner).not.toContain('git pull did not finish'); }); }); + +describe.skipIf(process.platform === 'win32')('gstack-session-update honors workflow leases (#3363)', () => { + const head = (fx: Fx) => git(fx.install, 'rev-parse', 'HEAD'); + const sleepers: ReturnType[] = []; + afterEach(() => { for (const s of sleepers.splice(0)) s.kill('SIGKILL'); }); + // The test preload points GSTACK_LEASE_DIR at a per-run directory; each + // fixture gets its own, shared by the lease writer and the hook. + const leaseEnv = (fx: Fx) => ({ GSTACK_LEASE_DIR: path.join(fx.base, 'leases') }); + function touchLease(fx: Fx, pid: number) { + const touch = spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-lease'), 'touch', '--root', fx.install, '--pid', String(pid)], + { encoding: 'utf8', env: { ...process.env, ...leaseEnv(fx) }, timeout: 10_000 }); + expect(touch.status, touch.stderr).toBe(0); + } + /** A live process holding a workflow lease on the fixture install, as a running skill would. */ + function holdLease(fx: Fx): number { + const sleeper = spawn('sleep', ['120'], { stdio: 'ignore' }); + sleepers.push(sleeper); + touchLease(fx, sleeper.pid!); + return sleeper.pid!; + } + const throttle = (fx: Fx) => path.join(fx.state, '.last-session-update'); + + test('a live lease: no fast-forward, no setup, no throttle stamp; the next start after it ends updates', async () => { + const fx = makeFixture(); + const before = head(fx); + upstreamRelease(fx, '1.1.0'); + const pid = holdLease(fx); + + expect(runHook(fx, { env: leaseEnv(fx) }).stdout).toBe(''); + const log = await waitFor(fx, /SKIP leased /); + expect(log).toMatch(new RegExp(`SKIP leased pid=${pid}/\\d+m`)); + expect(log).not.toContain('UPDATING'); + expect(head(fx)).toBe(before); + expect(setupRuns(fx)).toBe(0); + expect(fs.existsSync(throttle(fx))).toBe(false); + expect(fs.existsSync(marker(fx))).toBe(false); + + await Promise.all(sleepers.splice(0).map(s => new Promise(r => { s.once('exit', r); s.kill('SIGKILL'); }))); + runHook(fx, { env: leaseEnv(fx) }); + await waitFor(fx, /UPDATED from=1\.0\.0 to=1\.1\.0/); + expect(head(fx)).not.toBe(before); + expect(setupRuns(fx)).toBe(1); + }, 30_000); + + test('a live lease also holds a pending setup retry', async () => { + const fx = makeFixture(); + fs.writeFileSync(fx.control, 'fail'); + upstreamRelease(fx, '1.1.0'); + runHook(fx); + await waitFor(fx, /Z PENDING stages=setup attempt=1/); + expireThrottle(fx); + expireBackoff(fx); + fs.writeFileSync(fx.control, 'ok'); + holdLease(fx); + runHook(fx, { env: leaseEnv(fx) }); + const log = await waitFor(fx, /SKIP leased /); + expect(log).not.toContain('RETRY_PENDING'); + expect(setupRuns(fx)).toBe(1); + }, 30_000); + + test('controls: GSTACK_UPGRADE_FORCE=1 updates under a live lease, and a dead holder never blocks', async () => { + const forced = makeFixture(); + upstreamRelease(forced, '1.1.0'); + holdLease(forced); + runHook(forced, { env: { ...leaseEnv(forced), GSTACK_UPGRADE_FORCE: '1' } }); + await waitFor(forced, /UPDATED from=1\.0\.0 to=1\.1\.0/); + expect(setupRuns(forced)).toBe(1); + + const dead = makeFixture(); + upstreamRelease(dead, '1.1.0'); + const sleeper = spawn('sleep', ['120'], { stdio: 'ignore' }); + touchLease(dead, sleeper.pid!); + await new Promise(r => { sleeper.once('exit', r); sleeper.kill('SIGKILL'); }); + runHook(dead, { env: leaseEnv(dead) }); + const log = await waitFor(dead, /UPDATED from=1\.0\.0 to=1\.1\.0/); + expect(log).not.toContain('SKIP leased'); + }, 30_000); +}); + diff --git a/test/ship-queue-train-logic.test.ts b/test/ship-queue-train-logic.test.ts index 3e2a4969f..15bae88b2 100644 --- a/test/ship-queue-train-logic.test.ts +++ b/test/ship-queue-train-logic.test.ts @@ -8,13 +8,15 @@ import { describe, expect, test } from 'bun:test'; import { DISPATCH_INPUTS, dedupeKey, dispatchPlan, findDuplicateRun, makeNonce, nonceScope, type RunSummary } from '../scripts/eval-bg'; import { DEFAULT_POLICY, TRAIN_KEYS_VERSION, validatePolicy } from '../lib/ship-policy'; -import { fillDue, orderSlots, parsePins } from '../lib/ship-queue-train'; +import { fillDue, orderSlots, parseOrder, parsePins, prsArgs } from '../lib/ship-queue-train'; import { gstackPaidSelection, skillOwnCases, touchedSkills } from '../lib/ship-queue-train-ready'; -import type { TrainSlot } from '../lib/ship-queue-train-record'; +import { normalizeRemoteUrl, repoIdentity, type TrainSlot } from '../lib/ship-queue-train-record'; import { INFRA_REDISPATCH, REBUILD_ONCE, classifyBisect, classifyGate } from '../lib/ship-queue-train-verdict'; import { EVAL_POLICY } from './helpers/periodic-exclude-data'; import { COMPLETE, FAIL, PASS, TRAIN_BLOCK } from './helpers/ship-queue-train-fixture'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -54,6 +56,54 @@ describe('ordering and fill', () => { expect(parsePins('12')).toMatch(/entries are @/); expect(parsePins(`12@${SHA},12@${SHA}`)).toBe('a PR appears twice in --prs'); }); + + test('--order (GSTA-67, #3337 docs slotted ahead of the #3343 catalog it describes): an explicit order replaces screened-first and READY order; absent, the default is unchanged', () => { + const docs = slot('3337', false, '2026-10-22T18:00:00Z'); + const catalog = slot('3343', false, '2026-10-22T19:00:00Z'); + expect(orderSlots([docs, catalog], 5).train.map(s => s.pr)).toEqual(['3337', '3343']); + expect(orderSlots([docs, catalog], 5, undefined).train.map(s => s.pr)).toEqual(['3337', '3343']); + const pins = [{ pr: '3337' }, { pr: '3343' }]; + const order = parseOrder('3343,#3337', pins); + expect(order).toEqual(['3343', '3337']); + expect(orderSlots([docs, catalog], 5, order as string[]).train.map(s => s.pr)).toEqual(['3343', '3337']); + const screened = slot('9', true, '2026-10-22T20:00:00Z'); + const over = orderSlots([docs, catalog, screened], 2, ['3343', '3337', '9']); + expect([over.train.map(s => s.pr), over.held.map(s => s.pr), over.split]).toEqual([['3343', '3337'], ['9'], false]); + expect(parseOrder(undefined, pins)).toBeUndefined(); + for (const bad of ['3343', '3343,3337,3337', '3343,3337,1', '3343,x']) expect(parseOrder(bad, pins)).toEqual({ error: `--order must list every PR in --prs exactly once (--prs has #3337,#3343; got ${bad})` }); + expect(prsArgs([{ pr: '3343', head: SHA }, { pr: '3337', head: SHA }], true)).toBe(`--prs 3343@${SHA},3337@${SHA} --order 3343,3337`); + expect(prsArgs([{ pr: '3343', head: SHA }], false)).toBe(`--prs 3343@${SHA}`); + }); +}); + +describe('repo identity (GSTA-67, #3331 on 2026-10-21)', () => { + test("every URL form of one GitHub repository gives one key, the github.com key gstack's records already use", () => { + const canonical = 'https://github.com/garrytan/gstack'; + for (const url of ['https://github.com/garrytan/gstack', 'https://github.com/garrytan/gstack.git', 'https://git.capy.ai/garrytan/gstack', 'https://git.capy.ai/garrytan/gstack.git/', + 'git@github.com:garrytan/gstack.git', 'ssh://git@github.com/garrytan/gstack.git', 'ssh://git@ssh.github.com:443/garrytan/gstack.git', 'https://x-access-token:PASSWORD@github.com/garrytan/gstack.git', + 'http://www.github.com/GarryTan/GStack']) expect(normalizeRemoteUrl(url)).toBe(canonical); + expect(normalizeRemoteUrl('https://gitlab.com/acme/widget.git')).toBe('https://gitlab.com/acme/widget'); + expect(normalizeRemoteUrl('https://user:PASSWORD@gitlab.com/acme/widget.git')).toBe('https://gitlab.com/acme/widget'); + expect(normalizeRemoteUrl('/tmp/fixture/origin.git')).toBe('/tmp/fixture/origin'); + }); + + test("#3331's lane (origin https://git.capy.ai/garrytan/gstack) now writes b321d228efd2bc14, and readers still accept the 1fb5dd69740a8cf8 receipt it wrote", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-identity-')); + try { + const git = (...args: string[]) => spawnSync('git', args, { cwd: dir, encoding: 'utf8', timeout: 30_000 }); + git('init', '-q'); + git('remote', 'add', 'origin', 'https://git.capy.ai/garrytan/gstack'); + const lane = repoIdentity(dir); + expect(lane.key).toBe('b321d228efd2bc14'); + expect(lane.url).toBe('https://github.com/garrytan/gstack'); + expect(lane.keys).toContain('1fb5dd69740a8cf8'); + git('remote', 'set-url', 'origin', 'https://github.com/garrytan/gstack.git'); + const coordinator = repoIdentity(dir); + expect(coordinator.key).toBe('b321d228efd2bc14'); + expect(coordinator.keys).toEqual(expect.arrayContaining(['b321d228efd2bc14', '1fb5dd69740a8cf8'])); + expect(coordinator.keys[0]).toBe('b321d228efd2bc14'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); }); describe('verdict class mapping', () => { diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 40cf32cb8..08dd50033 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -19,7 +19,7 @@ import { TRAIN_HELP } from '../lib/ship-queue-train-cli'; import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture'; import { coordinatorClone } from './helpers/ship-queue-fixture'; import { - COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson, + COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson, urlKey, type Rule, type TrainFixture, } from './helpers/ship-queue-train-fixture'; @@ -127,6 +127,30 @@ describe('ready under a train policy: the screen choice', () => { expect(dispatches(fx)).toEqual([]); }, 120_000); + test("GSTA-67: a lane whose origin is another URL form of the repository (#3331's git.capy.ai) writes its receipt under the owner/repo key", () => { + const { fx, head, pregate } = readyFixture(); + githubOrigin(fx, 'https://git.capy.ai/fixture/repo.git'); + const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']); + expect(ok.status).toBe(EXIT.ok); + const canonical = urlKey('https://github.com/fixture/repo'); + expect(canonical).not.toBe(urlKey('https://git.capy.ai/fixture/repo')); + expect(line(ok.out, 'READY_RECEIPT:')).toContain(path.join(fx.state, 'ship-queue', 'ready', canonical, `1-${head}.json`)); + }, 120_000); + + test("GSTA-67: ready refuses a head the Version Gate refuses (#3345's CHANGELOG replaced a released heading)", () => { + const fx = make(); + const head = openPr(fx, '1', { changelog: cl => cl.replace('## [1.2.3.0] - 2026-10-01', '## [Unreleased]\n\n- #1 change') }); + fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; }); + const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim(); + const pregate = path.join(fx.repo.root, 'pregate.json'); + fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] })); + const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toContain(`the Version Gate refuses ${head.slice(0, 12)}: CHANGELOG.md drops released heading 1.2.3.0`); + expect(r.err).toContain('(QUEUE_NOT_READY)'); + expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false); + }, 120_000); + test('--screen dispatches one screen with the PR, its merge SHA and the policy; a repeat adopts it; a broad screen never adopts the narrow one', () => { const { fx, head, pregate } = readyFixture(); const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']); @@ -207,6 +231,57 @@ describe('train build', () => { expect(dispatches(fx)).toEqual([]); }, 120_000); + test("GSTA-67: a PR whose CHANGELOG drops a released heading (#3345's pin 1eb7092) is a CHANGELOG conflict, never merged with the damage", () => { + const fx = make(); + const h1 = openPr(fx, '1'); + const h2 = openPr(fx, '2', { changelog: cl => cl.replace('## [1.2.3.0] - 2026-10-01', '## [Unreleased]\n\n- #2 change') }); + readyReceipt(fx, '1', h1); + readyReceipt(fx, '2', h2); + const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']); + expect(r.status).toBe(EXIT.fail); + expect(line(r.out, 'QUEUE_TRAIN_CONFLICT:')).toContain('stopped at #2 (conflicts: CHANGELOG.md (ours drops released heading 1.2.3.0 that the merge base carries)) after merging #1'); + expect(dispatches(fx)).toEqual([]); + }, 120_000); + + test("GSTA-67: READY receipts under any URL form of the repository admit (#3331's receipt was keyed on git.capy.ai, the coordinator's origin on github.com)", () => { + const fx = make(); + githubOrigin(fx, 'https://github.com/fixture/repo.git'); + const h1 = openPr(fx, '1'); + const h2 = openPr(fx, '2'); + readyReceipt(fx, '1', h1, { key: urlKey('https://git.capy.ai/fixture/repo') }); + readyReceipt(fx, '2', h2, { key: urlKey('https://github.com/fixture/repo') }); + const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']); + expect(r.status).toBe(EXIT.ok); + expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#1(unscreened) 2:#2(unscreened)'); + const h3 = openPr(fx, '3'); + readyReceipt(fx, '3', h3, { key: urlKey('https://gitlab.com/fixture/repo') }); + expect(queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--dry-run']).err).toMatch(/no READY receipt for #3 .*\(QUEUE_NOT_READY\)/); + }, 120_000); + + test('GSTA-67 --order: an explicit slot order (#3343 catalog before the #3337 docs that describe it) replaces READY order and carries into NEXT; the default is unchanged', () => { + const fx = make(); + const docs = openPr(fx, '3337'); + const catalog = openPr(fx, '3343'); + readyReceipt(fx, '3337', docs, { at: '2026-10-18T07:00:00.000Z' }); + readyReceipt(fx, '3343', catalog, { at: '2026-10-18T08:00:00.000Z' }); + const pins = `3337@${docs},3343@${catalog}`; + expect(line(queue(fx, ['train', 'build', '--prs', pins, '--dry-run']).out, 'ORDER:')).toBe('ORDER: 1:#3337(unscreened) 2:#3343(unscreened)'); + const dry = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343,3337', '--dry-run']); + expect(dry.status).toBe(EXIT.ok); + expect(line(dry.out, 'ORDER:')).toBe('ORDER: 1:#3343(unscreened) 2:#3337(unscreened) (explicit --order)'); + expect(dry.out).toMatch(new RegExp(`^SLOT: 1 #3343 head=${catalog} `, 'm')); + expect(next(dry.out)).toBe(`NEXT: gstack-ship-queue train build --prs 3343@${catalog},3337@${docs} --order 3343,3337`); + const partial = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343']); + expect(partial.status).toBe(EXIT.refused); + expect(partial.err).toContain('--order must list every PR in --prs exactly once (--prs has #3337,#3343; got 3343)'); + expect(queue(fx, ['train', 'build', '--train', 'x', '--order', '3343']).status).toBe(EXIT.usage); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const real = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343,3337']); + expect(real.status).toBe(EXIT.ok); + const t = trainJson(fx, nonceOf(real.out)); + expect([t.slots.map((s: any) => s.pr), t.explicit_order, t.prefixes[1].pr]).toEqual([['3343', '3337'], true, '3343']); + }, 180_000); + test('refusals: a fork PR, a moved head, a PR without READY, and a train still filling (until --urgent)', () => { const fx = make(); const h1 = openPr(fx, '1'); @@ -557,4 +632,50 @@ describe('verify, freeze, repair and clear', () => { expect(moved.status).toBe(EXIT.fail); expect(moved.err).toMatch(new RegExp(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps \\(${stray.slice(0, 12)}: .*\\(QUEUE_TRAIN_MOVED\\)`)); }, 300_000); + + test("GSTA-67 (#3345 at 593e61c): a merged-in head whose CHANGELOG was resolved by hand verifies even where T's mechanical CHANGELOG differs; an invented line or a head without main is still refused", () => { + const fx = make(); + const h1 = openPr(fx, '1', { changelog: false }); + const h2 = openPr(fx, '2', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- #2 change\n').replace('## [1.2.2.0] - 2026-09-01\n\n', '') }); + readyReceipt(fx, '1', h1); + readyReceipt(fx, '2', h2); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + expect(fx.repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).not.toContain('## [1.2.2.0]'); + const coord = coordinatorClone(fx.repo); + const v = () => queue(fx, ['verify', '--pr', '2', '--train', nonce], {}, coord); + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + const { repo } = fx; + const laneHead = (entry: string, parents: string[]) => { + repo.git('reset', '-q', '--hard'); + repo.git('checkout', '-q', '-B', 'lane2', h2); + repo.git('rm', '-rq', '.'); + repo.git('checkout', t.prefixes[2].commit, '--', '.'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); + fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', `# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n${entry}\n`) + '\n'); + repo.git('add', '-A'); + const head = repo.git('commit-tree', repo.git('write-tree'), ...parents.flatMap(p => ['-p', p]), '-m', 'merge main into #2 (CHANGELOG by hand), stamp 1.2.5.0'); + repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`); + return head; + }; + + const byHand = laneHead('- #2 change', [h2, main1]); + const ok = v(); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toContain(`head: ${byHand} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`); + expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${byHand}`); + + const invented = laneHead('- #2 change\n- added after the gate', [h2, main1]); + const refused = v(); + expect(refused.status).toBe(EXIT.fail); + expect(refused.err).toContain(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps (${invented.slice(0, 12)}: differs from T2 in CHANGELOG.md, which is not main's plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate"; against the pin it differs: `); + expect(refused.err).toContain('(QUEUE_TRAIN_MOVED)'); + + const orphan = laneHead('- #2 change', [h2]); + const noMain = v(); + expect(noMain.status).toBe(EXIT.fail); + expect(noMain.err).toContain(`(${orphan.slice(0, 12)}: does not contain main ${main1.slice(0, 12)}; against the pin it differs: `); + }, 300_000); }); diff --git a/test/ship-version-sync.test.ts b/test/ship-version-sync.test.ts index 705cd61fd..329af41bb 100644 --- a/test/ship-version-sync.test.ts +++ b/test/ship-version-sync.test.ts @@ -308,3 +308,44 @@ test("rendered /ship handles every version-source outcome the classifier emits", expect(step18).toMatch(/NO_VERSION[^.]*replaces items 1-3/); expect(step18).toContain("no version prefix"); }); + +// GSTA-70: next-version marks a pick provisional with `complete:false` when +// open PRs' VERSION claims could not be read. Step 12 used to print the +// warning and write the pick as if it were a verified slot. +test("rendered Step 12 reruns, then asks, on an incomplete queue instead of treating it as verified", () => { + const ship = readFileSync(join(import.meta.dir, "../ship/SKILL.md"), "utf8"); + const nextVersion = readFileSync(join(import.meta.dir, "../bin/gstack-next-version"), "utf8"); + expect(nextVersion).toMatch(/unknown_claims: unknownClaims,\s+complete,/); + const qualifyAt = ship.indexOf("**Qualify first:**"); + const incompleteAt = ship.indexOf("**Incomplete** (`complete:false`)"); + const usableAt = ship.indexOf("**Usable candidate:**"); + expect(incompleteAt).toBeGreaterThan(qualifyAt); + expect(usableAt).toBeGreaterThan(incompleteAt); + const incomplete = ship.slice(incompleteAt, usableAt).replace(/\s+/g, " "); + expect(incomplete).toContain("`unknown_claims`"); + expect(incomplete).toContain("may collide"); + expect(incomplete).toContain("Rerun item 3 once"); + expect(incomplete).toMatch(/still incomplete, AskUserQuestion: use it \(recommended[^)]*\) or stop/); +}); + +// GSTA-70: the rendered Step 12 write command, run verbatim against the real +// CLI, re-heads the branch's [Unreleased] entry under NEW_VERSION. +test("rendered Step 12 write re-heads an existing [Unreleased] entry and leaves released entries alone", () => { + const ship = readFileSync(join(import.meta.dir, "../ship/SKILL.md"), "utf8"); + const step12 = ship.slice(ship.indexOf("## Step 12:"), ship.indexOf("## Step 14:")); + const cmd = step12.match(/bun --no-env-file --config=\/dev\/null run \S+\/bin\/gstack-version-bump write [^\n]+/)?.[0]; + expect(cmd).toContain("--changelog CHANGELOG.md"); + const bin = join(import.meta.dir, "../bin"); + const runnable = cmd!.replace(/\S+\/bin\/gstack-version-bump/, join(bin, "gstack-version-bump")); + const released = "## [1.0.0.0] - 2026-10-01\n\n- first\n"; + writeFiles({ VERSION: "1.0.0.0\n", "CHANGELOG.md": `# Changelog\n\n## [Unreleased]\n\n- branch change\n\n${released}` }); + execSync(runnable, { cwd: dir, shell: "/bin/bash", env: { ...process.env, NEW_VERSION: "1.0.1.0" }, stdio: "pipe", timeout: 30_000 }); + const changelog = readFileSync(join(dir, "CHANGELOG.md"), "utf8"); + expect(changelog).toMatch(/^# Changelog\n\n## \[1\.0\.1\.0\] - \d{4}-\d{2}-\d{2}\n\n- branch change\n\n/); + expect(changelog.endsWith(released)).toBe(true); + expect(changelog).not.toContain("[Unreleased]"); + writeFiles({ VERSION: "1.0.1.0\n", "CHANGELOG.md": `# Changelog\n\n${released}` }); + execSync(runnable, { cwd: dir, shell: "/bin/bash", env: { ...process.env, NEW_VERSION: "1.0.2.0" }, stdio: "pipe", timeout: 30_000 }); + expect(readFileSync(join(dir, "CHANGELOG.md"), "utf8")).toBe(`# Changelog\n\n${released}`); +}); + diff --git a/test/skill-e2e-failure-routing.test.ts b/test/skill-e2e-failure-routing.test.ts new file mode 100644 index 000000000..c08c5b1f6 --- /dev/null +++ b/test/skill-e2e-failure-routing.test.ts @@ -0,0 +1,175 @@ +/** + * Router failure routing (#3157, #3158). The gstack router checks a reported + * failure's cause before it routes: a user-supplied cause that one read-only + * check confirms is fixed directly; an unknown cause gets one bounded triage + * pass; anything still unverified goes to /investigate before any edit. + * + * Four fixtures, two per issue. The direct cases (verified cause, triage that + * resolves it) must fix the bug without /investigate and leave the suite + * green. The fallback cases (contradicted cause, unclear intermittent failure) + * are the negative controls: an edit before /investigate is a contract + * violation. Every case needs one to four read-only calls before it routes. The agent sees the rendered router section, sliced from + * SKILL.md; GSTACK_ROUTER_ARM=before slices the base branch's router instead. + */ +import { afterAll, expect } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { runSkillTest } from './helpers/session-runner'; +import type { SkillTestResult } from './helpers/session-runner'; +import { expectContract } from './helpers/eval-store'; +import { + ROOT, runId, describeIfSelected, testConcurrentIfSelected, logCost, recordE2E, + createEvalCollector, finalizeEvalCollector, +} from './helpers/e2e-helpers'; +import { CAPTURE_MS } from './helpers/eval-budgets'; +import { extractSkillHead, sliceBetween } from './helpers/skill-fixture'; +import { bunIsolationArgs } from '../lib/bun-isolation'; + +const collector = createEvalCollector('e2e-failure-routing'); +const ARM = process.env.GSTACK_ROUTER_ARM === 'before' ? 'before' : 'current'; +const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); +const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'Bash']); +const SHELL_WRITE = /\b(?:sed|perl)\b[^|;&]*\s-[a-z]*i|(?])>>?\s*(?!&|\/dev\/null)\S|\btee\b|\b(?:mv|cp|rm)\s|\bgit\s+(?:apply|checkout|restore)\b/; + +function isEdit(call: { tool: string; input: any }): boolean { + return EDIT_TOOLS.has(call.tool) || (call.tool === 'Bash' && SHELL_WRITE.test(String(call.input?.command ?? ''))); +} + +interface Fixture { + name: string; + route: 'direct' | 'investigate'; + files: Record; + request: string; +} + +const FIXTURES: Fixture[] = [ + { + name: 'router-failure-verified-cause', + route: 'direct', + files: { + 'src/slug.js': "export function slugify(title) {\n return title.trim().toLowerCase().replace(/ /, '-');\n}\n", + 'test/slug.test.js': "import { expect, test } from 'bun:test';\nimport { slugify } from '../src/slug.js';\n\ntest('slugify joins every word with a hyphen', () => {\n expect(slugify('Hello Big World')).toBe('hello-big-world');\n});\n", + }, + request: "test/slug.test.js fails: slugify('Hello Big World') returns 'hello-big world'. The cause is the regex in src/slug.js: / / has no g flag, so only the first space is replaced. Fix it by making it / /g.", + }, + { + name: 'router-failure-contradicted-cause', + route: 'investigate', + files: { + 'src/price.js': "import { discountRate } from './rates.js';\n\nexport function applyDiscount(price, code) {\n return Math.round(price * (1 - discountRate(code)));\n}\n", + 'src/rates.js': "import rates from '../config/rates.json' with { type: 'json' };\n\nexport function discountRate(code) {\n return rates[code] ?? 0;\n}\n", + 'config/rates.json': '{\n "SAVE10": 10,\n "SAVE25": 0.25\n}\n', + 'test/price.test.js': "import { expect, test } from 'bun:test';\nimport { applyDiscount } from '../src/price.js';\n\ntest('SAVE10 takes ten percent off', () => {\n expect(applyDiscount(200, 'SAVE10')).toBe(180);\n});\n", + }, + request: "test/price.test.js fails: applyDiscount(200, 'SAVE10') returns -1800 instead of 180. It's a floating-point rounding problem in src/price.js; wrap the result in Math.round() and it will pass.", + }, + { + name: 'router-failure-triage-resolves', + route: 'direct', + files: { + 'src/initials.js': "export function initials(fullName) {\n const [first, last] = fullName.split(',');\n return (first[0] + last[0]).toUpperCase();\n}\n", + 'test/initials.test.js': "import { expect, test } from 'bun:test';\nimport { initials } from '../src/initials.js';\n\ntest('initials of a first and last name', () => {\n expect(initials('Ada Lovelace')).toBe('AL');\n});\n", + }, + request: "bun test fails on test/initials.test.js with: TypeError: undefined is not an object (evaluating 'last[0]') at initials (src/initials.js:3). Can you fix it?", + }, + { + name: 'router-failure-triage-unclear', + route: 'investigate', + files: { + 'src/queue.js': "const pending = [];\nlet timer = null;\n\nexport function enqueue(job, sink) {\n pending.push(job);\n if (!timer) timer = setTimeout(() => flush(sink), Number(process.env.FLUSH_MS ?? 5));\n}\n\nexport function flush(sink) {\n timer = null;\n sink.push(...pending.splice(0));\n}\n", + 'src/metrics.js': "import { enqueue } from './queue.js';\n\nexport function track(event, sink) {\n if (event.sampled !== false) enqueue({ type: 'metric', event }, sink);\n}\n", + 'test/queue.test.js': "import { expect, test } from 'bun:test';\nimport { enqueue } from '../src/queue.js';\n\ntest('three jobs reach the sink', async () => {\n const sink = [];\n enqueue(1, sink); enqueue(2, sink); enqueue(3, sink);\n await new Promise(r => setTimeout(r, 10));\n expect(sink.length).toBe(3);\n});\n", + 'test/metrics.test.js': "import { expect, test } from 'bun:test';\nimport { track } from '../src/metrics.js';\n\ntest('sampled events are tracked', async () => {\n const sink = [];\n track({ name: 'signup' }, sink);\n await new Promise(r => setTimeout(r, 10));\n expect(sink.length).toBe(1);\n});\n", + }, + request: "test/queue.test.js fails about one CI run in five with 'expected 3, received 2'. It has never failed on my machine. Can you fix it?", + }, +]; + +function routerSection(): string { + const skill = ARM === 'before' + ? spawnSync('git', ['show', `${process.env.EVALS_BASE || 'origin/main'}:SKILL.md`], { cwd: ROOT, encoding: 'utf8', timeout: 10_000 }).stdout + : fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf8'); + return sliceBetween(skill, '## Route first', 'Best-effort, record') + sliceBetween(skill, '**Routing rules', 'If the user opts out'); +} + +function makeRepo(fixture: Fixture): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'failure-routing-')); + fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ name: 'fixture', type: 'module', scripts: { test: 'bun test' } }, null, 2)); + for (const [file, body] of Object.entries(fixture.files)) { + fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true }); + fs.writeFileSync(path.join(repo, file), body); + } + for (const skill of ['investigate', 'qa', 'review', 'ship']) { + fs.mkdirSync(path.join(repo, '.claude', 'skills', skill), { recursive: true }); + fs.writeFileSync(path.join(repo, '.claude', 'skills', skill, 'SKILL.md'), extractSkillHead(path.join(ROOT, skill, 'SKILL.md'))); + } + const git = (...args: string[]) => spawnSync('git', ['-c', 'user.email=test@example.invalid', '-c', 'user.name=Test', ...args], { cwd: repo, timeout: 10_000 }); + git('init', '-q', '-b', 'main'); + git('add', '.'); + git('commit', '-qm', 'initial'); + return repo; +} + +function sourceSnapshot(repo: string, fixture: Fixture): string { + return Object.keys(fixture.files).filter(f => !f.startsWith('test/')).map(f => fs.readFileSync(path.join(repo, f), 'utf8')).join('\0'); +} + +function routing(result: SkillTestResult) { + const calls = result.toolCalls; + const investigateAt = calls.findIndex(c => c.tool === 'Skill' && String(c.input?.skill ?? '').includes('investigate')); + const firstEditAt = calls.findIndex(isEdit); + const before = investigateAt >= 0 ? calls.slice(0, investigateAt) : firstEditAt >= 0 ? calls.slice(0, firstEditAt) : calls; + return { + investigated: investigateAt >= 0, + editedFirst: firstEditAt >= 0 && (investigateAt < 0 || firstEditAt < investigateAt), + readsBeforeRoute: before.filter(c => READ_TOOLS.has(c.tool) && !isEdit(c)).length, + sequence: calls.map(c => c.tool === 'Skill' ? `Skill(${c.input?.skill})` : c.tool).join(' → '), + }; +} + +describeIfSelected('Router failure routing (#3157, #3158)', FIXTURES.map(f => f.name), () => { + const dirs: string[] = []; + afterAll(() => { + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + finalizeEvalCollector(collector); + }); + + for (const fixture of FIXTURES) { + testConcurrentIfSelected(fixture.name, async () => { + const repo = makeRepo(fixture); + dirs.push(repo); + const sourceBefore = sourceSnapshot(repo, fixture); + const result = await runSkillTest({ + prompt: `The user invoked the gstack router skill (/gstack) in this repository. The router's instructions:\n\n${routerSection()}\n\nThe user's request: "${fixture.request}"`, + workingDirectory: repo, + maxTurns: 6, + tools: ['Skill', 'Read', 'Grep', 'Glob', 'Edit', 'Bash'], + allowedTools: ['Skill', 'Read', 'Grep', 'Glob', 'Edit', 'Bash'], + timeout: CAPTURE_MS, + testName: fixture.name, + runId, + }); + logCost(`router ${fixture.name} (${ARM})`, result); + const r = routing(result); + const suite = spawnSync(process.execPath, [...bunIsolationArgs(), 'test'], { cwd: repo, encoding: 'utf8', timeout: 60_000 }); + const green = suite.status === 0; + const sourceChanged = sourceSnapshot(repo, fixture) !== sourceBefore; + const summary = `arm=${ARM} route=${r.investigated ? 'investigate' : 'direct'} reads=${r.readsBeforeRoute} green=${green} tools: ${r.sequence}`; + const bounded = r.readsBeforeRoute >= 1 && r.readsBeforeRoute <= 4; + const passed = bounded && (fixture.route === 'direct' ? !r.investigated && green : r.investigated && !r.editedFirst); + recordE2E(collector, fixture.name, 'Router failure routing', result, { passed, output: summary }); + + if (fixture.route === 'investigate') { + expectContract(!r.editedFirst && (r.investigated || !sourceChanged), `edited before /investigate on an unverified cause: ${summary}`, { collector, name: fixture.name }); + expect(r.investigated, summary).toBe(true); + } else { + expect(r.investigated, summary).toBe(false); + expect(green, `suite still red after the direct fix: ${summary}\n${suite.stdout}${suite.stderr}`).toBe(true); + } + expect(r.readsBeforeRoute, `no read-only check before routing: ${summary}`).toBeGreaterThanOrEqual(1); + expect(r.readsBeforeRoute, `triage was not bounded: ${summary}`).toBeLessThanOrEqual(4); + }, CAPTURE_MS); + } +}); diff --git a/test/skill-validation.test.ts b/test/skill-validation.test.ts index 3a003970f..454694de0 100644 --- a/test/skill-validation.test.ts +++ b/test/skill-validation.test.ts @@ -7,6 +7,8 @@ import * as os from 'os'; import * as path from 'path'; import { usePrivateStateRoot } from './helpers/private-state-root'; import { expectMentions } from './helpers/prompt-structure'; +import { generateFailureRouting } from '../scripts/resolvers/preamble/generate-test-failure-triage'; +import { readShippedSkillRouting } from './helpers/shipped-skill-routing'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -2043,6 +2045,37 @@ describe('Test failure triage in ship skill', () => { }); }); +describe('Failure routing: verified cause → fix, unknown cause → bounded triage (#3157, #3158)', () => { + const rule = generateFailureRouting(); + const unknownCause = rule.slice(rule.indexOf('**Cause unknown:** ') + '**Cause unknown:** '.length); + + test('the router sends bug reports through the failure rule, not straight to /investigate', () => { + const router = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); + expect(router).toContain(rule); + const bugRule = router.split('\n').find(line => line.startsWith('- User reports a bug')); + expect(bugRule).toContain('**Failures**'); + expect(bugRule).not.toMatch(/→ invoke `\/investigate`$/); + }); + + test('the rule keeps the root-cause bar on both paths', () => { + expect(rule).toMatch(/\*\*Cause supplied\*\*[^\n]*one read-only check[^\n]*If it does not, invoke `\/investigate` before any further tracing or edit/); + expect(unknownCause).toMatch(/^make one read-only triage pass[^\n]*Do not reproduce, test hypotheses or search wider[^\n]*still unclear, invoke `\/investigate` before any broader diagnosis or edit\.$/); + }); + + test('the routing gstack offers for CLAUDE.md, and this repo\'s own, check the cause before /investigate', () => { + const bugRule = readShippedSkillRouting().rules.find(line => line.startsWith('- Bugs/errors')); + expect(bugRule).toMatch(/→ invoke \/investigate, unless one read-only check confirms the cause/); + expect(fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf-8').split('\n')).toContain(bugRule!); + }); + + test('/ship fixes a pre-existing failure through the same bounded triage', () => { + const tests = fs.readFileSync(path.join(ROOT, 'ship', 'sections', 'tests.md'), 'utf-8'); + const fixNow = tests.slice(tests.indexOf('**If "Investigate and fix now":**'), tests.indexOf('**If "Add as P0 TODO":**')); + expect(fixNow).toContain('- One read-only root-cause pass, then `/investigate` if unclear.'); + expect(fixNow).not.toContain('/investigate mindset'); + }); +}); + describe('no compiled binaries in git', () => { // Tracked files enumerated once and reused by both assertions. git ls-files -z // + split is ~ms; the previous xargs-per-file shell loops blew past 5s on CI.