mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.366.0 stamp: train gate.train-f581d44c4086-088bfc5914f8-83fafc S2 (#3683)
This commit is contained in:
commit
4414e6bb7e
26 files changed
+589
-88
No files matched your search
@@ -1,5 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.366.0] - 2026-10-31
|
||||
|
||||
**A partial `gstack-post pr-body` post keeps the PR's intro.** Since GST-412, any body file with a `## ` section replaced the text above the PR's first heading, so a lane that posted only `## Screenshots` deleted a human intro or a `Review-loop exception:` line under the receipt (GStack Minder: "a partial pr-body post must keep the human intro").
|
||||
|
||||
### Fixed
|
||||
- **Only a full post owns the text above the first heading.** A file with a receipt block above its first `## ` section, as /ship composes it, still replaces stale lead text and stale notes under the receipt. Any other file keeps them; its own lead text still replaces the PR's lead text.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-post.test.ts`: a partial post keeps the intro and the note under the receipt, even with a caption of its own or a receipt further down; the full-post cases now carry a receipt, as /ship's body does.
|
||||
|
||||
## [1.91.365.0] - 2026-10-31
|
||||
|
||||
**An unclosed code fence in an outside review no longer hides a later `[P1]` or not-ready verdict (GST-436).** Before, an outside-voice reply that said no issues were found, opened a code fence it never closed, and then listed a `[P1]` finding or ended with `Verdict: not ready` was classified clean, because everything after the fence was treated as quoted code. Now a P0 or P1 severity or a last `Verdict: not ready` after an unclosed fence still blocks. Text inside that fence still can't make a reply clean, so a quoted `Verdict: ready`, `[P3]` or `No issues found.` there is ignored, as before.
|
||||
|
||||
### Fixed
|
||||
- `lib/outside-review-result.ts` reads the reply after an unclosed fence's opening line for severities (P0 and P1, or Critical and High words, block), an unparsed label (which makes the review `unverified`) and a last `Verdict: not ready`. The evidence for clean (a parsed severity, the last verdict line, the no-findings conclusion) keeps that text blanked. Closed fences still hide quoted severities.
|
||||
- Fence matching is one pass, and an opener counts only when a later marker of its kind is at least as long. A reply with thousands of unmatched fences classifies in milliseconds instead of rescanning the rest once per fence.
|
||||
|
||||
### Tests
|
||||
- `test/outside-review-unclosed-fence.test.ts` covers:
|
||||
- a `[P1]` and a `Verdict: not ready` after an unclosed fence
|
||||
- a closed fence that still hides a quoted `[P1]`
|
||||
- a quoted `Verdict: ready`, `[P3]` or `No issues found.` inside an unclosed fence, which stays findings or unverified
|
||||
- 3,000 unmatched fences
|
||||
|
||||
## [1.91.364.0] - 2026-10-31
|
||||
|
||||
**`--policy-from head` now only adds test lanes, and the repo can set pregate's per-process timeout.** Two /ship tooling gaps cost other crews a rerun each. GEVA-2 (gstack-evals#93) had a PR that added its own pregate lane, but `gstack-ship-preflight` could not pass `--policy-from head`, so Step 5 failed with `PREGATE_NO_LANE` and the lane called runLanes by hand (GST-419). HARN-1 (HARN-12) had lanes of about 780 s, while preflight and the `finish` fallback ran pregate at its 600 s default, so the result was always `PREGATE: incomplete` (GST-420). There was also a hole behind the first gap: `--policy-from head` took the branch's whole `.gstack/pregate.json`, so a branch could delete its own lanes.
|
||||
|
||||
### Added
|
||||
- **`timeout_s` in `.gstack/pregate.json`** (1 to 3600 s): gstack-pregate's per-process timeout when `--timeout` is not passed, so preflight, `finish` and plain runs all pick it up. The remote gate gets the same value as `PREGATE_TIMEOUT_S`. A timed-out lane names where its timeout came from (`--timeout`, `timeout_s from <policy>` or the 600 s default).
|
||||
- **`gstack-ship-preflight --policy-from head`** forwards to Step 5's pregate. When pregate does not pass, preflight prints up to three `PREGATE_DETAIL:` lines with pregate's own reasons, and a refusal reads `PREGATE: refused (<code>)` instead of `unreadable`.
|
||||
|
||||
### Changed
|
||||
- **A head policy may only add test lanes** (`PREGATE_HEAD_POLICY_LOOSENS`). It may add a `dependencies` glob with at least one test, or add tests to a glob the trusted policy already maps. Anything else that differs from the trusted policy refuses the run and names each difference. The trusted policy is the base copy, or the user-owned file when the base has none. A branch behind its base is measured from the copy at its merge base and runs the base tip's policy plus its additions. A head file with no trusted policy anywhere is still a bootstrap.
|
||||
- **`--remote` works with `--policy-from head`** once that check passes, so a lane on a machine with under 8 cores can clear its obligation. It still runs the trusted copy's `remote_gate`. With no trusted policy at all, `--remote` under head is refused, because a bootstrap head file's own `remote_gate` would be clearing its own obligation.
|
||||
- **Under `--policy-from head`, an invalid user-owned policy now refuses** (`PREGATE_CONFIG_INVALID`) instead of being ignored, because it is the policy the head file is compared with.
|
||||
- **A `[]` declaration added only on the branch is refused under `--policy-from head`**, the Dart device-test workaround included: declare `"**/integration_test/**": []` on the base branch first. The `PREGATE_NO_LANE` fix line and docs say so.
|
||||
|
||||
### Tests
|
||||
- `test/pregate-head-policy.test.ts`: a head policy that adds a lane passes; one that removes or swaps a lane, adds `[]`, changes the runner, downgrades a check, allowlists a finding or deletes the file is refused; reorders are not changes; a stale branch keeps the base's newer lanes; the user-owned policy is compared when the base has none; a bootstrap still runs; `timeout_s` bounds each runner process, `--timeout` overrides it, and out-of-range values are invalid.
|
||||
- `test/ship-preflight.test.ts`: `--policy-from head` reaches pregate, a removed lane stops with `PREGATE_HEAD_POLICY_LOOSENS`, and `timeout_s` reaches preflight's pregate with the cause printed.
|
||||
- `test/pregate-remote.test.ts`: `--remote` under head runs the base gate with the `timeout_s` deadline (or `--timeout`), and a head policy that changes the gate is refused. `test/pregate-dart.test.ts` and `test/pregate-user-policy.test.ts` follow the two behavior changes above.
|
||||
|
||||
## [1.91.363.0] - 2026-10-31
|
||||
|
||||
**A passing test lane that leaves Bun's `.bun/` folder behind now grades FRESH, `--check-prose` no longer reads the Approvals table as a claim, and `gstack-post pr-body` drops a stale intro.** Three /ship false alarms from GST-421 and GST-412 (HARN-1, HARN-12, GSTA-2, GEVA-69, gstack-evals#90).
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1.
|
||||
- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1.
|
||||
|
||||
### P2/P3: pregate head-policy follow-ups (filed 2026-10-31, from the GST-419/GST-420 /ship review)
|
||||
- **`finish` fallback drops head-policy lanes (P2)** — `lib/ship-release.ts` reruns pregate on the stamped tree under the base policy, so after a version bump a lane the branch added under `--policy-from head` is gone and `finish` reports `PREGATE: fail (rerun on the stamped tree)`. GSTA-194 owns that file and has the note. **Effort:** S. **Priority:** P2.
|
||||
- **The 3600 s preflight and `finish` wrapper deadline (P3)** — it caps the whole pregate run, so several runners that each fit `timeout_s` can still be cut off together. Derive the wrapper deadline from `timeout_s` and the lane count, or document the cap. **Effort:** S. **Priority:** P3.
|
||||
- **A bootstrap head policy can still certify itself without `--remote` (P3)** — with no trusted policy, the head file may set `remote_gate: null`, or produce an obligation that `gstack-pregate clear --receipt` accepts from the branch's own gate. Decide what a bootstrap `pregate.json` may certify, then enforce it in `verify` from the recorded policy label instead of per entry point. The `--remote` refusal also prints the generic `PREGATE_HEAD_POLICY_LOOSENS` fix line, which does not apply when there is no trusted policy. **Effort:** S. **Priority:** P3.
|
||||
|
||||
### P3: `/codex --effort` follow-ups (filed 2026-10-11, from the #3146 autoplan review)
|
||||
|
||||
- **Show the effort that ran.** Step 0.5 prints `CODEX_EFFORT: <level> (source: /codex --effort)` once, but the result footers do not repeat the effort, so it scrolls away. Add an `Effort:` line to the review, challenge and consult result footers. Blocked on skeleton bytes in `codex/SKILL.md` (cap 60,200). **Effort:** S. **Priority:** P3.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.363.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.366.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,
|
||||
|
||||
@@ -323,6 +323,7 @@ gstack_result_anchor() {
|
||||
QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;;
|
||||
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
|
||||
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
|
||||
PREGATE_HEAD_POLICY_LOOSENS) printf '%s' 'pregate-head-policy-loosens' ;;
|
||||
APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;;
|
||||
APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;;
|
||||
APPROVAL_RECORD_NO_BRANCH) printf '%s' 'approval-record-no-branch' ;;
|
||||
|
||||
+11
-8
@@ -67,15 +67,17 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
|
||||
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch, else main)
|
||||
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
|
||||
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands)
|
||||
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands; the head file may
|
||||
only add test lanes over the base copy, else PREGATE_HEAD_POLICY_LOOSENS)
|
||||
--out <file> pregate.json path (default ${PREGATE_OUT_REL})
|
||||
--timeout <s> timeout for each runner process (default 600): the unit-file group, each serial file, each guard
|
||||
without timeout_s; the stall timeout catches a silent one sooner
|
||||
--timeout <s> timeout for each runner process (default timeout_s from the base .gstack/pregate.json, else 600):
|
||||
the unit-file group, each serial file, each guard without timeout_s, the remote gate; the stall
|
||||
timeout catches a silent one sooner
|
||||
--stall-timeout <s> kill a test command's process group after this long with no output and record the check
|
||||
incomplete (PREGATE_STALLED); default stall_timeout_s from the base .gstack/pregate.json,
|
||||
else ${DEFAULT_STALL_TIMEOUT_S}; 0 disables
|
||||
--remote run the base branch's remote_gate now instead of local tests (needs --allow-repo-commands and
|
||||
--policy-from base); it clears the requires-remote obligation only on its
|
||||
--remote run the base branch's remote_gate now instead of local tests (needs --allow-repo-commands;
|
||||
under --policy-from head the gate is still the base copy's); it clears the requires-remote obligation only on its
|
||||
PREGATE_REMOTE: tree=<tested tree> tests=<n> failed=0 line with n > 0
|
||||
--spend-cap <usd> the cap --remote passes to the gate (default \$GSTACK_SPEND_CAP_USD); spend is reported as
|
||||
spent, reserved and unknown
|
||||
@@ -139,14 +141,15 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
|
||||
if (!['base', 'head'].includes(policyFrom)) { process.stderr.write('gstack-pregate: --policy-from needs base|head\n'); return EXIT.usage; }
|
||||
const allow = rest.includes('--allow-repo-commands');
|
||||
if (policyFrom === 'head' && !allow) { process.stderr.write('gstack-pregate: --policy-from head needs --allow-repo-commands (the working-tree pin files are unreviewed)\n'); return EXIT.refused; }
|
||||
const timeoutMs = Number(flag(rest, '--timeout') ?? 600) * 1000;
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { process.stderr.write('gstack-pregate: --timeout needs a positive number of seconds\n'); return EXIT.usage; }
|
||||
const timeoutFlag = flag(rest, '--timeout');
|
||||
const timeoutMs = timeoutFlag === undefined ? undefined : Number(timeoutFlag) * 1000;
|
||||
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { process.stderr.write('gstack-pregate: --timeout needs a positive number of seconds\n'); return EXIT.usage; }
|
||||
const stallFlag = flag(rest, '--stall-timeout');
|
||||
const stallTimeoutMs = stallFlag === undefined ? undefined : Number(stallFlag) * 1000;
|
||||
if (stallTimeoutMs !== undefined && (!Number.isFinite(stallTimeoutMs) || stallTimeoutMs < 0)) { process.stderr.write('gstack-pregate: --stall-timeout needs a number of seconds (0 disables)\n'); return EXIT.usage; }
|
||||
let remote: { spendCapUsd: number } | null = null;
|
||||
if (rest.includes('--remote')) {
|
||||
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the active policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none) and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
|
||||
if (!allow) { process.stderr.write('gstack-pregate: --remote runs the trusted policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none; a head policy may not change it) and needs --allow-repo-commands\n'); return EXIT.refused; }
|
||||
const cap = Number(flag(rest, '--spend-cap') ?? process.env.GSTACK_SPEND_CAP_USD ?? NaN);
|
||||
if (!Number.isFinite(cap) || cap <= 0) { process.stderr.write('gstack-pregate: --remote spends money; pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; }
|
||||
remote = { spendCapUsd: cap };
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
**A partial `gstack-post pr-body` post keeps the PR's intro.** Since GST-412, any body file with a `## ` section replaced the text above the PR's first heading, so a lane that posted only `## Screenshots` deleted a human intro or a `Review-loop exception:` line under the receipt (GStack Minder: "a partial pr-body post must keep the human intro").
|
||||
|
||||
### Fixed
|
||||
- **Only a full post owns the text above the first heading.** A file with a receipt block above its first `## ` section, as /ship composes it, still replaces stale lead text and stale notes under the receipt. Any other file keeps them; its own lead text still replaces the PR's lead text.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-post.test.ts`: a partial post keeps the intro and the note under the receipt, even with a caption of its own or a receipt further down; the full-post cases now carry a receipt, as /ship's body does.
|
||||
+33
-18
@@ -25,9 +25,20 @@ passes. Standalone, `gstack-regen check` lists the commands and exits 3
|
||||
(`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes`
|
||||
`incomplete`, never `pass`. `--policy-from head` (a PR that introduces or
|
||||
changes a pin file) is accepted only with `--allow-repo-commands` and the record
|
||||
says `policy head (unreviewed)`. So a lane or allowlist entry a branch adds to
|
||||
its own `pregate.json` counts only once it is on the base; `PREGATE_NO_LANE`'s
|
||||
fix line says so when the branch edits the file
|
||||
says `policy head (unreviewed)`. Under it the branch's `pregate.json` may only
|
||||
add test lanes over the trusted policy (the base copy, or the user-owned file
|
||||
when the base has none): a new `dependencies` glob with at least one test, or
|
||||
more tests on a glob the trusted policy already maps. A removed or shrunk glob,
|
||||
a new `[]` declaration, or any other key that differs (runner, guards, warn,
|
||||
allowlist, timeouts, the remote gate, ...) refuses the run with
|
||||
[`PREGATE_HEAD_POLICY_LOOSENS`](troubleshooting.md#pregate-head-policy-loosens).
|
||||
A branch behind its base is measured from the copy at its merge base and runs
|
||||
the base tip's policy plus its additions. With no trusted policy at all, the
|
||||
head file is a bootstrap and runs as written. `.gstack/generated.json` is still
|
||||
read from the branch under `--policy-from head`. So an allowlist entry or a
|
||||
`[]` declaration a branch adds counts only once it is on the base, and a lane it
|
||||
adds counts on the base policy only once it lands; `PREGATE_NO_LANE`'s fix line
|
||||
says so when the branch edits the file
|
||||
([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)). An unresolvable base exits 3 with
|
||||
`POLICY_SOURCE_UNAVAILABLE`. Globs are repo-relative (no `..`, no absolute
|
||||
paths, at most 64 per list); commands are one line under 512 bytes.
|
||||
@@ -75,6 +86,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
|
||||
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
|
||||
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) |
|
||||
| `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)) |
|
||||
| `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)) |
|
||||
|
||||
@@ -86,7 +98,7 @@ state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
|
||||
else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `<slug>` is what
|
||||
`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository
|
||||
(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none
|
||||
(never under `--policy-from head`), it lives outside the repository so a PR still
|
||||
(under `--policy-from head` it is the trusted policy the head file is compared with), it lives outside the repository so a PR still
|
||||
cannot reach it, and the record names it (`policy user <path>`) and fingerprints
|
||||
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
|
||||
ship receipt treat an edited or deleted user file, or one that appears (or a
|
||||
@@ -207,7 +219,8 @@ Two stages with separate clocks, both printed:
|
||||
`*_test.dart` files under a `test/` directory are tests, except under `integration_test/` and
|
||||
`test_driver/`: they need a device, so pregate never runs them, and a touched
|
||||
one has no lane until you declare `"**/integration_test/**": []` (and
|
||||
`"**/test_driver/**": []`) under `dependencies` and let CI run them; Dart files, device tests
|
||||
`"**/test_driver/**": []`) under `dependencies` on the base branch and let CI run them
|
||||
(a `[]` only on the branch is refused under `--policy-from head`); Dart files, device tests
|
||||
included, are source code, so a `dependencies` `[]` declaration clears them and `empty_lanes`
|
||||
does not); in a Rust crate (the nearest Cargo.toml with a `[package]` name) a `.rs` file is a
|
||||
test when it sits under `<crate>/tests/`, holds a `#[cfg(test)]` (any cfg predicate naming
|
||||
@@ -321,9 +334,9 @@ Two stages with separate clocks, both printed:
|
||||
the project's `engines.bun` (the row prints `runtime: bun <v> from <dir>`),
|
||||
so a Capy host's older image Bun cannot flip the verdict.
|
||||
- Every test-tier command (the lanes runner, each guard, the `--remote` gate)
|
||||
runs supervised: see [supervision](#supervision). `--timeout` bounds each
|
||||
runner process (the unit-file group, each serial file, each guard without
|
||||
its own `timeout_s`), not their total; the stall timeout catches a hung or
|
||||
runs supervised: see [supervision](#supervision). `--timeout` (else the
|
||||
policy's `timeout_s`, else 600 s) bounds each runner process (the unit-file
|
||||
group, each serial file, each guard without its own `timeout_s`), not their total; the stall timeout catches a hung or
|
||||
silent one sooner.
|
||||
- Under 8 cores, a `remote_gate` in the active policy or a remote `test_backend`
|
||||
(`gstack-config set test_backend ubicloud`) records the local test tier as a
|
||||
@@ -399,9 +412,10 @@ test-tier command:
|
||||
(`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the
|
||||
check is `incomplete` with `PREGATE_STALLED` and names the last file
|
||||
started; the next serial file still runs;
|
||||
- is killed as **timed out** when that runner process reaches `--timeout`
|
||||
(per runner process, so many serial files that each fit still pass); the
|
||||
check is `incomplete` and the next serial file still runs.
|
||||
- is killed as **timed out** when that runner process reaches its timeout
|
||||
(`--timeout <s>`, else `timeout_s`, else 600; per runner process, so many
|
||||
serial files that each fit still pass): the check is `incomplete`, its detail
|
||||
names which of the three set the timeout, and the next serial file still runs.
|
||||
|
||||
A kill is SIGTERM to the group, then SIGKILL after 5 s (a group that ignores
|
||||
SIGTERM still dies). SIGINT or SIGTERM to `gstack-pregate` itself SIGKILLs every
|
||||
@@ -429,11 +443,12 @@ the supervision above. Close the obligation one of two ways:
|
||||
`gstack-pregate clear --remote test_backend/<backend> --run-url <url>`, which
|
||||
has no machine-checkable receipt and is recorded as `attested:<url>`; or
|
||||
- `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`,
|
||||
which runs the declared `remote_gate` now (never under `--policy-from head`;
|
||||
which runs the declared `remote_gate` now (under `--policy-from head`, still the trusted copy's gate;
|
||||
a `test_backend` without `remote_gate` cannot be run this way). The gate runs
|
||||
in the repository root with `PREGATE_TREE` (the working-tree fingerprint
|
||||
being tested), `PREGATE_FILES_FILE` (the selected files, one per line) and
|
||||
`PREGATE_SPEND_CAP_USD`, and must print
|
||||
being tested), `PREGATE_FILES_FILE` (the selected files, one per line),
|
||||
`PREGATE_SPEND_CAP_USD` and `PREGATE_TIMEOUT_S` (this run's per-process
|
||||
timeout in whole seconds), and must print
|
||||
`PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]`, reporting the
|
||||
tree it actually tested. The obligation clears only when that tree is
|
||||
`PREGATE_TREE`, `tests` > 0, `failed` = 0 and the gate exits 0; anything else
|
||||
@@ -548,10 +563,10 @@ are named and left to the paid PR gate; a selection of only paid files reports
|
||||
`tests=0`, which never clears the obligation. The command is the same either way
|
||||
(`--remote` always needs a cap; the keyless path spends nothing).
|
||||
|
||||
pregate kills the gate at its `--timeout` (default 600 s) with a 5 s grace, so
|
||||
the script finishes before that deadline: it reads the deadline from
|
||||
`PREGATE_TIMEOUT_S`, else from the `gstack-pregate` command it runs under (its
|
||||
`--timeout`, else 600), reserves 120 s for teardown plus 15 s margin, and tells
|
||||
pregate kills the gate at its `--timeout` (default the policy's `timeout_s`, else
|
||||
600 s) with a 5 s grace, so the script finishes before that deadline: it reads the
|
||||
deadline from `PREGATE_TIMEOUT_S`, which pregate sets to that same timeout, else from
|
||||
the `gstack-pregate` command it runs under (its `--timeout`, else 600), reserves 120 s for teardown plus 15 s margin, and tells
|
||||
the VM to stop at whichever comes first, that point or the cap's wall (at most
|
||||
45 minutes, minus 3 for teardown). A deadline that leaves the VM under 300 s
|
||||
refuses before any VM (`PREGATE_REMOTE_TIMEOUT_TOO_SHORT`). At create time it
|
||||
|
||||
+35
-8
@@ -160,7 +160,11 @@ the readiness dashboard and the PR body, and never count it as a pass.
|
||||
cell `| P1 |`. P0 and P1 block; P2 and P3 are advisory. A P0-P3 token in that
|
||||
label position that none of these forms reads (`## P1/P2 findings`) makes the
|
||||
review [`unverified`](#outside-review-unparsed-severity), never `clean`. Code
|
||||
fences and inline code are quotes, not labels.
|
||||
fences and inline code are quotes, not labels. A fence that never closes
|
||||
still hides its text from the evidence for `clean`, but after its opening
|
||||
line a P0 or P1 label (or a Critical or High word) and a last
|
||||
`Verdict: not ready` still block, and an unreadable label still makes the
|
||||
review `unverified`.
|
||||
- **Severity words in label position.** `Severity: High`, `Priority: low`, a
|
||||
line that starts with `High:`, `High —`, `[High]` or `**High**` (after an
|
||||
optional heading, bullet or number), a bold `**High**` anywhere, or a table
|
||||
@@ -3379,7 +3383,10 @@ for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or
|
||||
Other non-Markdown documentation goes in `empty_lanes` on the base branch.
|
||||
Dart files under `integration_test/` or `test_driver/` need a device, so pregate
|
||||
never runs them: declare `"**/integration_test/**": []` (and
|
||||
`"**/test_driver/**": []`) under `dependencies` and let CI run them.
|
||||
`"**/test_driver/**": []`) under `dependencies` on the base branch and let CI run
|
||||
them. A `[]` declaration added only on the branch is refused under
|
||||
`--policy-from head` ([`PREGATE_HEAD_POLICY_LOOSENS`](#pregate-head-policy-loosens)),
|
||||
so land it in its own PR first.
|
||||
With no policy anywhere (no base copy, no user-owned file) an unmapped file is
|
||||
the warning [`PREGATE_NO_POLICY`](#pregate-no-policy) with a starter policy
|
||||
printed, not this error; `gstack-pregate init` writes that draft and lists the
|
||||
@@ -3403,13 +3410,33 @@ two routes:
|
||||
|
||||
1. Land the policy change in its own PR first (the reviewed route), then merge
|
||||
the base into this branch and rerun.
|
||||
2. Rerun with `--policy-from head --allow-repo-commands`. The run reads the
|
||||
branch's copy and its record says `policy head (unreviewed)`, so a
|
||||
reviewer sees that the gate ran under a policy nobody has reviewed yet;
|
||||
`gstack-ship-queue` never accepts a head-policy run as a remote mirror.
|
||||
2. Rerun with `--policy-from head --allow-repo-commands` (in /ship,
|
||||
`gstack-ship-preflight --tests-only --policy-from head`). The run uses the
|
||||
trusted policy plus the lanes the branch adds, and its record says
|
||||
`policy head (unreviewed)`, so a reviewer sees that the gate ran under a
|
||||
policy nobody has reviewed yet; `gstack-ship-queue` never accepts a
|
||||
head-policy run as a remote mirror. A head policy that does anything but add
|
||||
test lanes is refused ([`PREGATE_HEAD_POLICY_LOOSENS`](#pregate-head-policy-loosens)).
|
||||
|
||||
An allowlist entry the branch adds to its own `pregate.json` is read the same
|
||||
way: it clears a finding only once it is on the base.
|
||||
An allowlist entry or a `[]` declaration the branch adds to its own
|
||||
`pregate.json` counts only once it is on the base.
|
||||
|
||||
<a id="pregate-head-policy-loosens"></a>
|
||||
### `PREGATE_HEAD_POLICY_LOOSENS`
|
||||
|
||||
**Meaning.** Under `--policy-from head`, the branch's `.gstack/pregate.json`
|
||||
does more than add test lanes over the trusted policy (the base branch's copy,
|
||||
or the user-owned file when the base has none). The message lists each
|
||||
difference: a `dependencies` glob removed, a test dropped from a glob, a new
|
||||
`[]` declaration, a deleted file, or another key (`runner`, `guards`, `warn`,
|
||||
`allowlist`, `timeout_s`, `remote_gate`, ...) that differs. A branch behind
|
||||
its base is compared with the copy at its merge base, so a lane the base added
|
||||
later is not a difference. Nothing ran and no record was written.
|
||||
|
||||
**Fix.** Keep every key of the trusted policy as it is and only add
|
||||
`dependencies` globs with at least one test, or tests on an existing glob. Land
|
||||
any other change, a `[]` declaration included, in its own PR on the base branch
|
||||
first, then merge the base into this branch and rerun.
|
||||
|
||||
<a id="pregate-lane-failed"></a>
|
||||
### `PREGATE_LANE_FAILED`
|
||||
|
||||
@@ -195,17 +195,33 @@ function plainReview(text: string): string {
|
||||
* Code quotes text, so a severity inside a fence or an inline code span is not
|
||||
* a label. An inline span holding only a severity token in label position
|
||||
* (`- \`P1\`: x`, `Severity: \`High\``, a table cell) is formatting and stays.
|
||||
* `readUnclosed` reads a fence that never closes as prose instead (GST-436): the
|
||||
* blocking signals use that view, so an unclosed fence cannot hide a later `[P1]`
|
||||
* or `Verdict: not ready`, while the evidence for clean keeps it blanked. One pass:
|
||||
* an opener closes only when a later marker of its kind is at least as long.
|
||||
*/
|
||||
const CODE_LABEL_BEFORE = /^[\t ]*(?:>[\t ]*)?(?:#{1,6}[\t ]+|[-+*][\t ]+|\(?\d{1,3}[.)][\t ]+)?(?:\*\*|__)?$|\b(?:severity|priority)[\t ]*[:=][\t ]*(?:\*\*|__)?$|\|[\t ]*(?:\*\*)?$/i;
|
||||
function withoutCode(text: string): string {
|
||||
function withoutCode(text: string, readUnclosed = false): string {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const markers = lines.map(line => /^[\t ]*(`{3,}|~{3,})/.exec(line)?.[1]);
|
||||
const closes = markers.map(() => true);
|
||||
if (readUnclosed) {
|
||||
const longestLater: Record<string, number> = { '`': 0, '~': 0 };
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const m = markers[i];
|
||||
if (!m) continue;
|
||||
closes[i] = longestLater[m[0]!]! >= m.length;
|
||||
longestLater[m[0]!] = Math.max(longestLater[m[0]!]!, m.length);
|
||||
}
|
||||
}
|
||||
let fence: string | undefined;
|
||||
return text.split(/\r?\n/).map(line => {
|
||||
const marker = /^[\t ]*(`{3,}|~{3,})/.exec(line)?.[1];
|
||||
return lines.map((line, i) => {
|
||||
const marker = markers[i];
|
||||
if (fence) {
|
||||
if (marker && marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
|
||||
return '';
|
||||
}
|
||||
if (marker) { fence = marker; return ''; }
|
||||
if (marker) { if (closes[i]) fence = marker; return ''; }
|
||||
return line.replace(/`([^`]+)`/g, (span, inner: string, at: number) =>
|
||||
inner.trim() === 'NO_FINDINGS' || /^\[?(?:P[0-3]|critical|high|medium|low)\]?$/i.test(inner.trim()) && CODE_LABEL_BEFORE.test(line.slice(0, at)) ? inner : ' ');
|
||||
}).join('\n');
|
||||
@@ -243,9 +259,12 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC
|
||||
const plain = plainReview(input.text);
|
||||
const reviewed = input.gate !== 'proposal';
|
||||
const code = withoutCode(input.text), plainCode = plainReview(code);
|
||||
const levels = [...[...plainCode.matchAll(/\[(P[0-3])\]|^(P[0-3]):/gm)].map(m => (m[1] ?? m[2]) as Severity),
|
||||
...severityTokens(code), ...(reviewed ? severityWords(code) : [])];
|
||||
const read = withoutCode(input.text, true), plainRead = plainReview(read);
|
||||
const levelsOf = (c: string, p: string) => [...[...p.matchAll(/\[(P[0-3])\]|^(P[0-3]):/gm)].map(m => (m[1] ?? m[2]) as Severity),
|
||||
...severityTokens(c), ...(reviewed ? severityWords(c) : [])];
|
||||
const levels = levelsOf(read, plainRead);
|
||||
const findings = { highest: levels.length ? levels.sort()[0]! : null };
|
||||
const cleanEvidence = levelsOf(code, plainCode).length > 0;
|
||||
const ran = execution(input);
|
||||
if (ran.state === 'unavailable') return { execution: ran, findings, verdict: 'unavailable', reason: ran.reason, detail: ran.detail };
|
||||
const blocking = findings.highest === 'P0' || findings.highest === 'P1';
|
||||
@@ -262,12 +281,12 @@ export function classifyOutsideReview(input: OutsideReviewInput): OutsideReviewC
|
||||
return result('unavailable', 'missing_markers', 'missing review completion recommendation');
|
||||
}
|
||||
if (!reviewed) return result(blocking ? 'findings' : 'clean');
|
||||
const unparsed = firstUnparsed(code);
|
||||
const unparsed = firstUnparsed(read);
|
||||
// A last explicit not-ready blocks even when the only parsed severities are advisory.
|
||||
const notReady = verdictNotReady(plainCode);
|
||||
const notReady = verdictNotReady(plainCode) || verdictNotReady(plainRead);
|
||||
if (blocking || notReady) return { ...result('findings'), ...(unparsed ? { unparsed } : {}) };
|
||||
if (unparsed) return { ...result('unverified', 'unparsed_severity', unparsed), unparsed };
|
||||
if ((input.gate === 'structured' || input.gate === 'review') && !findings.highest && !concludesNoFindings(code)) {
|
||||
if ((input.gate === 'structured' || input.gate === 'review') && !cleanEvidence && !concludesNoFindings(code)) {
|
||||
return result('unverified', 'untagged_review', 'missing severity or explicit no-findings conclusion');
|
||||
}
|
||||
return result('clean');
|
||||
|
||||
@@ -344,7 +344,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
|
||||
const killed = r.runs.find(x => x.outcome === 'exited' && x.exit === null);
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `the runner process ended without an exit code (killed by ${killed?.signal ?? 'a signal'}; ${total} files; last file started: ${killed?.lastFile ?? 'none reported'}${rest})`, fix: `read ${r.log}; a runner killed by a signal (out of memory, an outside kill) never passes; rerun, or split the selection` };
|
||||
}
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (${ctx.timeoutSource ?? '--timeout'} applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
|
||||
}
|
||||
|
||||
function describe(sel: Selection, noPolicy = false): string[] {
|
||||
@@ -401,7 +401,8 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string
|
||||
|
||||
/**
|
||||
* `--remote`: run the declared remote gate now, under the spend cap. The
|
||||
* gate gets PREGATE_TREE, PREGATE_FILES_FILE and PREGATE_SPEND_CAP_USD and
|
||||
* gate gets PREGATE_TREE, PREGATE_FILES_FILE, PREGATE_SPEND_CAP_USD and
|
||||
* PREGATE_TIMEOUT_S (this run's per-process timeout, whole seconds) and
|
||||
* must print `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]`;
|
||||
* the obligation clears only when that tree is the tested tree, tests > 0,
|
||||
* failed = 0 and the gate exited 0. Anything else leaves it uncleared and
|
||||
@@ -417,7 +418,7 @@ async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult)
|
||||
const filesFile = path.join(dir, 'files.txt');
|
||||
fs.writeFileSync(log, '');
|
||||
fs.writeFileSync(filesFile, sel.localTests.join('\n') + '\n');
|
||||
const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress });
|
||||
const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap), PREGATE_TIMEOUT_S: String(Math.floor(ctx.timeoutMs / 1000)) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress });
|
||||
const output = readRange(log, r.start, r.end);
|
||||
const reported = parseRemoteReport(output);
|
||||
const spent = reported?.spent;
|
||||
@@ -527,10 +528,10 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
* routes; it never switches to the head policy on its own.
|
||||
*/
|
||||
function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string {
|
||||
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : '';
|
||||
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) on ${ctx.base} and let CI run them` : '';
|
||||
const fix = `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`;
|
||||
if (ctx.configKind === 'head' || !ctx.touched.some(t => t.path === PREGATE_CONFIG_PATH && t.status !== 'D')) return fix;
|
||||
return `this branch edits ${PREGATE_CONFIG_PATH}, but the policy comes from ${ctx.configLabel} so a branch cannot loosen its own gate, and a lane it adds counts only once it is on ${ctx.base}: land the policy change in its own PR first, or rerun with --policy-from head --allow-repo-commands, which records the run as policy head (unreviewed); otherwise ${fix}`;
|
||||
return `this branch edits ${PREGATE_CONFIG_PATH}, but the policy comes from ${ctx.configLabel} so a branch cannot loosen its own gate, and a lane it adds counts only once it is on ${ctx.base}: land the policy change in its own PR first, or rerun with --policy-from head --allow-repo-commands (gstack-ship-preflight forwards it; a head policy may only add test lanes, and a [] declaration must land on ${ctx.base} first), which records the run as policy head (unreviewed); otherwise ${fix}`;
|
||||
}
|
||||
|
||||
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };
|
||||
+11
-3
@@ -30,6 +30,9 @@
|
||||
* a paid gate from (GSTACK_EVAL_CALLS v1; never executed)
|
||||
* stall_timeout_s seconds without test output before the lanes check kills
|
||||
* the runner's process group (default 300; 0 disables)
|
||||
* timeout_s seconds each runner process may take (the unit-file group,
|
||||
* each serial file, each guard without its own timeout_s, the
|
||||
* remote gate) when `--timeout` is not passed (default 600)
|
||||
* remote_gate the repo's remote test gate command: under 8 cores the test
|
||||
* tier is a requires-remote obligation naming it, and
|
||||
* `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`)
|
||||
@@ -78,17 +81,18 @@ export interface PregateConfig {
|
||||
serial_patterns: string[];
|
||||
price_file: string | null;
|
||||
stall_timeout_s: number | null;
|
||||
timeout_s: number | null;
|
||||
remote_gate: string | null;
|
||||
allowlist: AllowlistEntry[];
|
||||
}
|
||||
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
|
||||
export interface AllowlistEntry { finding: string; paths?: string[]; pattern?: string; reason: string }
|
||||
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null, allowlist: [] };
|
||||
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, timeout_s: null, remote_gate: null, allowlist: [] };
|
||||
export interface ConfigError { code: ResultCodeName; path: string; message: string }
|
||||
|
||||
export const MAX_LIST = 256;
|
||||
const MAX_COMMAND = 512;
|
||||
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate', 'allowlist'];
|
||||
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'timeout_s', 'remote_gate', 'allowlist'];
|
||||
const LANE_ID = /^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/;
|
||||
const MAX_GUARDS = 32;
|
||||
const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s'];
|
||||
@@ -172,6 +176,9 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
|
||||
if ('stall_timeout_s' in obj && obj.stall_timeout_s !== null && (!Number.isInteger(obj.stall_timeout_s) || (obj.stall_timeout_s as number) < 0 || (obj.stall_timeout_s as number) > 86_400)) {
|
||||
errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'stall_timeout_s', message: 'must be an integer number of seconds from 0 (disabled) to 86400, or null' });
|
||||
}
|
||||
if ('timeout_s' in obj && obj.timeout_s !== null && (!Number.isInteger(obj.timeout_s) || (obj.timeout_s as number) < 1 || (obj.timeout_s as number) > 3600)) {
|
||||
errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'timeout_s', message: 'must be an integer number of seconds from 1 to 3600 (preflight and finish allow gstack-pregate one hour), or null' });
|
||||
}
|
||||
if ('remote_gate' in obj && obj.remote_gate !== null) {
|
||||
const why = commandError(obj.remote_gate);
|
||||
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'remote_gate', message: why });
|
||||
@@ -244,7 +251,7 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []):
|
||||
const errors = validateConfig(raw, knownChecks);
|
||||
if (errors.length) return { config: DEFAULT_CONFIG, errors };
|
||||
const o = raw as Partial<PregateConfig>;
|
||||
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [] }, errors: [] };
|
||||
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [] }, errors: [] };
|
||||
}
|
||||
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string };
|
||||
@@ -324,6 +331,7 @@ export function explainConfig(): string {
|
||||
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
|
||||
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
|
||||
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
|
||||
'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)',
|
||||
`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)',
|
||||
|
||||
+14
-6
@@ -18,7 +18,8 @@ import { resolveStateRoot } from '../state-root';
|
||||
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
|
||||
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
|
||||
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
|
||||
import { headPolicy } from './head-policy';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
|
||||
import type { CheckResult, PregateContext, TouchedFile } from './types';
|
||||
import { discoverLanes } from './workflows';
|
||||
import { createScratch, scratchRunId } from './run-scratch';
|
||||
@@ -47,7 +48,7 @@ export function readTestBackend(env: NodeJS.ProcessEnv, invocation?: InvocationO
|
||||
return (r.status === 0 && (r.stdout ?? '').trim()) || 'local';
|
||||
}
|
||||
export interface ContextRefusal {
|
||||
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
|
||||
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
|
||||
message: string;
|
||||
/** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair; so does a `--base` that is no origin branch). */
|
||||
fix?: string;
|
||||
@@ -211,10 +212,10 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
|
||||
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
|
||||
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
|
||||
let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
|
||||
let configLoad: ConfigLoad = loadConfigFromBase(o.repoRoot, base, o.knownChecks);
|
||||
const explain: string[] = [];
|
||||
let absentUserPolicy: string | undefined;
|
||||
if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
|
||||
if (o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
|
||||
const rejected = userConfigRejection(o.repoRoot, o.userConfigPath);
|
||||
const user = rejected ? null : loadConfigFromUser(o.userConfigPath, o.knownChecks);
|
||||
if (user) configLoad = user;
|
||||
@@ -222,6 +223,13 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
if (!user && !rejected) absentUserPolicy = o.userConfigPath;
|
||||
}
|
||||
if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } };
|
||||
if (o.policyFrom === 'head' && !configLoad.errors.length) {
|
||||
if (o.remote && configLoad.kind === 'none') return { refused: { code: 'PREGATE_HEAD_POLICY_LOOSENS', message: '--remote under --policy-from head needs a trusted policy (the base copy or the user-owned file); with none, the head file is a bootstrap and its remote_gate cannot clear its own obligation' } };
|
||||
const head = headPolicy(o.repoRoot, configLoad, mergeBase, base, o.knownChecks ?? []);
|
||||
if ('loosens' in head) return { refused: { code: 'PREGATE_HEAD_POLICY_LOOSENS', message: head.loosens.join('; ') } };
|
||||
configLoad = head.load;
|
||||
explain.push(...head.explain);
|
||||
}
|
||||
if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors, ...(configLoad.kind === 'user' ? { fix: `edit ${o.userConfigPath} directly at each path the message names, then rerun gstack-pregate; it lives outside the repository, so a base-branch change does not reach it` } : {}) } };
|
||||
const registryLoad = o.policyFrom === 'head' ? loadRegistryFromHead(o.repoRoot) : loadRegistryFromBase(o.repoRoot, base);
|
||||
if (isRegistryUnavailable(registryLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: registryLoad.detail } };
|
||||
@@ -239,8 +247,8 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
return {
|
||||
ctx: {
|
||||
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
|
||||
config, configLabel: configLoad.label, configKind: configLoad.kind, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.kind === 'user' && configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
|
||||
config, configLabel: configLoad.label, configKind: configLoad.kind, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? (config.timeout_s ?? 600) * 1000, timeoutSource: o.timeoutMs !== undefined ? '--timeout' : config.timeout_s ? `timeout_s from ${configLoad.label}` : 'the 600 s default',
|
||||
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
|
||||
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
|
||||
cores: o.cores ?? os.availableParallelism(), testBackend: o.testBackend ?? readTestBackend(env, o.invocation), policyFrom: o.policyFrom, remote: o.remote ?? null,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* pregate/head-policy — `--policy-from head` (GST-419): a branch's own
|
||||
* `.gstack/pregate.json` may only add test lanes over the trusted policy (the
|
||||
* base branch's copy, or the user-owned file when the base has none). A lane
|
||||
* is a `dependencies` glob with a non-empty test list, or extra tests on a glob
|
||||
* the trusted policy already maps. Everything else (a removed or shrunk glob, a
|
||||
* new `[]` declaration, the runner, guards, warn, allowlist, timeouts, the
|
||||
* remote gate, ...) must equal the trusted copy, or the run is refused with
|
||||
* PREGATE_HEAD_POLICY_LOOSENS. A branch behind its base is compared with the
|
||||
* copy at its merge base and runs the base tip's policy plus its additions, so
|
||||
* a lane the base gained later never reads as a removal. With no trusted policy
|
||||
* at all, the head file is a bootstrap and runs as written. `sealed` is not
|
||||
* compared: its guard reads the base branch's copy itself.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { PREGATE_CONFIG_PATH, loadConfigFromHead, parseConfig, type ConfigLoad, type PregateConfig } from './config';
|
||||
|
||||
type Loaded = Exclude<ConfigLoad, { unavailable: true }>;
|
||||
|
||||
const FROZEN = ['runner', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'stall_timeout_s', 'timeout_s', 'remote_gate', 'allowlist'] as const;
|
||||
|
||||
const canonical = (v: unknown): unknown => {
|
||||
if (Array.isArray(v)) return v.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])]));
|
||||
return v;
|
||||
};
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
||||
|
||||
/** Each way `head` loosens `ref`, and the tests it adds per glob. */
|
||||
export function headPolicyDiff(ref: PregateConfig, head: PregateConfig, base: string): { loosens: string[]; adds: Record<string, string[]> } {
|
||||
const loosens: string[] = FROZEN.filter(k => !same(ref[k], head[k])).map(k => `${k} differs from the trusted policy`);
|
||||
const adds: Record<string, string[]> = {};
|
||||
for (const [glob, tests] of Object.entries(ref.dependencies)) {
|
||||
const now = head.dependencies[glob];
|
||||
if (!now) { loosens.push(`dependencies[${JSON.stringify(glob)}] is removed`); continue; }
|
||||
const dropped = tests.filter(t => !now.includes(t));
|
||||
if (dropped.length) loosens.push(`dependencies[${JSON.stringify(glob)}] drops ${dropped.join(', ')}`);
|
||||
const extra = now.filter(t => !tests.includes(t));
|
||||
if (extra.length) adds[glob] = extra;
|
||||
}
|
||||
for (const [glob, tests] of Object.entries(head.dependencies)) {
|
||||
if (glob in ref.dependencies) continue;
|
||||
if (!tests.length) loosens.push(`dependencies[${JSON.stringify(glob)}] declares no lane needed; a [] declaration must land on ${base} first`);
|
||||
else adds[glob] = tests;
|
||||
}
|
||||
return { loosens, adds };
|
||||
}
|
||||
|
||||
/** The policy at the merge base when it carries a valid copy; the branch's additions are measured from there. */
|
||||
function forkCopy(repoRoot: string, mergeBase: string, knownChecks: readonly string[]): PregateConfig | null {
|
||||
const r = spawnSync('git', ['show', `${mergeBase}:${PREGATE_CONFIG_PATH}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
if (r.status !== 0 || r.error) return null;
|
||||
const parsed = parseConfig(r.stdout, knownChecks);
|
||||
return parsed.errors.length ? null : parsed.config;
|
||||
}
|
||||
|
||||
/** The policy a `--policy-from head` run uses over `trusted`, or what the head file loosens. */
|
||||
export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string, base: string, knownChecks: readonly string[]): { load: Loaded; explain: string[] } | { loosens: string[] } {
|
||||
const head = loadConfigFromHead(repoRoot, knownChecks) as Loaded;
|
||||
if (trusted.kind === 'none' || head.errors.length) return { load: head, explain: [] };
|
||||
if (head.kind === 'none') return { loosens: [`${PREGATE_CONFIG_PATH} is absent at head while ${trusted.label} declares a policy`] };
|
||||
const ref = (trusted.kind === 'base' && forkCopy(repoRoot, mergeBase, knownChecks)) || trusted.config;
|
||||
const { loosens, adds } = headPolicyDiff(ref, head.config, base);
|
||||
if (loosens.length) return { loosens };
|
||||
const dependencies = { ...trusted.config.dependencies };
|
||||
for (const [glob, tests] of Object.entries(adds)) dependencies[glob] = [...new Set([...(dependencies[glob] ?? []), ...tests])];
|
||||
const n = Object.keys(adds).length;
|
||||
return {
|
||||
load: { ...trusted, config: { ...trusted.config, dependencies }, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` },
|
||||
explain: Object.entries(adds).map(([glob, tests]) => `head policy adds: dependencies[${JSON.stringify(glob)}] → ${tests.join(', ')}`),
|
||||
};
|
||||
}
|
||||
@@ -182,7 +182,7 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
|
||||
* environment: the policy file and its digest, or under the defaults the path that was looked for and absent.
|
||||
*/
|
||||
function userPolicyChange(file: PregateFile): string | null {
|
||||
const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : null;
|
||||
const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : file.policy?.startsWith('head ') ? / over user (.+)$/.exec(file.policy)?.[1] ?? null : null;
|
||||
if (recorded) {
|
||||
if (!file.policy_digest) return `pregate.json names the user-owned policy ${recorded} without its digest; rerun gstack-pregate`;
|
||||
let text: string;
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface PregateContext {
|
||||
platform: NodeJS.Platform;
|
||||
allowRepoCommands: boolean;
|
||||
timeoutMs: number;
|
||||
/** Where timeoutMs came from: `--timeout`, `timeout_s from <policy>`, or the 600 s default. */
|
||||
timeoutSource?: string;
|
||||
/** No output for this long kills a test-tier process group (`--stall-timeout`, `stall_timeout_s`); 0 disables. */
|
||||
stallTimeoutMs: number;
|
||||
/** Live stderr lines: per-file progress, heartbeats, the stage result. */
|
||||
|
||||
@@ -341,4 +341,9 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'the watcher did not start, or a local error stopped it before a verdict',
|
||||
fix: 'pass a PR number or URL the current gh login can read (--repo owner/name when the checkout is not that repository), stop the watcher the line names before starting another for the same PR, or fix the named local error',
|
||||
},
|
||||
PREGATE_HEAD_POLICY_LOOSENS: {
|
||||
anchor: 'pregate-head-policy-loosens',
|
||||
summary: 'under --policy-from head, the branch’s .gstack/pregate.json removes, shrinks or loosens something the trusted policy declares; a head policy may only add test lanes',
|
||||
fix: 'keep every key of the trusted policy as it is and only add dependencies globs with tests (or tests on an existing glob); land any other change, a [] declaration included, in its own PR on the base branch first',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
+16
-6
@@ -15,11 +15,14 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste
|
||||
|
||||
gstack-ship-preflight --base <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
|
||||
gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>... Step 5 only (after a repair)
|
||||
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
|
||||
(a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS
|
||||
gstack-ship-preflight --audit --base <base> --out <dir> the single reads and Steps 7-8's deterministic audits
|
||||
(CONTEXT, TODOS, COVERAGE_MAP, Coverage, PLAN_*, READER: coverage=needed|none plan=needed|none)
|
||||
|
||||
Prints at most 30 lines: BRANCH, STATUS, DIFF, PR/CARRY, APPLE, SCOPE, MERGE, DISTRIBUTION, POLICY,
|
||||
RUNTIME_LINES, PREGATE, one LANE line per lane, then NEXT: lines. STOP: names what the agent must
|
||||
RUNTIME_LINES, PREGATE (with up to three PREGATE_DETAIL lines when it does not pass), one LANE line per lane,
|
||||
then NEXT: lines. STOP: names what the agent must
|
||||
decide or repair; nothing after it ran. Exit codes: 0 ok · 2 usage · 3 STOP`;
|
||||
|
||||
const DIST = /(^|\/)(cmd\/[^/]+\/main\.go|bin\/[^/]+|Cargo\.toml|setup\.py|package\.json)$/;
|
||||
@@ -100,11 +103,16 @@ function merge(cwd: string, base: string, r: Report): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function tests(cwd: string, base: string, list: { label: string; command: string }[], r: Report): Promise<void> {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
|
||||
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, r: Report): Promise<void> {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--policy-from', policyFrom], 3_600_000);
|
||||
const refusal = pg.status === 3 ? /\((PREGATE_[A-Z_]+|POLICY_[A-Z_]+)[;)]/.exec(pg.stderr)?.[1] : undefined;
|
||||
const verdict = refusal ? `refused (${refusal})` : /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
r.add(`PREGATE: ${verdict}`);
|
||||
if (/^(fail|incomplete|unreadable)/.test(verdict)) { r.stop(`pregate ${verdict}: fix the failing checks it lists (gstack-pregate --allow-repo-commands), then rerun --tests-only`); return; }
|
||||
if (/^(fail|incomplete|unreadable|refused)/.test(verdict)) {
|
||||
for (const line of pg.stderr.split('\n').filter(l => l.startsWith('gstack-pregate: ')).slice(0, 3)) r.add(`PREGATE_DETAIL: ${line.length > 300 ? `${line.slice(0, 220)} … ${line.slice(-80)}` : line}`);
|
||||
r.stop(`pregate ${verdict}: fix what PREGATE_DETAIL names (gstack-pregate --allow-repo-commands${policyFrom === 'head' ? ' --policy-from head' : ''}), then rerun --tests-only`);
|
||||
return;
|
||||
}
|
||||
if (/uncleared/.test(verdict)) r.next('pregate requires-remote: gstack-pregate clear, or --remote --spend-cap <usd> under a user cap, before Step 18');
|
||||
const results = (await runLanes(cwd, list)).map(laneLine);
|
||||
for (const l of results) r.add(l.line);
|
||||
@@ -123,11 +131,13 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
|
||||
if (!base || !out) return { text: 'gstack-ship-preflight: --audit needs --base <base> --out <dir>\n', exit: 2 };
|
||||
return { text: audit(cwd, base, out).render(), exit: 0 };
|
||||
}
|
||||
const policyFrom = flag('--policy-from') ?? 'base';
|
||||
if (!['base', 'head'].includes(policyFrom)) return { text: 'gstack-ship-preflight: --policy-from needs base|head\n', exit: 2 };
|
||||
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
|
||||
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
|
||||
const r = new Report();
|
||||
if (has('--tests-only') || integrate(cwd, base, r)) {
|
||||
if (list.length) await tests(cwd, base, list, r);
|
||||
if (list.length) await tests(cwd, base, list, policyFrom, r);
|
||||
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
|
||||
}
|
||||
return { text: r.render(), exit: r.exit };
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.363",
|
||||
"version": "1.91.366",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -117,7 +117,8 @@ usd() { awk -v m="$1" -v p="$ppm" 'BEGIN { s = m * p; c = int(s * 100); if (c <
|
||||
if [ "$wall_min" -lt "$min_min" ]; then
|
||||
stop 5 PREGATE_REMOTE_CAP_TOO_LOW "worst case \$$(usd "$min_min") ($min_min min x \$$ppm/min on $size) exceeds the spend cap \$$cap" "nothing created, nothing spent" "rerun with --spend-cap $(usd "$min_min") or more"
|
||||
fi
|
||||
# The enclosing gstack-pregate kills this gate at its --timeout (default 600 s)
|
||||
# The enclosing gstack-pregate kills this gate at its --timeout (default the
|
||||
# policy's timeout_s, else 600 s; pregate passes it as PREGATE_TIMEOUT_S)
|
||||
# with a 5 s grace, so the VM must be told to stop early enough for teardown to
|
||||
# be confirmed first: 120 s reserved for teardown plus 15 s margin. The deadline
|
||||
# comes from PREGATE_TIMEOUT_S, else from the gstack-pregate process this gate
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* GST-436: an unclosed code fence must not hide the severity lines and verdict after it.
|
||||
* withoutCode() blanks fenced text so a quoted severity is not read as a label; a fence that
|
||||
* never closes used to blank the rest of the reply, so a no-findings line before it read as clean.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { classifyOutsideReview } from '../lib/outside-review-result';
|
||||
|
||||
const NO_ISSUES = 'No issues found in changed.ts.';
|
||||
const RECOMMEND = 'Recommendation: approve because changed.ts keeps its data.';
|
||||
|
||||
describe('an unclosed fence does not hide what follows it (GST-436)', () => {
|
||||
// Value: protects=the outside-voice verdict on review gates; fails_when=a reply with NO_FINDINGS, an unclosed
|
||||
// fence and then a [P1] or a not-ready verdict classifies clean; seam=classifyOutsideReview
|
||||
test('NO_FINDINGS, an unclosed fence, then [P1] is not clean', () => {
|
||||
const text = [NO_ISSUES, '```ts', 'const x = 1;', '[P1] changed.ts drops the last row', RECOMMEND].join('\n');
|
||||
const r = classifyOutsideReview({ text, gate: 'review' });
|
||||
expect(r.verdict).not.toBe('clean');
|
||||
expect(r.verdict).toBe('findings');
|
||||
expect(r.findings.highest).toBe('P1');
|
||||
});
|
||||
|
||||
test('NO_FINDINGS, an unclosed fence, then Verdict: not ready is not clean', () => {
|
||||
const text = [NO_ISSUES, '~~~', 'quoted', RECOMMEND, 'Verdict: not ready'].join('\n');
|
||||
expect(classifyOutsideReview({ text, gate: 'review' }).verdict).toBe('findings');
|
||||
});
|
||||
|
||||
test('a closed fence still hides a quoted severity', () => {
|
||||
const text = [NO_ISSUES, '```', '[P1] quoted from the diff', '```', RECOMMEND].join('\n');
|
||||
const r = classifyOutsideReview({ text, gate: 'review' });
|
||||
expect(r.verdict).toBe('clean');
|
||||
expect(r.findings.highest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unclosed fence never makes a reply clean (GST-436 review)', () => {
|
||||
// Value: protects=the outside-voice verdict on review gates; fails_when=text quoted inside an unclosed fence turns a
|
||||
// not-ready or unverified reply clean; seam=classifyOutsideReview
|
||||
test('a quoted Verdict: ready in an unclosed fence does not override a real not-ready', () => {
|
||||
const text = ['[P2] parser.ts drops the trailing row.', 'Verdict: not ready', 'Recommendation: fix because parser.ts loses data.', '```md', 'Verdict: ready'].join('\n');
|
||||
expect(classifyOutsideReview({ text, gate: 'review' }).verdict).toBe('findings');
|
||||
});
|
||||
|
||||
test('a quoted advisory severity in an unclosed fence does not stand in for a tagged review', () => {
|
||||
const text = ['Recommendation: hold because I ran out of time.', 'Looking at the fixture:', '```', '[P3] example finding text'].join('\n');
|
||||
expect(classifyOutsideReview({ text, gate: 'review' }).verdict).toBe('unverified');
|
||||
expect(classifyOutsideReview({ text, gate: 'structured' }).verdict).toBe('unverified');
|
||||
});
|
||||
|
||||
test('a quoted no-findings line in an unclosed fence does not conclude the review', () => {
|
||||
const text = ['The retry loop in fetch.ts has no cap.', 'Recommendation: fix before merge because the retry loop has no cap.', 'Output of the lint run:', '```text', '$ bun run lint', 'No issues found.'].join('\n');
|
||||
expect(classifyOutsideReview({ text, gate: 'review' }).verdict).toBe('unverified');
|
||||
expect(classifyOutsideReview({ text, gate: 'structured' }).verdict).toBe('unverified');
|
||||
});
|
||||
|
||||
test('many unmatched fences classify in one linear pass', () => {
|
||||
const lines: string[] = [];
|
||||
for (let n = 3002; n >= 3; n--) lines.push((n % 2 ? '`' : '~').repeat(n));
|
||||
const text = [NO_ISSUES, ...lines, '[P1] changed.ts drops the last row', RECOMMEND].join('\n');
|
||||
const started = performance.now();
|
||||
const r = classifyOutsideReview({ text, gate: 'review' });
|
||||
expect(performance.now() - started).toBeLessThan(2000);
|
||||
expect(r.verdict).toBe('findings');
|
||||
});
|
||||
});
|
||||
@@ -553,8 +553,9 @@ describe('gstack-pregate on a Dart repo with a committed policy', () => {
|
||||
}, 30_000);
|
||||
|
||||
// Value: protects=a touched device test (integration_test/) is never run locally, fails with a fix naming .gstack/pregate.json and the
|
||||
// `[]` remedy, and that documented declaration clears it; fails_when=the docs' remedy stops working or the fix loses the device hint;
|
||||
// why_new=device tests were only checked at isTestFile; seam=none; no_claim=CI running the device test
|
||||
// `[]` remedy, and that documented declaration clears it once it is on the base (a branch-only [] under --policy-from head is refused,
|
||||
// GST-419); fails_when=the docs' remedy stops working, the fix loses the device hint, or a head [] runs; why_new=device tests were only
|
||||
// checked at isTestFile; seam=none; no_claim=CI running the device test
|
||||
test('a touched integration test (root or nested package) has no local lane until declared []', () => {
|
||||
const repo = dartRepo();
|
||||
commit(repo, { 'integration_test/app_test.dart': "import 'package:integration_test/integration_test.dart';\nvoid main() {}\n" });
|
||||
@@ -570,7 +571,14 @@ describe('gstack-pregate on a Dart repo with a committed policy', () => {
|
||||
expect(stuck.out).toContain('FAIL lanes — packages/core/integration_test/core_it_test.dart has no test lane');
|
||||
expect(stuck.out).toContain('in .gstack/pregate.json; pregate does not run device tests, so declare "**/integration_test/**": []');
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...DART_PREGATE, dependencies: { ...DART_PREGATE.dependencies, '**/integration_test/**': [] } }) + '\n' });
|
||||
const declared = run(repo, env, ['--policy-from', 'head']);
|
||||
const headOnly = run(repo, env, ['--policy-from', 'head']);
|
||||
expect(headOnly.status).toBe(EXIT.refused);
|
||||
expect(headOnly.err).toContain('a [] declaration must land on main first');
|
||||
expect(headOnly.err).toContain('(PREGATE_HEAD_POLICY_LOOSENS');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
commit(repo, { 'integration_test/app_test.dart': "import 'package:integration_test/integration_test.dart';\nvoid main() { }\n", 'packages/core/integration_test/core_it_test.dart': "void main() { }\n" });
|
||||
const declared = run(repo, env);
|
||||
expect(declared.status).toBe(EXIT.ok);
|
||||
expect(declared.out).not.toContain('has no test lane');
|
||||
expect(declared.out).toContain('nothing touched selects a test');
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* GST-419 and GST-420 (GSTA-195): `--policy-from head` may only add test lanes over the
|
||||
* trusted policy (the base branch's copy, or the user-owned file when the base has none),
|
||||
* and the base policy's `timeout_s` is gstack-pregate's per-process timeout when
|
||||
* `--timeout` is not passed.
|
||||
*/
|
||||
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 { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const SLUG = 'headpolicy';
|
||||
const pregate = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...args], { GSTACK_PROJECT_SLUG: SLUG });
|
||||
const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes');
|
||||
const policy = (deps: Record<string, string[]>, extra: Record<string, unknown> = {}) => JSON.stringify({ ...BASE_PREGATE, dependencies: deps, ...extra }, null, 2) + '\n';
|
||||
const NEW_LANE = { ...BASE_PREGATE.dependencies, 'lib/orphan.js': ['test/util.test.js'] };
|
||||
|
||||
function branchWithOrphanEdit(base?: Record<string, unknown> | null): FixtureRepo {
|
||||
const repo = makeFixtureRepo(base === undefined ? {} : { pregate: base });
|
||||
repos.push(repo);
|
||||
commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' });
|
||||
return repo;
|
||||
}
|
||||
|
||||
describe('gstack-pregate --policy-from head: additive only (GST-419)', () => {
|
||||
// Value: protects=a branch that adds the lane for a file it touches can gate under its own policy; fails_when=the head lane is ignored or refused;
|
||||
// why_new=GEVA-2 (gstack-evals#93) hit PREGATE_NO_LANE and had to call runLanes by hand; seam=import graph; no_claim=queue treatment of head records
|
||||
test('a head policy that only adds a lane gates the touched file; the base policy still reports no lane', () => {
|
||||
const repo = branchWithOrphanEdit();
|
||||
commit(repo, { '.gstack/pregate.json': policy(NEW_LANE) });
|
||||
const viaBase = pregate(repo, []);
|
||||
expect(viaBase.status).toBe(EXIT.fail);
|
||||
expect(viaBase.out).toContain('FAIL lanes — lib/orphan.js has no test lane');
|
||||
const viaHead = pregate(repo, ['--policy-from', 'head']);
|
||||
expect(viaHead.err).not.toContain('PREGATE_HEAD_POLICY_LOOSENS');
|
||||
expect(viaHead.status).toBe(EXIT.ok);
|
||||
expect(lanes(repo).status).toBe('pass');
|
||||
expect(viaHead.out).toMatch(/^pregate: policy head \(unreviewed\) .*adds 1 lane/m);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the trust boundary: a head policy cannot remove, shrink or loosen anything the base declares; fails_when=any of these runs under head;
|
||||
// why_new=--policy-from head took the whole head file; seam=none; no_claim=generated.json, which head mode still reads from the branch
|
||||
test.each([
|
||||
['removes a lane', policy({ 'docs/**': [], 'README.md': [] }), 'dependencies["bin/tool"]'],
|
||||
['swaps a lane for another test', policy({ ...BASE_PREGATE.dependencies, 'bin/tool': ['test/util.test.js'] }), 'dependencies["bin/tool"]'],
|
||||
['adds a [] declaration', policy({ ...BASE_PREGATE.dependencies, 'lib/orphan.js': [] }), 'dependencies["lib/orphan.js"]'],
|
||||
['changes the runner', policy(NEW_LANE, { runner: 'node --test --test-only {files}' }), 'runner'],
|
||||
['downgrades a check', policy(NEW_LANE, { warn: ['strays', 'literals'] }), 'warn'],
|
||||
['allowlists a finding', policy(NEW_LANE, { allowlist: [{ finding: 'pii', paths: ['lib/**'], reason: 'x' }] }), 'allowlist'],
|
||||
['deletes the file', null, '.gstack/pregate.json'],
|
||||
])('a head policy that %s is refused', (_what, head, names) => {
|
||||
const repo = branchWithOrphanEdit();
|
||||
commit(repo, { '.gstack/pregate.json': head });
|
||||
const r = pregate(repo, ['--policy-from', 'head']);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain('(PREGATE_HEAD_POLICY_LOOSENS');
|
||||
expect(r.err).toContain(names);
|
||||
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/pregate.json'))).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a reordered list is not a change; fails_when=key or list order counts as loosening; why_new=set equality; seam=none; no_claim=none
|
||||
test('reordering lists and adding a test to an existing lane is additive', () => {
|
||||
const repo = branchWithOrphanEdit();
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...BASE_PREGATE, strays: { allow: ['Makefile'] }, warn: ['strays'], dependencies: { 'README.md': [], 'docs/**': [], 'bin/tool': ['test/util.test.js', 'test/tool-cli.test.js'], 'lib/orphan.js': ['test/util.test.js'] } }) });
|
||||
expect(pregate(repo, ['--policy-from', 'head']).status).toBe(EXIT.ok);
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a branch behind the base is compared with the policy it forked from and runs the base tip plus its additions; fails_when=a lane the base added later reads as a removal;
|
||||
// why_new=stamp-at-merge preflight skips the base merge; seam=merge-base; no_claim=none
|
||||
test('a stale branch keeps the lanes the base added after it forked', () => {
|
||||
const repo = branchWithOrphanEdit();
|
||||
commit(repo, { '.gstack/pregate.json': policy(NEW_LANE) });
|
||||
repo.git('checkout', '-q', 'main');
|
||||
commit(repo, { '.gstack/pregate.json': policy({ ...BASE_PREGATE.dependencies, 'lib/util.js': ['test/util.test.js'] }) }, 'base adds a lane');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('fetch', '-q', 'origin');
|
||||
const r = pregate(repo, ['--policy-from', 'head', '--explain']);
|
||||
expect(r.err).not.toContain('PREGATE_HEAD_POLICY_LOOSENS');
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('head policy adds: dependencies["lib/orphan.js"]');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=with no base copy, the user-owned policy is the trusted one a head policy is compared with; fails_when=head mode drops the user guard;
|
||||
// why_new=head mode never read the user file; seam=state root; no_claim=none
|
||||
test('with no base copy, a head policy that drops the user-owned guard is refused; one that adds a lane runs', () => {
|
||||
const repo = branchWithOrphanEdit(null);
|
||||
const user = { ...BASE_PREGATE, guards: [{ id: 'always', when: ['lib/**'], command: 'true' }] };
|
||||
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
|
||||
fs.writeFileSync(userPolicy, JSON.stringify(user));
|
||||
commit(repo, { '.gstack/pregate.json': policy(NEW_LANE) });
|
||||
const dropped = pregate(repo, ['--policy-from', 'head']);
|
||||
expect(dropped.status).toBe(EXIT.refused);
|
||||
expect(dropped.err).toContain('guards');
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...user, dependencies: NEW_LANE }) });
|
||||
expect(pregate(repo, ['--policy-from', 'head']).status).toBe(EXIT.ok);
|
||||
const verify = () => runBin(repo, 'gstack-pregate', ['verify'], { GSTACK_PROJECT_SLUG: SLUG });
|
||||
expect(verify().status).toBe(EXIT.ok);
|
||||
fs.writeFileSync(userPolicy, JSON.stringify({ ...user, guards: [] }));
|
||||
const stale = verify();
|
||||
expect(stale.status).not.toBe(EXIT.ok);
|
||||
expect(stale.err + stale.out).toContain('changed after pregate.json was recorded');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a PR that introduces the first policy still bootstraps under head; fails_when=the bootstrap path is refused; why_new=rule scope; seam=none; no_claim=none
|
||||
test('with no base copy and no user-owned file, a head policy is a bootstrap and runs as written', () => {
|
||||
const repo = branchWithOrphanEdit(null);
|
||||
commit(repo, { '.gstack/pregate.json': policy({ 'lib/orphan.js': ['test/util.test.js'], 'docs/**': [] }) });
|
||||
expect(pregate(repo, ['--policy-from', 'head']).status).toBe(EXIT.ok);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('timeout_s in the base policy (GST-420)', () => {
|
||||
const SLOW = "const test = require('node:test');\ntest('slow', async () => { await new Promise(r => setTimeout(r, 4000)); });\n";
|
||||
// Value: protects=the repo's per-process timeout reaches every caller that does not pass --timeout (preflight, finish); fails_when=pregate keeps its 600 s default;
|
||||
// why_new=HARN-1 (HARN-12) lanes of ~780 s were always incomplete under preflight; seam=supervise; no_claim=remote wall clock
|
||||
test('timeout_s bounds each runner process when --timeout is absent, and --timeout still overrides it', () => {
|
||||
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, timeout_s: 2 } }); repos.push(repo);
|
||||
commit(repo, { 'test/slow.serial.test.js': SLOW });
|
||||
expect(pregate(repo, []).status).toBe(EXIT.fail);
|
||||
expect(lanes(repo)).toMatchObject({ status: 'incomplete', code: 'PREGATE_INCOMPLETE' });
|
||||
expect(lanes(repo).detail).toContain('a runner process timed out after 2000 ms (timeout_s from origin/main');
|
||||
expect(pregate(repo, ['--timeout', '30']).status).toBe(EXIT.ok);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=timeout_s stays inside the one-hour wrapper deadline preflight and finish use; fails_when=values over 3600 or under 1 validate;
|
||||
// why_new=new key; seam=none; no_claim=none
|
||||
test.each([[0], [3601], [1.5], ['600']])('timeout_s %p is invalid', (value) => {
|
||||
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, timeout_s: value } }); repos.push(repo);
|
||||
const r = pregate(repo, []);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain('timeout_s');
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -21,7 +21,7 @@ const GATE = 'node scripts/remote-gate.js';
|
||||
/** A fake remote gate: reports the tree it was handed (or GATE_TREE), GATE_TESTS / GATE_FAILED, and records its env. */
|
||||
const GATE_SCRIPT = [
|
||||
"const fs = require('node:fs');",
|
||||
"fs.writeFileSync('../gate-env.json', JSON.stringify({ tree: process.env.PREGATE_TREE, cap: process.env.PREGATE_SPEND_CAP_USD, files: fs.readFileSync(process.env.PREGATE_FILES_FILE, 'utf8') }));",
|
||||
"fs.writeFileSync('../gate-env.json', JSON.stringify({ tree: process.env.PREGATE_TREE, cap: process.env.PREGATE_SPEND_CAP_USD, timeout: process.env.PREGATE_TIMEOUT_S, files: fs.readFileSync(process.env.PREGATE_FILES_FILE, 'utf8') }));",
|
||||
"if (process.env.GATE_SILENT) process.exit(0);",
|
||||
"const spent = process.env.GATE_SPENT ? ` spent=${process.env.GATE_SPENT}` : '';",
|
||||
"console.log(`PREGATE_REMOTE: tree=${process.env.GATE_TREE || process.env.PREGATE_TREE} tests=${process.env.GATE_TESTS ?? 3} failed=${process.env.GATE_FAILED ?? 0}${spent}`);",
|
||||
@@ -129,10 +129,9 @@ describe('gstack-pregate --remote (C-c)', () => {
|
||||
const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes');
|
||||
const pregateFile = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8'));
|
||||
|
||||
test('needs --allow-repo-commands, the base policy and a spend cap', () => {
|
||||
test('needs --allow-repo-commands and a spend cap', () => {
|
||||
const repo = gated();
|
||||
expect(remote(repo, ['--remote', '--spend-cap', '5']).status).toBe(EXIT.refused);
|
||||
expect(remote(repo, ['--remote', '--spend-cap', '5', '--allow-repo-commands', '--policy-from', 'head']).status).toBe(EXIT.refused);
|
||||
const noCap = remote(repo, ['--remote', '--allow-repo-commands']);
|
||||
expect(noCap.status).toBe(EXIT.usage);
|
||||
expect(noCap.err).toContain('pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)');
|
||||
@@ -150,10 +149,44 @@ describe('gstack-pregate --remote (C-c)', () => {
|
||||
expect(lanes(repo).detail).toContain('spend: spent $0.37, reserved $0, unknown $0 (cap $4)');
|
||||
expect(r.err).toContain('PREGATE_REMOTE_SPEND: spent $0.37, reserved $0, unknown $0 (cap $4)');
|
||||
const env = JSON.parse(fs.readFileSync(path.join(repo.root, 'gate-env.json'), 'utf8'));
|
||||
expect(env).toEqual({ tree: file.tree, cap: '4', files: 'test/util.test.js\n' });
|
||||
expect(env).toEqual({ tree: file.tree, cap: '4', timeout: '600', files: 'test/util.test.js\n' });
|
||||
expect(runBin(repo, 'gstack-pregate', ['verify']).status).toBe(EXIT.ok);
|
||||
});
|
||||
|
||||
// Value: protects=a head policy that only adds lanes can clear the remote obligation on a small machine, with the base copy's gate and the
|
||||
// policy's timeout_s as the gate's deadline (PREGATE_TIMEOUT_S); a head policy that changes the gate is refused (GST-419, GST-420);
|
||||
// fails_when=--remote refuses head mode, the gate deadline ignores timeout_s or --timeout, or a head remote_gate runs; why_new=head mode
|
||||
// could never clear on under-8-core crew machines; seam=fake gate; no_claim=the Ubicloud VM
|
||||
test('under --policy-from head the base gate runs with the head lanes and the timeout_s deadline; a head policy that changes the gate is refused', () => {
|
||||
const policy = { ...BASE_PREGATE, remote_gate: GATE, timeout_s: 900 };
|
||||
const repo = gated(policy);
|
||||
const lane = { ...BASE_PREGATE.dependencies, 'lib/orphan.js': ['test/util.test.js'] };
|
||||
commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ ...policy, dependencies: lane }) + '\n' });
|
||||
const env = () => JSON.parse(fs.readFileSync(path.join(repo.root, 'gate-env.json'), 'utf8'));
|
||||
expect(remote(repo, ['--remote', '--allow-repo-commands', '--policy-from', 'head', '--spend-cap', '2']).status).toBe(EXIT.ok);
|
||||
expect(env().timeout).toBe('900');
|
||||
expect(env().files).toContain('test/util.test.js');
|
||||
expect(remote(repo, ['--remote', '--allow-repo-commands', '--policy-from', 'head', '--spend-cap', '2', '--timeout', '1200']).status).toBe(EXIT.ok);
|
||||
expect(env().timeout).toBe('1200');
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...policy, remote_gate: 'node scripts/other-gate.js', dependencies: lane }) + '\n' });
|
||||
const changed = remote(repo, ['--remote', '--allow-repo-commands', '--policy-from', 'head', '--spend-cap', '2']);
|
||||
expect(changed.status).toBe(EXIT.refused);
|
||||
expect(changed.err).toContain('remote_gate differs from the trusted policy');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=a bootstrap head policy (no base copy, no user-owned file) cannot run its own remote_gate to clear its obligation;
|
||||
// fails_when=--remote under head runs a branch-authored gate and verify passes with no test run; why_new=--remote now runs under
|
||||
// head once the additive check passes, and a bootstrap has nothing to check against; seam=fake gate; no_claim=none
|
||||
test('--remote under --policy-from head with no trusted policy is refused before any gate runs', () => {
|
||||
const repo = makeFixtureRepo({ pregate: null });
|
||||
repos.push(repo);
|
||||
commit(repo, { 'scripts/remote-gate.js': GATE_SCRIPT, '.gstack/pregate.json': JSON.stringify({ ...BASE_PREGATE, remote_gate: GATE }) + '\n', 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||||
const r = remote(repo, ['--remote', '--allow-repo-commands', '--policy-from', 'head', '--spend-cap', '2'], { GSTACK_PROJECT_SLUG: 'remote-bootstrap' });
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain('the head file is a bootstrap and its remote_gate cannot clear its own obligation');
|
||||
expect(fs.existsSync(path.join(repo.root, 'gate-env.json'))).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
test('a wrong tree, zero tests, a failure, a missing line or a non-zero exit leaves the obligation uncleared and the check incomplete; unreported spend counts at the cap', () => {
|
||||
const repo = gated();
|
||||
const cases: Array<[Record<string, string>, string]> = [
|
||||
|
||||
@@ -174,16 +174,17 @@ describe('gstack-pregate with a user-owned policy', () => {
|
||||
expect(unreadable.err).toContain('cannot read the user-owned policy');
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the user file is consulted only for --policy-from base with no base copy: head mode and a committed base copy never
|
||||
// read it; fails_when=either guard on the fallback is dropped and an unreviewed user file overrides reviewed policy; why_new=the
|
||||
// fallback is new; seam=none; no_claim=none beyond protects
|
||||
test('--policy-from head and a base copy never read the user file', () => {
|
||||
// Value: protects=the user file is consulted only with no base copy: a committed base copy never reads it, and under --policy-from head it
|
||||
// is the trusted policy the head file is compared with, so an invalid one refuses instead of vanishing (GST-419); fails_when=a base
|
||||
// copy is overridden by the user file, or head mode drops an unreadable user policy; why_new=the fallback is new, and head mode
|
||||
// now compares with it; seam=none; no_claim=none beyond protects
|
||||
test('a base copy never reads the user file; under --policy-from head an invalid user file refuses', () => {
|
||||
const { repo, userPolicy, run } = setup();
|
||||
writePolicy(userPolicy, { local: ['not-a-lane'] });
|
||||
|
||||
const head = run(['--policy-from', 'head']);
|
||||
expect(head.err).not.toContain('PREGATE_CONFIG_INVALID');
|
||||
expect(head.out).not.toContain('policy user');
|
||||
expect(head.status).toBe(EXIT.refused);
|
||||
expect(head.err).toContain('PREGATE_CONFIG_INVALID');
|
||||
|
||||
commit(repo, { '.gstack/pregate.json': JSON.stringify(BASE_PREGATE) + '\n' });
|
||||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
|
||||
@@ -208,3 +208,44 @@ describe('GSTA-167 part 4a: RUNTIME_LINES and REVIEW_DEPTH share one owner', ()
|
||||
expect(plan.consumed).toEqual(['data/used.json']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-preflight Step 5 pregate: head policy and timeout_s (GST-419, GST-420)', () => {
|
||||
const POLICY = { runner: 'node --test {files}', dependencies: { 'README.md': [], 'CHANGELOG.md': [] } };
|
||||
const PASSING = "const test = require('node:test');\ntest('x', () => {});\n";
|
||||
const base = (extra: Record<string, unknown> = {}) => ({ '.gstack/pregate.json': JSON.stringify({ ...POLICY, ...extra }) + '\n', 'test/x.test.js': PASSING });
|
||||
// Value: protects=a branch that adds the lane for a file it touches passes Step 5 through preflight with --policy-from head; fails_when=preflight drops the flag and pregate reports PREGATE_NO_LANE;
|
||||
// why_new=GEVA-2 (gstack-evals#93) had to call runLanes by hand; seam=gstack-pregate --policy-from head; no_claim=queue treatment of head records
|
||||
test('--policy-from head reaches pregate: the branch lane counts, and without the flag PREGATE_DETAIL names the missing lane', () => {
|
||||
const f = fx(base(), { 'src/a.ts': 'export const a = 2;\n', '.gstack/pregate.json': JSON.stringify({ ...POLICY, dependencies: { ...POLICY.dependencies, 'src/a.ts': ['test/x.test.js'] } }) + '\n' });
|
||||
const viaBase = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE]);
|
||||
expect(viaBase.status).toBe(3);
|
||||
expect(lineOf(viaBase.stdout, 'PREGATE_DETAIL')).toContain('with no test lane: src/a.ts');
|
||||
expect(lineOf(viaBase.stdout, 'PREGATE_DETAIL')).toContain('(PREGATE_NO_LANE');
|
||||
const viaHead = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--lane', LANE]);
|
||||
expect(lineOf(viaHead.stdout, 'PREGATE')).toMatch(/^PREGATE: pass/);
|
||||
expect(viaHead.status).toBe(0);
|
||||
expect(lineOf(viaHead.stdout, 'LANE')).toMatch(/^LANE: tests pass /);
|
||||
});
|
||||
|
||||
// Value: protects=the head-policy rule holds through preflight: a head policy that removes a lane is refused and named; fails_when=preflight passes or hides the refusal as unreadable;
|
||||
// why_new=preflight now forwards --policy-from; seam=PREGATE_HEAD_POLICY_LOOSENS; no_claim=none
|
||||
test('--policy-from head with a head policy that removes a lane stops with PREGATE_HEAD_POLICY_LOOSENS', () => {
|
||||
const f = fx(base(), { 'src/a.ts': 'export const a = 2;\n', '.gstack/pregate.json': JSON.stringify({ ...POLICY, dependencies: { 'src/a.ts': ['test/x.test.js'] } }) + '\n' });
|
||||
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--lane', LANE]);
|
||||
expect(r.status).toBe(3);
|
||||
expect(lineOf(r.stdout, 'PREGATE')).toBe('PREGATE: refused (PREGATE_HEAD_POLICY_LOOSENS)');
|
||||
expect(lineOf(r.stdout, 'PREGATE_DETAIL')).toContain('dependencies["README.md"] is removed');
|
||||
expect(r.stdout).not.toContain('LANE: tests');
|
||||
});
|
||||
|
||||
// Value: protects=the repo's timeout_s reaches preflight's pregate and the cause is printed; fails_when=preflight's pregate runs at the 600 s default or prints only the verdict;
|
||||
// why_new=HARN-1 (HARN-12) lanes of ~780 s were always PREGATE: incomplete under preflight; seam=gstack-pregate timeout_s; no_claim=the finish fallback, which runs the same pregate default
|
||||
test('timeout_s from the base policy bounds Step 5 pregate and PREGATE_DETAIL names it', () => {
|
||||
const slow = "const test = require('node:test');\ntest('slow', async () => { await new Promise(r => setTimeout(r, 4000)); });\n";
|
||||
const f = fx(base({ timeout_s: 2 }), { 'test/slow.serial.test.js': slow });
|
||||
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE]);
|
||||
expect(r.status).toBe(3);
|
||||
expect(lineOf(r.stdout, 'PREGATE')).toMatch(/^PREGATE: (tests |all )?incomplete/);
|
||||
expect(lineOf(r.stdout, 'PREGATE_DETAIL')).toContain('timed out after 2000 ms (timeout_s from origin/main');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user