v1.93.20.0 stamp: merge main cb72cc13b1 into #3882 (land)

This commit is contained in:
garrytan committed 2026-11-02 21:17:50 +00:00
commit a8b61dd427
47 files changed
+1371 -149

No files matched your search

+10 -2
View File
@@ -52,10 +52,16 @@ on:
# that free-suite reads. FREE_SUITE_JOBS is k in `--ci-plan --shard-jobs k`:
# each shard runs min(k, its parallel files) sandboxed sub-shards side by side
# (scripts/lib/shard-jobs.ts); the exclusive host-state shard stays one child.
# FREE_SUITE_SETUP_SECONDS is a free-suite job's time outside the "Run free
# suite" step. --ci-plan adds it to each shard's runner wall and flaky-retry
# wall to emit that shard's job timeout (#3850), so GitHub never kills a job
# before the runner can name a hung file. Measured over 110 free-suite jobs in
# PR runs 41467261350..41478988224: p50 60 s, p90 80 s, max 142 s.
env:
FREE_SUITE_RUNNER: ubicloud-standard-8
FREE_SUITE_SHARDS: '10'
FREE_SUITE_JOBS: '4'
FREE_SUITE_SETUP_SECONDS: '300'
# Keyed on the PR number, not head_ref: a bare branch name carries no fork
# prefix, so same-name branches from two forks would share one group and a
@@ -149,7 +155,7 @@ jobs:
if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ] || [ "$jobs" != "$FREE_SUITE_JOBS" ]; then
echo "- Runner-sizing experiment: $shards shards on $runner at $jobs sub-shards each (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER at $FREE_SUITE_JOBS); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY"
fi
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs")
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs" --job-setup-seconds "$FREE_SUITE_SETUP_SECONDS")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
echo "runner=$runner" >> "$GITHUB_OUTPUT"
- name: Seed growth ratchet (60 s ceiling, shrink-only allowlist)
@@ -434,10 +440,12 @@ jobs:
run: xvfb-run -a bun run test:quick
free-suite:
name: free-suite (${{ matrix.shard }})
needs: [free-plan, reuse]
if: ${{ !cancelled() && needs.free-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
runs-on: ${{ needs.free-plan.outputs.runner }}
timeout-minutes: 20
# Per shard, from the plan: runner wall + flaky retry + setup (#3850).
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
max-parallel: 20
+5
View File
@@ -22,6 +22,11 @@
"ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" },
"ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" }
},
"remote_gate_runs": {
"profiles": ["ci-reuse/free-tests"],
"inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'",
"skipped": "bun --no-env-file --config=/dev/null -e 'import { isPaidTestFile } from \"./test/helpers/paid-test-set.ts\"; import * as fs from \"node:fs\"; for (const f of fs.readFileSync(process.env.PREGATE_FILES_FILE, \"utf8\").split(/\\r?\\n/)) if (f && isPaidTestFile(f)) console.log(f);'"
},
"warn": [],
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
}
+98
View File
@@ -1,5 +1,103 @@
# Changelog
## [1.93.20.0] - 2026-11-02
### Changed
- `gstack-factory-scorecard` now counts a PR as "at the cap" once it reaches 2 review passes, the two-round cap the review policy sets, instead of 3. The default `--pass-cap` is 2, and the "Review passes per PR" benchmark reads "median 1 pass, few at the cap". Pass `--pass-cap 3` to get the old count. Rows from earlier weeks in `trend.csv` were counted at the old cap, and each row names the cap it used.
### Docs
- CLAUDE.md now describes how gstack versions its own PRs today: a branch writes its entry as `changelog.d/<branch-slug>.md` and never edits VERSION, package.json or CHANGELOG.md, and the merge train's stamp assigns the version (`bump: minor` or `bump: major` front matter asks for more than a patch). The old text said to claim a version slot with `bin/gstack-next-version`.
- docs/CHANGELOG_STYLE.md, which CLAUDE.md says to read before writing an entry, now describes the same fragment flow: what goes in the fragment, when to add `bump:` front matter, and what to check after merging main. It no longer tells a branch to bump VERSION or claim a slot with `bin/gstack-next-version`.
- docs/ship-policy.md marks the queue-side procedure as being for repos on `stamp-at-merge` without changelog fragments, and points gstack's own PRs to the fragment flow and `train land`. The old link to that section still works.
## [1.93.19.0] - 2026-11-02
**`gstack-ship-receipt read --check-prose` no longer reads the Documentation exception line or code fences as counter claims (GST-983, GST-984).** `write --docs` with an owner exception requires the PR body to carry the whole `Documentation exception: …` line word for word, but the prose check read the owner's reason in it as prose, so a reason like "sections 1 and 2 skip the audit" was a `2 skip` claim and no body could pass both checks. That is the deadlock GST-977 fixed for the Review-loop exception line. Separately, on gstack-evals #142, test output pasted in a fenced block ("113 passed, 0 failed") and a quoted code comment ("the first 35 fail") were read as receipt counts and refused.
### Fixed
- **`lib/ship-receipt-prose.ts`:** the line that matches the receipt's own docs exception exactly is skipped, like the Review-loop exception line. The same words anywhere else, or a line for a different exception, are still checked.
- **`lib/ship-receipt-prose.ts`:** a closed code fence (backticks or tildes, closed by a run of its own character at least as long as the opener) is skipped. Counts outside the fence are still checked. A line that starts with inline code like ```` ```npm test``` ```` opens no fence, and a fence that never closes stays prose along with the rest of the body, so an unclosed opener cannot hide the claims after it.
### Tests
- `test/ship-receipt-prose.test.ts`: the docs exception line passes `--check-prose` beside a matching `70 skip` claim; the #142 fenced evidence passes while a wrong count outside the fence still fails; an unclosed fence stays prose; inline triple backticks and a shorter fence inside an unclosed one hide no claim. On main the first three cases failed.
## [1.93.18.0] - 2026-11-02
**A green Free Tests run at the pregate tree now clears gstack's remote gate, so a slow Ubicloud VM no longer blocks a PR whose CI already passed (GST-1024).** docs/pregate.md described `gstack-pregate clear --remote pregate.json/remote_gate --run <id|url> --allow-repo-commands`, but gstack's own `.gstack/pregate.json` declared no `remote_gate_runs`, so the route always refused. On #3869, every check passed at 3f2bcf3 (Free Tests run 41484634592), while two remote gate runs at the same tree failed on bun timeouts in files the PR doesn't touch.
### Added
- **`.gstack/pregate.json` `remote_gate_runs`:** the `ci-reuse/free-tests` profile, with `scripts/test-free-shards.ts --list` as the inventory (the complete free suite the Free Tests plan shards).
- **`remote_gate_runs.skipped`** (optional, `lib/run-evidence.ts`, `lib/pregate/index.ts`, `lib/pregate/run-check.ts`): a command that reads the selected files from `PREGATE_FILES_FILE` and prints the ones the remote gate itself never runs. Those files need no run, and the receipt records them as `skipped=<n>`. An inventory file stays required even when `skipped` names it, and a failing command refuses with `skipped_failed`. When every selected file is skipped, the clear refuses with `paid_only`, just as the gate's own `tests=0` report never clears. gstack's command prints the selected paid test files, the same split `scripts/ubicloud/pregate-remote.sh` makes. Without it, run 41484634592 still refused: all 71 of the selected files it didn't cover are paid.
### Tests
- `test/pregate-remote-gate-runs.test.ts`: gstack's inventory equals the Free Tests plan's file set, and its skipped command prints exactly the paid files of a mixed list. In a fixture with gstack's real profile, a green Free Tests `pull_request` run clears the gate. A run at another tree, a run of another workflow, a failed or cancelled run, a fork run, a merge commit behind its base, an uncovered inventory file, a skipped list naming an inventory file, a failing skipped command, a paid-only selection, and a declaration without `skipped` all refuse. Malformed `skipped` values fail config validation. On main, all four tests fail.
## [1.93.17.0] - 2026-11-02
- `gstack-ship-queue wave plan` now leaves out a READY head that does not merge onto main. It prints `NOT_MERGEABLE: #<n> (...)` with the conflicting files, the same way `NOT_GREEN` works for red CI. A conflict only in the CHANGELOG doesn't count, since the train merges those. Before, such a head was planned into a wave and the wave build stopped on it.
- A PR that renames a file and a PR that edits the old path are now linked into one wave group when the edit conflicts with the rename. Before, the conflict was reported at the new path, which the editing PR never touched, so the two ended up in different waves.
- Linked PRs are never split across waves anymore. A group of linked PRs larger than the wave size prints `WAVE_OVERSIZE` with no build command and stays whole. Before, the packer cut such a group at the wave size, so its conflict reached two integrators.
- `gstack-ship-queue ready` reports `STAMP_VERSION=unstamped` for a PR whose own changes leave VERSION alone, even after main moves to a newer version. Before, it compared the head with current main, so an unstamped PR showed its old version as a stamp (#3804 showed 1.92.30.0 while main was 1.92.32.0; #3558).
## [1.93.16.0] - 2026-11-02
### Fixed
- **A test lane's scratch folder no longer breaks the /ship review bind.** When an untracked `.review-units-test-*/` folder appeared or vanished while reviewers ran, Step 11.5 saw a different tree and the only repair was rerunning Steps 9 to 11.5 (about 20M tokens). Review records now fingerprint with `gstack-wtree --review`, which leaves out root dot-folders that hold no tracked file. `.github/` and `.gstack/` always count, and any change to a tracked file still breaks the bind. The pregate, test evidence and every other caller keep the whole tree. (GST-985)
## [1.93.15.0] - 2026-11-02
**Pregate stops blaming a clean branch for a missing pytest, Playwright specs and mixed-language root tests, and the literals check stops flagging stray digits (#3598, #3492, gstack-evals#97).** A machine without pytest reported "fix the in-branch failure". Playwright specs in a vitest repo went to vitest, which found no tests and failed. Root tests in `.mjs` and `.py` got no runner at all. And any lone `2` in a file that named a changed constant (a comment, a `$2` in a shell stub, a fixture) failed `literals`. A wrapper bump from 3 to 4 would have raised 5 false hits here, and gstack-evals#97 hit 11. Rewritten from outside PRs #3639 and #3600 by @vilano93 and #3629 by @nfitzsimmons.
### Fixed
- **Missing pytest is the machine's gap.** Before planning `python3 -m pytest`, `lanes` asks that python3 whether it can import pytest (10 s probe, from the repo root). If it can't, the file is unclaimed and the lane is `PREGATE_ENVIRONMENT` (missing-binary, `pytest` or `python3`): "install pytest on this machine and rerun, or run the lane remotely; the diff is not at fault".
- **Playwright specs run under Playwright.** A JS/TS test file that imports `@playwright/test` at the start of a line runs as `<pm exec> playwright test --reporter=list` from its nearest package. Other tests keep their runner. A declared `runner` still takes every file. Playwright's summary is read count line by count line. Failed and interrupted count as failed, flaky and passed as passed, and skipped as not run. A run where every test fails and Playwright says its browser is not installed is `PREGATE_ENVIRONMENT` naming `npx playwright install`.
- **Mixed-language root tests each get a runner.** A repo's bun, vitest or jest now claims only JS/TS files. When no one runner fits every root test, they are split by language and each part is detected on its own. So `.mjs` with `.py` runs `node --test` and pytest, and a `.py` test in a vitest repo gets pytest instead of vitest. `gstack-pregate init` drops its "a selection mixing these languages needs runner set" note.
- **`literals` binds a one-character old value to its statement.** The value counts on a line that names the constant, or on up to three lines that continue that statement, never on a comment line. Version-history rows count as before. A lowercase call-site name (`attempts`, `limit`) also needs the changed module named in the file. Values of two or more characters are unchanged.
### Changed
- **docs/pregate.md** describes the new detection and the literals binding. **docs/troubleshooting.md** names the Playwright missing-browsers case under `PREGATE_ENVIRONMENT`. **docs/state-root.md** lists `pregate/runs/<run id>/`.
## [1.93.14.0] - 2026-11-02
Free Tests no longer goes red with no named file when one test hangs. Each `free-suite` job's GitHub timeout used to be a fixed 20 minutes, shorter than the runner's own wall for a 10-shard plan (about 37 minutes), so GitHub killed the job before the runner could name the hung file or retry it, and no shard result was uploaded. The plan now emits each shard's job timeout as its runner wall plus the flaky-retry pass plus the job's measured setup (`--ci-plan --job-setup-seconds`), 49 minutes per shard today, and fails loudly above 90 minutes instead of truncating. See docs/TESTING_INTERNALS.md#runners-how-the-suites-execute-2026-08-overhaul. Closes #3850.
## [1.93.13.0] - 2026-11-02
**gstack's own tests no longer write into a developer's real gbrain.** If you export `GBRAIN_DATABASE_URL` for your working brain and run gstack's test suite, tests that spawn bins with a temp `HOME` used to pass that URL straight through. With no gbrain config under the temp `HOME`, the real `gbrain` on your PATH then added `brain-sync-remote-*` sources and test pages to your live brain. From #3466 by @AndreIntelas.
### Fixed
- **The test preload (`test-setup.ts`) strips the variables gbrain uses to pick a brain** before any test module loads: `GBRAIN_DATABASE_URL`, `GBRAIN_DIRECT_DATABASE_URL`, `DATABASE_URL` and `GBRAIN_HOME`. It prints one line on stderr for each variable it strips. This covers `bun test <file>` and every shard of `bun run test`.
### Tests
- `test/state-root-config-merge.test.ts`: a `bun test` run with all four variables exported spawns a child under a temp `HOME` that calls `buildGbrainEnv`, and the child sees none of them. It fails on main.
## [1.93.12.0] - 2026-11-02
**The Opus 5.5 `lite` skills now keep the install's gbrain blocks, disabled skills and the Claims Need Evidence rule (GST-944).** The lite tree Capy publishes was rendered without the install's settings. On a machine with gbrain detected, Opus 5.5 sessions lost the brain context-load and save-results blocks that the full install serves, and skills turned off with `disabled_skills` showed up again in the lite router. `./setup --loadout lite` was not affected.
### Fixed
- **`gstack-modules install`** renders lite with the state root's gbrain detection and `gstack-config` `disabled_skills`, and both are part of the folder's identity. A change in either publishes a new folder instead of reusing a stale one.
### Changed
- **`preamble:evidence` is pinned**, so `lite` drops ten coaching pieces instead of eleven. The honest-work design (W4) never retires the Claims Need Evidence rule while gstack reports evidence. It adds 804 bytes back to lite's shared preamble (16,175 → 16,979 bytes; full is 22,396).
### Tests
- `test/module-loadouts-install.test.ts`: with gbrain detected and `disabled_skills: qa`, the lite office-hours file carries the brain blocks, the router lists `/qa` as disabled, and the folder differs from the one published without those settings.
- `test/module-loadouts.test.ts`: `preamble:evidence` is pinned and lite excludes the other ten pieces.
## [1.93.11.0] - 2026-11-02
**Capy sessions get their model's loadout again without a sentence in the skill (GST-982).** The browse wave (#3858) removed the Capy skill's instruction to run the installer with `GSTACK_MODEL`, to keep `capy/skill/gstack/SKILL.md` under its context budget. After that, every Capy session installed the full skills, including Opus 5.5 sessions that should get `lite`. Now the installer finds the model by itself.
### Fixed
- **`gstack-capy-install` reads the owner's model preference.** When it gets no `--model` and no `GSTACK_MODEL`, it uses the `agent_model` in the session's `GSTACK_PREFERENCES_FILE` (the Drive skill's `preferences.yaml`, which `gstack-capy-session` already exports), resolved by `gstack-models resolve --role agent`. So `opus` means `claude-opus-5-5` today. The line reads `LOADOUT: … source=preferences` (`source=config` when this machine's `config.yaml` sets it), followed by a `LOADOUT_ASSUMED:` note that the model came from the preference and not the session. Eval runs (`GSTACK_EVAL_DIR` or `EVALS_RUN_ID` set) skip the preference, and a preference `gstack-models` rejects prints `LOADOUT_FALLBACK` instead of serving full silently. `GSTACK_MODEL` and `--model` still win, and a `host` preference or no file still serves full. The frozen bootstrapper `capy/skill/gstack/scripts/install.sh` and the skill text are unchanged.
### Tests
- `test/gstack-capy-install.test.ts`: an `opus` preference picks lite with `source=preferences` and its own launch file, `GSTACK_MODEL` overrides the preference, a `config.yaml` preference reads `source=config`, eval runs skip the preference, a rejected preference prints `LOADOUT_FALLBACK`, a `host` preference prints no loadout lines, and the real `gstack-models resolve --role agent` keeps the `agent: <provider>/<model>` first line the installer reads.
## [1.93.10.0] - 2026-11-02
### Changed
+1 -1
View File
@@ -1 +1 @@
1.93.10.0
1.93.20.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.93.10.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.93.20.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,
+18 -4
View File
@@ -42,7 +42,8 @@
# mode-0600 file beside the verdict; callers and their children `. <file>` it,
# never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
# grep that, never pipe through tail).
# --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), names the model this session runs:
# --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), else the agent_model of the session's
# GSTACK_PREFERENCES_FILE as gstack-models resolve --role agent reads it (GST-982), names the model this session runs:
# bin/gstack-modules install picks its loadout, publishes a non-full one beside
# the full Claude install and prints LOADOUT and SKILL_ROOT lines before the
# launch lines; a non-full loadout gets its own launch file, keyed by loadout
@@ -723,16 +724,29 @@ fi
[ "$RC" -eq 0 ] || { [ "$PROFILE" -eq 1 ] && rm -rf "${VERDICT%.json}.profile" 2>/dev/null; verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)"
LOADOUT_LINES=""
if [ "$RC" -eq 0 ] && [ -z "$MODEL" ] && [ -n "${GSTACK_PREFERENCES_FILE:-}" ] && [ -z "${GSTACK_EVAL_DIR:-}${EVALS_RUN_ID:-}" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
_ag_out=$(cd "$ROOT" && bin/gstack-models resolve --role agent 2>&1) && _ag_rc=0 || _ag_rc=$?
_ag_first=$(printf '%s\n' "$_ag_out" | head -1)
case "$_ag_first" in
*", this machine") MODEL_SOURCE=config ;;
*", the preferences file") MODEL_SOURCE=preferences ;;
esac
[ "$_ag_rc" -eq 0 ] || LOADOUT_LINES="LOADOUT_FALLBACK: agent_model unreadable: $(printf '%s' "$_ag_first" | tr -d '\000-\037' | cut -c1-200); serving full (docs/troubleshooting.md#loadout-fallback)"
[ "$MODEL_SOURCE" = GSTACK_MODEL ] || MODEL=$(printf '%s' "$_ag_first" | sed -n 's|^agent: [a-z0-9-]*/\([A-Za-z0-9._-]*\) .*|\1|p')
fi
if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then
_lo_full=$(. "$SELF/bin/gstack-claude-root.sh" && dirname "$(gstack_canonical_claude_root)")
if [ "$MODEL_SOURCE" = model-flag ]; then
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
else
if [ "$MODEL_SOURCE" = GSTACK_MODEL ]; then
_lo_out=$(cd "$ROOT" && GSTACK_MODEL=$MODEL bin/gstack-modules install --full-root "$_lo_full" 2>&1)
else
_lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1)
fi
_lo_line=$(printf '%s\n' "$_lo_out" | grep -m1 '^LOADOUT: ')
case "$MODEL_SOURCE" in preferences|config) _lo_line=$(printf '%s' "$_lo_line" | sed "s/ source=model-flag/ source=$MODEL_SOURCE/") ;; esac
_lo_root=$(printf '%s\n' "$_lo_out" | sed -n 's/^SKILL_ROOT: //p' | head -1)
_lo_notes=$(printf '%s\n' "$_lo_out" | grep -E '^(LOADOUT_INVALID_MODEL|LOADOUT_FALLBACK): ')
case "$MODEL_SOURCE" in preferences|config) _lo_notes="${_lo_notes:+$_lo_notes
}LOADOUT_ASSUMED: model=$MODEL is the owner's agent_model ($MODEL_SOURCE), not read from the session; a session on another model sets GSTACK_MODEL=<its id>" ;; esac
_lo_name=$(printf '%s' "$_lo_line" | sed -n 's/^LOADOUT: \([a-z0-9-]*\) .*/\1/p')
_lo_digest=$(printf '%s' "$_lo_line" | sed -n 's/.* digest=\([0-9a-f]\{12\}\) .*/\1/p')
if [ -n "$_lo_name" ] && [ "$_lo_name" != full ]; then
+1 -1
View File
@@ -69,7 +69,7 @@ if [ -n "$COMMIT_FULL" ]; then
esac
trap 'rm -rf "$WTREE_OBJECTS"' EXIT
trap 'exit 130' INT TERM
WTREE=$(GSTACK_WTREE_OBJECT_DIR="$WTREE_OBJECTS" "$SCRIPT_DIR/gstack-wtree" || true)
WTREE=$(GSTACK_WTREE_OBJECT_DIR="$WTREE_OBJECTS" "$SCRIPT_DIR/gstack-wtree" --review || true)
export GIT_ALTERNATE_OBJECT_DIRECTORIES="$WTREE_OBJECTS${GIT_ALTERNATE_OBJECT_DIRECTORIES:+:$GIT_ALTERNATE_OBJECT_DIRECTORIES}"
else
echo "gstack-review-log: cannot create a private object directory under ${TMPDIR:-/tmp}; no working-tree fingerprint. Set TMPDIR to a writable directory." >&2
+2 -2
View File
@@ -4,7 +4,7 @@
#
# Emits, in order: reviews JSONL with computed review_freshness, ---CONFIG--- (skip_eng_review),
# ---HEAD--- (short sha), ---WTREE--- (current working-tree fingerprint from
# bin/gstack-wtree, or "unknown"), ---TREE--- (HEAD tree, informational) and
# bin/gstack-wtree --review, or "unknown"), ---TREE--- (HEAD tree, informational) and
# ---DIRTY--- (tracked-file dirty flag). Consumers grade diff-scoped review
# rows using review_freshness; legacy or uncaptured diff evidence cannot fall
# back to HEAD equality. Plan rows are unchanged.
@@ -19,7 +19,7 @@ SLUG=$("$SCRIPT_DIR/gstack-slug" --get REPO_SLUG 2>/dev/null || true)
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown")
WTREE=$("$SCRIPT_DIR/gstack-wtree" --review 2>/dev/null || echo "unknown")
if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then
GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
const { reviewFreshness } = await import(process.env.GSTACK_REVIEW_LIB);
+28 -1
View File
@@ -9,6 +9,12 @@
# look like a mid-run edit (GST-421); tracked files under it still count, and a
# tree without that folder (or with it gitignored) hashes exactly as a plain
# `git add -A` would.
#
# --review (gstack-review-log and gstack-review-read only, GST-985) also leaves
# out every root dot-directory that holds no tracked file, such as a test
# lane's `.review-units-test-*/` scratch, so scratch appearing or vanishing
# mid-review cannot unbind a review. `.github/` and `.gstack/` always stay in:
# they hold CI and gate policy. Every other caller keeps the whole tree.
# Properties that make this the right staleness fingerprint, vs
# `git rev-parse HEAD^{tree}`:
#
@@ -40,6 +46,13 @@
# treat that as "no fingerprint"; a partial hash is never printed.
set -euo pipefail
REVIEW=""
case "$#:${1:-}" in
0:) ;;
1:--review) REVIEW=1 ;;
*) echo "Usage: gstack-wtree [--review]" >&2; exit 2 ;;
esac
TOP=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
# Resolve the REAL index path BEFORE exporting GIT_INDEX_FILE — with the env
# var set, `git rev-parse --git-path index` returns the temp index itself and
@@ -95,7 +108,21 @@ fi
# call .bun not ignored.
EXCLUDE_BUN=""
if [ -e "$TOP/.bun" ] && ! git -C "$TOP" check-ignore --no-index -q .bun 2>/dev/null; then EXCLUDE_BUN=':(exclude,top).bun'; fi
if ! ERR=$(git -C "$TOP" add -u 2>&1 >/dev/null) || ! ERR=$(git -C "$TOP" add -A -- . ${EXCLUDE_BUN:+"$EXCLUDE_BUN"} 2>&1 >/dev/null); then
EXCLUDES=()
if [ -n "$EXCLUDE_BUN" ]; then EXCLUDES+=("$EXCLUDE_BUN"); fi
if [ -n "$REVIEW" ]; then
TRACKED_INDEX="$TMPIDX"
if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ]; then TRACKED_INDEX="$REAL_INDEX"; fi
for DOTDIR in "$TOP"/.[!.]* "$TOP"/..?*; do
NAME=${DOTDIR##*/}
case "$NAME" in .git|.github|.gstack|.bun) continue ;; esac
if [ ! -d "$DOTDIR" ] || [ -L "$DOTDIR" ]; then continue; fi
if GIT_INDEX_FILE="$TRACKED_INDEX" git -C "$TOP" ls-files --error-unmatch -- ":(top,literal)$NAME" >/dev/null 2>&1; then continue; fi
if git -C "$TOP" check-ignore --no-index -q -- "$NAME" 2>/dev/null; then continue; fi
EXCLUDES+=(":(exclude,top,literal)$NAME")
done
fi
if ! ERR=$(git -C "$TOP" add -u 2>&1 >/dev/null) || ! ERR=$(git -C "$TOP" add -A -- . ${EXCLUDES[@]+"${EXCLUDES[@]}"} 2>&1 >/dev/null); then
echo "gstack-wtree: cannot stage the working tree: $(printf '%s' "$ERR" | head -1)" >&2
exit 1
fi
@@ -1,9 +0,0 @@
### Changed
- `gstack-factory-scorecard` now counts a PR as "at the cap" once it reaches 2 review passes, the two-round cap the review policy sets, instead of 3. The default `--pass-cap` is 2, and the "Review passes per PR" benchmark reads "median 1 pass, few at the cap". Pass `--pass-cap 3` to get the old count. Rows from earlier weeks in `trend.csv` were counted at the old cap, and each row names the cap it used.
### Docs
- CLAUDE.md now describes how gstack versions its own PRs today: a branch writes its entry as `changelog.d/<branch-slug>.md` and never edits VERSION, package.json or CHANGELOG.md, and the merge train's stamp assigns the version (`bump: minor` or `bump: major` front matter asks for more than a patch). The old text said to claim a version slot with `bin/gstack-next-version`.
- docs/CHANGELOG_STYLE.md, which CLAUDE.md says to read before writing an entry, now describes the same fragment flow: what goes in the fragment, when to add `bump:` front matter, and what to check after merging main. It no longer tells a branch to bump VERSION or claim a slot with `bin/gstack-next-version`.
- docs/ship-policy.md marks the queue-side procedure as being for repos on `stamp-at-merge` without changelog fragments, and points gstack's own PRs to the fragment flow and `train land`. The old link to that section still works.
+10 -1
View File
@@ -206,7 +206,16 @@ GSTA-203 chose them by a 3-run A/B against 20 × `ubicloud-standard-4` at k = 1
(the PR that set them has the table). A `workflow_dispatch` run may
override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`),
`free_shards` (1–40) and `free_jobs` (1–8) for runner-sizing experiments only;
its job summary says so, and it is never cited as a PR or train Free Tests result. Each shard prints
its job summary says so, and it is never cited as a PR or train Free Tests result.
Each `free-suite` job's GitHub timeout comes from the plan, not a fixed number
(#3850): `--ci-plan --job-setup-seconds $FREE_SUITE_SETUP_SECONDS` emits every
shard's `timeout_minutes` as the shard's runner wall (the packed wall above),
plus the flaky-retry pass's wall (at most 5 files, so the 6-minute base), plus
the job's setup and teardown (`ciShardJobTimeoutMinutes`). So GitHub never kills
a hung file before the runner names it and retries it. A shard that would need
more than 90 minutes fails the plan with `FREE_JOB_TIMEOUT_OVER_CAP`; plan more
shards or a higher k instead. The plan step prints the emitted timeouts and adds
them to its job summary. Each shard prints
`RESOURCES: peak_used_mib=… min_free_disk_mib=… samples=…` from a memory and
disk sample every 2 seconds, even when the suite fails. The public
`TREE_MUTATING` map now classifies exclusive host-state fixtures; its sole
+3 -2
View File
@@ -472,8 +472,9 @@ make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory
(LAUNCH_FILE_UNWRITABLE)`, and a failing run prints no `GSTACK_LAUNCH_FILE` line
and removes the file an earlier passing run left beside the verdict it rewrites
(a profile check that keeps a fuller ok verdict leaves that verdict's file).
With `--model <id>`, or `GSTACK_MODEL=<id>` in its environment (what the Capy
skill passes), an ok run other than `--for review-voice` also prints `LOADOUT: …`
With `--model <id>`, `GSTACK_MODEL=<id>` in its environment, or else an
`agent_model` in the session's `GSTACK_PREFERENCES_FILE` outside an eval run (GST-982, with a
`LOADOUT_ASSUMED:` note), an ok run other than `--for review-voice` also prints `LOADOUT: …`
and `SKILL_ROOT: <dir>` just before `GSTACK_LAUNCH`. For a loadout other than
`full`, `GSTACK_LAUNCH_FILE` then names `<verdict file without .json>.loadout-<name>-<digest>.env`,
a copy of the verdict's launch file that adds `GSTACK_LOADOUT_SKILL_ROOT`; the
+8 -7
View File
@@ -9,7 +9,7 @@ One model has its own loadout: Claude Opus 5.5 gets `lite` ([below](#lite-on-cla
| Term | Meaning |
|---|---|
| Module | One of four kinds. `skill:<dir>` is a whole skill. `section:<skill>/<id>` is one entry of `<skill>/sections/manifest.json`. `overlay:<name>` is one `model-overlays/<name>.md`; excluding a base overlay also removes the base's text from every overlay that inherits it; the inheriting overlay's own text stays. `preamble:<id>` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). |
| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the Claims Need Evidence rule, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. |
| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. |
| Model map | `models` in `modules/loadouts.json`: an exact model id to a loadout, checked before rungs. The id also matches its dated snapshot (`-YYYYMMDD`) and `-latest`, never a point release or any other suffix (`claude-opus-5-5` matches `claude-opus-5-5-20261101`, not `claude-opus-5-50` or `claude-opus-5-5-1`). |
@@ -40,21 +40,22 @@ bin/gstack-modules install --full-root "$HOME/.claude/skills" --model claude-opu
## Lite on Claude Opus 5.5
`lite` is `full` minus eleven coaching pieces of the shared preamble: voice, writing style, completeness, confusion, evidence, context health, question tuning, search before building, repo mode, brain health and context recovery. It keeps every pinned piece, the upgrade check (the PROACTIVE and skill-prefix rules) and brain sync (the privacy stop-gate). Only `claude-opus-5-5` maps to it, because only Opus 5.5 has been measured: office-hours showed no loss on Opus 5.5 (GSTA-161 preamble run), and the land-and-deploy confirmation (GSTA-161, PR 1b) compared full, lite and no gstack on the same tasks. Haiku 5.5 leaned slightly against a larger cut on the same office-hours run, so small models stay on `full`. Two skills point at a preamble section lite drops: office-hours and plan-ceo-review name ETHOS.md through the "Search Before Building" section, so under lite they find ETHOS.md in the gstack checkout instead.
`lite` is `full` minus ten coaching pieces of the shared preamble: voice, writing style, completeness, confusion, context health, question tuning, search before building, repo mode, brain health and context recovery. It keeps every pinned piece, including the Claims Need Evidence rule (GST-944: the honest-work design never retires it while gstack reports evidence, and it adds 804 bytes back to lite's 16,175-byte shared preamble), the upgrade check (the PROACTIVE and skill-prefix rules) and brain sync (the privacy stop-gate). Only `claude-opus-5-5` maps to it, because only Opus 5.5 has been measured: office-hours showed no loss on Opus 5.5 (GSTA-161 preamble run), and the land-and-deploy confirmation (GSTA-161, PR 1b) compared full, lite and no gstack on the same tasks. Haiku 5.5 leaned slightly against a larger cut on the same office-hours run, so small models stay on `full`. Two skills point at a preamble section lite drops: office-hours and plan-ceo-review name ETHOS.md through the "Search Before Building" section, so under lite they find ETHOS.md in the gstack checkout instead.
### How gstack picks a loadout
gstack never guesses the model. It uses, in order:
gstack does not detect the running model. It uses, in order:
1. An explicit loadout: `./setup --loadout <name>` on Claude Code, or `bin/gstack-modules install --loadout <name>`. It is served as asked and bound to no model.
2. An explicit model id: `--model <id>` on the Capy installer (`scripts/install.sh --model <id>`, which passes it to `bin/gstack-capy-install`), else `GSTACK_MODEL`, which the installer also reads.
3. Nothing: `full`.
3. On Capy only, with neither and outside an eval run: the owner's `agent_model` (this machine's `config.yaml`, else the Drive skill's `preferences.yaml`), as `gstack-models resolve --role agent` resolves it (`opus` is the newest Opus). This is an assumption, not a detection, and the installer prints `LOADOUT_ASSUMED:` beside it. `host` or no preference means no model.
4. Nothing: `full`.
A model id is lowercased, its `provider/` prefix and a trailing `[…]` are dropped, and it must then be a plain id (`^[a-z0-9][a-z0-9._-]{0,79}$`). An unmapped id serves `full`; an id that is not plain also serves `full` and prints `LOADOUT_INVALID_MODEL`. Nothing reads Claude Code settings or an inherited loadout variable, so a Sonnet subagent of an Opus session gets `full` unless its own install names Opus.
A model id is lowercased, its `provider/` prefix and a trailing `[…]` are dropped, and it must then be a plain id (`^[a-z0-9][a-z0-9._-]{0,79}$`). An unmapped id serves `full`; an id that is not plain also serves `full` and prints `LOADOUT_INVALID_MODEL`. Nothing reads Claude Code settings or an inherited loadout variable, so on Claude Code a Sonnet subagent of an Opus session gets `full` unless its own install names Opus. On Capy every session shares the owner's preference, so a session on another model sets `GSTACK_MODEL=<its id>` to get its own loadout.
### Capy
The Capy skill passes the thread's model id in the environment: `GSTACK_MODEL=<id> bash scripts/install.sh --check --for <skill>`. The variable, unlike `--model`, is safe on a checkout whose installer predates loadouts: that installer ignores it instead of refusing an unknown argument. The installer prints two lines before the launch lines:
A plain `bash scripts/install.sh --check --for <skill>` picks the loadout from the owner's `agent_model` preference, so the skill needs no model sentence (`source=preferences` on the line below, or `source=config` when this machine's `config.yaml` sets `agent_model`). The preference names the model Garry starts sessions on, not the model reading the skill, so the installer says so in a `LOADOUT_ASSUMED:` line, and it skips the preference inside an eval run (`GSTACK_EVAL_DIR` or `EVALS_RUN_ID` set), where the model under test may differ. A session on another model names its own with `GSTACK_MODEL=<id>`, which wins over the preference and, unlike `--model`, is safe on a checkout whose installer predates loadouts: that installer ignores it instead of refusing an unknown argument. The installer prints two lines before the launch lines:
```
LOADOUT: lite model=claude-opus-5-5 source=GSTACK_MODEL digest=3f2a9c01d4e7 reused=yes
@@ -63,7 +64,7 @@ SKILL_ROOT: /home/user/.gstack/loadouts/lite-3f2a9c01d4e7
Read skills from `SKILL_ROOT` (`<root>/gstack/SKILL.md` routes, `<root>/<skill>/SKILL.md` runs) and source the printed `GSTACK_LAUNCH_FILE`, which adds `GSTACK_LOADOUT_SKILL_ROOT`. For `full`, `SKILL_ROOT` is `~/.claude/skills` and the launch file is the install verdict's own.
The lite tree is published by `bin/gstack-modules install` under `<state root>/loadouts/<loadout>-<digest12>/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. The digest covers the selection, the bound model, the full root, host, overlay, plan mode and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced. Pruning removes only folders it published (a `<loadout>-<digest12>` name with an install lock) beyond the three most recently used per loadout, and never one used in the last day. When `--full-root` is not `~/.claude/skills`, the tree's command paths point at that install's `gstack` folder. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
The lite tree is published by `bin/gstack-modules install` under `<state root>/loadouts/<loadout>-<digest12>/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. It carries over two of the full render's install settings: gbrain detection (`gbrain-detection.json` in the state root, so the brain context-load and save-results blocks appear when gbrain is detected) and `gstack-config` `disabled_skills` (the router omits those skills). The digest covers the selection, the bound model, the full root, host, overlay, plan mode, gbrain detection, disabled skills and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced. Pruning removes only folders it published (a `<loadout>-<digest12>` name with an install lock) beyond the three most recently used per loadout, and never one used in the last day. When `--full-root` is not `~/.claude/skills`, the tree's command paths point at that install's `gstack` folder. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
### The start line carries the model
+45 -8
View File
@@ -74,7 +74,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| Key | Meaning |
|---|---|
| `runner` | the local test command with `{files}` (gstack: `bun test --timeout=30000 {files}`, the 30 s per-test timeout CI uses); 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 <name>` 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) |
| `runner` | the local test command with `{files}` (gstack: `bun test --timeout=30000 {files}`, the 30 s per-test timeout CI uses); unset, it is detected (bun, vitest or jest for JS/TS files, pytest when python3 can import it (else `pytest` or `python3` is named missing), `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 <name>` 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`; a JS/TS test file that imports `@playwright/test` at the start of a line runs as `playwright test --reporter=list` from its nearest package through that same package manager, whatever the unit runner is, a type-only import (`import type …`, or one whose every specifier is `type X`) does not count, file arguments are passed as escaped regexes anchored to the path (Playwright reads them as regexes), and a run where every test fails and Playwright says its browser is not installed is `PREGATE_ENVIRONMENT` (a partial install, where some tests pass, stays the diff's failure); every other test keeps the root detection, split by language when no one runner fits them all, so `.mjs` and `.py` root tests each get their own runner); 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). A `[]` that matches a deleted test does not skip it: the deleted test still runs its owning suite, and a file naming it that `[]` covers (a durations list) counts as covered. With this key present, even empty, the policy [declares lanes](#lane-requirements) |
| `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`) |
@@ -89,7 +89,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
| `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)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
| `remote_gate_runs` | `{ profiles, inventory }`: the run profiles that together clear `pregate.json/remote_gate`, and the command that prints the remote gate's test files, one per line, which their runs must cover (see [run evidence](#run-evidence)) |
| `remote_gate_runs` | `{ profiles, inventory, skipped? }`: the run profiles that together clear `pregate.json/remote_gate`, the command that prints the remote gate's test files, one per line, which their runs must cover, and an optional command that prints which selected files the gate itself never runs (see [run evidence](#run-evidence)) |
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
Only the `origin/<base>` copy or the user-owned file below is honoured, so a
@@ -220,6 +220,14 @@ Two stages with separate clocks, both printed:
other line holding the old value still fail. Only the row itself is exempt:
the old value after its closing bracket on the same line still fails. Rows in object form (`{ v: 45 }`) or spread over several
lines are not recognized as history rows.
A one-character old value (`2`) counts only in a statement that names the
constant: on the name's line, or on up to three lines that continue it (the
line before ends with `(`, `[`, `{`, `,`, `.`, `=` or another operator, or the
line starts with `.`, `)`, `]`, `}` or an operator), never on a comment line, plus the history rows
above. A `$2` in a shell stub, a comment, or `export default 2` in a fixture
elsewhere in the file is not a consumer. A call-site name such as `attempts`
or `limit` is an ordinary word, so for it the file must also name the changed
module.
- `timing`: added or changed test lines that compare an elapsed wall-clock or
CPU time against a bound, or sleep and then assert, print
@@ -437,7 +445,7 @@ test-tier command:
Per-file lines come from a progress adapter where the runner's reporter
provides them: `bun test` (its `<file>:` 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, cargo test) get heartbeats
runner. Other runners (node --test, vitest, jest, pytest, playwright test, 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 <s>`, else `stall_timeout_s`, else 300; `0` disables): the
@@ -734,6 +742,16 @@ Then, for the lane:
while the working tree is pregate.json's tree (checked before and after it
runs); an empty or failing inventory refuses, and it runs under `pipefail`,
so a pipeline whose first command fails refuses too.
- When `remote_gate_runs` declares `skipped`, that command runs the same way,
with `PREGATE_FILES_FILE` naming a file of the obligation's selected test
files, one per line, and prints the ones the remote gate itself never runs
(gstack's gate leaves paid test files to the paid PR gate). Those files need
no run, and the receipt counts them as `skipped=<n>`. A file the inventory
prints stays required even when `skipped` names it, and a failing `skipped`
command refuses (`skipped_failed`). When `skipped` names every selected file,
no selected test ran, so the clear refuses (`paid_only`), just as the gate's
own `tests=0` report never clears. Without `skipped`, every selected file
must appear in a run's `files`.
- Every run is read once more after the inventory: a run that moved to a new
attempt or stopped being a success meanwhile refuses (`run_changed`).
@@ -745,13 +763,14 @@ verification is named with its own reason (`fork_run`, `event_mismatch`,
`os_mismatch`, `api_error`, …); the clear-level codes are `base_mismatch`,
`no_verified_run`, `tree_mismatch` (runs on different trees),
`tree_not_stamp_only`, `no_tests`, `inventory_failed`, `inventory_empty`,
`inventory_uncovered` (the remote gate) and `tests_uncovered` (a lane's own
`skipped_failed`, `paid_only`, `inventory_uncovered` (the remote gate) and `tests_uncovered` (a lane's own
selected tests). A wrong run count, a repeated run or a lane with no
profile is a usage error (exit 2), not a REUSE refusal.
Accepted, `clear` prints `REUSE: accepted <lane> <receipt>`, and the receipt
reads `remote-gate tree=<t12> tests=<n> runs=<id>/<attempt>,… profiles=<p>,…
heads=<h12> verified=<time>` (with `accepted-for=` and the substitution for a
heads=<h12> verified=<time>` (with `skipped=<n>` after `tests=` when the
`skipped` command excused selected files, and `accepted-for=` and the substitution for a
stamp-only predecessor). A lane with its own profile gets `run tree=… runs=…`
the same way; for such a lane `--run-url` is verified as `--run` is, and
`--bundle-lane` is refused. A lane with no profile still clears by
@@ -796,7 +815,21 @@ every non-matrix job and the `free-tests` aggregate, `os` Linux) and
aggregate, `os` Windows). Each accepts `pull_request` and `workflow_dispatch`
runs and reads the `tested-tree` artifact, which both aggregates upload only
after their strict verification passes. The main-push reuse job and
`--from-run` read them. No lane has a profile, so every macOS and Windows lane
`--from-run` read them, and `remote_gate_runs` names `ci-reuse/free-tests`,
so a green Free Tests run at pregate.json's tree clears
`pregate.json/remote_gate` without a Ubicloud VM:
```bash
gstack-pregate clear --remote pregate.json/remote_gate --run <free-tests run id or url> --allow-repo-commands
```
Its `inventory` is `scripts/test-free-shards.ts --list` (the complete free
suite, the same set the Free Tests plan shards), and its `skipped` command
prints the selected files `test/helpers/paid-test-set.ts` marks paid, the
same split `scripts/ubicloud/pregate-remote.sh` makes before it runs. A
`pull_request` run counts only when the branch already contains its base
(the merge commit's tree is the head's), so a branch behind main still needs
the gate or a fresh run. No lane has a profile, so every macOS and Windows lane
still clears by `--run-url`, checked for its workflow file and a green
conclusion. On main the free tier's Windows smoke lane is
`windows-smoke.yml/windows-smoke`, and a `windows-smoke.yml` run clears it.
@@ -994,7 +1027,7 @@ failure the branch caused carries `cause: "diff"` (`PREGATE_LANE_FAILED`,
| Kind | Evidence |
|---|---|
| `missing-binary` | exit 127, `command not found`, a spawn `ENOENT`, a runner tool `not on PATH` (dart, flutter, cargo, a package manager) |
| `missing-binary` | exit 127, `command not found`, a spawn `ENOENT`, a runner tool `not on PATH` (dart, flutter, cargo, a package manager, `python3`, or `pytest` when python3 cannot import it), Playwright's browsers not installed |
| `network` | `ENOTFOUND`, `ECONNREFUSED`, `ECONNRESET`, `ETIMEDOUT`, `EAI_AGAIN`, `Could not resolve host`, `fetch failed`, a TLS handshake or certificate failure |
| `quota` | `quota`, `429 Too Many Requests`, `rate limit`, `insufficient_quota` (a vCPU quota on the remote backend) |
| `disk` | `ENOSPC`, `No space left on device`, `EDQUOT` |
@@ -1005,7 +1038,11 @@ The classifier is conservative. A local lane that exited non-zero is read from
its output **only when its runner reported no test summary** (nothing ran) and
the runner was not seen starting a test file; a run whose summary counts `N
ran, M failed` is the diff's failure whatever its log says, so a test that
failed on `ECONNREFUSED` is still a red test. A remote gate that exited
failed on `ECONNREFUSED` is still a red test. One exception: when every
failed run is a Playwright run in which every test failed and Playwright says
its browsers are not installed, the lane is `missing-binary` with `proof:
"output"` (a partial install, where some tests pass, stays the diff's
failure). A remote gate that exited
without a `PREGATE_REMOTE:` line is read from its **preamble only** (its first
40 lines, before any test output could stream): a VM refused for quota or an
unreachable API there never ran the tests, so it is environment and the
+22 -11
View File
@@ -606,13 +606,20 @@ gstack-ship-queue wave settle --wave <nonce> [--dry-run] # after the wave
- **When.** `wave plan` counts the live READY PRs (open, head equal to their
READY receipt, CI green at that head; a READY head whose CI is red, still
running or absent prints a `NOT_GREEN` line and stays out of the plan). A
READY PR whose CI, head or diff cannot be read fails the whole plan with
`QUEUE_PROOF_MISSING`. Over the
running or absent prints a `NOT_GREEN` line and stays out of the plan; a
READY head that does not merge onto main with the train's merge rules prints
`NOT_MERGEABLE` with the conflicting files and stays out too, while a
conflict only in the CHANGELOG, which the train merges, does not count). A
READY PR whose CI, head, diff or merge onto main cannot be read fails the
whole plan with `QUEUE_PROOF_MISSING`. Over the
trigger (6) it prints `WAVE_ADVISED` and suggested groups of 3 to 6: PRs
whose changes to a common code file conflict (`git merge-tree` cannot merge
the two heads, or the check itself fails) always share a group, so that
conflict is resolved once. Hub
conflict is resolved once. A PR that renames a file and one that edits the
old path are linked when the edit conflicts at the new path. A group of
linked PRs is never split across waves: one larger than the wave maximum
prints `WAVE_OVERSIZE` with no build command (land part of it as single
slots, then plan again). Hub
files never link PRs: anything under `docs/`, Markdown (generated skill
renders included; their `.tmpl` sources still link) and every `.json` or
`.jsonl` file (ledgers and registries). Most PRs append to them, and
@@ -1232,13 +1239,17 @@ both values; prose that quotes no counter is not a claim. The `## Approvals`
block `gstack-approvals` renders (its heading, the Date | Asked | Exact words
table and the `Source: gstack-approvals record` line) quotes the owner's
words, not this PR's numbers, so it is skipped: "one Step 11 pass" in that table
is not a `pass` claim. Text you write under the heading after that block, and
a `## Approvals` example inside a code fence, are still checked. The
`Review-loop exception: …` line the receipt's own exception requires is
skipped the same way (GST-977): it quotes the owner's reason, so "outside 1 and
2 skip detector" is not a `2 skip` claim. Only a line that matches the rendered
line exactly is skipped, inside a code fence too, as the receipt check accepts
a fenced copy; the same words inside a longer line are still checked. Data PRs (evals,
is not a `pass` claim. Text you write under the heading after that block is
still checked. The `Review-loop exception: …` line (GST-977) and the
`Documentation exception: …` line (GST-983) the receipt's own exceptions
require are skipped the same way: they quote the owner's reason, so "outside 1
and 2 skip detector" is not a `2 skip` claim. Only a line that matches the
rendered line exactly is skipped; the same words inside a longer line are still
checked. A closed code fence is skipped too (GST-984): pasted test output
("113 pass, 0 fail") and quoted code are evidence, not this PR's counters, so
put a count you mean as a claim outside the fence. A line like ```` ```npm test``` ran ````
opens no fence, and a fence that never closes stays prose, along with the rest
of the body. Data PRs (evals,
measurements, sweeps) run it before READY so the body's numbers are the run's.
<a id="review-scope"></a>
+3 -1
View File
@@ -105,7 +105,9 @@ lives in `projects/<slug>/`. `bin/gstack-slug` picks the slug from the git
- 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)).
`.gstack/` out of git (see [pregate v1](pregate.md#pregate-v1)), and
`pregate/runs/<run id>/`, the scratch each pregate run writes (lane and guard
logs, the remote gate's file list); old runs are pruned when a new one starts.
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.
+10 -3
View File
@@ -570,6 +570,9 @@ the base has moved.
**Meaning.** The working tree changed between the pass's `gstack-review-log --start` and
its logged row: an edit, commit or merge landed while reviewers were still reading. The
reviewers may not have seen that change, so the row's `review_binding.state` is `changed`.
Review records fingerprint with `gstack-wtree --review`, which skips untracked root dot-folders
that hold no tracked file (never `.github/` or `.gstack/`), so a test lane's scratch folder alone
does not move the tree.
Step 11.5 cannot bind it and inserts `9 → 10 → 11 → 11.5`. Logging the same results again
cannot repair it.
@@ -3932,7 +3935,10 @@ and the table prints `environment: <kind> — <evidence>`. A test that failed an
whose log also names a connection error is **not** this code: the runner's own
summary counted a failure, so it is `PREGATE_LANE_FAILED` with `cause:
"diff"`; nor is a runner, guard or gate script whose own binary is present but
exits 127. `proof: "launch"` is certain (the command could not start, its
exits 127. One exception: a Playwright lane where every test failed and
Playwright says its browsers are not installed is `missing-binary` with
`proof: "output"` (`npx playwright install`); a partial install stays the
diff's failure. `proof: "launch"` is certain (the command could not start, its
binary is off PATH, the runtime or a gstack tool refused); `proof: "output"`
means a rule matched the command's text before any test was counted, which the
diff's own output could have written, so the line says `(inferred from output)`
@@ -5915,8 +5921,9 @@ body that names a counter the receipt records (`passes=3` against
against `gate.passed: 41`) with a different value. The receipt is written from
the run; the prose was typed, so a reader of the body would learn a number the
run did not produce. The `PROSE:` line names each field with both values. The
`## Approvals` table `gstack-approvals` renders, and the `Review-loop exception: …`
line the receipt's exception requires, are never read as claims.
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`
and `Documentation exception: …` lines the receipt's exceptions require, and
the inside of a closed code fence are never read as claims.
**Fix.** Correct the body to the receipt's number. When the receipt is the
stale one (the run was repeated), rerun `/ship` so the receipt is rewritten
+81 -15
View File
@@ -27,6 +27,7 @@
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { baseInputs, sha256 } from '../context';
@@ -43,17 +44,21 @@ import { verifyFromRun } from '../../run-evidence';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
/** The test command with `{files}`; detected from the repo when the config does not name one. */
/** The runner a `.py`-only selection gets; it needs the pytest module importable by python3 (`pytestMissing`). */
const PYTEST = 'python3 -m pytest {files}';
/** The test command with `{files}`; detected from the repo when the config does not name one. The repo's bun, vitest or jest claims JS/TS files only. */
export function detectRunner(ctx: Pick<PregateContext, 'config' | 'repoRoot'>, files: readonly string[]): string | null {
if (ctx.config.runner) return ctx.config.runner;
const has = (f: string) => fs.existsSync(path.join(ctx.repoRoot, f));
let pkg: { devDependencies?: Record<string, string>; dependencies?: Record<string, string>; scripts?: Record<string, string> } = {};
try { pkg = JSON.parse(fs.readFileSync(path.join(ctx.repoRoot, 'package.json'), 'utf8')); } catch { /* no package.json */ }
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (has('bunfig.toml') || has('bun.lock') || has('bun.lockb')) return 'bun test {files}';
if (deps.vitest) return 'npx vitest run {files}';
if (deps.jest) return 'npx jest {files}';
if (files.every(f => /\.py$/.test(f)) && files.length) return 'python3 -m pytest {files}';
const jsts = files.every(f => /\.[cm]?[jt]sx?$/.test(f));
if (jsts && (has('bunfig.toml') || has('bun.lock') || has('bun.lockb'))) return 'bun test {files}';
if (jsts && deps.vitest) return 'npx vitest run {files}';
if (jsts && deps.jest) return 'npx jest {files}';
if (files.every(f => /\.py$/.test(f)) && files.length) return PYTEST;
if (files.every(f => /\.[cm]?js$/.test(f)) && files.length) return 'node --test {files}';
if (files.every(f => /\.[cm]?[jt]sx?$/.test(f)) && files.length && Bun.which('bun')) return 'bun test {files}';
return null;
@@ -113,6 +118,18 @@ export function dartRunner(repoRoot: string, root: string, env: NodeJS.ProcessEn
return { runner: `${fvm ? 'fvm ' : ''}${tool} test {files}` };
}
/**
* The tool `python3 -m pytest` lacks: `python3` off the PATH of `env`, or `pytest` when that python3, run from the repo root as the
* runner is, cannot import it (a `pytest` script on PATH may belong to another interpreter); null when both are there, or when the
* probe itself times out or dies, so the run decides.
*/
function pytestMissing(repoRoot: string, env: NodeJS.ProcessEnv): string | null {
const python = Bun.which('python3', { PATH: env.PATH ?? '' });
if (!python) return 'python3';
const r = spawnSync(python, ['-c', 'import importlib.util, sys; sys.exit(3 if importlib.util.find_spec("pytest") is None else 0)'], { cwd: repoRoot, env, stdio: 'ignore', timeout: 10_000 });
return r.status === 3 ? 'pytest' : null;
}
/**
* `cargo test -p <name>` 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
@@ -163,6 +180,23 @@ function packageRunner(repoRoot: string, dir: string, env: NodeJS.ProcessEnv): {
return { runner: `${PM_EXEC[pm]} ${tool} {files}` };
}
/**
* Whether a JS/TS test imports a value from Playwright's runner in a statement that starts a line: `import … from '@playwright/test'`
* (one line or several), a bare import, or `require`. A type-only import (`import type …`, or every specifier `type X`) and a string or
* fixture body that mentions the import mid-line do not count.
*/
function importsPlaywright(text: string): boolean {
const code = text.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '');
if (/^[\t ]*(?:import[\t ]*|(?:const|let|var)\b[^=\n]*=[\t ]*require\([\t ]*)['"]@playwright\/test['"]/m.test(code)) return true;
for (const m of code.matchAll(/^[\t ]*import(?=[\s{*])([^'"`;]*?)\bfrom[\t ]*['"]@playwright\/test['"]/gm)) {
const spec = m[1]!.trim();
if (!/^type(?=[\s{*])/.test(spec) && spec.replace(/[{}]/g, '').split(',').some(s => s.trim() && !/^type\s/.test(s.trim()))) return true;
}
return false;
}
/** Playwright's own text when its browsers are not installed on this machine. */
const PLAYWRIGHT_NO_BROWSERS = /^.*(?:Executable doesn't exist at|download new browsers).*$/m;
/** 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[] }
@@ -171,8 +205,11 @@ export interface RunPlan { groups: RunGroup[]; unclaimed: string[]; missing: str
* Dart tests run from their own package root (a sub-package's imports resolve
* against its own pubspec); Rust tests run per crate as `cargo test -p <name>`
* 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,
* declares vitest or jest run from that package; a JS/TS test that imports
* `@playwright/test` runs through `playwright test` from its nearest package;
* the rest go to the detected runner from the repo root, split by language
* when no one runner fits them all (`.mjs` + `.py` runs both), and pytest only
* when python3 can import it. 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.
*/
@@ -182,6 +219,7 @@ export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan
const dart = new Map<string, string[]>();
const cargo = new Map<string, { name: string; cwd: string; files: string[] }>();
const packages = new Map<string, string[]>();
const playwright = new Map<string, string[]>();
const seen = new Map<string, string | null>();
const seenPackage = new Map<string, string | null>();
let model: CargoModel | null = null;
@@ -204,6 +242,7 @@ export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan
continue;
}
const pkg = /\.[cm]?[jt]sx?$/.test(f) ? nearestRoot(ctx.repoRoot, f, 'package.json', seenPackage) : null;
if (pkg !== null && importsPlaywright(readRepoFile(ctx.repoRoot, f) ?? '')) { add(playwright, pkg, under(pkg, f)); continue; }
if (pkg) {
if (!pkgRunners.has(pkg)) pkgRunners.set(pkg, packageRunner(ctx.repoRoot, pkg, ctx.env));
const r = pkgRunners.get(pkg);
@@ -225,12 +264,24 @@ export function planRuns(ctx: PregateContext, files: readonly string[]): RunPlan
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);
const language = (f: string) => (/\.py$/.test(f) ? 'py' : /\.[cm]?js$/.test(f) ? 'js' : /\.[cm]?[jt]sx?$/.test(f) ? 'ts' : f);
const parts = !rest.length ? [] : detectRunner(ctx, rest) ? [rest] : [...Map.groupBy(rest, language).values()];
const byRunner = new Map<string, string[]>();
for (const part of parts) {
const runner = detectRunner(ctx, part);
const lacking = runner === PYTEST ? pytestMissing(ctx.repoRoot, ctx.env) : null;
if (lacking) missing.add(lacking);
if (runner && !lacking) for (const f of part) add(byRunner, runner, f);
else unclaimed.push(...part);
}
for (const [runner, list] of byRunner) groups.push({ runner, cwd: '', files: list });
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 });
for (const [root, list] of [...playwright].sort(([a], [b]) => a.localeCompare(b))) {
const pm = packageManager(ctx.repoRoot, root);
const bin = pm === 'npm' ? 'npx' : pm;
if (onPath(bin, ctx.env)) groups.push({ runner: `${PM_EXEC[pm]} playwright test --reporter=list {files}`, cwd: root, files: list });
else { missing.add(bin); unclaimed.push(...list.map(f => (root ? `${root}/${f}` : f))); }
}
return { groups, unclaimed: [...unclaimed, ...noPubspec], missing: [...missing].sort(), noPubspec };
}
@@ -269,11 +320,20 @@ function parseCargo(output: string): RunnerSummary | null {
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. */
/** playwright test: the last `N failed`, `N interrupted`, `N flaky`, `N passed` and `N skipped` lines (its closing summary, with test names between them; a test's own output earlier does not count); skipped tests did not run. */
function parsePlaywright(output: string): RunnerSummary | null {
if (!/^\s*\d+ (?:passed|failed|flaky|interrupted|skipped)\b/m.test(output)) return null;
const count = (word: string) => Number([...output.matchAll(new RegExp(`^\\s*(\\d+) ${word}\\b`, 'gm'))].pop()?.[1] ?? 0);
const failed = count('failed') + count('interrupted');
return { ran: count('passed') + count('flaky') + failed, failed };
}
/** The runner's own summary line: bun, node --test, vitest, jest, pytest, bats, dart/flutter test, cargo test, playwright test. A bats, dart/flutter, playwright 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 (/(?<![\w-])cargo\s+test(?![\w-])/.test(runner)) return parseCargo(output) ?? { ran: null, failed: null };
if (/(?<![\w-])bats(?![\w-])/.test(runner)) { const b = parseBats(output); if (b) return b; }
if (/(?<![\w-])playwright\s+test(?![\w-])/.test(runner)) { const p = parsePlaywright(output); if (p) return p; }
const dartRun = /(?<![\w-])(?:dart|flutter)(?![\w-])/.test(runner);
if (dartRun) { const d = parseDart(output); if (d) return d; }
let m: RegExpExecArray | null;
@@ -287,19 +347,22 @@ export function parseSummary(raw: string, runner = ''): RunnerSummary {
return (dartRun ? null : parseDart(output)) ?? parseBats(output) ?? { ran: null, failed: null };
}
interface LocalRun { exit: number | null; outcome: Supervised['outcome']; signal: string | null; observedStart: boolean; ran: number | null; failed: number | null; tail: string[]; runner: string; command: string; files: string[]; lastFile: string | null; ms: number; error?: string }
interface LocalRun { exit: number | null; outcome: Supervised['outcome']; signal: string | null; observedStart: boolean; ran: number | null; failed: number | null; tail: string[]; runner: string; command: string; files: string[]; lastFile: string | null; ms: number; error?: string; noBrowsers?: string }
const quote = (f: string) => `'${f.replace(/'/g, `'\\''`)}'`;
async function runOnce(ctx: PregateContext, env: NodeJS.ProcessEnv, g: RunGroup, files: string[], log: string, counter: Counter): Promise<LocalRun> {
const joined = files.map(quote).join(' ');
// playwright test reads each file argument as a case-insensitive regex: escape it and anchor it to a path segment, so `a[1].spec.ts` runs that file, not `a1.spec.ts`.
const args = /(?<![\w-])playwright\s+test(?![\w-])/.test(g.runner) ? files.map(f => `(?:^|/)${f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) : files;
const joined = args.map(quote).join(' ');
const run = g.runner.replace('{files}', () => joined);
const command = g.cwd ? `(cd ${quote(g.cwd)} && ${run})` : run;
const shown = g.cwd ? files.map(f => `${g.cwd}/${f}`) : files;
const r = await supervise({ command: run, cwd: path.join(ctx.repoRoot, g.cwd), env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `lanes (${shown.length === 1 ? shown[0] : `${shown.length} files`})`, files, adapter: adapterFor(g.runner), counter, progress: ctx.progress });
const output = readRange(log, r.start, r.end);
const summary = parseSummary(output, g.runner);
return { exit: r.exit, outcome: r.outcome, signal: r.signal, observedStart: r.observedStart, ...summary, tail: output.trim().split('\n').slice(-25), runner: g.runner, command, files: shown, lastFile: r.lastFile && g.cwd ? `${g.cwd}/${r.lastFile}` : r.lastFile, ms: r.ms, ...(r.error ? { error: r.error } : {}) };
const noBrowsers = /(?<![\w-])playwright\s+test(?![\w-])/.test(g.runner) ? PLAYWRIGHT_NO_BROWSERS.exec(output)?.[0].trim().slice(0, 200) : undefined;
return { ...(noBrowsers ? { noBrowsers } : {}), exit: r.exit, outcome: r.outcome, signal: r.signal, observedStart: r.observedStart, ...summary, tail: output.trim().split('\n').slice(-25), runner: g.runner, command, files: shown, lastFile: r.lastFile && g.cwd ? `${g.cwd}/${r.lastFile}` : r.lastFile, ms: r.ms, ...(r.error ? { error: r.error } : {}) };
}
/**
@@ -496,6 +559,9 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul
// only when the runner's binary is off PATH.
const environment = r.ran === null && r.bad && !r.bad.observedStart ? classifyCommandEnvironment(r.bad.runner, r.tail.join('\n'), r.exit, runtime.env) : null;
if (environment) return environmentResult(withRun, environment, `the runner exited ${r.exit} before any test was counted (${total} files): ${environment.evidence}`);
// Playwright counts a test whose browser is not installed as failed; when every test of every failed run failed and Playwright says so, the machine lacks the browsers.
const failedRuns = r.runs.filter(x => x.exit !== 0);
if (failedRuns.every(x => x.noBrowsers && x.failed !== null && x.failed === x.ran)) return environmentResult(withRun, { kind: 'missing-binary', evidence: `Playwright browsers not installed (npx playwright install): ${failedRuns[0]!.noBrowsers}`, proof: 'output' }, `every Playwright test failed to launch its browser (${r.failed} of ${r.ran}; ${total} files)`);
return { ...withRun, status: 'fail', cause: 'diff', code: 'PREGATE_LANE_FAILED', detail: `local lane exited ${r.exit} (${r.failed ?? '?'} failed of ${r.ran ?? '?'} ran; ${total} files; pipefail)`, fix: `read ${r.log}, fix the in-branch failure, rerun gstack-pregate --stage tests` };
}
if (r.ran === 0) return { ...withRun, status: 'fail', cause: 'diff', code: 'PREGATE_LANE_FAILED', detail: `ZERO-RUN: the runner reported 0 tests${r.zero ? ` in ${r.zero}` : ''} (${total} selected file(s))`, fix: 'a bad selector, a missing file or a skipped describe; exit 0 is not a pass' };
+19 -3
View File
@@ -69,7 +69,12 @@ function unquote(v: string): string { return /^['"`]/.test(v) ? v.slice(1, -1) :
* `/retry.ts.snap`, or `lib/install.ts` for `install.sh`, is not the module (GST-820): a bare stem such as `install` or `retry` is an
* English word in half the test tree, and a one-character old value (`2`)
* sits in every test, so that value needs the constant's name (a wrapper
* version 2 → 3 flagged 575 consumers through the stem alone).
* version 2 → 3 flagged 575 consumers through the stem alone), and counts only
* in a statement that names it: on the name's line, or on a line that
* continues it (the line before ends open, `(` `,` `.` `=`, or the line
* starts with `.` `)` `]`), comments excluded (#3492: `$2`, "from revision 2",
* `export default 2`). A call-site name (`attempts`, `limit`) is an ordinary
* word, so for it the file must also name the module (gstack-evals#97).
*
* A version-history row (`[45, "<digest>"]`, the old value first in a
* bracketed row) is skipped when the same table, a run of consecutive
@@ -94,12 +99,23 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan
const moduleRe = new RegExp(`(?:^|[/\\\\])${escape(stem)}(?=[^\\w.-]|$|\\.${dts}(?:${exts})(?![\\w.-])|\\.(?!\\w))|(?<![\\w-])${escape(base)}(?![\\w-]|\\.\\w)`, 'm');
const nameRe = new RegExp(`\\b${escape(change.name)}\\b`);
const nameRequired = value.length < 2;
const callSite = !/^[A-Z]/.test(change.name);
const comment = (l: string) => /^\s*(?:\/\/|#|\*|\/\*|<!--)/.test(l);
const continues = (prev: string, cur: string) => /[([{,.=+\-*/&|?:\\]\s*$/.test(prev) || /^\s*[.)\]}?:&|+]/.test(cur);
const hits: string[] = [];
for (const rel of testFiles) {
let text: string;
try { text = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
if (!(nameRe.test(text) || (!nameRequired && moduleRe.test(text)))) continue;
if (nameRequired ? !(nameRe.test(text) && (!callSite || moduleRe.test(text))) : !(nameRe.test(text) || moduleRe.test(text))) continue;
const lines = text.split('\n');
const named = (i: number) => {
if (comment(lines[i]!)) return false;
for (let j = i; j >= 0 && i - j < 4; j--) {
if (j < i && (comment(lines[j]!) || !continues(lines[j]!, lines[j + 1]!))) return false;
if (nameRe.test(lines[j]!)) return true;
}
return false;
};
const table: number[] = [];
const tableHasNew: boolean[] = [];
for (let i = 0; i < lines.length; i++) {
@@ -115,7 +131,7 @@ export function consumersOf(repoRoot: string, testFiles: readonly string[], chan
if (extended === undefined) { const base = baseText?.(rel); extended = base === null || (base !== undefined && !newRowRe.test(base)); }
return extended;
};
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!) && !skip(i)) { hits.push(`${rel}:${i + 1}`); break; }
for (let i = 0; i < lines.length; i++) if (valueRe.test(lines[i]!) && (!nameRequired || oldRowRe.test(lines[i]!) || named(i)) && !skip(i)) { hits.push(`${rel}:${i + 1}`); break; }
}
return hits;
}
+6 -4
View File
@@ -40,9 +40,11 @@
* run_profiles name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`:
* the GitHub Actions runs `clear --run` accepts as evidence
* (lib/run-evidence.ts); a profile named after a lane clears it
* remote_gate_runs `{ profiles, inventory }`: the run profiles that together
* clear the remote gate obligation on the identical tree, and
* the command listing the gate's test files they must cover
* remote_gate_runs `{ profiles, inventory, skipped? }`: the run profiles that
* together clear the remote gate obligation on the identical
* tree, the command listing the gate's test files they must
* cover, and the command naming the selected files the gate
* itself never runs (gstack: paid files)
* allowlist `{ finding, paths?, pattern?, reason }` entries the `secrets`
* check accepts as benign: a gstack-redact finding id or a
* non-secret category, scoped by path globs and/or a regex
@@ -354,7 +356,7 @@ export function explainConfig(): string {
'timeout_s: seconds each runner process may take (the unit-file group, each serial file, each guard without its own timeout_s, and the remote gate\'s deadline) when --timeout is not passed; 1 to 3600, default 600',
'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=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
'run_profiles: name → { workflow, events, jobs, artifact, max_age_hours?, os? }; the GitHub Actions runs gstack-pregate clear --run verifies as evidence on the identical tree (docs/pregate.md#run-evidence)',
'remote_gate_runs: { profiles, inventory }; the run profiles that together clear the remote gate obligation, and the command listing the gate\'s test files they must cover (docs/pregate.md#run-evidence)',
'remote_gate_runs: { profiles, inventory, skipped? }; the run profiles that together clear the remote gate obligation, the command listing the gate\'s test files they must cover, and an optional command printing which selected files (PREGATE_FILES_FILE) the gate itself does not run, which then need no run (docs/pregate.md#run-evidence)',
`allowlist: [{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted. finding is a gstack-redact id (env.kv, google.api_key, pii.phone.e164, …) or a category (${ALLOWLISTABLE_CATEGORIES.join(', ')}); paths are globs; pattern is a regex matched at the finding's position in the added line; one of the two is required, reason always. Never ${NON_ALLOWLISTABLE.join(' or ')} or engine.* (docs/pregate.md#allowlist)`,
'no .gstack/pregate.json on the base: <state root>/projects/<slug>/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';
+13 -3
View File
@@ -205,7 +205,7 @@ function userPolicyChange(file: PregateFile): string | null {
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck }
/** What a run clear needs: the base branch's run profiles, the verifier's dependencies, the clock, and the remote gate's inventory. */
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; inventory: (command: string, tree: string) => string[] | { error: string } }
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string } }
export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
/**
@@ -312,7 +312,8 @@ function treeSubstitution(file: PregateFile, gatedTree: string, lane: string, re
/**
* `clear --run`: GitHub Actions runs as the obligation's evidence (lib/run-evidence.ts). The remote gate needs one
* verified run per `remote_gate_runs` profile, all on one tree, whose tree.json file lists together cover every file
* the gate's inventory command prints; any other lane needs one verified run of the profile named after it. The run
* the gate's inventory command prints and every selected file except those its skipped command says the gate itself
* does not run (never an inventory file); any other lane needs one verified run of the profile named after it. The run
* tree must be pregate.json's tree or a stamp-only predecessor of it, exactly as a gate report's tree must.
*/
function clearByRuns(file: PregateFile, lane: string, refs: string[], check: RunCheck | undefined, repoRoot: string): ClearDecision {
@@ -359,10 +360,19 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run
const idle = evidence.filter(e => (e.tests ?? e.files.length) === 0);
if (idle.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=no_tests — ${idle.map(e => `run ${e.run_id} (${e.profile})`).join(', ')} records no tests; ${lane} stays uncleared` };
const required = new Set(file.requires_remote?.find(r => r.lane === lane)?.tests ?? []);
let skippedCount = 0;
if (isGate) {
const listed = check.inventory(check.gateRuns!.inventory, file.tree);
if ('error' in listed) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=inventory_failed — the remote gate's inventory command failed: ${listed.error}; ${lane} stays uncleared` };
if (!listed.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=inventory_empty — the remote gate's inventory command listed no test files; ${lane} stays uncleared` };
const skipCommand = check.gateRuns!.skipped;
if (skipCommand && required.size) {
const skipped = check.inventory(skipCommand, file.tree, [...required]);
if ('error' in skipped) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=skipped_failed — the remote gate's skipped command failed: ${skipped.error}; ${lane} stays uncleared` };
const inventory = new Set(listed);
for (const f of skipped) if (!inventory.has(f) && required.delete(f)) skippedCount++;
if (!required.size) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=paid_only — the remote gate skips every selected test file (${skippedCount}), so no selected test ran; like the gate's own tests=0 report, that never clears; ${lane} stays uncleared` };
}
for (const f of listed) required.add(f);
}
const missing = uncoveredFiles([...required], evidence);
@@ -375,7 +385,7 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run
}
const tests = evidence.reduce((n, e) => n + (e.tests ?? e.files.length), 0);
const accepted = sub.substitution ? ` accepted-for=${file.tree.slice(0, 12)} stamp-only=${sub.substitution.files.length}` : '';
return { receipt: `${isGate ? 'remote-gate' : 'run'} tree=${trees[0]!.slice(0, 12)} tests=${tests}${accepted} ${runReceiptFields(evidence, check.now)}`, attested: false, substitution: sub.substitution };
return { receipt: `${isGate ? 'remote-gate' : 'run'} tree=${trees[0]!.slice(0, 12)} tests=${tests}${skippedCount ? ` skipped=${skippedCount}` : ''}${accepted} ${runReceiptFields(evidence, check.now)}`, attested: false, substitution: sub.substitution };
}
/** Record the receipt that clears one obligation (a run URL, an evidence-bundle lane label, or a remote gate report, with the stamp-only substitution when one was accepted). */
+1 -2
View File
@@ -79,8 +79,7 @@ export function draftConfig(repoRoot: string): InitDraft {
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' && l !== 'rust').length > 1 ? '; a selection mixing these languages needs runner set' : '';
const runnerNote = notes.length ? notes.join('; ') + mixed : 'no test files found';
const runnerNote = notes.length ? notes.join('; ') : 'no test files found';
const selectionTodo = discoverLanes(repoRoot).filter(l => l.runsTests && l.matrixExpression && l.testFiles.length === 0).map(l => l.id);
const config = { $schema: PREGATE_CONFIG_SCHEMA, runner: null, dependencies, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] } };
+16 -6
View File
@@ -1,12 +1,16 @@
/**
* The run-evidence inputs one consumer needs (GSTA-156): the base branch's run
* profiles, this repository on GitHub, the verifier's gh- and git-backed
* dependencies, and the remote gate's inventory command (a pin-file command,
* so it runs only with --allow-repo-commands). Shared by `gstack-pregate
* dependencies, and the remote gate's inventory and skipped commands (pin-file
* commands, so they run only with --allow-repo-commands; skipped reads the
* selected files from PREGATE_FILES_FILE). Shared by `gstack-pregate
* clear` and `gstack-pregate --from-run`, so both read profiles from the
* base and nowhere else.
*/
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 { ghRunDeps, runInventory } from '../run-evidence';
import { wtreeFingerprint } from '../script-invocation';
import { resolveBaseBranch } from '../ship-policy';
@@ -28,13 +32,19 @@ export function buildRunCheck(root: string, lane: string | null, allowRepoComman
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === null ? false : lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] };
return {
repo, deps: ghRunDeps(repo, root), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
inventory: (command, tree) => {
inventory: (command, tree, selected) => {
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
if (at() !== tree) return { error: `the working tree is not pregate.json's tree ${tree.slice(0, 12)}; the inventory must list that tree's gate files` };
const listed = runInventory(command, root);
if (at() !== tree) return { error: `the working tree changed while ${command} ran` };
return listed;
const dir = selected && fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-skipped-'));
try {
if (dir) fs.writeFileSync(path.join(dir, 'selected.txt'), selected!.map(f => `${f}\n`).join(''));
const listed = runInventory(command, root, dir ? { PREGATE_FILES_FILE: path.join(dir, 'selected.txt') } : undefined);
if (at() !== tree) return { error: `the working tree changed while ${command} ran` };
return listed;
} finally {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
},
};
}
+7 -6
View File
@@ -43,7 +43,7 @@ import { sha256Hex, writeReceipt } from './egress-receipt';
import { runBashSync } from './script-invocation';
export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string }
export interface RemoteGateRuns { profiles: string[]; inventory: string }
export interface RemoteGateRuns { profiles: string[]; inventory: string; skipped?: string }
export interface ApiRun {
id: number; run_attempt: number; status: string; conclusion: string | null; event: string; head_sha: string;
path?: string; created_at?: string; run_started_at?: string; updated_at?: string;
@@ -108,15 +108,16 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
}
if (gateRuns !== undefined && gateRuns !== null) {
const g = gateRuns as Record<string, unknown>;
if (!g || typeof g !== 'object' || Array.isArray(g)) errors.push({ path: 'remote_gate_runs', message: 'must be { profiles: [names], inventory: command }' });
if (!g || typeof g !== 'object' || Array.isArray(g)) errors.push({ path: 'remote_gate_runs', message: 'must be { profiles: [names], inventory: command, skipped?: command }' });
else {
for (const k of Object.keys(g)) if (k !== 'profiles' && k !== 'inventory') errors.push({ path: `remote_gate_runs.${k}`, message: 'unknown key; valid keys: profiles, inventory' });
for (const k of Object.keys(g)) if (k !== 'profiles' && k !== 'inventory' && k !== 'skipped') errors.push({ path: `remote_gate_runs.${k}`, message: 'unknown key; valid keys: profiles, inventory, skipped' });
if (!Array.isArray(g.profiles) || g.profiles.length === 0 || g.profiles.some(p => typeof p !== 'string')) errors.push({ path: 'remote_gate_runs.profiles', message: 'must be a non-empty array of run_profiles names' });
else {
for (const p of g.profiles as string[]) if (!names.has(p)) errors.push({ path: 'remote_gate_runs.profiles', message: `${JSON.stringify(p)} is not a run_profiles name` });
if (new Set(g.profiles).size !== g.profiles.length) errors.push({ path: 'remote_gate_runs.profiles', message: 'names each profile once' });
}
if (typeof g.inventory !== 'string' || !g.inventory.trim() || g.inventory.length > 512 || /[\0\r\n]/.test(g.inventory)) errors.push({ path: 'remote_gate_runs.inventory', message: 'must be one command line under 512 characters that prints the remote gate\'s test files, one per line' });
if ('skipped' in g && (typeof g.skipped !== 'string' || !g.skipped.trim() || g.skipped.length > 512 || /[\0\r\n]/.test(g.skipped))) errors.push({ path: 'remote_gate_runs.skipped', message: 'must be one command line under 512 characters that prints which selected test files (PREGATE_FILES_FILE) the remote gate itself does not run, one per line' });
}
}
return errors;
@@ -201,9 +202,9 @@ export function runChanged(deps: RunDeps, id: number, attempt: number): string |
return again.run_attempt === attempt && again.status === 'completed' && again.conclusion === 'success' ? null : `run ${id} moved to attempt ${again.run_attempt} (${again.status === 'completed' ? again.conclusion : again.status}) while it was being verified`;
}
/** The remote gate's inventory: `command`'s non-empty output lines under pipefail, so a pipeline whose producer fails refuses instead of listing part of the gate. */
export function runInventory(command: string, cwd: string): string[] | { error: string } {
const r = runBashSync(`set -o pipefail\n${command}`, { cwd, encoding: 'utf8', timeout: 300_000, maxBuffer: 64 * 1024 * 1024 });
/** The remote gate's inventory (or skipped list, with PREGATE_FILES_FILE in `env`): `command`'s non-empty output lines under pipefail, so a pipeline whose producer fails refuses instead of listing part of the gate. */
export function runInventory(command: string, cwd: string, env?: Record<string, string>): string[] | { error: string } {
const r = runBashSync(`set -o pipefail\n${command}`, { cwd, encoding: 'utf8', timeout: 300_000, maxBuffer: 64 * 1024 * 1024, ...(env && { env: { ...process.env, ...env } }) });
if (r.status !== 0 || r.error) return { error: `${command} exited ${r.status ?? r.error}: ${(r.stderr ?? '').trim().slice(0, 200)}` };
return [...new Set(r.stdout.split('\n').map(l => l.trim()).filter(Boolean))];
}
+38 -22
View File
@@ -27,7 +27,7 @@ import { versionAtRef } from './restamp';
import { globToRegExp, type ShipPolicy } from './ship-policy';
import { ciAtHead, type QueueContext } from './ship-queue';
import { git, sha256, type QueueFailure } from './ship-queue-gate';
import { fetchBase, mergeCommit, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
import { fetchBase, mergeCommit, mergeStep, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
import { prInfo, type PrInfo } from './ship-queue-train-github';
import { listWaves, loadWave, nowIso, persist, queueRoot, rebuildFromNotes, redactCause, wavePath, type ReadyReceipt, type WaveRecord } from './ship-queue-train-record';
@@ -67,7 +67,8 @@ export function areaKey(file: string, isSkillDir: (dir: string) => boolean): str
return parts.length > 1 ? top : 'root';
}
export interface PlanPr { pr: string; head: string; files: string[]; lines: number }
/** renamed maps a path the PR renamed to its new path; files lists both. */
export interface PlanPr { pr: string; head: string; files: string[]; lines: number; renamed?: Record<string, string> }
export interface WaveGroup { area: string; prs: PlanPr[]; integrator: string }
/** The most common area across files, ties broken by name; 'misc' with no files. */
@@ -91,11 +92,13 @@ export const isHubFile = (file: string) => file.startsWith('docs/') || /\.(md|js
* Group PRs into waves: PRs whose changes to a common code file conflict always travel together
* (one integrator resolves the conflict once); hub files never link. conflicts(a, b) names the
* files a merge of the two heads cannot resolve, or null when unknown, which counts every shared
* file as conflicting. Each cluster takes its largest PR's area, clusters of one area are packed
* in PR order into waves of at most max, and a wave below min rides solo. The integrator is the
* PR with the most changed lines.
* file as conflicting; a conflict at the path either PR renamed the shared file to counts as one
* on that file. Each cluster takes its largest PR's area and is packed whole, first fit in PR
* order, into the waves of its area of at most max; a cluster larger than max is oversize (never
* split, so its conflicts still meet one integrator), and a wave below min rides solo. The
* integrator is the PR with the most changed lines.
*/
export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[] } {
export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[]; oversize: Array<{ area: string; prs: PlanPr[] }> } {
const sorted = [...prs].sort((a, b) => Number(a.pr) - Number(b.pr));
const root = new Map(sorted.map(p => [p.pr, p.pr]));
const find = (x: string): string => { const r = root.get(x)!; return r === x ? x : find(r); };
@@ -106,7 +109,8 @@ export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = W
const key = `${a.pr} ${b.pr}`;
if (!clash.has(key)) clash.set(key, conflicts(a, b));
const hit = clash.get(key);
if ((!hit || hit.includes(f)) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr));
const at = [f, a.renamed?.[f], b.renamed?.[f]];
if ((!hit || at.some(p => p && hit.includes(p))) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr));
}));
const clusters = new Map<string, PlanPr[]>();
for (const p of sorted) clusters.set(find(p.pr), [...(clusters.get(find(p.pr)) ?? []), p]);
@@ -117,17 +121,20 @@ export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = W
}
const groups: WaveGroup[] = [];
const solo: string[] = [];
const oversize: Array<{ area: string; prs: PlanPr[] }> = [];
for (const [area, list] of [...byArea].sort((a, b) => a[0].localeCompare(b[0]))) {
let cur: PlanPr[] = [];
const flush = () => {
if (cur.length >= min) groups.push({ area, prs: cur, integrator: [...cur].sort(largestFirst)[0]!.pr });
else solo.push(...cur.map(p => p.pr));
cur = [];
};
for (const c of list) for (const p of c) { if (cur.length >= max) flush(); cur.push(p); }
flush();
const waves: PlanPr[][] = [];
for (const c of list) {
if (c.length > max) { oversize.push({ area, prs: c }); continue; }
const fit = waves.find(w => w.length + c.length <= max);
if (fit) fit.push(...c); else waves.push([...c]);
}
for (const w of waves) {
if (w.length >= min) groups.push({ area, prs: w, integrator: [...w].sort(largestFirst)[0]!.pr });
else solo.push(...w.map(p => p.pr));
}
}
return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)) };
return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)), oversize };
}
export interface WaveChain { integrator: string[]; merges: string[]; stamps: string[] }
@@ -225,10 +232,14 @@ function readyReceipts(env: TrainEnv): ReadyReceipt[] {
return [...newest.values()];
}
function changed(env: TrainEnv, head: string): { files: string[]; lines: number } | null {
function changed(env: TrainEnv, head: string): { files: string[]; lines: number; renamed: Record<string, string> } | null {
const fork = git(env.ctx.repoRoot, ['merge-base', `${env.remote}/${env.ctx.base}`, head]);
const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', '--no-renames', fork.out, head]) : fork;
if (!stat.ok) return null;
const moves = fork.ok ? git(env.ctx.repoRoot, ['diff', '--name-status', '-z', '-M', '--diff-filter=R', fork.out, head]) : fork;
if (!stat.ok || !moves.ok) return null;
const renamed: Record<string, string> = {};
const fields = moves.out.split('\0');
for (let i = 0; i + 2 < fields.length; i += 3) if (!isReleasePath(fields[i + 1]!, env.policy.policy)) renamed[fields[i + 1]!] = fields[i + 2]!;
let lines = 0;
const files: string[] = [];
for (const row of stat.out.split('\n').filter(Boolean)) {
@@ -237,7 +248,7 @@ function changed(env: TrainEnv, head: string): { files: string[]; lines: number
files.push(file);
lines += (Number(add) || 0) + (Number(del) || 0);
}
return { files, lines };
return { files, lines, renamed };
}
const areaOf = (env: TrainEnv) => (f: string) => areaKey(f, dir => fs.existsSync(path.join(env.ctx.repoRoot, dir, 'SKILL.md.tmpl')));
@@ -269,12 +280,15 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m
if (ci.line.startsWith('unknown')) { unreadable.push(`#${r.pr} (CI ${ci.line})`); continue; }
if (!ci.green) { lines.push(`NOT_GREEN: #${r.pr} (${ci.line}; left out until CI is green at the READY head)`); continue; }
if (!ensureCommit(env, r.head, r.pr)) { unreadable.push(`#${r.pr} (head ${short(r.head)} not fetchable)`); continue; }
const onBase = mergeStep(env.ctx.repoRoot, `${env.remote}/${env.ctx.base}`, r.head, env.policy.policy);
if ('conflicts' in onBase && onBase.conflicts.some(f => f.startsWith('('))) { unreadable.push(`#${r.pr} (merge onto ${env.ctx.base} unreadable: ${onBase.conflicts.join(', ')})`); continue; }
if ('conflicts' in onBase) { lines.push(`NOT_MERGEABLE: #${r.pr} (conflicts with ${env.remote}/${env.ctx.base} at ${short(r.head)} in ${onBase.conflicts.join(', ')}; left out until the head merges cleanly onto ${env.ctx.base})`); continue; }
const c = changed(env, r.head);
if (!c) { unreadable.push(`#${r.pr} (diff unreadable)`); continue; }
live.push({ pr: r.pr, head: r.head, ...c });
}
if (unreadable.length) return outFailed(fail('QUEUE_PROOF_MISSING', `cannot read ${unreadable.length} READY PR(s): ${unreadable.join('; ')}; a plan from a partial queue would mislead`), lines, `${Q} wave plan (after gh and git can read every PR)`, 'none');
lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there); wave trigger ${o.trigger}`);
lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there, merging cleanly onto ${env.ctx.base}); wave trigger ${o.trigger}`);
if (live.length <= o.trigger) return outOk([...lines, `WAVE: not needed (${live.length} <= ${o.trigger}); build trains of single PRs`], `${Q} train build --prs <pins>`, 'none');
const plan = groupWaves(live, areaOf(env), o.min, o.max, (a, b) => {
const m = git(env.ctx.repoRoot, ['merge-tree', '--write-tree', '--name-only', '--no-messages', a.head, b.head]);
@@ -285,6 +299,7 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m
lines.push(`WAVE_GROUP: area=${g.area} prs=${g.prs.map(p => '#' + p.pr).join(',')} integrator=#${g.integrator} (most changed lines)`);
lines.push(` build: ${buildCmd(g.area, g.prs)}`);
}
for (const c of plan.oversize) lines.push(`WAVE_OVERSIZE: area=${c.area} prs=${c.prs.map(p => '#' + p.pr).join(',')} (${c.prs.length} PRs whose changes conflict, over the wave maximum ${o.max}; kept whole with no build command: land part of it as single slots, then run wave plan again)`);
if (plan.solo.length) lines.push(`SOLO: ${plan.solo.map(p => '#' + p).join(',')} (no area group of ${o.min}+; they ride as single slots)`);
const first = plan.groups[0];
return outOk(lines, first ? `#${first.integrator}'s owner integrates: ${buildCmd(first.area, first.prs)}` : `${Q} train build --prs <pins>`, 'none');
@@ -577,8 +592,9 @@ export function waveSettle(ctx: QueueContext, o: { wave: string; dryRun: boolean
export const WAVE_HELP = `gstack-ship-queue wave: wave PRs for merge trains (docs/ship-policy.md#wave-prs)
wave plan [--trigger <n>] [--min <n>] [--max <n>]
read-only: live READY PRs; over the trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and
WAVE_GROUP lines (area, PRs, integrator) with each group's build command
read-only: live READY PRs (NOT_GREEN and NOT_MERGEABLE heads left out); over the
trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and WAVE_GROUP lines (area, PRs, integrator)
with each group's build command; WAVE_OVERSIZE for linked PRs over --max (no command)
wave build --prs <n>@<sha>,... [--area <name>] [--min <n>] [--max <n>] [--dry-run]
the integrator branch wave/<nonce>: main plus each pin merged unchanged (train merge
rules), the wave record in refs/notes/gstack-trains, the branch pushed, a frozen notice
+2 -2
View File
@@ -226,8 +226,8 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run);
const atHead = versionAtRef(ctx.repoRoot, head);
const baseSha = git(ctx.repoRoot, ['rev-parse', `origin/${ctx.base}`]).out;
const atBase = versionAtRef(ctx.repoRoot, baseSha);
const version = 'version' in atHead && 'version' in atBase && atHead.version !== atBase.version ? atHead.version : 'unstamped';
const atFork = versionAtRef(ctx.repoRoot, git(ctx.repoRoot, ['merge-base', baseSha, head]).out || baseSha);
const version = 'version' in atHead && 'version' in atFork && atHead.version !== atFork.version ? atHead.version : 'unstamped';
const ci = ciAtHead(ctx, head);
const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL);
let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`;
+44 -14
View File
@@ -8,6 +8,7 @@
import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record';
import { RECEIPT_FENCE } from './ship-receipt';
import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth';
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
export interface ProseClaim { field: string; prose: number; receipt: number }
export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
@@ -29,6 +30,32 @@ export function receiptCounters(receipt: Record<string, unknown>): Map<string, n
const BLOCK_RE = new RegExp('```' + RECEIPT_FENCE + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
const ESCAPE = /[.*+?^${}()|[\]\\]/g;
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
const fenceClose = (fence: string) => new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`);
/**
* The body without its closed code fences (GST-984): pasted test output (`113 pass, 0 fail`) and quoted code
* (`the first 35 failing`) are evidence, not this PR's counters. The fence rules are withoutApprovalsBlocks' (a fence
* closes only on a run of its own character at least as long as the opener with nothing after it). A backtick run with
* another backtick later on its line (```npm test``` inline code) opens no fence, as in CommonMark. A fence that never
* closes stays prose, and so does the rest of the body, so an unclosed opener cannot hide the claims after it or let a
* shorter fence inside it hide them. A marker line takes each fence's place so the text on either side never joins
* into a claim.
*/
function withoutFences(body: string): string {
const lines = body.split(/(?<=\n)/);
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!.replace(/\r?\n$/, '');
const open = FENCE_OPEN.exec(line);
if (!open || (open[1]![0] === '`' && line.slice(open[0].length).includes('`'))) { out.push(lines[i]!); continue; }
const close = fenceClose(open[1]!);
const end = lines.findIndex((l, j) => j > i && close.test(l.replace(/\r?\n$/, '')));
if (end < 0) return out.join('') + lines.slice(i).join('');
out.push('(code fence)\n');
i = end;
}
return out.join('');
}
/**
* The body without the `## Approvals` blocks gstack-approvals renders (lib/approval-record.ts renderRecord): the
@@ -55,7 +82,7 @@ function withoutApprovalsBlocks(body: string): string {
for (let i = 0; i < lines.length; i++) {
const line = bare(i);
if (fence !== null) {
if (new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(line)) fence = null;
if (fenceClose(fence).test(line)) fence = null;
} else if (FENCE_OPEN.test(line)) fence = FENCE_OPEN.exec(line)![1]!;
else if (/^ {0,3}## Approvals\s*$/.test(line)) {
const end = blockEnd(i);
@@ -67,23 +94,26 @@ function withoutApprovalsBlocks(body: string): string {
}
/**
* The body without the `Review-loop exception: …` line the receipt's own exception requires (GST-977): write and the
* receipt check want it word for word, and it quotes the owner's reason (`outside 1 and 2 skip detector`), not this
* PR's counters. Only a line whose trimmed text is that rendered line, the test write applies, is left out (a fenced
* copy too, as write accepts one); the same words inside a longer line stay prose. A marker line takes its place so
* the text on either side never joins into a claim.
* The body without the exception lines the receipt's own exceptions require: the `Review-loop exception: …` line
* (GST-977) and the `Documentation exception: …` line (GST-983). write and the receipt check want each word for word,
* and each quotes the owner's reason (`outside 1 and 2 skip detector`), not this PR's counters. Only a line whose
* trimmed text is a rendered line, the test write applies, is left out; the same words inside a longer line stay
* prose. A marker line takes its place so the text on either side never joins into a claim.
*/
function withoutExceptionLine(body: string, exception: unknown): string {
if (exception === undefined) return body;
const parsed = parseReviewLoopException(exception);
if (!parsed.ok) return body;
const line = reviewLoopExceptionLine(parsed.exception);
return body.split(/(?<=\n)/).map(l => (l.trim() === line ? '(review-loop exception line)\n' : l)).join('');
function withoutExceptionLines(body: string, receipt: Record<string, unknown>): string {
const lines = new Set<string>();
const loop = receipt.review_loop_exception === undefined ? undefined : parseReviewLoopException(receipt.review_loop_exception);
if (loop?.ok) lines.add(reviewLoopExceptionLine(loop.exception));
const docs = receipt.docs === undefined ? undefined : parseReceiptDocs(receipt.docs);
const docsLine = docs?.ok ? docsExceptionLine(docs.docs) : null;
if (docsLine) lines.add(docsLine);
if (!lines.size) return body;
return body.split(/(?<=\n)/).map(l => (lines.has(l.trim()) ? '(exception line)\n' : l)).join('');
}
/**
* Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body
* without its receipt block, its rendered Approvals block and its review-loop exception line), where `<name>` is a counter's leaf key with `_`
* without its receipt block, its code fences, its rendered Approvals block and its exception lines), where `<name>` is a counter's leaf key with `_`
* matched as `_`, space or `-` and not part of a longer word (`3 pass-through`
* is not a `pass` claim), compared with the receipt. A leaf key shared by
* several counters (`pass` under every gate lane, `passed` under gate and ci)
@@ -93,7 +123,7 @@ function withoutExceptionLine(body: string, exception: unknown): string {
* `<n> <name>` claim about the next key (#3513).
*/
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
const prose = withoutExceptionLine(withoutApprovalsBlocks(body.replace(BLOCK_RE, '')), receipt.review_loop_exception);
const prose = withoutExceptionLines(withoutApprovalsBlocks(withoutFences(body.replace(BLOCK_RE, ''))), receipt);
const byLeaf = new Map<string, Array<{ field: string; value: number }>>();
for (const [field, value] of receiptCounters(receipt)) {
const leaf = field.split('.').at(-1)!;
+2 -2
View File
@@ -42,6 +42,7 @@
"preamble:plan-mode-info",
"preamble:ask-user-format",
"preamble:completion-status",
"preamble:evidence",
"section:autoplan/unattended",
"section:ship/review-army",
"section:ship/adversarial"
@@ -58,7 +59,6 @@
"preamble:writing-style",
"preamble:completeness",
"preamble:confusion",
"preamble:evidence",
"preamble:context-health",
"preamble:question-tuning",
"preamble:search-before-building",
@@ -66,7 +66,7 @@
"preamble:brain-health",
"preamble:context-recovery"
],
"note": "Opus 5.5 only (GSTA-161). Drops eleven coaching preamble pieces and keeps upgrade-check and brain-sync. Evidence: the GSTA-161 office-hours preamble run (handoffs/GSTA-161/preamble-measurement.md) and the three-arm land-and-deploy confirmation cited in docs/loadouts.md."
"note": "Opus 5.5 only (GSTA-161). Drops ten coaching preamble pieces and keeps upgrade-check, brain-sync and the pinned Claims Need Evidence rule (GST-944). Evidence: the GSTA-161 office-hours preamble run (handoffs/GSTA-161/preamble-measurement.md) and the three-arm land-and-deploy confirmation cited in docs/loadouts.md."
}
},
"models": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.93.10",
"version": "1.93.20",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+4 -2
View File
@@ -55,6 +55,8 @@ export interface GenerationOptions {
catalogMode?: 'trim' | 'full';
explainLevel?: 'default' | 'terse';
respectDetection?: boolean;
/** A detection result the caller already read; wins over respectDetection so one read serves identity and render. */
gbrainDetected?: boolean;
log?: (message: string) => void;
planMode?: boolean;
/** Module loadout (scripts/modules.ts selectModules); null/undefined = `full`. */
@@ -94,7 +96,7 @@ export interface GenerationResult {
}
/** Canonical generation never reads local detection state unless opted in. */
function loadGbrainOverride(respectDetection: boolean): boolean {
export function loadGbrainOverride(respectDetection: boolean): boolean {
if (!respectDetection) return false;
const stateDir = resolveStateRoot();
try {
@@ -989,7 +991,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
model: settings.model ?? null,
catalogMode: settings.catalogMode ?? 'trim',
explainLevel: settings.explainLevel ?? 'default',
gbrainDetected: loadGbrainOverride(settings.respectDetection ?? false),
gbrainDetected: settings.gbrainDetected ?? loadGbrainOverride(settings.respectDetection ?? false),
planMode: settings.planMode ?? true,
modules: settings.modules ?? null,
};
+9 -2
View File
@@ -26,7 +26,8 @@ import { createHash, randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runGeneration } from './gen-skill-docs';
import { readConfigKey } from '../lib/state-root';
import { loadGbrainOverride, runGeneration } from './gen-skill-docs';
import { resolveModel } from './models';
import { ModuleError, type ModuleSelection } from './modules';
import { LOCK_FILE, danglingReferences } from './modules-render';
@@ -148,6 +149,10 @@ export async function installLoadout(request: InstallRequest): Promise<InstallRe
const planMode = request.planMode ?? true;
const model = request.boundModel ? resolveModel(request.boundModel) : null;
const selection: ModuleSelection = { ...request.selection, boundModel: request.boundModel ?? undefined, fullRoot: request.boundModel ? request.fullRoot : undefined };
const gbrainDetected = loadGbrainOverride(true);
const disabledSkills = [...new Set((readConfigKey('disabled_skills') ?? '').replace(/["']/g, '').split(/[\s,]+/)
.map(n => n.replace(/^\//, '')).filter(n => n !== 'gstack' && n !== 'gstack-upgrade')
.map(n => n.replace(/^gstack-/, '')).filter(n => /^[a-z0-9-]+$/.test(n)))].sort();
const identity = {
loadout: selection.name,
excluded: [...selection.excluded].sort(),
@@ -157,6 +162,8 @@ export async function installLoadout(request: InstallRequest): Promise<InstallRe
host,
overlay_model: model,
plan_mode: planMode,
gbrain_detected: gbrainDetected,
disabled_skills: disabledSkills,
source_tree: sourceTree(source),
};
const identitySha = sha256(JSON.stringify(identity));
@@ -174,7 +181,7 @@ export async function installLoadout(request: InstallRequest): Promise<InstallRe
}
const stage = `${final}.stage-${process.pid}-${randomBytes(4).toString('hex')}`;
try {
const result = await runGeneration({ host, outputRoot: stage, contentLinkRoot: final, installRoot: request.fullRoot && request.fullRoot !== path.join(os.homedir(), '.claude/skills') ? path.join(request.fullRoot, 'gstack') : null, model, planMode, modules: selection });
const result = await runGeneration({ host, outputRoot: stage, contentLinkRoot: final, installRoot: request.fullRoot && request.fullRoot !== path.join(os.homedir(), '.claude/skills') ? path.join(request.fullRoot, 'gstack') : null, model, planMode, modules: selection, gbrainDetected, disabledSkills });
const errors = result.diagnostics.filter(d => d.kind === 'error').map(d => d.message);
if (result.exitCode !== 0 || errors.length) {
throw new ModuleError('RENDER_FAILED', `gen-skill-docs failed: ${errors.join('; ') || 'exit 1'}`, 'fix the named template or module, then rerun; side effects: none, safe to retry');
+47 -5
View File
@@ -201,6 +201,24 @@ export function wallTimeoutForPackedShard(predictedMs: number, baseMs = DEFAULT_
// tighter — it keeps the per-file floor the runner has always guaranteed.
return Math.max(baseMs, Math.ceil(predictedMs * 3), fileCount * PER_FILE_WALL_MS);
}
/** The flaky-retry pass re-runs at most this many failing files, once, serially, in the same CI step. */
export const FLAKY_RETRY_CAP = 5;
/** No free-suite GitHub job gets a longer timeout; a plan whose shard would need more fails instead. */
export const MAX_CI_JOB_TIMEOUT_MINUTES = 90;
/**
* The GitHub job timeout for one planned CI shard (#3850): the wall --ci-run
* gives the shard, plus the flaky-retry pass's wall, plus the job's measured
* setup and teardown. GitHub must never kill the job before the runner can
* name a hung file and retry it; that kill uploads no result.
*/
export function ciShardJobTimeoutMinutes(shard: { shard: number; files: string[]; predictedMs: number }, setupSeconds: number): number {
const runnerMs = wallTimeoutForPackedShard(shard.predictedMs, DEFAULT_WALL_TIMEOUT_MS, shard.files.length) + wallTimeoutForShard(FLAKY_RETRY_CAP);
const minutes = Math.ceil((runnerMs / 1000 + setupSeconds) / 60);
if (minutes > MAX_CI_JOB_TIMEOUT_MINUTES) {
throw new Error(`FREE_JOB_TIMEOUT_OVER_CAP: CI shard ${shard.shard} needs a ${minutes}-minute job (runner wall plus retry plus ${setupSeconds} s setup), over the ${MAX_CI_JOB_TIMEOUT_MINUTES}-minute cap; plan more shards or a higher --shard-jobs instead of a longer job`);
}
return minutes;
}
/**
* Full-suite parallelism: use all available CPUs, with a floor of one and a
* per-platform cap (maxFullSuiteJobs). Shards stay serial internally; separate
@@ -453,6 +471,16 @@ export function createFreeCiPlan(files: string[], count: number, durations: Reco
return { ...body, id: planDigest(body) };
}
/**
* --ci-plan's stdout, the GitHub matrix. With --job-setup-seconds each entry
* carries the shard's job timeout (ciShardJobTimeoutMinutes); without it, the
* bare shard list the Windows lane reads.
*/
export function ciPlanMatrix(plan: FreeCiPlan, setupSeconds?: number): { shard: number[] } | { include: Array<{ shard: number; timeout_minutes: number }> } {
if (setupSeconds === undefined) return { shard: plan.shards.map(shard => shard.shard) };
return { include: plan.shards.map(shard => ({ shard: shard.shard, timeout_minutes: ciShardJobTimeoutMinutes(shard, setupSeconds) })) };
}
export function validateFreeCiPlan(plan: FreeCiPlan, files: string[], revision: string): void {
const { id, version, shards } = plan;
if (version !== 1 || plan.revision !== revision || !Array.isArray(shards) || !shards.length
@@ -575,6 +603,8 @@ type CliOptions = {
jobs?: number;
/** Set only by --shard-jobs (with --ci-plan); absent means k = 1 in every shard. */
shardJobs?: number;
/** Set only by --job-setup-seconds (with --ci-plan): the matrix then carries each shard's job timeout. */
jobSetupSeconds?: number;
};
export function parseCliOptions(argv: string[]): CliOptions {
@@ -591,6 +621,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
let attributeHome = false;
let jobs: number | undefined;
let shardJobs: number | undefined;
let jobSetupSeconds: number | undefined;
const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results' | 'filesFrom', string | null> = {
ciPlan: null, ciRun: null, ciVerify: null, result: null, results: null, filesFrom: null,
};
@@ -616,6 +647,11 @@ export function parseCliOptions(argv: string[]): CliOptions {
'--files-from': pathFlag('--files-from', 'filesFrom'),
'--jobs': (next) => { jobs = parseJobsFlag(next()); },
'--shard-jobs': (next) => { shardJobs = parseJobsFlag(next(), '--shard-jobs'); },
'--job-setup-seconds': (next) => {
const value = next() ?? '';
jobSetupSeconds = /^\d+$/.test(value) ? Number(value) : NaN;
if (!(jobSetupSeconds > 0 && jobSetupSeconds <= 3600)) throw new Error('--job-setup-seconds needs a whole number of seconds from 1 to 3600');
},
'--shards': (next) => {
const value = next();
if (!value) throw new Error('Missing value for --shards');
@@ -641,8 +677,9 @@ export function parseCliOptions(argv: string[]): CliOptions {
if (paths.ciVerify && !paths.results) throw new Error('--ci-verify requires --results');
if (jobs !== undefined && !paths.ciRun) rejectJobsWithoutCiRun();
if (shardJobs !== undefined && !paths.ciPlan) rejectShardJobsWithoutCiPlan();
if (jobSetupSeconds !== undefined && !paths.ciPlan) throw new Error('--job-setup-seconds applies only to --ci-plan');
if (quick && (recordDurations || windowsOnly || shardIndex !== null)) throw new Error('--quick cannot change recording, Windows or shard selection');
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths, ...(jobs === undefined ? {} : { jobs }), ...(shardJobs === undefined ? {} : { shardJobs }) };
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths, ...(jobs === undefined ? {} : { jobs }), ...(shardJobs === undefined ? {} : { shardJobs }), ...(jobSetupSeconds === undefined ? {} : { jobSetupSeconds }) };
}
const describeLines = (lines: string[]) => `${lines.slice(0, 5).join(', ')}${lines.length > 5 ? ` (+${lines.length - 5} more)` : ''}`;
@@ -1891,7 +1928,6 @@ async function retryFailedFreeFiles(
// Exists for syscall-supervised sandboxes (see fullSuiteJobs) where a run
// lands 0-1 spurious browser-timing failures under an otherwise-green
// suite. Capped so a genuinely broken tree never masquerades as flaky.
const RETRY_CAP = 5;
if (
worst !== 0
&& process.env.GSTACK_FREE_RETRY_FLAKY === '1'
@@ -1906,7 +1942,7 @@ async function retryFailedFreeFiles(
// mask the unattributable evidence as a FLAKY-PASS.
const allAttributed = outcomes.every((o) => hasScopedFailureAttribution(o) && (o.status === 'passed'
|| (o.failingFiles.length > 0 && o.unattributedFailures === 0)));
if (allAttributed && flakyFiles.length > 0 && flakyFiles.length <= RETRY_CAP) {
if (allAttributed && flakyFiles.length > 0 && flakyFiles.length <= FLAKY_RETRY_CAP) {
console.log(`[test:free] flaky-retry: re-running ${flakyFiles.length} failing file(s) once, serially: ${flakyFiles.join(', ')}`);
const retryOutcome = await runFreeShard(flakyFiles, totalShards + 1, totalShards + 1, {
wallTimeoutMs: shardTimeout(flakyFiles.length),
@@ -1942,7 +1978,7 @@ async function retryFailedFreeFiles(
console.error('[test:free] flaky-retry FAILED — the failures reproduce serially; not flaky.');
}
} else {
console.log(`[test:free] flaky-retry skipped: ${allAttributed ? `${flakyFiles.length} failing file(s) exceeds cap ${RETRY_CAP}` : 'failures not fully attributed'}.`);
console.log(`[test:free] flaky-retry skipped: ${allAttributed ? `${flakyFiles.length} failing file(s) exceeds cap ${FLAKY_RETRY_CAP}` : 'failures not fully attributed'}.`);
}
}
return { exitCode: worst, retry };
@@ -1978,8 +2014,14 @@ async function main(): Promise<number> {
warnUnseededFreeFiles(ciFiles, durations);
const plan = createFreeCiPlan(ciFiles, options.shardCount, durations, revision, options.shardJobs);
validateFreeCiPlan(plan, ciFiles, revision);
const matrix = ciPlanMatrix(plan, options.jobSetupSeconds);
if ('include' in matrix) {
const line = `job timeout per shard (runner wall + flaky retry + ${options.jobSetupSeconds} s setup): ${matrix.include.map(entry => `${entry.shard}=${entry.timeout_minutes}m`).join(' ')}`;
console.error(`[test:free] ${line}`);
appendStepSummary(line);
}
writeJson(options.ciPlan, plan);
console.log(JSON.stringify({ shard: plan.shards.map(shard => shard.shard) }));
console.log(JSON.stringify(matrix));
return 0;
}
const plan = JSON.parse(fs.readFileSync((options.ciRun ?? options.ciVerify)!, 'utf8')) as FreeCiPlan;
+9
View File
@@ -39,6 +39,15 @@ for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR']) {
// an unattended Capy session it never set up. The shard engine strips the same
// list for the lanes; this reaches direct runs too.
for (const name of CAPY_LAUNCH_MARKERS) delete process.env[name];
// The variables gbrain reads to pick a brain (#3466). Tests spawn bins with
// { ...process.env, HOME: <tmp> }; with no gbrain config under the temp HOME,
// buildGbrainEnv passes an exported URL through and a real gbrain on PATH
// writes test pages and sources into the developer's live brain.
for (const name of ['GBRAIN_DATABASE_URL', 'GBRAIN_DIRECT_DATABASE_URL', 'DATABASE_URL', 'GBRAIN_HOME']) {
if (process.env[name] === undefined) continue;
delete process.env[name];
process.stderr.write(`test-setup: stripped inherited ${name}; tests must not reach a live gbrain\n`);
}
process.env.GSTACK_TEST_LEGACY_ROOT = path.join(os.tmpdir(), `gstack-test-legacy-root-${process.pid}-${Date.now()}-unused`);
// Workflow leases (bin/gstack-lease) default to the installation's git dir;
// for the dev checkout under test that is this repo's .git, and a live test
+30
View File
@@ -19,6 +19,10 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'node:child_process';
import {
DEFAULT_WALL_TIMEOUT_MS, FLAKY_RETRY_CAP, ciPlanMatrix, collectFreeTestFiles, createFreeCiPlan,
loadFreeTestDurations, wallTimeoutForPackedShard, wallTimeoutForShard,
} from '../scripts/test-free-shards';
const WORKFLOW = path.resolve(import.meta.dir, '..', '.github', 'workflows', 'free-tests.yml');
@@ -235,4 +239,30 @@ describe('free-tests workflow wiring', () => {
expect(source).toMatch(/permissions:\s*\n\s*contents:\s*read/);
expect(source).toMatch(/persist-credentials:\s*false/);
});
test('every free-suite job timeout covers its shard\'s runner wall, flaky retry and setup (#3850)', () => {
// GitHub must never kill a free-suite job before the runner's own wall can
// name the hung file and its flaky-retry pass can run: that kill uploads no
// result and leaves red CI with no attribution. The job timeout therefore
// follows the real plan for the committed duration seed and the live
// FREE_SUITE_SHARDS / FREE_SUITE_JOBS, never a hand-picked number.
const workflow = Bun.YAML.parse(source) as any;
const setupSeconds = Number(workflow.env.FREE_SUITE_SETUP_SECONDS);
expect(Number.isInteger(setupSeconds) && setupSeconds > 0).toBe(true);
const plan = createFreeCiPlan(collectFreeTestFiles(), Number(workflow.env.FREE_SUITE_SHARDS),
loadFreeTestDurations() ?? {}, 'wiring', Number(workflow.env.FREE_SUITE_JOBS));
const declared = workflow.jobs['free-suite']['timeout-minutes'];
const emitted = ciPlanMatrix(plan, setupSeconds);
if (!('include' in emitted)) throw new Error('the CI matrix carries no per-shard timeouts');
const short = plan.shards.flatMap(shard => {
const neededMs = wallTimeoutForPackedShard(shard.predictedMs, DEFAULT_WALL_TIMEOUT_MS, shard.files.length)
+ wallTimeoutForShard(FLAKY_RETRY_CAP) + setupSeconds * 1000;
const minutes = declared === '${{ matrix.timeout_minutes }}'
? emitted.include.find(entry => entry.shard === shard.shard)?.timeout_minutes ?? 0
: Number(declared);
return minutes * 60_000 >= neededMs ? [] : [`shard ${shard.shard}: ${minutes} min < ${Math.ceil(neededMs / 1000)} s`];
});
expect(short).toEqual([]);
const planStep = workflow.jobs['free-plan'].steps.find((step: any) => step.id === 'plan');
expect(planStep.run).toContain('--job-setup-seconds "$FREE_SUITE_SETUP_SECONDS"');
});
});
+76
View File
@@ -593,6 +593,82 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
expect(fs.existsSync(lite)).toBe(true);
});
test('with no --model and no GSTACK_MODEL, the agent_model in the session preferences file picks the loadout (GST-982); GSTACK_MODEL still wins and a host preference serves full', () => {
const f = fixture();
script(path.join(f.root, 'bin/gstack-modules'), [
'echo "modules $*" >> "$CAPY_LOG"',
'model="${GSTACK_MODEL:-}" full=""; while [ $# -gt 0 ]; do case "$1" in --model) model="$2"; shift ;; --full-root) full="$2"; shift ;; esac; shift; done',
'case "$model" in',
' claude-opus-5-5) mkdir -p "$HOME/lite-0123456789ab"; echo "LOADOUT: lite model=claude-opus-5-5 source=model-flag digest=0123456789ab reused=no"; echo "SKILL_ROOT: $HOME/lite-0123456789ab" ;;',
' *) echo "LOADOUT: full model=none source=GSTACK_MODEL reason=unmapped"; echo "SKILL_ROOT: $full" ;;',
'esac',
]);
script(path.join(f.root, 'bin/gstack-models'), [
'echo "models $* prefs=${GSTACK_PREFERENCES_FILE:-}" >> "$CAPY_LOG"',
'[ -z "${FAKE_BAD_MODEL:-}" ] || { echo "agent: agent_model $FAKE_BAD_MODEL is not a model"; exit 1; }',
'[ -z "${FAKE_CONFIG_MODEL:-}" ] || { echo "agent: anthropic/$FAKE_CONFIG_MODEL (opus) - from agent_model in $HOME/.gstack/config.yaml, this machine"; exit 0; }',
'case "$(sed -n "s/^agent_model: *//p" "${GSTACK_PREFERENCES_FILE:-/dev/null}" 2>/dev/null)" in',
' opus) echo "agent: anthropic/claude-opus-5-5 (opus) - from agent_model in $GSTACK_PREFERENCES_FILE, the preferences file" ;;',
' *) echo "agent: host - keep the running agent\'s own model and omit the model field (no preference set)" ;;',
'esac',
]);
const prefs = path.join(f.home, 'drive-skill/preferences.yaml');
write(prefs, 'agent_model: opus\n');
const launchOf = (out: string) => out.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)![1];
const opus = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs });
expect(opus.status, opus.stdout + opus.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('modules '))).toEqual([`modules install --model claude-opus-5-5 --full-root ${f.home}/.claude/skills`]);
expect(opus.stdout).toContain(`LOADOUT: lite model=claude-opus-5-5 source=preferences digest=0123456789ab reused=no\nSKILL_ROOT: ${f.home}/lite-0123456789ab\n`);
expect(launchOf(opus.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.loadout-lite-0123456789ab.env'));
expect(opus.stdout).toContain("LOADOUT_ASSUMED: model=claude-opus-5-5 is the owner's agent_model (preferences), not read from the session; a session on another model sets GSTACK_MODEL=<its id>\n");
const config = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, FAKE_CONFIG_MODEL: 'claude-opus-5-5' });
expect(config.status, config.stdout + config.stderr).toBe(0);
expect(config.stdout).toContain('LOADOUT: lite model=claude-opus-5-5 source=config digest=0123456789ab');
expect(config.stdout).toContain("is the owner's agent_model (config)");
const modulesBefore = f.calls().filter(l => l.startsWith('modules ')).length;
for (const evalEnv of [{ GSTACK_EVAL_DIR: path.join(f.home, 'evals') }, { EVALS_RUN_ID: 'r1' }] as Record<string, string>[]) {
const ev = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, ...evalEnv });
expect(ev.status, ev.stdout + ev.stderr).toBe(0);
expect(ev.stdout).not.toMatch(/^(LOADOUT|LOADOUT_ASSUMED|SKILL_ROOT):/m);
}
expect(f.calls().filter(l => l.startsWith('modules ')).length).toBe(modulesBefore);
const bad = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, FAKE_BAD_MODEL: 'gpt-6.1-sol' });
expect(bad.status, bad.stdout + bad.stderr).toBe(0);
expect(bad.stdout).toContain('LOADOUT_FALLBACK: agent_model unreadable: agent: agent_model gpt-6.1-sol is not a model; serving full (docs/troubleshooting.md#loadout-fallback)\n');
expect(bad.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m);
expect(launchOf(bad.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.env'));
expect(f.calls().filter(l => l.startsWith('modules ')).length).toBe(modulesBefore);
const env = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs, GSTACK_MODEL: 'claude-sonnet-5-5' });
expect(env.status, env.stdout + env.stderr).toBe(0);
expect(f.calls().filter(l => l.startsWith('modules ')).at(-1)).toBe(`modules install --full-root ${f.home}/.claude/skills`);
expect(env.stdout).toContain('LOADOUT: full model=none source=GSTACK_MODEL reason=unmapped\n');
write(prefs, 'agent_model: host\n');
const host = run(f, ['--for', 'ship'], { GSTACK_PREFERENCES_FILE: prefs });
expect(host.status, host.stdout + host.stderr).toBe(0);
expect(host.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m);
expect(launchOf(host.stdout)).toBe(verdicts(f)[0].replace(/\.json$/, '.env'));
});
test('the first line of gstack-models resolve --role agent keeps the provider/model shape the installer reads (GST-982)', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst982-'));
try {
for (const [pref, want] of [['opus', /^agent: anthropic\/claude-opus-[0-9a-z.-]+ \(opus\) /], ['host', /^agent: host /]] as const) {
const file = path.join(dir, `${pref}.yaml`);
fs.writeFileSync(file, `agent_model: ${pref}\n`);
const out = spawnSync(path.join(REPO, 'bin/gstack-models'), ['resolve', '--role', 'agent'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_PREFERENCES_FILE: file, GSTACK_STATE_ROOT: dir, GSTACK_HOME: dir } });
expect(out.status, out.stderr).toBe(0);
expect(out.stdout.split('\n')[0]).toMatch(want);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('the verdict file records checkout, HEAD, workflow, trailer and the Bun resolution, keyed by checkout and project; --check certifies without setup or downloads', () => {
const f = fixture();
const r = run(f, ['--for', 'autoplan']);
+20
View File
@@ -361,6 +361,26 @@ describe('ready', () => {
expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/);
}, 180_000);
test('STAMP_VERSION is unstamped for a PR whose own diff leaves VERSION alone after main moved to a newer version, and the stamp for one that changes it (GST-770 follow-up)', () => {
const repo = fixture();
const head = makePr(repo, 'pr-a');
repo.git('checkout', '-q', 'origin/main', '-B', 'main-moves');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
repo.git('commit', '-qam', 'v1.2.4.0 another PR landed');
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', 'pr-a');
const shim = (prHead: string) => ghQueueShim(repo, { prs: { '1': { headRefOid: prHead, url: 'https://github.com/o/r/pull/1', body: shippedBody('1', head) } }, checks: { [prHead]: green } });
const forked = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, head)], shim(head));
expect([forked.status, forked.err]).toEqual([EXIT.ok, '']);
expect(forked.out.split('\n').slice(0, 4)).toEqual([`READY #1 ${head} STAMP_VERSION=unstamped`, 'pr: https://github.com/o/r/pull/1', `head: ${head}`, 'version: unstamped']);
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
repo.git('commit', '-qam', 'v1.2.5.0 stamp');
const stamped = repo.git('rev-parse', 'HEAD');
const ok = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, stamped)], shim(stamped));
expect(ok.out.split('\n')[0]).toBe(`READY #1 ${stamped} STAMP_VERSION=1.2.5.0`);
}, 180_000);
// Value: protects=a head whose required targeted reader is missing or failed is never READY (GST-966, gstack-evals#140);
// fails_when=ready prints REVIEW_TARGETED verdict=missing or failed and still exits 0, or refuses a covered or unrequired head;
// seam=bindShippedReceipt (ready, train build and land share it); no_claim=the ledger recording a clean reader as failed (GSTA-167)
+37 -3
View File
@@ -6,7 +6,7 @@
* from the printed SKILL_ROOT; concurrent publishers of one identity agree;
* the committed checkout stays byte-identical.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs';
@@ -19,7 +19,14 @@ import { LOCK_FILE } from '../scripts/modules-render';
const ROOT = path.resolve(import.meta.dir, '..');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loadout-install-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const savedState = { GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_STATE_ROOT: process.env.GSTACK_STATE_ROOT };
beforeAll(() => {
process.env.GSTACK_HOME = process.env.GSTACK_STATE_ROOT = path.join(tmp, 'empty-state');
});
afterAll(() => {
for (const [key, value] of Object.entries(savedState)) if (value === undefined) delete process.env[key]; else process.env[key] = value;
fs.rmSync(tmp, { recursive: true, force: true });
});
const FULL_ROOT = '/opt/claude/skills';
const lite = selectModules({ loadout: 'lite' })!;
const run = (...args: string[]) => {
@@ -64,7 +71,8 @@ describe('gstack-modules install', () => {
expect(landing).toContain(`${FULL_ROOT}/gstack/bin/`);
expect(landing).not.toContain('~/.claude/skills/gstack/bin/');
expect(lock).toMatchObject({ kind: 'install', loadout: 'lite', loadout_model: 'claude-opus-5-5', full_root: FULL_ROOT });
expect(lock.excluded).toHaveLength(11);
expect(lock.excluded).toHaveLength(10);
expect(lock.excluded).not.toContain('preamble:evidence');
expect(lock.excluded).not.toContain('preamble:upgrade-check');
expect(lock.excluded).not.toContain('preamble:brain-sync');
expect(JSON.parse(fs.readFileSync(path.join(root, LOCK_FILE), 'utf-8')).identity.source_tree).toBe(sourceTree(ROOT));
@@ -81,6 +89,32 @@ describe('gstack-modules install', () => {
expect(explicit).not.toContain('--loadout');
}, 240_000);
test('the lite tree keeps the install settings the full render uses: gbrain detection and disabled_skills, both in the identity (GST-944)', () => {
const state = path.join(tmp, 'settings-state');
const store = path.join(tmp, 'settings');
fs.mkdirSync(state, { recursive: true });
const install = () => {
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), 'install', '--model', 'claude-opus-5-5', '--full-root', FULL_ROOT, '--store', store], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000, env: { ...process.env, GSTACK_HOME: state, GSTACK_STATE_ROOT: state } });
expect(r.status, r.stderr).toBe(0);
expect(r.stderr).not.toContain('LOADOUT_FALLBACK');
return r.stdout.match(/^SKILL_ROOT: (.+)$/m)![1];
};
const plain = install();
fs.writeFileSync(path.join(state, 'config.yaml'), 'disabled_skills: qa\n');
const onlyDisabled = install();
expect(onlyDisabled).not.toBe(plain);
fs.writeFileSync(path.join(state, 'config.yaml'), 'disabled_skills: "/qa, gstack-qa gstack-upgrade"\n');
expect(install()).toBe(onlyDisabled);
fs.writeFileSync(path.join(state, 'gbrain-detection.json'), JSON.stringify({ gbrain_local_status: 'ok' }));
const set = install();
expect(set).not.toBe(onlyDisabled);
const officeHours = (root: string) => fs.readFileSync(path.join(root, 'office-hours/SKILL.md'), 'utf-8');
expect(officeHours(set).split('gbrain').length).toBeGreaterThan(officeHours(plain).split('gbrain').length);
const router = fs.readFileSync(path.join(set, 'SKILL.md'), 'utf-8');
expect(router).toContain('Disabled on this install (never invoke or suggest them): `/qa`.');
expect(fs.readFileSync(path.join(plain, 'SKILL.md'), 'utf-8')).not.toContain('Disabled on this install');
}, 240_000);
test('concurrent publishers of one identity agree on one folder; a tampered folder is refused, not reused', async () => {
const store = path.join(tmp, 'race');
const [x, y] = await Promise.all([1, 2].map(() => installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store })));
+3 -2
View File
@@ -48,8 +48,9 @@ describe('modules/loadouts.json', () => {
expect(Object.values(manifest.default)).toEqual(['full', 'full', 'full', 'full', 'full']);
});
test('lite drops the eleven coaching pieces, keeps upgrade-check and brain-sync, and only Opus 5.5 maps to it', () => {
const coaching = ['voice', 'writing-style', 'completeness', 'confusion', 'evidence', 'context-health', 'question-tuning', 'search-before-building', 'repo-mode', 'brain-health', 'context-recovery'];
test('lite drops ten coaching pieces, keeps upgrade-check, brain-sync and the pinned Claims Need Evidence rule, and only Opus 5.5 maps to it', () => {
expect(manifest.pinned).toContain('preamble:evidence');
const coaching = ['voice', 'writing-style', 'completeness', 'confusion', 'context-health', 'question-tuning', 'search-before-building', 'repo-mode', 'brain-health', 'context-recovery'];
expect(manifest.loadouts.lite).toMatchObject({ base: 'full', exclude: coaching.map(id => `preamble:${id}`) });
expect(manifest.models).toEqual({ 'claude-opus-5-5': 'lite' });
const s = selectModules({ loadout: 'lite' })!;
+80
View File
@@ -12,8 +12,11 @@ import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { planRuns } from '../lib/pregate/checks/lanes';
import { DEFAULT_CONFIG } from '../lib/pregate/config';
import { classifyCommandEnvironment, classifyEnvironment, commandBinary } from '../lib/pregate/environment';
import { verifyPregate } from '../lib/pregate/index';
import type { PregateContext } from '../lib/pregate/types';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
@@ -171,6 +174,83 @@ describe('environment failures are reported apart from diff failures', () => {
});
});
describe('a detected pytest runner needs pytest importable by python3', () => {
const PY_TEST = { 'tests/test_alpha.py': 'import unittest\n\nclass T(unittest.TestCase):\n def test_a(self):\n self.assertEqual(1 + 1, 2)\n' };
/** A directory holding only a `python3` whose `-c` probe exits `probeExit` (0: pytest importable, 3: not, anything else: the probe itself failed). */
const python3Shim = (repo: FixtureRepo, probeExit: number): string => {
const dir = path.join(repo.root, `py-${probeExit}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'python3'), `#!/bin/sh\n[ "$1" = "-c" ] && exit ${probeExit}\necho "unexpected: python3 $*"\nexit 3\n`, { mode: 0o755 });
return dir;
};
const ctxFor = (repo: FixtureRepo, PATH: string) => ({ repoRoot: repo.work, config: DEFAULT_CONFIG, env: { PATH } }) as unknown as PregateContext;
// Value: protects=a `.py` selection plans `python3 -m pytest` only when python3 can import pytest; otherwise the tool to install is
// named (`pytest`, or `python3` when it is off PATH) and the file is unclaimed; fails_when=the pytest branch plans the runner
// unchecked again (#3598); why_new=pnpm/cargo/dart had a pre-run check, pytest none; seam=none; no_claim=a real pytest install
test('plans pytest when python3 imports it, else names pytest or python3 as missing', () => {
const repo = makeFixtureRepo({ pregate: null, baseFiles: PY_TEST }); repos.push(repo);
const files = ['tests/test_alpha.py'];
expect(planRuns(ctxFor(repo, python3Shim(repo, 0)), files)).toEqual({ groups: [{ runner: 'python3 -m pytest {files}', cwd: '', files }], unclaimed: [], missing: [], noPubspec: [] });
expect(planRuns(ctxFor(repo, python3Shim(repo, 3)), files)).toEqual({ groups: [], unclaimed: files, missing: ['pytest'], noPubspec: [] });
// A probe that crashes (a repo-root module shadowing importlib exits 1) is not read as a missing pytest: the run decides.
expect(planRuns(ctxFor(repo, python3Shim(repo, 1)), files).groups).toEqual([{ runner: 'python3 -m pytest {files}', cwd: '', files }]);
expect(planRuns(ctxFor(repo, path.join(repo.root, 'empty-path')), files)).toEqual({ groups: [], unclaimed: files, missing: ['python3'], noPubspec: [] });
});
// Value: protects=the reported case end to end: a machine without pytest gets PREGATE_ENVIRONMENT missing-binary naming pytest and
// "the diff is not at fault", and the lane never runs; fails_when=it is PREGATE_LANE_FAILED with "fix the in-branch failure"
// (#3598); why_new=no pytest environment case existed; seam=none; no_claim=a real pytest install
test('a machine without pytest is PREGATE_ENVIRONMENT naming pytest, never PREGATE_LANE_FAILED', () => {
const repo = makeFixtureRepo({ pregate: null, baseFiles: PY_TEST }); repos.push(repo);
commit(repo, { 'tests/test_alpha.py': `${PY_TEST['tests/test_alpha.py']}# touched\n` });
const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'], { PATH: `${python3Shim(repo, 3)}:${process.env.PATH ?? ''}` });
expect(r.err).toContain('(PREGATE_ENVIRONMENT)');
expect(r.err).not.toContain('PREGATE_LANE_FAILED');
expect(r.out).not.toContain('unexpected: python3');
const c = check(repo, 'lanes');
expect([c.status, c.code, c.cause, c.environment.kind]).toEqual(['incomplete', 'PREGATE_ENVIRONMENT', 'environment', 'missing-binary']);
expect(c.environment.evidence).toContain('pytest');
expect(c.fix).toContain('install pytest');
expect(c.fix).toContain('the diff is not at fault');
});
});
describe('Playwright without its browsers is the machine, not the diff', () => {
const SPEC = { 'package.json': JSON.stringify({ name: 'fixture', devDependencies: { '@playwright/test': '^1.48.0' } }) + '\n', 'tests/e2e/home.spec.ts': "import { test } from '@playwright/test';\ntest('home', async ({ page }) => { await page.goto('/'); });\ntest('about', async ({ page }) => { await page.goto('/about'); });\n" };
const NO_BROWSERS = ['Running 2 tests using 1 worker', ' x 1 tests/e2e/home.spec.ts:2:5 > home (12ms)', ' x 2 tests/e2e/home.spec.ts:3:5 > about (8ms)', '', ' 1) tests/e2e/home.spec.ts:2:5 > home', " Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1169/chrome-linux/headless_shell", ' Looks like Playwright Test or Playwright was just installed or updated.', ' Please run the following command to download new browsers:', ' npx playwright install', ' 2 failed', ' tests/e2e/home.spec.ts:2:5 > home', ' tests/e2e/home.spec.ts:3:5 > about'];
const ASSERTION = ['Running 2 tests using 1 worker', ' 1) tests/e2e/home.spec.ts:2:5 > home', ' Error: expect(received).toBe(expected)', ' 1 failed', ' tests/e2e/home.spec.ts:2:5 > home', ' 1 passed (2.0s)'];
/** An `npx` first on PATH (and again through BASH_ENV, since the lane puts its Bun's directory first) that prints `lines` and exits 1. */
const npxShim = (repo: FixtureRepo, lines: string[]) => {
const dir = path.join(repo.root, 'npx-shim');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'out.txt'), lines.join('\n') + '\n');
fs.writeFileSync(path.join(dir, 'npx'), `#!/bin/sh\ncat '${path.join(dir, 'out.txt')}'\nexit 1\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') };
};
// Value: protects=a Playwright run whose every failure is a missing browser is PREGATE_ENVIRONMENT missing-binary naming
// `npx playwright install`, while an ordinary assertion failure stays the diff's; fails_when=the browsers case is PREGATE_LANE_FAILED
// "fix the in-branch failure" again (the #3598 misreading for Playwright); why_new=Playwright had no runner before #3629;
// seam=none; no_claim=a real Playwright install
test('every test failing to launch its browser is PREGATE_ENVIRONMENT; an assertion failure is PREGATE_LANE_FAILED', () => {
const repo = makeFixtureRepo({ pregate: null, baseFiles: SPEC }); repos.push(repo);
commit(repo, { 'tests/e2e/home.spec.ts': `${SPEC['tests/e2e/home.spec.ts']}// touched\n` });
const args = ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'];
const r = runBin(repo, 'gstack-pregate', args, npxShim(repo, NO_BROWSERS));
expect(r.err).toContain('(PREGATE_ENVIRONMENT)');
const c = check(repo, 'lanes');
expect([c.status, c.code, c.cause, c.environment.kind]).toEqual(['incomplete', 'PREGATE_ENVIRONMENT', 'environment', 'missing-binary']);
expect(c.environment.evidence).toContain('Playwright browsers not installed (npx playwright install)');
expect(c.lines.join('\n')).toContain("playwright test --reporter=list '(?:^|/)tests/e2e/home\\.spec\\.ts$'");
runBin(repo, 'gstack-pregate', args, npxShim(repo, ASSERTION));
const d = check(repo, 'lanes');
expect([d.status, d.code]).toEqual(['fail', 'PREGATE_LANE_FAILED']);
expect(d.detail).toContain('1 failed of 2 ran');
});
});
describe('verify: a mixed incomplete record stays PREGATE_INCOMPLETE and names the environment-blocked check', () => {
test('one environment-caused check beside a plain incomplete one is not promoted to PREGATE_ENVIRONMENT', () => {
const file: any = { schema_version: 1, tree: 'T', checks: [
+1 -1
View File
@@ -175,7 +175,7 @@ describe('gstack-pregate init drafts a policy from the repo (#3268)', () => {
expect(r.out).toContain('PREGATE_CONFIG_WRITTEN: .gstack/pregate.json');
expect(r.out).toMatch(/PREGATE_INIT: languages python \d+, javascript \d+/);
expect(r.out).toContain('python tests detected at run time: python3 -m pytest {files}');
expect(r.out).toContain('a selection mixing these languages needs runner set');
expect(r.out).not.toContain('needs runner set');
expect(r.out).not.toContain('TODO src/version.js');
expect(r.out).toContain('TODO tools/release.py: no test lane and no nearby test');
expect(r.out).not.toContain('TODO orders/services.py');
+154
View File
@@ -0,0 +1,154 @@
/**
* GST-1024: gstack's own .gstack/pregate.json declares remote_gate_runs, so a green Free Tests run at the pregate tree
* clears pregate.json/remote_gate through `gstack-pregate clear --run`. The declaration's commands run against this
* checkout (the inventory must be exactly the free suite CI plans, the skipped list exactly the paid files
* pregate-remote.sh leaves to the paid gate); the clear runs through buildRunCheck in a fixture repository with this
* repository's real run profile and a fake Actions API shaped like a real Free Tests pull_request run (41484634592).
*/
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 { bunIsolationArgs } from '../lib/bun-isolation';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, runPregate, type RunCheck } from '../lib/pregate/index';
import { buildRunCheck } from '../lib/pregate/run-check';
import { runInventory, runProfileErrors, type ApiRun, type RemoteGateRuns, type RunProfile } from '../lib/run-evidence';
import { isPaidTestFile } from './helpers/paid-test-set';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const REAL = JSON.parse(fs.readFileSync(path.join(ROOT, '.gstack/pregate.json'), 'utf8')) as { run_profiles: Record<string, RunProfile>; remote_gate_runs?: RemoteGateRuns };
const LANE = 'pregate.json/remote_gate';
const PROFILE = 'ci-reuse/free-tests';
const PAID = 'test/skill-e2e-ship-gate.test.ts';
const dirs: string[] = [];
const repos: FixtureRepo[] = [];
afterEach(() => {
while (repos.length) cleanup(repos.pop()!);
while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true });
});
const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1024-')); dirs.push(d); return d; };
const lines = (text: string) => text.split('\n').filter(Boolean);
describe.skipIf(process.platform === 'win32')('gstack declares remote_gate_runs (GST-1024)', () => {
test('the declaration names the Free Tests profile; its inventory is exactly the free suite CI plans, and its skipped list is exactly the selected paid files', () => {
const g = REAL.remote_gate_runs;
expect(g?.profiles).toEqual([PROFILE]);
expect(REAL.run_profiles[PROFILE]).toMatchObject({ workflow: 'free-tests.yml', artifact: 'tested-tree', os: 'Linux' });
expect(runProfileErrors(REAL.run_profiles, g)).toEqual([]);
const listed = runInventory(g!.inventory, ROOT);
if ('error' in listed) throw new Error(listed.error);
const planFile = path.join(tmp(), 'plan.json');
const plan = spawnSync('bun', [...bunIsolationArgs(), 'run', 'scripts/test-free-shards.ts', '--ci-plan', planFile, '--shards', '11', '--shard-jobs', '2'], { cwd: ROOT, encoding: 'utf8', timeout: 120_000 });
expect(plan.status).toBe(0);
const planned = (JSON.parse(fs.readFileSync(planFile, 'utf8')) as { shards: Array<{ files: string[] }> }).shards.flatMap(s => s.files);
expect(listed.length).toBeGreaterThan(1000);
expect([...listed].sort()).toEqual([...new Set(planned)].sort());
expect(listed.filter(isPaidTestFile)).toEqual([]);
const selected = path.join(tmp(), 'selected.txt');
fs.writeFileSync(selected, [listed[0], PAID, 'test/codex-e2e-plan-format.test.ts', listed[1]].join('\n') + '\n');
const skipped = runInventory(g!.skipped!, ROOT, { PREGATE_FILES_FILE: selected });
expect(skipped).toEqual([PAID, 'test/codex-e2e-plan-format.test.ts']);
}, 180_000);
});
const INVENTORY = 'printf "test/tool-cli.test.js\\ntest/other.test.js\\n"';
const SKIPPED = 'grep -E "^test/skill-e2e-" "$PREGATE_FILES_FILE" || true';
function fixture(gateRuns: Record<string, unknown>): FixtureRepo {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js', run_profiles: REAL.run_profiles, remote_gate_runs: gateRuns } });
repos.push(repo);
repo.git('checkout', '-q', 'main');
commit(repo, { 'scripts/remote-gate.js': "console.log('PREGATE_REMOTE: tree=' + process.env.PREGATE_TREE + ' tests=3 failed=0');\n" }, 'remote gate');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--ff-only', 'main');
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'the real change');
return repo;
}
/** A Free Tests pull_request run as GitHub reports it: the profile's jobs, a tested-tree artifact naming the merge commit. */
interface Run { head: string; files: string[]; conclusion?: string; path?: string; headRepo?: string; mergeTree?: string }
function gateCheck(repo: FixtureRepo, run: Run): RunCheck {
const built = buildRunCheck(repo.work, LANE, true, 'main');
if ('error' in built) throw new Error(built.error);
const profile = REAL.run_profiles[PROFILE]!;
const merge = 'f'.repeat(40);
const finished = new Date(Date.now() - 3600_000).toISOString();
const api: ApiRun = {
id: 41484634592, run_attempt: 1, status: 'completed', conclusion: run.conclusion ?? 'success', event: 'pull_request', head_sha: run.head,
path: run.path ?? `.github/workflows/${profile.workflow}`, created_at: finished, updated_at: finished,
repository: { full_name: 'fx/fx' }, head_repository: { full_name: run.headRepo ?? 'fx/fx' },
};
const tree = (sha: string) => repo.git('rev-parse', `${sha}^{tree}`);
return {
...built,
repo: 'fx/fx',
deps: {
run: id => { if (id !== api.id) throw new Error('HTTP 404'); return api; },
runsForHead: () => [api],
jobs: () => profile.jobs.map(name => ({ name, conclusion: 'success', completed_at: finished })),
artifact: () => ({ run_id: api.id, run_attempt: 1, event: 'pull_request', sha: merge, head_sha: run.head, tree: tree(run.head), workflow: profile.workflow, os: ['Linux', 'macOS', 'Windows'], files: run.files, tests: run.files.length * 20 }),
commitTree: sha => (sha === merge ? run.mergeTree ?? tree(run.head) : tree(sha)),
},
};
}
async function gatedFile(repo: FixtureRepo) {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
const gate = file.requires_remote?.find(r => r.lane === LANE);
if (!gate) throw new Error('no remote gate obligation');
gate.tests = [...(gate.tests ?? []), PAID];
repo.git('config', 'remote.origin.url', 'https://github.com/fx/fx.git');
return { file, selected: gate.tests.filter(f => f !== PAID) };
}
describe.skipIf(process.platform === 'win32')('clear --run with the Free Tests profile (GST-1024)', () => {
const RUN = 'https://github.com/fx/fx/actions/runs/41484634592';
const msg = (d: ReturnType<typeof clearDecision>) => ('refused' in d ? `${d.refused}: ${d.message}` : `cleared ${d.receipt}`);
test('a green Free Tests run at the pregate tree clears the gate; the selected paid file the gate skips is counted, not required', async () => {
const repo = fixture({ profiles: [PROFILE], inventory: INVENTORY, skipped: SKIPPED });
const head = repo.git('rev-parse', 'HEAD');
const { file, selected } = await gatedFile(repo);
const files = [...new Set([...selected, 'test/tool-cli.test.js', 'test/other.test.js'])];
const d = clearDecision(file, LANE, { runs: [RUN], evidence: gateCheck(repo, { head, files }) }, repo.work);
expect(msg(d)).toStartWith(`cleared remote-gate tree=${file.tree.slice(0, 12)} tests=${files.length * 20} skipped=1 runs=41484634592/1 profiles=${PROFILE} heads=${head.slice(0, 12)} verified=`);
}, 120_000);
test('refusals: no skipped command, a paid-only selection, the wrong tree, the wrong workflow, a failed or cancelled run, a fork run, a merge behind its base, an uncovered inventory file, a skipped list that names an inventory file', async () => {
const repo = fixture({ profiles: [PROFILE], inventory: INVENTORY, skipped: SKIPPED });
const older = repo.git('rev-parse', 'HEAD~1');
const head = repo.git('rev-parse', 'HEAD');
const { file, selected } = await gatedFile(repo);
const files = [...new Set([...selected, 'test/tool-cli.test.js', 'test/other.test.js'])];
const clear = (run: Run, over: Partial<RunCheck> = {}) => msg(clearDecision(file, LANE, { runs: [RUN], evidence: { ...gateCheck(repo, run), ...over } }, repo.work));
expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY } })).toMatch(new RegExp(`reason=inventory_uncovered — 1 of the \\d+ test file\\(s\\) ${LANE} must cover appear in no run's tree.json \\(${PAID}\\)`));
expect(clear({ head: older, files })).toMatch(/^PREGATE_RECEIPT_INVALID: REUSE: refused reason=tree_not_stamp_only — .*lib\/util\.js/);
expect(clear({ head, files, path: '.github/workflows/windows-free-tests.yml' })).toContain('reason=workflow_mismatch run 41484634592 attempt 1 ran .github/workflows/windows-free-tests.yml, not .github/workflows/free-tests.yml');
expect(clear({ head, files, conclusion: 'failure' })).toContain('reason=run_not_success run 41484634592 attempt 1 concluded failure');
expect(clear({ head, files, conclusion: 'cancelled' })).toContain('reason=run_not_success run 41484634592 attempt 1 concluded cancelled');
expect(clear({ head, files, headRepo: 'attacker/fx' })).toContain('reason=fork_run run 41484634592 attempt 1 ran for attacker/fx, not fx/fx');
expect(clear({ head, files, mergeTree: repo.git('rev-parse', `${older}^{tree}`) })).toContain('reason=tree_mismatch');
expect(clear({ head, files: files.filter(f => f !== 'test/other.test.js') })).toContain("reason=inventory_uncovered — 1 of the");
expect(clear({ head, files: files.filter(f => f !== 'test/other.test.js') }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: `${SKIPPED}; echo test/other.test.js` } })).toContain('(test/other.test.js)');
expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: 'exit 3' } })).toContain('reason=skipped_failed');
const paidOnly = structuredClone(file);
paidOnly.requires_remote!.find(r => r.lane === LANE)!.tests = [PAID];
expect(msg(clearDecision(paidOnly, LANE, { runs: [RUN], evidence: gateCheck(repo, { head, files }) }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=paid_only — the remote gate skips every selected test file (1), so no selected test ran; like the gate's own tests=0 report, that never clears; ${LANE} stays uncleared`);
}, 120_000);
test('remote_gate_runs.skipped must be one command line, and an unknown key names it as valid', () => {
const profiles = { p: { workflow: 'w.yml', events: ['push'], jobs: ['j'], artifact: 'a' } };
for (const skipped of [3, '', ' ', 'a\nb', 'x'.repeat(513)]) {
expect(runProfileErrors(profiles, { profiles: ['p'], inventory: 'echo t', skipped }).map(e => e.path)).toEqual(['remote_gate_runs.skipped']);
}
expect(runProfileErrors(profiles, { profiles: ['p'], inventory: 'echo t', skipped: 'echo s' })).toEqual([]);
expect(runProfileErrors(profiles, { profiles: ['p'], inventory: 'echo t', skip: 'echo s' })).toEqual([{ path: 'remote_gate_runs.skip', message: 'unknown key; valid keys: profiles, inventory, skipped' }]);
});
});
+132
View File
@@ -258,6 +258,46 @@ describe('literals (pure)', () => {
expect(consumersOf(root, all, { name: 'TIMEOUT_MS', old: '5000', new: '9000', file: 'lib/retry.js' })).toEqual(['test/retry.test.ts:1', 'test/retry-path.test.ts:2', 'test/retry-bare.test.ts:2']);
fs.rmSync(root, { recursive: true, force: true });
});
// Value: protects=a one-character old value counts only in a statement that names the constant (its line, or lines that continue it),
// so comments, shell positionals and fixture bodies elsewhere in a file that names it are not consumers, while a formatter-split
// assertion still is; fails_when=any lone digit in such a file is reported again
// (#3492: five false hits on a wrapper 2 -> 3 bump, the same five on 3 -> 4 at v1.91.376.0); why_new=only file-level gating was
// tested; seam=none; no_claim=longer values (module-gated as before)
test('consumersOf: a one-character old value counts only in a statement that names the constant (#3492)', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-bound-'));
const nl = String.fromCharCode(10);
const files: Record<string, string> = {
'scripts/install.sh': ['#!/usr/bin/env bash', '# handing the held lock over (GSTACK_DEMO_LOCK_HELD=1) and,', "# from revision 2, origin's VERSION read in this run", 'GSTACK_DEMO_WRAPPER=3', ''].join(nl),
'test/prose.test.ts': ["const rev = read(BOOT).match(/^GSTACK_DEMO_WRAPPER=(\\d+)$/m);", '', '// Field list 2 item 13: lanes relay only the trailer', ''].join(nl),
'test/stub.test.ts': ["const env = { GSTACK_DEMO_WRAPPER: '9' };", 'const script = [', " 'bash \"$1\" touch --root \"$2\" --pid $$ || exit 9',", '];', ''].join(nl),
'test/fixture.test.ts': ['// covers the GSTACK_DEMO_WRAPPER readers', "write('src/a.js', 'export default 1;');", "write('src/b.js', 'export default 2;');", ''].join(nl),
'test/same-line.test.ts': ['expect(GSTACK_DEMO_WRAPPER).toBe(2);', ''].join(nl),
'test/later.test.ts': ['// readers of the wrapper revision', "it('x', () => { const n = 2; });", 'expect(env.GSTACK_DEMO_WRAPPER).toBe(2);', ''].join(nl),
'test/split.test.ts': ['expect(read().GSTACK_DEMO_WRAPPER)', ' .toBe(2);', ''].join(nl),
'test/split-args.test.ts': ['assert.strictEqual(', ' mod.GSTACK_DEMO_WRAPPER,', ' 2,', ');', ''].join(nl),
'test/adjacent.test.ts': ['const rev = mod.GSTACK_DEMO_WRAPPER;', 'const two = 2;', ''].join(nl),
};
for (const [rel, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); }
expect(consumersOf(root, Object.keys(files), { name: 'GSTACK_DEMO_WRAPPER', old: '2', new: '3', file: 'scripts/install.sh' })).toEqual(['test/same-line.test.ts:1', 'test/later.test.ts:3', 'test/split.test.ts:2', 'test/split-args.test.ts:3']);
fs.rmSync(root, { recursive: true, force: true });
});
// Value: protects=a one-character call-site literal (`attempts: 1`) counts only in a file that also names the changed module, on a line
// naming the call-site name, because `attempts`, `limit` and `cap` are ordinary words across a test tree; fails_when=any file with
// the word and a lone `1` is reported again (gstack-evals#97: `retry: { attempts: 1 }` -> `JUDGE_RETRY` raised 11 false hits,
// GSTA-2); why_new=call-site names had only the constant-name gate; seam=none; no_claim=constant names (previous test)
test('consumersOf: a one-character call-site literal needs the changed module in the file and the name on its line (gstack-evals#97)', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-call-'));
const nl = String.fromCharCode(10);
const files: Record<string, string> = {
'tests/grid.test.ts': ['const grid = { tasks: ["a"], attempts: 1, n_concurrent: 4 };', ''].join(nl),
'tests/replay.test.ts': ['// attempts are counted per finding', 'expect(r.findings.length).toBe(1);', ''].join(nl),
'tests/proxy.test.ts': ["import { retryBudget } from '../lanes/proxy';", 'const cell = { trial: 1, attempt: 1 };', 'expect(retryBudget().attempts).toBeGreaterThan(0);', ''].join(nl),
'tests/judge-run.test.ts': ["import { run } from '../lanes/review-noloss/judge-run';", 'const n = 1;', 'expect(run({ retry: { attempts: 1 } }).sent).toBe(n);', ''].join(nl),
};
for (const [rel, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), text); }
expect(consumersOf(root, Object.keys(files), { name: 'attempts', old: '1', new: '3', file: 'lanes/review-noloss/judge-run.ts' })).toEqual(['tests/judge-run.test.ts:3']);
fs.rmSync(root, { recursive: true, force: true });
});
test('consumersOf: a history row is skipped only when this branch added the new-value row to the same table; other old-value lines still count (GST-344)', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-literals-history-'));
const nl = String.fromCharCode(10);
@@ -301,6 +341,13 @@ describe('literals (pure)', () => {
expect(parseSummary('Tests: 1 failed, 7 passed, 8 total')).toEqual({ ran: 8, failed: 1 });
expect(parseSummary('===== 3 passed in 0.1s =====')).toEqual({ ran: 3, failed: 0 });
expect(parseSummary('No tests found')).toEqual({ ran: 0, failed: 0 });
// Playwright lists the failed tests between its counts, so each count line is read on its own.
expect(parseSummary(' 2 failed\n [e2e] › a.spec.ts:9:1 › one\n [e2e] › b.spec.ts:4:1 › two\n 1 flaky\n [e2e] › c.spec.ts:2:1 › three\n 3 skipped\n 135 passed (3.0m)', 'npx playwright test {files}')).toEqual({ ran: 138, failed: 2 });
expect(parseSummary(' 137 passed (1.1m)', 'npx playwright test {files}')).toEqual({ ran: 137, failed: 0 });
expect(parseSummary(' 3 failed\n [e2e] › a.spec.ts:9:1 › one\n 1 interrupted\n 2 did not run', 'npx playwright test {files}')).toEqual({ ran: 4, failed: 4 });
expect(parseSummary(' 5 skipped', 'npx playwright test {files}')).toEqual({ ran: 0, failed: 0 });
// A test's own output that looks like a count is not the summary: the last count lines are.
expect(parseSummary(' 0 passed\n 1 failed\n [e2e] › a.spec.ts:9:1 › one\n 4 passed (2.0s)', 'npx playwright test {files}')).toEqual({ ran: 5, failed: 1 });
expect(parseSummary('ok')).toEqual({ ran: null, failed: null });
});
test('config: unknown keys, secrets in warn, a runner without {files} and an uncontained glob are PREGATE_CONFIG_INVALID', () => {
@@ -440,6 +487,19 @@ describe('gstack-pregate (bin)', () => {
expect(pregate(repo, ['--stage', 'preflight', '--only', 'literals']).status).toBe(EXIT.ok);
});
// Value: protects=a real stale one-character constant still fails literals end to end after the #3492 narrowing, and an incidental
// digit in the same file is not what it reports; fails_when=the statement binding drops a named assertion; why_new=only
// multi-character values had a bin-level control; seam=none; no_claim=two-character values
test('#3492 control: a one-character constant change still fails literals on the test that asserts the old value by name', () => {
const repo = fixture();
commit(repo, { 'test/nightly/retries.test.js': "const test = require('node:test');\nconst assert = require('node:assert');\n// covers 3 retry shapes\nconst { MAX_RETRIES } = require('../../lib/retry.js');\ntest('retries', () => assert.strictEqual(\n MAX_RETRIES,\n 3,\n));\n" });
commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" });
const r = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toContain('literals: test/nightly/retries.test.js:7: MAX_RETRIES still 3 (now 4 in lib/retry.js)');
expect(r.err).toContain('(PREGATE_LITERALS)');
});
test('GST-344: a history table the branch extends passes literals through the merge-base read; one it does not extend fails', () => {
const history = (...ns: number[]) => ["const { MAX_RETRIES } = require('../lib/retry.js');", 'const versions = [', ...ns.map(n => ` [${n}, 'digest-${n}'],`), '];', ''].join('\n');
const repo = fixture({ baseFiles: { 'test/history.test.js': history(2, 3) } });
@@ -767,6 +827,78 @@ describe('nested JS packages run their own runner from their own directory', ()
expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }]);
});
/** `env` with a `python3` first on PATH whose `-c` probe exits `probeExit` (0: pytest importable, 3: not). */
const withPython = (repo: FixtureRepo, env: { PATH: string; BASH_ENV: string }, probeExit: number) => {
const dir = path.join(repo.root, `py-${probeExit}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'python3'), `#!/bin/sh\n[ "$1" = "-c" ] && exit ${probeExit}\nexit 3\n`, { mode: 0o755 });
return { ...env, PATH: `${dir}:${env.PATH}` };
};
// Value: protects=root-owned tests in two languages each get their language's runner instead of the whole lane going incomplete, and a
// language whose runner this machine lacks is named while the other still runs; fails_when=detection again needs one language
// across every root file (#3600: `.mjs` + `.py` was PREGATE_INCOMPLETE asking for one `runner` for both); why_new=only
// single-language root selections were tested; seam=none; no_claim=the runners themselves (plan only)
test('root tests in two languages plan one group per language; a file no runner claims stays unclaimed', () => {
const repo = fixture({ pregate: null, baseFiles: APP });
const ctx = ctxFor(repo, withPython(repo, pmShim(repo), 0));
const mixed = ['scripts/doku-gate.test.mjs', 'scripts/test_assets.py', 'scripts/version-gate.test.mjs'];
expect(planRuns(ctx, mixed)).toEqual({ groups: [
{ runner: 'node --test {files}', cwd: '', files: ['scripts/doku-gate.test.mjs', 'scripts/version-gate.test.mjs'] },
{ runner: 'python3 -m pytest {files}', cwd: '', files: ['scripts/test_assets.py'] },
], unclaimed: [], missing: [], noPubspec: [] });
expect(planRuns(ctx, ['scripts/doku-gate.test.mjs', 'test/run.bats'])).toEqual({ groups: [
{ runner: 'node --test {files}', cwd: '', files: ['scripts/doku-gate.test.mjs'] },
], unclaimed: ['test/run.bats'], missing: [], noPubspec: [] });
expect(planRuns(ctxFor(repo, withPython(repo, pmShim(repo), 3)), mixed)).toEqual({ groups: [
{ runner: 'node --test {files}', cwd: '', files: ['scripts/doku-gate.test.mjs', 'scripts/version-gate.test.mjs'] },
], unclaimed: ['scripts/test_assets.py'], missing: ['pytest'], noPubspec: [] });
});
// Value: protects=a repo-wide bun or vitest claims only JS/TS files, so a root `.py` test beside them gets pytest instead of being handed to
// the JS runner; fails_when=bun.lock or a vitest dependency again takes every extension; why_new=the split was only tested with no
// repo runner; seam=none; no_claim=the runners themselves (plan only)
test('a repo-wide bun or vitest runner claims JS/TS only; a root .py test beside it gets pytest; parts with one runner run together', () => {
const repo = fixture({ pregate: null, baseFiles: APP });
const ctx = ctxFor(repo, withPython(repo, pmShim(repo), 0));
write(repo.work, 'package.json', JSON.stringify({ name: 'fixture', devDependencies: { vitest: '^3.2.0' } }) + '\n');
expect(planRuns(ctx, ['test/a.test.ts', 'scripts/b.test.mjs', 'scripts/test_c.py', 'test/run.bats'])).toEqual({ groups: [
{ runner: 'npx vitest run {files}', cwd: '', files: ['test/a.test.ts', 'scripts/b.test.mjs'] },
{ runner: 'python3 -m pytest {files}', cwd: '', files: ['scripts/test_c.py'] },
], unclaimed: ['test/run.bats'], missing: [], noPubspec: [] });
expect(planRuns(ctx, ['test/a.test.ts', 'scripts/b.test.mjs']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: '', files: ['test/a.test.ts', 'scripts/b.test.mjs'] }]);
write(repo.work, 'bun.lock', '');
expect(planRuns(ctx, ['test/a.test.ts', 'scripts/test_c.py']).groups).toEqual([
{ runner: 'bun test {files}', cwd: '', files: ['test/a.test.ts'] },
{ runner: 'python3 -m pytest {files}', cwd: '', files: ['scripts/test_c.py'] },
]);
});
// Value: protects=a Playwright spec in a repo whose unit runner is vitest runs through `playwright test`, never vitest (which finds
// no test files and exits 1 on a diff that touches only specs); fails_when=the repo-wide runner takes every JS/TS test again (#3629);
// why_new=no detection knew Playwright; seam=none; no_claim=a real Playwright run (plan only)
test('a spec importing @playwright/test runs through playwright test from its package; the other tests keep their runner', () => {
const repo = fixture({ pregate: null, baseFiles: APP });
const ctx = ctxFor(repo, pmShim(repo));
write(repo.work, 'package.json', JSON.stringify({ name: 'fixture', devDependencies: { vitest: '^3.2.0', '@playwright/test': '^1.48.0' } }) + '\n');
write(repo.work, 'tests/e2e/home.spec.ts', "import { expect, test } from '@playwright/test';\ntest('home', async ({ page }) => expect(page).toBeTruthy());\n");
write(repo.work, 'app/e2e/app.spec.ts', "import {\n test, // the runner's fixture\n /* it's */ expect,\n} from \"@playwright/test\";\ntest('app', () => {});\n");
write(repo.work, 'tests/e2e/tight.spec.ts', "import{test}from'@playwright/test';\ntest('tight', () => {});\n");
write(repo.work, 'test/pad.test.js', 'import' + ' '.repeat(5_000) + '\n');
write(repo.work, 'test/util.test.js', "import { test } from 'vitest';\nimport type { Page } from '@playwright/test';\nimport type {\n Locator,\n} from '@playwright/test';\nimport { type BrowserContext } from '@playwright/test';\nconst spec = \"import { test } from '@playwright/test';\";\ntest('fixture text and type imports only', () => {});\n");
expect(planRuns(ctx, ['tests/e2e/home.spec.ts', 'tests/e2e/tight.spec.ts', 'test/util.test.js', 'test/pad.test.js', 'app/src/sum.test.ts', 'app/e2e/app.spec.ts'])).toEqual({ groups: [
{ runner: 'npx vitest run {files}', cwd: '', files: ['test/util.test.js', 'test/pad.test.js'] },
{ runner: 'pnpm exec vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] },
{ runner: 'npx playwright test --reporter=list {files}', cwd: '', files: ['tests/e2e/home.spec.ts', 'tests/e2e/tight.spec.ts'] },
{ runner: 'pnpm exec playwright test --reporter=list {files}', cwd: 'app', files: ['e2e/app.spec.ts'] },
], unclaimed: [], missing: [], noPubspec: [] });
// A declared runner still takes every file, Playwright specs included.
const declared = { ...ctx, config: { ...DEFAULT_CONFIG, runner: 'bun test {files}' } } as PregateContext;
expect(planRuns(declared, ['tests/e2e/home.spec.ts']).groups).toEqual([{ runner: 'bun test {files}', cwd: '', files: ['tests/e2e/home.spec.ts'] }]);
// A package manager missing from PATH leaves the spec unclaimed naming it.
expect(planRuns(ctxFor(repo, { PATH: path.join(repo.root, 'empty-path') }), ['app/e2e/app.spec.ts'])).toEqual({ groups: [], unclaimed: ['app/e2e/app.spec.ts'], missing: ['pnpm'], noPubspec: [] });
});
// 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
+77
View File
@@ -166,6 +166,83 @@ describe('review start/end binding (#2803)', () => {
});
}
for (const when of ['created', 'removed']) {
test(`GST-985: an untracked scratch dir ${when} mid-review still binds; a tracked edit still breaks it`, () => {
const scratchDir = join(repo, '.review-units-test-x');
if (when === 'removed') { mkdirSync(scratchDir); writeFileSync(join(scratchDir, 'unit.json'), '{}\n'); }
const token = cli('gstack-review-log', ['--start', 'review']);
if (when === 'created') { mkdirSync(scratchDir); writeFileSync(join(scratchDir, 'unit.json'), '{}\n'); }
else rmSync(scratchDir, { recursive: true, force: true });
const row = log(token);
expect(row.review_binding.state).toBe('verified');
expect(row.review_binding.start_wtree).toBe(row.review_binding.end_wtree);
expect(row.review_freshness.status).toBe('CURRENT');
expect(git('diff', '--name-only', row.wtree, cli('gstack-wtree'))).toBe(when === 'created' ? '.review-units-test-x/unit.json' : '');
const edited = cli('gstack-review-log', ['--start', 'review']);
writeFileSync(join(repo, 'source.ts'), 'export const unreviewed = true;\n');
expect(log(edited).review_binding.state).toBe('changed');
});
}
test('GST-985: ignored root dot-directories still fingerprint under --review', () => {
writeFileSync(join(repo, '.git', 'info', 'exclude'), '.cache/\n');
mkdirSync(join(repo, '.cache'));
writeFileSync(join(repo, '.cache', 'blob'), 'x\n');
expect(cli('gstack-wtree', ['--review'])).toBe(cli('gstack-wtree'));
});
test('GST-985: an untracked file in a dot-directory that holds a tracked file still breaks the bind', () => {
mkdirSync(join(repo, '.tool'));
writeFileSync(join(repo, '.tool', 'a.json'), '{}\n');
git('add', '.tool/a.json');
git('commit', '-qm', 'tool');
const token = cli('gstack-review-log', ['--start', 'review']);
writeFileSync(join(repo, '.tool', 'b.json'), '{}\n');
expect(log(token).review_binding.state).toBe('changed');
});
test('GST-985: a root ..x scratch dir created mid-review still binds', () => {
const token = cli('gstack-review-log', ['--start', 'review']);
mkdirSync(join(repo, '..x'));
writeFileSync(join(repo, '..x', 'unit.json'), '{}\n');
expect(log(token).review_binding.state).toBe('verified');
});
test('GST-985: a git add -N file in a new dot-directory counts under the read-tree HEAD fallback', () => {
mkdirSync(join(repo, '.newdir'));
writeFileSync(join(repo, '.newdir', 'a.ts'), 'export const a = 1;\n');
git('add', '-N', '.newdir/a.ts');
const fakeBin = mkdtempSync(join(tmpdir(), 'review-start-faketouch-'));
writeFileSync(join(fakeBin, 'touch'), '#!/bin/sh\nexit 1\n');
chmodSync(join(fakeBin, 'touch'), 0o755);
try {
const env = { PATH: `${fakeBin}:${process.env.PATH}` };
expect(cli('gstack-wtree', ['--review'], repo, env)).toBe(cli('gstack-wtree', [], repo, env));
} finally {
rmSync(fakeBin, { recursive: true, force: true });
}
});
test('GST-985: gstack-wtree refuses an unknown or extra argument', () => {
for (const args of [['--bogus'], ['--review', 'x']]) {
const r = spawnSync(join(ROOT, 'bin', 'gstack-wtree'), args, { cwd: repo, encoding: 'utf8', timeout: 10_000 });
expect(r.status).toBe(2);
expect(r.stderr).toContain('Usage: gstack-wtree [--review]');
}
});
for (const file of ['.github/workflows/x.yml', '.gstack/ship-policy.json']) {
test(`GST-985: an untracked ${file} added mid-review still breaks the bind`, () => {
const token = cli('gstack-review-log', ['--start', 'review']);
mkdirSync(join(repo, file, '..'), { recursive: true });
writeFileSync(join(repo, file), '{}\n');
const row = log(token);
expect(row.review_binding.state).toBe('changed');
expect(git('diff', '--name-only', row.review_binding.start_wtree, row.review_binding.end_wtree)).toBe(file);
});
}
test('fix commits do not certify the final tree until a new unchanged pass', () => {
const token = cli('gstack-review-log', ['--start', 'review']);
writeFileSync(join(repo, 'source.ts'), 'export const fixed = true;\n');
+79
View File
@@ -24,6 +24,37 @@ const nonceOf = (out: string) => /^WAVE: (\S+)/m.exec(out)?.[1] ?? '';
const waveJson = (fx: TrainFixture, nonce: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'waves', `${nonce}.json`), 'utf8'));
const gitIn = (fx: TrainFixture, cwd: string, ...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } });
/** Commit seed files onto main and push them. */
function seedMain(fx: TrainFixture, files: Record<string, string>, message = 'seed'): void {
fx.repo.git('checkout', '-q', 'origin/main', '-B', 'seed');
for (const [f, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(fx.repo.work, f), t); }
fx.repo.git('add', '-A');
fx.repo.git('commit', '-q', '-m', message);
fx.repo.git('push', '-q', 'origin', 'HEAD:main');
fx.repo.git('fetch', '-q', 'origin');
}
/** A READY PR off main (or, orphan, with no history in common with main) writing (or, for null, deleting) files, with green CI and a READY receipt at its head. */
function readyPr(fx: TrainFixture, n: string, files: Record<string, string | null>, orphan = false): string {
if (orphan) { fx.repo.git('checkout', '-q', '--orphan', `pr-${n}`); fx.repo.git('rm', '-rqf', '.'); }
else fx.repo.git('checkout', '-q', 'origin/main', '-B', `pr-${n}`);
for (const [f, t] of Object.entries(files)) {
if (t === null) { fs.rmSync(path.join(fx.repo.work, f)); continue; }
fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true });
fs.writeFileSync(path.join(fx.repo.work, f), t);
}
fx.repo.git('add', '-A');
fx.repo.git('commit', '-q', '-m', `feat #${n}`);
const head = fx.repo.git('rev-parse', 'HEAD');
fx.repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${n}/head`);
fx.write(d => {
d.prs[n] = { headRefOid: head, isCrossRepository: false, state: 'OPEN', headRefName: `pr-${n}`, url: `https://github.com/fixture/repo/pull/${n}`, body: shippedBody(n, head) };
d.ci[head] = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }];
});
readyReceipt(fx, n, head);
return head;
}
function threePrs(fx: TrainFixture) {
return ['1', '2', '3'].map(pr => openPr(fx, pr, { file: `lib/ship-${pr}.ts`, changelog: pr === '1' }));
}
@@ -79,6 +110,15 @@ describe('areas and grouping', () => {
for (const hub of ['docs/troubleshooting.md', 'ship/SKILL.md', 'scripts/harness-version.json']) expect(linked([hub], () => [hub])).toEqual([]);
expect([isHubFile('docs/x.ts'), isHubFile('TODOS.md'), isHubFile('test/fixtures/x.json'), isHubFile('ship/SKILL.md.tmpl'), isHubFile('lib/x.ts')]).toEqual([true, true, true, false, false]);
});
test('groupWaves never splits a conflict cluster across waves, and a cluster over the wave maximum is oversize and stays whole (GST-770 follow-up)', () => {
const pr = (n: number, file: string): PlanPr => ({ pr: String(n), head: String(n).repeat(40).slice(0, 40), files: [file], lines: 10 });
const cluster = (from: number, to: number, file: string) => Array.from({ length: to - from + 1 }, (_, i) => pr(from + i, file));
const plan = groupWaves([...cluster(1, 4, 'lib/ship-x.ts'), ...cluster(5, 8, 'lib/ship-y.ts'), ...cluster(9, 15, 'lib/ship-z.ts')], f => areaKey(f, () => false), 3, 6);
expect(plan.groups.map(g => g.prs.map(p => p.pr))).toEqual([['1', '2', '3', '4'], ['5', '6', '7', '8']]);
expect(plan.oversize.map(g => [g.area, g.prs.map(p => p.pr)])).toEqual([['ship', ['9', '10', '11', '12', '13', '14', '15']]]);
expect(plan.solo).toEqual([]);
});
});
describe('wave build', () => {
@@ -309,6 +349,45 @@ describe('ready and train build read the wave', () => {
expect([unread.status, unread.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_PROOF_MISSING')]);
expect(unread.err).toContain('#4 (CI unknown at');
}, 180_000);
test('wave plan leaves out a READY head that conflicts with main as NOT_MERGEABLE, and keeps one whose only conflict is the CHANGELOG the train merges (GST-770 follow-up)', () => {
const fx = make();
seedMain(fx, { 'lib/ship-a.ts': 'export const a = 0;\n' });
const changelog = (entry: string) => fx.repo.git('show', 'origin/main:CHANGELOG.md').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- ${entry}\n`) + '\n';
const conflicting = readyPr(fx, '1', { 'lib/ship-a.ts': "export const a = 'one';\n" });
readyPr(fx, '2', { 'lib/ship-b.ts': 'export const b = 2;\n', 'CHANGELOG.md': changelog('#2 change') });
seedMain(fx, { 'lib/ship-a.ts': "export const a = 'main';\n", 'CHANGELOG.md': changelog('a change that landed first') }, 'main moves');
const plan = queue(fx, ['wave', 'plan']);
expect([plan.status, plan.err]).toEqual([EXIT.ok, '']);
expect(plan.out.split('\n').filter(l => l.startsWith('NOT_MERGEABLE:'))).toEqual([
`NOT_MERGEABLE: #1 (conflicts with origin/main at ${conflicting.slice(0, 12)} in lib/ship-a.ts; left out until the head merges cleanly onto main)`,
]);
expect(line(plan.out, 'QUEUE:')).toContain('1 live READY PR(s)');
readyPr(fx, '3', { 'lib/ship-c.ts': 'export const c = 3;\n' }, true);
const unread = queue(fx, ['wave', 'plan']);
expect([unread.status, unread.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_PROOF_MISSING')]);
expect(unread.err).toContain('#3 (merge onto main unreadable: (git merge-tree failed');
}, 180_000);
test('wave plan links a rename with an edit of the old path that conflicts with it, not with an edit that merges cleanly, and prints a cluster over --max as WAVE_OVERSIZE with no build command (GST-770 follow-up)', () => {
const fx = make();
const body = (first = '', last = '') => Array.from({ length: 10 }, (_, i) => i === 0 && first ? `export const o0 = '${first}';\n` : i === 9 && last ? `export const o9 = '${last}';\n` : `export const o${i} = ${i};\n`).join('');
seedMain(fx, { 'lib/ship-old.ts': body() });
readyPr(fx, '1', { 'lib/ship-old.ts': null, 'lib/ship-new.ts': body('one') });
readyPr(fx, '2', { 'lib/ship-old.ts': body('two'), 'lib/review-2.ts': 'export const r = 2;\n', 'test/review-2.test.ts': '// #2\n' });
readyPr(fx, '3', { 'lib/ship-old.ts': body('', 'three'), 'lib/pregate-3.ts': 'export const p = 3;\n', 'test/pregate-3.test.ts': '// #3\n' });
const plan = queue(fx, ['wave', 'plan', '--trigger', '1', '--min', '2']);
expect([plan.status, plan.err]).toEqual([EXIT.ok, '']);
expect(plan.out.split('\n').filter(l => l.startsWith('WAVE_GROUP:'))).toEqual(['WAVE_GROUP: area=ship prs=#1,#2 integrator=#1 (most changed lines)']);
expect(line(plan.out, 'SOLO:')).toBe('SOLO: #3 (no area group of 2+; they ride as single slots)');
const small = queue(fx, ['wave', 'plan', '--trigger', '1', '--min', '1', '--max', '1']);
expect([small.status, small.err]).toEqual([EXIT.ok, '']);
const rows = small.out.split('\n');
const at = rows.findIndex(l => l.startsWith('WAVE_OVERSIZE:'));
expect(rows[at]).toBe('WAVE_OVERSIZE: area=ship prs=#1,#2 (2 PRs whose changes conflict, over the wave maximum 1; kept whole with no build command: land part of it as single slots, then run wave plan again)');
expect(rows[at + 1]).not.toContain('build:');
expect(rows.filter(l => l.startsWith('WAVE_GROUP:'))).toEqual(['WAVE_GROUP: area=pregate prs=#3 integrator=#3 (most changed lines)']);
}, 180_000);
});
describe('wave settle', () => {
+51
View File
@@ -142,6 +142,57 @@ describe('the Review-loop exception line (GST-977, gstack-evals #139)', () => {
});
});
describe('the Documentation exception line (GST-983)', () => {
const excepted = {
...receipt,
gate: { status: 'green', run: '983', unit: { pass: 1181, skip: 70 } },
docs: { status: 'blocked', scope: 'docs/', exception: { accepted_by: 'Garry', date: '2026-11-02', via: 'GSTA-36', reason: 'sections 1 and 2 skip the audit' } },
};
const line = 'Documentation exception: accepted by Garry 2026-11-02 via GSTA-36: sections 1 and 2 skip the audit';
// Value: protects=a body that carries the docs exception line write requires can also pass --check-prose; fails_when=the
// owner's reason is read as counter claims ("2 skip" against 70); seam=lib/ship-receipt-prose.ts checkReceiptProse
test('the exact line the receipt\'s docs exception requires is not prose; the rest of the body still is', () => {
expect(checkReceiptProse(excepted, `## Documentation\n\n${line}\n\nGate: 70 skip.\n`)).toEqual({ checked: 1, mismatches: [] });
expect(checkReceiptProse(excepted, ` ${line}\r\nGate: 69 skip.`).mismatches).toEqual([{ field: 'gate.unit.skip', prose: 69, receipt: 70 }]);
});
test('the same words anywhere else, a line for a different exception, or a receipt with no exception stay prose', () => {
const twoSkip = [{ field: 'gate.unit.skip', prose: 2, receipt: 70 }];
expect(checkReceiptProse(excepted, `Note: ${line}`).mismatches).toEqual(twoSkip);
expect(checkReceiptProse(excepted, line.replace('GSTA-36', 'GSTA-37')).mismatches).toEqual(twoSkip);
expect(checkReceiptProse({ ...excepted, docs: { status: 'current', scope: 'docs/' } }, line).mismatches).toEqual(twoSkip);
});
});
describe('code fences are not prose (GST-984, gstack-evals #142)', () => {
const lanes = { ...receipt, gate: { status: 'green', run: '142', passed: 1181, failed: 0, unit: { pass: 1181, fail: 0 } } };
const evidence = '```text\n- // one none trial per band task (the first 35 fail) and one told trial per pilot task.\nr3-drop, r3, r3-sol: 113 passed, 0 failed with the real list\nunit: 113 pass, 0 fail\n```\n';
// Value: protects=pasted test output and quoted code in a fence never count as this PR's counters; fails_when=the
// fenced "113 passed" or "35 fail" is read against the receipt; seam=lib/ship-receipt-prose.ts checkReceiptProse
test('numbers inside a closed fence are skipped; counts outside it are still checked', () => {
expect(checkReceiptProse(lanes, `Gate: 1181 passed.\n\n${evidence}\nUnit: 1181 pass.\n`)).toEqual({ checked: 2, mismatches: [] });
expect(checkReceiptProse(lanes, `${evidence}\nUnit: 113 pass.\n`).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }]);
expect(checkReceiptProse(lanes, evidence.replace(/```/g, '~~~~').replace(/~~~~\n$/, '~~~~~\n'))).toEqual({ checked: 0, mismatches: [] });
});
test('a fence closes only on its own run, an unclosed fence stays prose, and the fence still separates the text around it', () => {
const short = '````\n113 pass\n```\nstill fenced 113 pass\n````\n';
expect(checkReceiptProse(lanes, short)).toEqual({ checked: 0, mismatches: [] });
expect(checkReceiptProse(lanes, 'Intro\n```text\n113 pass\n').mismatches).toEqual([{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }]);
expect(checkReceiptProse(lanes, 'Shipped 3\n```\nx\n```\npass rates are below.\n')).toEqual({ checked: 0, mismatches: [] });
});
// Value: protects=a line that only looks like a fence opener, or a shorter fence inside an unclosed one, cannot hide a
// claim GitHub shows as prose; fails_when=either shape skips the "113 pass" after it; seam=lib/ship-receipt-prose.ts checkReceiptProse
test('inline triple backticks open no fence, and nothing after an unclosed fence is skipped', () => {
const wrong = [{ field: 'gate.unit.pass', prose: 113, receipt: 1181 }];
expect(checkReceiptProse(lanes, '```npm test``` ran.\nUnit: 113 pass.\n\n```text\nlog\n```\n').mismatches).toEqual(wrong);
expect(checkReceiptProse(lanes, '````\n```\nUnit: 113 pass.\n```\n').mismatches).toEqual(wrong);
});
});
describe('gstack-ship-receipt read --check-prose', () => {
const run = (body: string) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-'));
+38
View File
@@ -163,4 +163,42 @@ describe('test preload hermeticity', () => {
expect(r.stderr).toContain('test-setup: stripped inherited GSTACK_STATE_DIR; tests isolate state via GSTACK_HOME');
expect(r.stderr.match(/test-setup: stripped/g)?.length).toBe(2);
});
test('bun test strips an inherited live-brain URL, so a temp-HOME gbrain child never sees it (#3466)', () => {
const live = path.join(tmp, 'live-gbrain');
fs.mkdirSync(live, { recursive: true });
fs.writeFileSync(path.join(live, 'config.json'), JSON.stringify({ database_url: 'postgresql://live-config.invalid/brain' }));
const childHome = path.join(tmp, 'child-home');
fs.mkdirSync(childHome, { recursive: true });
const probe = path.join(tmp, 'brain-probe.test.ts');
fs.writeFileSync(probe, [
"import { test, expect } from 'bun:test';",
"import { spawnSync } from 'child_process';",
"test('probe', () => {",
" const script = \"const { buildGbrainEnv } = require(\" + JSON.stringify(" + JSON.stringify(path.join(ROOT, 'lib', 'gbrain-exec.ts')) + ") + \"); const e = buildGbrainEnv(); console.log(['GBRAIN_DATABASE_URL','GBRAIN_DIRECT_DATABASE_URL','DATABASE_URL','GBRAIN_HOME'].map(k => k + '=' + (e[k] ?? '<unset>')).join(' '));\";",
" const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', '-e', script], {",
" env: { ...process.env, HOME: " + JSON.stringify(childHome) + " }, encoding: 'utf-8', timeout: 30_000,",
" });",
" expect(r.stdout.trim()).toBe('GBRAIN_DATABASE_URL=<unset> GBRAIN_DIRECT_DATABASE_URL=<unset> DATABASE_URL=<unset> GBRAIN_HOME=<unset>');",
"});",
].join('\n'));
const r = spawnSync('bun', ['test', '--preload', path.join(ROOT, 'test-setup.ts'), probe], {
cwd: tmp,
env: {
...env, GSTACK_HOME: resolved,
GBRAIN_DATABASE_URL: 'postgresql://live-brain.invalid/brain',
GBRAIN_DIRECT_DATABASE_URL: 'postgresql://live-brain.invalid/direct',
DATABASE_URL: 'postgresql://live-brain.invalid/fallback',
GBRAIN_HOME: live,
},
encoding: 'utf-8',
timeout: 60_000,
});
expect(r.stderr).not.toContain('live-brain.invalid');
expect(r.stderr).not.toContain('live-config.invalid');
expect(r.status).toBe(0);
for (const name of ['GBRAIN_DATABASE_URL', 'GBRAIN_DIRECT_DATABASE_URL', 'DATABASE_URL', 'GBRAIN_HOME']) {
expect(r.stderr).toContain(`test-setup: stripped inherited ${name}; tests must not reach a live gbrain`);
}
});
});
+29
View File
@@ -29,6 +29,9 @@ import {
loadFreeTestDurations,
packShardsByDuration,
wallTimeoutForPackedShard,
ciShardJobTimeoutMinutes,
ciPlanMatrix,
MAX_CI_JOB_TIMEOUT_MINUTES,
createFreeCiPlan,
validateFreeCiPlan,
verifyFreeCiResults,
@@ -1101,4 +1104,30 @@ describe('test-free-shards: duration-aware packing (full-suite LPT)', () => {
// When the prediction is the larger bound, it still wins.
expect(wallTimeoutForPackedShard(600_000, WALL_BASE_MS, 10)).toBe(1_800_000);
});
test('a shard\'s job timeout is its runner wall plus the flaky-retry wall plus setup, rounded up (#3850)', () => {
// 750 s predicted -> 2250 s wall, + 360 s retry, + 300 s setup = 2910 s -> 49 min.
expect(ciShardJobTimeoutMinutes({ shard: 1, files: ['a'], predictedMs: 750_000 }, 300)).toBe(49);
// A tiny shard still covers the 6-minute base wall and the retry pass.
expect(ciShardJobTimeoutMinutes({ shard: 2, files: ['a'], predictedMs: 1_000 }, 60)).toBe(13);
});
test('a shard that would need a job over the cap fails the plan instead of being truncated', () => {
const predictedMs = MAX_CI_JOB_TIMEOUT_MINUTES * 60_000 / 3;
expect(() => ciShardJobTimeoutMinutes({ shard: 4, files: ['a'], predictedMs }, 300)).toThrow(/FREE_JOB_TIMEOUT_OVER_CAP: CI shard 4 needs a 101-minute job/);
});
test('the matrix carries per-shard timeouts only when the plan is given a setup margin', () => {
const plan = createFreeCiPlan(['a.test.ts', 'b.test.ts'], 2, { 'a.test.ts': 750_000, 'b.test.ts': 1_000 }, 'rev');
expect(ciPlanMatrix(plan)).toEqual({ shard: [1, 2] });
expect(ciPlanMatrix(plan, 300)).toEqual({ include: [{ shard: 1, timeout_minutes: 49 }, { shard: 2, timeout_minutes: 17 }] });
});
test('--job-setup-seconds is a --ci-plan flag with whole seconds from 1 to 3600', () => {
expect(parseCliOptions(['--ci-plan', 'p.json', '--job-setup-seconds', '300']).jobSetupSeconds).toBe(300);
expect(() => parseCliOptions(['--shards', '2', '--job-setup-seconds', '300'])).toThrow('--job-setup-seconds applies only to --ci-plan');
for (const bad of ['0', '3601', '1.5', 'x']) {
expect(() => parseCliOptions(['--ci-plan', 'p.json', '--job-setup-seconds', bad])).toThrow('--job-setup-seconds needs a whole number of seconds from 1 to 3600');
}
});
});