From 4f5b043b3ea02f4fa9887c0122452856c045bb82 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 08:28:03 -0800 Subject: [PATCH] v1.92.35.0 fix(pregate): a timeout-only policy keeps lane verdicts, warn lanes runs the tests, [] keeps removed-test suites (GST-775) (#3792) * fix(pregate): a lane-less policy keeps no-policy verdicts; warn lanes runs the tests; [] keeps removed-test suites (GST-775) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): warn lanes keeps a platform-only selection requires-remote (GST-775) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): a head policy that adds a lane over a lane-less base is strict (GST-775) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): no empty no-lane line on mapped runs; trace [] refusals; absent lane flag stays strict (GST-775 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GST-775 pregate lane policy fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: recommended-harness no-policy row matches pregate (GST-775 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/pregate.md | 24 ++- docs/recommended-harness.md | 2 +- docs/troubleshooting.md | 15 +- lib/pregate/checks/lanes.ts | 51 ++++-- lib/pregate/config.ts | 26 ++- lib/pregate/context.ts | 2 +- lib/pregate/head-policy.ts | 2 +- lib/pregate/index.ts | 4 +- lib/pregate/lane-selection.ts | 19 +- lib/pregate/starter.ts | 9 +- lib/pregate/types.ts | 2 + lib/result-codes-release.ts | 4 +- package.json | 2 +- test/pregate-policy-lane-verdicts.test.ts | 210 ++++++++++++++++++++++ test/pregate-starter.test.ts | 8 +- 18 files changed, 336 insertions(+), 55 deletions(-) create mode 100644 test/pregate-policy-lane-verdicts.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9dab291f..fc4fff6ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.92.35.0] - 2026-11-01 + +- **A `.gstack/pregate.json` that only raises `timeout_s` no longer changes lane verdicts, `warn: ["lanes"]` no longer lets a failing test pass, and a `[]` declaration no longer skips a deleted test's suite** (GST-775, from gbrain-evals #272 and GBEV-22). + - Before, any policy file, even `{"timeout_s": 1800}`, made a touched file with no test lane a hard `PREGATE_NO_LANE` that stopped before any test ran. Now only a policy that declares lanes (a `dependencies`, `empty_lanes`, `selection` or `local` key, even an empty one) does that. A policy with none of them keeps the no-policy verdict: `warn` `PREGATE_NO_POLICY` with a starter, the selected tests run, and a red test is still `PREGATE_LANE_FAILED`. A hand-written policy that sets only `runner`, `guards`, `strays`, `allowlist` or `remote_gate` moves from the hard failure to that warning; add `dependencies` to keep unmapped files failing. See docs/pregate.md#lane-requirements. + - `warn: ["lanes"]` used to downgrade the missing-lane failure before the tests ran, so a red test beside an unmapped file passed. Now the selected tests run first. A red lane is `fail`, an unfinished one `incomplete`, a test only a platform lane runs stays `requires-remote`, and only a green run becomes `warn` `PREGATE_NO_LANE` naming the unmapped files. + - A `[]` declaration that matches a deleted test (the `"**": []` catch-all) used to clear it before the removed-test handling, so its owning suite did not run. Now the deleted test still runs its owning suite, and a file naming it that a `[]` covers, such as a durations list, counts as covered. + ## [1.92.34.0] - 2026-11-01 - `gstack-ship-queue wave plan` no longer packs the whole READY queue into one area. Shared files now link two PRs into one group only when `git merge-tree` finds a real conflict between them in a code file or skill template. PRs of the same area still pack together as before. Docs, other Markdown and JSON ledgers never link PRs, since most PRs add to them. Before, any shared file linked two PRs, so `docs/troubleshooting.md` and a few ledgers chained 55 READY PRs into eight waves all labeled `setup`. With this change, the 57-PR queue later that day gives five groups split by area. A READY head whose CI is red or still running now prints `NOT_GREEN: # (...)` and stays out of the plan, as `ready` would refuse it. A head whose CI cannot be read fails the plan with `QUEUE_PROOF_MISSING`, the same as an unreadable PR. Renamed files are compared by their real paths (GST-770). diff --git a/VERSION b/VERSION index 64d2db59e..a2a712ec1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.34.0 +1.92.35.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index a945033ba..b44d844b5 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.34.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.35.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/pregate.md b/docs/pregate.md index e6d45e4da..707cc6f72 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -75,10 +75,10 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | Key | Meaning | |---|---| | `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p ` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) | -| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences) | +| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences). A `[]` that matches a deleted test does not skip it: the deleted test still runs its owning suite, and a file naming it that `[]` covers (a durations list) counts as covered. With this key present, even empty, the policy [declares lanes](#lane-requirements) | | `empty_lanes` | extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a `join()` of path segments, an import, or a `dependencies` entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`, and so does licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`, `licenses/*.txt`). Source code and files under a test directory never qualify. A test that reads the file still selects that test | | `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) | -| `warn` | check ids downgraded to warn; `secrets` and required lanes never | +| `warn` | check ids downgraded to warn; `secrets` and required lanes never. `"lanes"` downgrades only a touched file with no lane: the selected tests still run, a red lane stays `fail` (`PREGATE_LANE_FAILED`) and an unfinished one `incomplete`; a green run is `warn` `PREGATE_NO_LANE` naming the unmapped files | | `strays.allow` | intentional root files or new top-level directories | | `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane | | `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) | @@ -141,6 +141,19 @@ unmapped file is `PREGATE_NO_LANE` again: the warning is for the first /ship in a new repository (three Captains lost their first pass to it), never a way to hide missing coverage. `--policy-from head` with a head policy is strict too. + +**Lane requirements.** "Any policy" above means a policy that declares lanes: +one with a `dependencies`, `empty_lanes`, `selection` or `local` key, even an +empty one (`gstack-pregate init` writes the first three). A policy with none of +them, such as +`{"timeout_s": 1800}` to give long tests more time, keeps the no-policy lane +verdicts: an unmapped file is the `warn` `PREGATE_NO_POLICY`, the selected +tests run, a red lane is `fail`, and the starter says the active policy +declares no lanes instead of claiming there is none. So raising +`timeout_s` never needs a `"**": []` catch-all. Add `dependencies` when you +want unmapped files to fail. The same rule applies to the user-owned file and to +a `--policy-from head` bootstrap (gbrain-evals #272, GST-775). + `gstack-pregate init` writes a draft built from the repo, not a fixed example. It prints the languages it found and the runner each language's tests would get, then maps every file the import graph cannot place to the tests of the @@ -266,9 +279,10 @@ Two stages with separate clocks, both printed: `.cjs`, `.mts`, `.cts`, then `index.ts`, `index.tsx`, `index.js`, `index.jsx` and `index.mjs` inside the directory; the first file that exists wins. A touched source file with no lane is `FAIL lanes — has no test lane` - once any policy exists (the base copy, the user-owned file or `--policy-from - head`); with no policy anywhere it is the `warn` `PREGATE_NO_POLICY` with a - starter printed ([no policy](#no-policy)); never a pass. Markdown that no + once a policy that [declares lanes](#lane-requirements) exists (the base copy, + the user-owned file or `--policy-from head`); with no policy anywhere, or one + that declares no lanes, it is the `warn` `PREGATE_NO_POLICY` with a starter + printed ([no policy](#no-policy)); never a pass. Markdown that no test depends on is a docs/no-tests empty lane instead (`docs/no-tests: N documentation file(s)`), including when the base branch has no `.gstack/pregate.json`. The dependency is the same import graph diff --git a/docs/recommended-harness.md b/docs/recommended-harness.md index 3486b131f..9e16984c3 100644 --- a/docs/recommended-harness.md +++ b/docs/recommended-harness.md @@ -82,7 +82,7 @@ a time; each is independent. | File | Why | Without it | |---|---|---| -| `.gstack/pregate.json` | Maps changed paths to test lanes, so `/ship` runs the tests that cover the diff and the remote gate selects correctly ([docs/pregate.md](pregate.md)) | `/ship` fails every touched file with `PREGATE_NO_LANE`. Write it once, from one owner, before the crew starts. | +| `.gstack/pregate.json` | Maps changed paths to test lanes, so `/ship` runs the tests that cover the diff and the remote gate selects correctly ([docs/pregate.md](pregate.md)) | `/ship` warns on every touched file with no test lane (`PREGATE_NO_POLICY`) and prints a starter policy; the selected tests still run. A policy that sets only `timeout_s` or other keys without `dependencies`, `empty_lanes`, `selection` or `local` behaves the same. Write it once, from one owner, before the crew starts. | | `.gstack/ship-policy.json` | `queue_mode`, the `train` block (gate and free workflow names, the full-gate check, the train size and fill wait), the stamp files ([docs/ship-policy.md](ship-policy.md)) | Per-PR queue; `train …` refuses with `QUEUE_TRAIN_OFF`. | | `CHANGELOG.md` + `VERSION` | A stamp per merge and a twice-weekly release with notes ([docs/releases.md](releases.md)) | Skip the version and changelog steps; the train still works on the tree receipt. | | `AGENTS.md` validation discipline | Diagnose before fixing, focused checks during repair, one full suite at the end on a frozen tree | Threads retry blindly and report passes from older revisions. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2fb72daf8..575871a04 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3561,9 +3561,12 @@ never runs them: declare `"**/integration_test/**": []` (and 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 +With no policy anywhere (no base copy, no user-owned file), or a policy with no +`dependencies`, `empty_lanes`, `selection` or `local` key (a timeout-only 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 +printed, not this error. Under `warn: ["lanes"]` it is a `warn` with this code, +given only after the selected tests ran green (a red lane is still +`PREGATE_LANE_FAILED`); `gstack-pregate init` writes that draft and lists the files it could not place. `--explain` prints every mapping and why. For a file with no lane it also prints the import specifiers in test files that resolved to no repo file (for example a `tsconfig.json` `paths` alias such as `@/lib/x` whose target is missing) and any @@ -3701,8 +3704,9 @@ differing path is a change. ### `PREGATE_NO_POLICY` **Meaning.** The repository has no pregate policy yet (no `.gstack/pregate.json` -on the base branch and no user-owned `/projects//pregate.json`) -and a touched file maps to no test lane. Under no policy that is a `warn`, not +on the base branch and no user-owned `/projects//pregate.json`), +or its policy declares no lanes (no `dependencies`, `empty_lanes`, `selection` or `local` key, a +timeout-only file for example), and a touched file maps to no test lane. Under no policy that is a `warn`, not `PREGATE_NO_LANE`: the selected tests still ran (a red lane is still `fail`), and the table printed a starter policy drafted from the repository (`PREGATE_STARTER:` lines: runner, test directories, CI lanes, the JSON between @@ -3713,7 +3717,8 @@ Details: [docs/pregate.md](pregate.md#no-policy). overwrite an existing file without `--force`), edit each `TODO` (a test that names the file, a `dependencies` entry, or `[]` for a file with no behaviour), run `gstack-pregate validate`, then commit `.gstack/pregate.json` on the base -branch. From then on an unmapped file is `PREGATE_NO_LANE`. +branch. When a policy already exists, add the starter's `dependencies` to it +instead. From then on an unmapped file is `PREGATE_NO_LANE`. ### `PREGATE_ENVIRONMENT` diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index f1206404c..58458d39a 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -4,10 +4,12 @@ * `set -o pipefail` with the exit code and the runner's own summary judged * (zero tests ran is a failure, exit 0 or not); platform lanes this machine * cannot run become persisted `requires-remote` obligations. A touched file - * with no lane is `FAIL lanes — has no test lane` once any policy - * exists; with no policy anywhere (no base `.gstack/pregate.json`, no - * user-owned file) it is the `warn` PREGATE_NO_POLICY with a starter printed - * (lib/pregate/starter.ts), never a pass either way. + * with no lane is `FAIL lanes — has no test lane` once the policy + * declares lanes (a `dependencies`, `empty_lanes`, `selection` or `local` key); with no policy + * anywhere, or one that declares neither (a timeout-only file), it is the + * `warn` PREGATE_NO_POLICY with a starter printed (lib/pregate/starter.ts), + * never a pass either way. `warn: ["lanes"]` downgrades only that condition: + * the selected tests still run and a red lane stays `fail` (GST-775). * Markdown no test depends on is the docs/no-tests empty lane instead * (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does. * A release manifest whose diff against the merge base changes only version @@ -347,8 +349,15 @@ function unfinished(ctx: PregateContext, r: Awaited> 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[] { - const lines = noPolicy ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`] : sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`); +/** How a touched file with no lane is judged: the no-policy warning, the `warn: ["lanes"]` downgrade, or the hard PREGATE_NO_LANE. */ +type NoLaneMode = 'no-policy' | 'warned' | 'strict'; + +function describe(ctx: PregateContext, sel: Selection, mode: NoLaneMode): string[] { + const lines = !sel.noLane.length ? [] + : mode === 'warned' ? sel.noLane.map(f => `WARN lanes — ${f} has no test lane (warn: ["lanes"] in ${ctx.configLabel}; the selected tests are still judged)`) + : mode === 'strict' ? sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`) + : ctx.configKind === 'none' ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`] + : [`no lane policy: ${sel.noLane.join(' ')} have no test lane (a warning while ${ctx.configLabel} declares no dependencies, empty_lanes, selection or local; the starter below maps them)`]; for (const r of sel.removed) lines.push(`removed (no lane owed): ${r.file} — ${r.note}`); if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`); if (sel.inert.length) lines.push(`no-tests (assets and tool config): ${sel.inert.join(' ')}`); @@ -500,9 +509,25 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul */ function withStarter(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult { const lines = [...(result.lines ?? []), ...starterPolicy(ctx, sel.noLane, detectRunner(ctx, sel.localTests.length ? sel.localTests : sel.tests))]; - const noted = `${sel.noLane.length} touched file(s) with no test lane under no policy (starter printed)`; + const noted = `${sel.noLane.length} touched file(s) with no test lane under ${ctx.configKind === 'none' ? 'no policy' : 'a policy that declares no lanes'} (starter printed)`; if (result.status !== 'pass') return { ...result, lines, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_POLICY' } : {}) }; - return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix: `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE` }; + const fix = ctx.configKind === 'none' + ? `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE` + : `review the starter above and add its dependencies to ${ctx.configFile}${ctx.configKind === 'user' ? '' : ' on the base branch'}, editing each TODO; from then on an unmapped file is PREGATE_NO_LANE`; + return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix }; +} + +/** + * `warn: ["lanes"]` under a policy that declares lanes: the selected tests ran + * and their verdict stands (a red lane is still `fail`, an unfinished one + * `incomplete`); only a green run becomes `warn` PREGATE_NO_LANE naming the + * files with no lane. Before GST-775 the downgraded failure returned before + * any test ran, so a red test beside an unmapped file passed. + */ +function withNoLaneWarning(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult { + const noted = `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')} (downgraded to warn by ${ctx.configLabel})`; + if (result.status !== 'pass') return { ...result, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_LANE' } : {}) }; + return { ...result, status: 'warn', code: 'PREGATE_NO_LANE', detail: `${result.detail}; ${noted}`, fix: noLaneFix(ctx, sel.noLane) }; } async function run(ctx: PregateContext): Promise { @@ -510,16 +535,18 @@ async function run(ctx: PregateContext): Promise { ctx.explain.push(...sel.provenance); for (const [lane, set] of Object.entries(sel.laneSets)) ctx.explain.push(`lane ${lane}: ${set === '?' ? 'file set unknown' : `${set.length} files`}`); const inputs = baseInputs(ctx, { selection: sha256(sel.tests.join('\n')), localTests: sha256(sel.localTests.join('\n')), remote: sha256(sel.remote.map(r => `${r.lane}:${r.tests.join(',')}`).join('\n')) }); - const noPolicy = sel.noLane.length > 0 && ctx.configKind === 'none'; - const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(sel, noPolicy), remote: sel.remote }; + const declared = ctx.configKind !== 'none' && ctx.policyDeclaresLanes !== false; + const mode: NoLaneMode = !declared ? 'no-policy' : ctx.config.warn.includes('lanes') ? 'warned' : 'strict'; + const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(ctx, sel, mode), remote: sel.remote }; const lost = new Set(sel.removed.flatMap(r => r.lostCoverage)).size; const removedNote = sel.removed.length ? `${sel.removed.length} removed path(s)${lost ? `; ${lost} subject(s) lost their last test` : ''}` : ''; const withRemoved = (r: CheckResult): CheckResult => (removedNote ? { ...r, detail: r.detail ? `${r.detail}; ${removedNote}` : removedNote } : r); - if (sel.noLane.length && !noPolicy) { + if (sel.noLane.length && mode === 'strict') { return withRemoved({ ...base, status: 'fail', cause: 'diff', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) }); } const result = withRemoved(await runSelected(ctx, sel, base)); - return noPolicy ? withStarter(ctx, sel, result) : result; + if (!sel.noLane.length) return result; + return mode === 'no-policy' ? withStarter(ctx, sel, result) : withNoLaneWarning(ctx, sel, result); } /** diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index b30ad4c83..ac2a511d1 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -13,8 +13,9 @@ * depends on it; source code and test trees never do) * selection `/` → the runner-owned command that lists * the lane's test files when the matrix is planner-computed - * warn check ids downgraded to warn (never `secrets`; `lanes` keeps - * its required-lane and requires-remote verdicts) + * warn check ids downgraded to warn (never `secrets`; `lanes` + * downgrades only a touched file with no lane, after the + * selected tests ran: a red lane stays `fail`) * strays.allow root files or new top-level directories that are intentional * local `/` lanes a local run satisfies (a * platform-agnostic toolchain, or a repo whose CI is off): @@ -49,6 +50,11 @@ * `env.value` (a configured credential's real value) and never * the whole `secret` category * + * A policy declares lanes when it has a `dependencies`, `empty_lanes`, + * `selection` or `local` key (LANE_KEYS), even an empty one; only then is a touched file with no lane + * the hard PREGATE_NO_LANE. A policy without either (a timeout-only file) + * keeps the no-policy lane verdicts (lib/pregate/checks/lanes.ts). + * * A repo that keeps `.gstack/` out of git can hold the same file at * `/projects//pregate.json` (lib/state-root.ts), honoured * only when the base carries none. It must sit outside the repo, so a PR @@ -254,17 +260,20 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = [] return errors; } -export function parseConfig(text: string, knownChecks: readonly string[] = []): { config: PregateConfig; errors: ConfigError[] } { +/** The keys that declare lanes: with either present, a touched file with no lane is PREGATE_NO_LANE. */ +export const LANE_KEYS = ['dependencies', 'empty_lanes', 'selection', 'local'] as const; + +export function parseConfig(text: string, knownChecks: readonly string[] = []): { config: PregateConfig; errors: ConfigError[]; declaresLanes: boolean } { let raw: unknown; try { raw = JSON.parse(text); } - catch (e: any) { return { config: DEFAULT_CONFIG, errors: [{ code: 'PREGATE_CONFIG_INVALID', path: PREGATE_CONFIG_PATH, message: `not valid JSON: ${e.message}` }] }; } + catch (e: any) { return { config: DEFAULT_CONFIG, errors: [{ code: 'PREGATE_CONFIG_INVALID', path: PREGATE_CONFIG_PATH, message: `not valid JSON: ${e.message}` }], declaresLanes: false }; } const errors = validateConfig(raw, knownChecks); - if (errors.length) return { config: DEFAULT_CONFIG, errors }; + if (errors.length) return { config: DEFAULT_CONFIG, errors, declaresLanes: false }; const o = raw as Partial; - return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, 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 ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, errors: [] }; + return { declaresLanes: LANE_KEYS.some(k => k in o), 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 ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, 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 }; +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; /** The policy has a LANE_KEYS key; absent under kind 'none'. */ declaresLanes?: boolean } | { unavailable: true; base: string; detail: string }; function git(cwd: string, args: string[]): { ok: boolean; out: string } { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); @@ -333,7 +342,8 @@ export function explainConfig(): string { 'dependencies: glob → test files the import graph cannot see (subprocess targets, runtime reads, generated inputs); [] declares no lane needed', 'empty_lanes: extra globs treated as docs/no-tests when nothing else maps them. Built-in, with or without this file: any Markdown file the import graph maps to no test (a quoted path, a join() of path segments, an import, or a dependencies entry), plus README/CHANGELOG/CONTRIBUTING/LICENSE/AGENTS/CLAUDE and licence text under any name; assets (images, fonts, media) and allowlisted tool config take their own empty lane. Source code and files under a test directory never qualify. A test that reads the file still selects that test', 'selection: / → the runner-owned command that lists that lane’s test files when its matrix is planner-computed', - `warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`, + `warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes downgrades only a touched file with no lane, after the selected tests ran (a red lane stays fail)`, + `lane requirements: a policy with any of ${LANE_KEYS.join(', ')} (even empty) makes a touched file with no lane PREGATE_NO_LANE; a policy with none of them (timeout_s only, say) keeps the no-policy warning (PREGATE_NO_POLICY) and starter`, 'strays.allow: root files or new top-level directories that are intentional', 'local: / lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote', 'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable', diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 5ed06266f..b745cb5fc 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -247,7 +247,7 @@ 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.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, + config, configLabel: configLoad.label, configKind: configLoad.kind, policyDeclaresLanes: configLoad.declaresLanes !== false, 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`)), diff --git a/lib/pregate/head-policy.ts b/lib/pregate/head-policy.ts index 9ef2dd062..0d898cef6 100644 --- a/lib/pregate/head-policy.ts +++ b/lib/pregate/head-policy.ts @@ -66,7 +66,7 @@ export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string, 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}` }, + load: { ...trusted, config: { ...trusted.config, dependencies }, declaresLanes: trusted.declaresLanes === true || head.declaresLanes === true, 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(', ')}`), }; } diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 81233836c..aea1fc748 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -37,12 +37,12 @@ export const PREGATE_TIER2 = ['ratchets', 'lint', 'links', 'hermetic', 'patches' export interface RunOptions { stage: Stage | 'all'; only?: string[]; run?: string } -/** Apply the repo's `warn` downgrades: never `secrets`, and never a lane that could run here and failed. */ +/** Apply the repo's `warn` downgrades: never `secrets`. `lanes` applies its own (a touched file with no lane, after the selected tests ran), so nothing it fails is downgraded here. */ export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: CheckDef): CheckResult { if (result.status !== 'fail') return result; if (!def.downgradable || (NON_DOWNGRADABLE as readonly string[]).includes(def.id)) return result; if (!ctx.config.warn.includes(def.id)) return result; - if (def.id === 'lanes' && result.code === 'PREGATE_LANE_FAILED') return { ...result, detail: `${result.detail} (required lane; warn downgrade does not apply)` }; + if (def.id === 'lanes') return result.code === 'PREGATE_LANE_FAILED' ? { ...result, detail: `${result.detail} (required lane; warn downgrade does not apply)` } : result; return { ...result, status: 'warn', detail: `${result.detail} (downgraded to warn by ${ctx.configLabel})` }; } diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 5ca134179..ffa5347d4 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -165,7 +165,7 @@ function removedPath(ctx: PregateContext, graph: Graph, gone: ReadonlySet !gone.has(f)).filter(n => { const m = testsFor(graph, n, ctx.config.dependencies); for (const t of m.tests) namerSuite.add(t); - return !m.tests.length; + return !m.tests.length && !m.declaredNone; }); if (uncovered.length) return { refused: `removed, but still named by ${shown(uncovered)}, which no test covers` }; const namerNote = namerSuite.size ? `files naming it run: ${shown([...namerSuite].sort())}` : ''; @@ -211,6 +211,11 @@ export function selectLanes(ctx: PregateContext): Selection { const stampOnly: string[] = []; const seeds = new Map(); const touchedByPath = new Map(touched.map(t => [t.path, t])); + const waive = (file: string, r: { removed: Removed; suite: string[] }) => { + removed.push(r.removed); + for (const t of r.suite) tests.add(t); + provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`); + }; for (const t of touched) { seeds.set(t.path, t.status === 'D' ? 'deleted' : 'touched'); if (t.renamedFrom) seeds.set(t.renamedFrom, `renamed to ${t.path}`); } for (const e of ctx.registry.entries) { if (!touched.some(t => matchesAny(e.inputs, t.path) || matchesAny(e.outputs, t.path))) continue; @@ -221,8 +226,11 @@ export function selectLanes(ctx: PregateContext): Selection { for (const t of m.tests) tests.add(t); provenance.push(...m.provenance.map(p => `${p} [${why}]`)); if (m.tests.length || why.startsWith('generated')) continue; - // An explicit `[]` already cleared the file. Label it docs/no-tests only when it is that class. + // An explicit `[]` already cleared the file. A removed test still runs its owning suite; label the rest docs/no-tests only when it is that class. if (m.declaredNone) { + const r = gone.includes(file) ? removedPath(ctx, graph, goneSet, file) : null; + if (r && 'removed' in r) { waive(file, r); continue; } + if (r) provenance.push(`${file}: ${r.refused}; cleared by its [] declaration`); if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) emptyLane.push(file); continue; } @@ -259,12 +267,7 @@ export function selectLanes(ctx: PregateContext): Selection { } if (gone.includes(file)) { const r = removedPath(ctx, graph, goneSet, file); - if ('removed' in r) { - removed.push(r.removed); - for (const t of r.suite) tests.add(t); - provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`); - continue; - } + if ('removed' in r) { waive(file, r); continue; } provenance.push(`${file}: ${r.refused}; no lane until declared`); } noLane.push(file); diff --git a/lib/pregate/starter.ts b/lib/pregate/starter.ts index 8ed5a3954..2e27af84f 100644 --- a/lib/pregate/starter.ts +++ b/lib/pregate/starter.ts @@ -2,7 +2,8 @@ * pregate/starter — the policy a repository with none would start from * (Lane D, GSTA-112; FOUN #292, harness-evals #81/#82). When no * `.gstack/pregate.json` exists on the base branch and no user-owned file is - * present, the lanes check does not fail a touched file with no lane + * present, or the active policy declares no lanes (no `dependencies` or + * `empty_lanes`, `selection` or `local` key, GST-775), the lanes check does not fail a touched file with no lane * (PREGATE_NO_LANE); it warns (PREGATE_NO_POLICY) and prints this starter: * the detected runner, a `dependencies` entry for each unmapped touched file * (the tests nearest it, else the tests sharing its stem; never `[]`, which @@ -29,7 +30,7 @@ export function nearestTestsFor(tests: readonly string[], file: string): string[ } /** The starter for `noLane` (touched files with no lane) under `runner` (detected by the lanes check; null when none). */ -export function starterPolicy(ctx: Pick, noLane: readonly string[], runner: string | null): string[] { +export function starterPolicy(ctx: Pick, noLane: readonly string[], runner: string | null): string[] { const tests = listRepoFiles(ctx.repoRoot).filter(isTestFile).sort(); const dependencies: Record = {}; const todo: string[] = []; @@ -43,7 +44,9 @@ export function starterPolicy(ctx: Pick b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_DIRS).map(([d]) => d); const lanes = ctx.lanes.filter(l => l.runsTests && !l.manual).map(l => `${l.id} (${l.platform}${l.matrixExpression && l.testFiles.length === 0 && !ctx.config.selection[l.id] ? ', matrix computed; declare selection' : ''})`); return [ - `PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)`, + ctx.configKind === 'none' + ? `PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)` + : `PREGATE_STARTER: ${ctx.configLabel} declares no lanes (no dependencies, empty_lanes, selection or local key); a starter drafted from this repository follows. Review it, add its dependencies to ${ctx.configFile}${ctx.configKind === 'user' ? '' : ' on the base branch'} and edit each TODO (from then on an unmapped file is PREGATE_NO_LANE)`, `PREGATE_STARTER: runner ${runner ?? 'null (none detected; set runner with {files})'}`, `PREGATE_STARTER: test directories: ${dirs.join(', ') || '(no test files found)'}${dirCounts.size > MAX_DIRS ? ` (+${dirCounts.size - MAX_DIRS} more)` : ''}`, `PREGATE_STARTER: CI lanes: ${lanes.join(', ') || '(no test job in .github/workflows)'}`, diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index 0a4e8a731..67d68c78d 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -35,6 +35,8 @@ export interface PregateContext { configLabel: string; /** Where the policy came from: the base branch, the unreviewed head, the user-owned file, or nowhere (defaults). */ configKind: 'base' | 'head' | 'user' | 'none'; + /** The active policy has a `dependencies`, `empty_lanes`, `selection` or `local` key (config LANE_KEYS); false keeps the no-policy lane verdicts. Absent counts as declared. */ + policyDeclaresLanes?: boolean; /** This run's scratch directory under the state root (lib/pregate/run-scratch.ts): logs and scan files, never os.tmpdir(). */ scratch: string; /** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */ diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 09a36afe4..9dc34904f 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -303,8 +303,8 @@ export const RELEASE_RESULT_CODES = { // Lane D (GSTA-112): the no-policy warning and the environment-cause verdict. PREGATE_NO_POLICY: { anchor: 'pregate-no-policy', - summary: 'the repository has no pregate policy yet (no .gstack/pregate.json on the base branch and no user-owned file) and a touched file maps to no test lane; a warn, with a starter policy printed', - fix: 'review the printed starter, write it with `gstack-pregate init`, edit the TODO entries, then commit .gstack/pregate.json on the base branch; from then on an unmapped file is PREGATE_NO_LANE', + summary: 'the repository has no pregate policy yet (no .gstack/pregate.json on the base branch and no user-owned file), or its policy declares no lanes (no dependencies, empty_lanes, selection or local key), and a touched file maps to no test lane; a warn, with a starter policy printed', + fix: 'review the printed starter, write it with `gstack-pregate init` (or add its dependencies to the existing policy), edit the TODO entries, then commit .gstack/pregate.json on the base branch; from then on an unmapped file is PREGATE_NO_LANE', }, PREGATE_ENVIRONMENT: { anchor: 'pregate-environment', diff --git a/package.json b/package.json index b8524302e..4e0eb5e2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.34", + "version": "1.92.35", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/pregate-policy-lane-verdicts.test.ts b/test/pregate-policy-lane-verdicts.test.ts new file mode 100644 index 000000000..c00848c46 --- /dev/null +++ b/test/pregate-policy-lane-verdicts.test.ts @@ -0,0 +1,210 @@ +/** + * GST-775 (from GBEV-3, gbrain-evals #272 and GBEV-22): `.gstack/pregate.json` could not raise the test timeout without + * changing lane verdicts. (1) Any policy file, even `{"timeout_s": 1800}`, turned a touched file with no test lane into a + * hard PREGATE_NO_LANE that returned before any test ran; with no file it only warned. (2) `warn: ["lanes"]` failed open: + * the downgraded PREGATE_NO_LANE returned before the selected tests ran, so a red test beside an unmapped file passed. + * (3) A `[]` declaration that matched a deleted test (the `"**": []` workaround) cleared it before the removed-test + * handling, so the deleted test's owning suite no longer ran. A policy that declares lanes (a `dependencies`, + * `empty_lanes`, `selection` or `local` key) keeps the hard PREGATE_NO_LANE. + */ +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[] = []; +const fixture = (opts?: Parameters[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; }; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +/** The fixture's planner-computed Windows lane replaced by a lint job, so the selected tests run here instead of becoming a requires-remote obligation. */ +const NO_PLATFORM_LANE = { '.github/workflows/windows.yml': 'name: Lint\non: [push]\njobs:\n lint:\n runs-on: ubuntu-24.04\n steps:\n - run: echo lint\n' }; +const SCHEMA = BASE_PREGATE.$schema; +const UNMAPPED = { 'lib/orphan.js': 'exports.lonely = () => 2;\n' }; +const GREEN = { ...UNMAPPED, 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }; +const RED = { ...UNMAPPED, 'lib/util.js': 'exports.add = (a, b) => a - b;\n' }; +const UTIL_V2 = "const test = require('node:test');\nconst assert = require('node:assert');\nconst { add } = require('../lib/util.js');\ntest('adds again', () => assert.strictEqual(add(2, 2), 4));\n"; + +function lanes(repo: FixtureRepo, extra: string[] = [], env: Record = {}) { + const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--only', 'lanes', ...extra], env); + const out = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')); + const row = out.checks.find((c: { id: string }) => c.id === 'lanes'); + const lines: string[] = row.lines ?? []; + return { ...r, row, lines, ran: lines.find(l => l.startsWith('ran: ')) ?? '', removed: lines.filter(l => l.startsWith('removed (no lane owed): ')) }; +} + +describe('a policy that declares no lanes keeps the no-policy lane verdicts (GST-775 bug 1)', () => { + // Value: protects=a timeout-only policy (gbrain-evals #272's intent) leaves an unmapped file a warning with the selected tests run, + // exactly as with no policy; fails_when=any policy file turns the unmapped file into a hard PREGATE_NO_LANE before the tests run; + // why_new=the lane-requirement condition is new; seam=none; no_claim=the 600 s default changes + test.each([ + ['timeout-only', { $schema: SCHEMA, timeout_s: 1800 }], + ['runner, warn and strays', { $schema: SCHEMA, runner: 'node --test {files}', warn: ['strays'], strays: { allow: ['Makefile'] } }], + ])('%s policy: unmapped file warns and the selected test runs, like no policy', (_name, policy) => { + const none = fixture({ pregate: null, baseFiles: NO_PLATFORM_LANE }); + commit(none, GREEN); + const n = lanes(none); + const withPolicy = fixture({ pregate: policy, baseFiles: NO_PLATFORM_LANE }); + commit(withPolicy, GREEN); + const p = lanes(withPolicy); + expect(p.err).not.toContain('PREGATE_NO_LANE'); + expect([p.status, p.row.status, p.row.code]).toEqual([n.status, n.row.status, n.row.code]); + expect([p.status, p.row.status, p.row.code]).toEqual([EXIT.ok, 'warn', 'PREGATE_NO_POLICY']); + expect(p.ran).toContain('test/util.test.js'); + expect(p.row.detail).toMatch(/\d+ tests ran in \d+ file\(s\)/); + expect(p.lines.some(l => l.startsWith('PREGATE_STARTER: '))).toBe(true); + }); + + // Value: protects=the warning never hides a red test under a lane-less policy; fails_when=the timeout-only branch returns warn for a + // failing lane; why_new=new branch; seam=none; no_claim=none + test('timeout-only policy: a red selected test beside an unmapped file is PREGATE_LANE_FAILED', () => { + const repo = fixture({ pregate: { $schema: SCHEMA, timeout_s: 1800 }, baseFiles: NO_PLATFORM_LANE }); + commit(repo, RED); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']); + expect(r.ran).toContain('test/util.test.js'); + }); + + // Value: protects=a fully mapped change prints no empty "have no test lane" line under no policy or a lane-less one; fails_when=the + // no-lane line is rendered for an empty list; why_new=the mode switch is new; seam=none; no_claim=none + test.each([['no policy', null], ['timeout-only', { $schema: SCHEMA, timeout_s: 1800 }]])('%s: a fully mapped change passes with no no-lane line', (_name, policy) => { + const repo = fixture({ pregate: policy, baseFiles: NO_PLATFORM_LANE }); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); + const r = lanes(repo); + expect([r.status, r.row.status]).toEqual([EXIT.ok, 'pass']); + expect(r.lines.some(l => l.includes('have no test lane'))).toBe(false); + }); + + // Value: protects=the starter names the policy that exists instead of claiming there is none; fails_when=the starter header says + // "no .gstack/pregate.json" while one is on the base; why_new=new wording; seam=none; no_claim=none + test('the starter says the active policy declares no lanes, not that there is no policy', () => { + const repo = fixture({ pregate: { $schema: SCHEMA, timeout_s: 1800 }, baseFiles: NO_PLATFORM_LANE }); + commit(repo, GREEN); + const r = lanes(repo); + const header = r.lines.find(l => l.startsWith('PREGATE_STARTER: ')) ?? ''; + expect(header).toContain('declares no lanes'); + expect(header).not.toContain('no .gstack/pregate.json on'); + }); +}); + +describe('warn: ["lanes"] never lets a red test pass (GST-775 bug 2)', () => { + const warnLanes = { ...BASE_PREGATE, warn: ['strays', 'lanes'] }; + + // Value: protects=the documented escape runs the selected tests and keeps a red lane fail; fails_when=the downgraded PREGATE_NO_LANE + // returns before the tests run (GBEV-22's fail-open); why_new=the warn path is new; seam=none; no_claim=none + test('a red selected test beside an unmapped file fails with PREGATE_LANE_FAILED', () => { + const repo = fixture({ pregate: warnLanes }); + commit(repo, RED); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']); + expect(r.ran).toContain('test/util.test.js'); + }); + + // Value: protects=only the missing-lane condition becomes the warning, after the tests ran green; fails_when=the warn row carries + // no test run, or the unmapped file is dropped from the row; why_new=new; seam=none; no_claim=none + test('a green run with an unmapped file is warn PREGATE_NO_LANE, naming the file, after the tests ran', () => { + const repo = fixture({ pregate: warnLanes }); + commit(repo, GREEN); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.ok, 'warn', 'PREGATE_NO_LANE']); + expect(r.ran).toContain('test/util.test.js'); + expect(r.row.detail).toMatch(/\d+ tests ran in \d+ file\(s\)/); + expect(r.row.detail).toContain('lib/orphan.js'); + expect(r.row.detail).toContain('downgraded to warn'); + }); + + // Value: protects=a selected test this machine cannot run stays a requires-remote obligation under the downgrade, never a warn or a + // pass; fails_when=withNoLaneWarning turns an unrun platform lane into warn; why_new=new arm; seam=none; no_claim=none + test('a selected test only a platform lane runs stays requires-remote, coded PREGATE_NO_LANE', () => { + const repo = fixture({ pregate: warnLanes }); + commit(repo, { ...UNMAPPED, 'test/windows-paths.test.js': "const test = require('node:test');\ntest('windows paths again', () => {});\n" }); + const r = lanes(repo); + expect([r.row.status, r.row.code]).toEqual(['requires-remote', 'PREGATE_NO_LANE']); + expect(r.row.detail).toContain('lib/orphan.js'); + }); +}); + +describe('a [] declaration does not skip a deleted test\'s owning suite (GST-775 bug 3)', () => { + const catchAll = { ...BASE_PREGATE, dependencies: { '**': [] } }; + + // Value: protects=gbrain-evals' "**": [] catch-all still runs a deleted test's owning suite; fails_when=the [] declaration clears + // the deleted test before removedPath(); why_new=the ordering is new; seam=none; no_claim=none + test('deleting a test under "**": [] runs its owning suite', () => { + const repo = fixture({ pregate: catchAll, baseFiles: { 'test/util-v2.test.js': UTIL_V2 } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.row.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + expect(r.ran).toContain('test/util-v2.test.js'); + }); + + // Value: protects=a file naming the deleted test that [] declares needs no lane (a durations manifest) does not stop the suite; + // fails_when=the namer reads as uncovered and the removal is cleared without its suite; why_new=new; seam=none; no_claim=none + test('a curation file that names the deleted test and is declared [] does not stop the owning suite', () => { + const repo = fixture({ pregate: catchAll, baseFiles: { 'test/util-v2.test.js': UTIL_V2, 'scripts/durations.json': '{"test/util.test.js": 1}\n' } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.row.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + expect(r.ran).toContain('test/util-v2.test.js'); + }); +}); + +describe('negative controls: a policy that declares lanes still fails hard', () => { + // Value: protects=declaring dependencies, empty_lanes, selection or local, even empty, keeps PREGATE_NO_LANE a hard stop before any test runs; + // fails_when=the lane-less branch fires for every policy; why_new=the boundary is new; seam=none; no_claim=none + test.each([ + ['dependencies', { ...BASE_PREGATE }], + ['an empty dependencies object', { $schema: SCHEMA, timeout_s: 1800, dependencies: {} }], + ['an empty empty_lanes list', { $schema: SCHEMA, timeout_s: 1800, empty_lanes: [] }], + ['an empty selection object', { $schema: SCHEMA, timeout_s: 1800, selection: {} }], + ['an empty local list', { $schema: SCHEMA, timeout_s: 1800, local: [] }], + ])('a base policy with %s keeps PREGATE_NO_LANE', (_name, policy) => { + const repo = fixture({ pregate: policy, baseFiles: NO_PLATFORM_LANE }); + commit(repo, GREEN); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_NO_LANE']); + expect(r.ran).toBe(''); + expect(r.lines.some(l => l.startsWith('PREGATE_STARTER: '))).toBe(false); + }); + + // Value: protects=the user-owned policy follows the same boundary; fails_when=the user file is treated differently from the base + // copy; why_new=new; seam=none; no_claim=none + test('a user-owned policy that declares dependencies keeps PREGATE_NO_LANE', () => { + const repo = fixture({ pregate: null, baseFiles: NO_PLATFORM_LANE }); + commit(repo, GREEN); + const userPolicy = path.join(repo.home, 'projects', 'gst775slug', 'pregate.json'); + fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); + fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } })); + const r = lanes(repo, [], { GSTACK_PROJECT_SLUG: 'gst775slug' }); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_NO_LANE']); + }); + + // Value: protects=a --policy-from head file that adds a lane over a lane-less base makes the run strict; fails_when=the head merge + // keeps the base's declaresLanes only; why_new=the merge is new; seam=none; no_claim=none + test('--policy-from head adding dependencies over a timeout-only base keeps PREGATE_NO_LANE', () => { + const repo = fixture({ pregate: { $schema: SCHEMA, timeout_s: 1800 }, baseFiles: NO_PLATFORM_LANE }); + commit(repo, { ...GREEN, '.gstack/pregate.json': JSON.stringify({ $schema: SCHEMA, timeout_s: 1800, dependencies: { 'bin/tool': ['test/util.test.js'] } }) + '\n' }); + const r = lanes(repo, ['--policy-from', 'head']); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_NO_LANE']); + }); + + // Value: protects=without a [] declaration a deleted test still runs its owning suite and an unmapped deleted source still fails; + // fails_when=moving the removed-test handling waives non-test removals; why_new=new; seam=none; no_claim=none + test('a deleted source file under a lane-declaring policy still owes a lane', () => { + const repo = fixture(); + commit(repo, { 'test/util.test.js': null, 'lib/util.js': null }); + const r = lanes(repo); + expect(r.row.code).toBe('PREGATE_NO_LANE'); + expect(r.row.detail).toContain('lib/util.js'); + }); + + // Value: protects=warn: ["lanes"] does not downgrade a red lane even with every file mapped; fails_when=the lanes downgrade starts + // applying to PREGATE_LANE_FAILED; why_new=regression guard; seam=none; no_claim=none + test('warn: ["lanes"] with every file mapped keeps a red lane fail', () => { + const repo = fixture({ pregate: { ...BASE_PREGATE, warn: ['strays', 'lanes'] } }); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a - b;\n' }); + const r = lanes(repo); + expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']); + }); +}); diff --git a/test/pregate-starter.test.ts b/test/pregate-starter.test.ts index fd911b866..f21ec3b81 100644 --- a/test/pregate-starter.test.ts +++ b/test/pregate-starter.test.ts @@ -85,8 +85,8 @@ describe('no policy anywhere: warn plus a printed starter (never PREGATE_NO_LANE expect(r.out).toContain('no policy: lib/orphan.js scripts/report.js have no test lane'); }); - // Value: protects=a repository with a policy keeps the hard PREGATE_NO_LANE (base copy, and the user-owned file); fails_when=the - // warn branch fires whenever an entry is unmapped; why_new=the branch condition is new; seam=none; no_claim=none + // Value: protects=a repository with a policy that declares lanes keeps the hard PREGATE_NO_LANE (base copy, the user-owned file and a + // head bootstrap; a runner-only policy declares none since GST-775); fails_when=the warn branch fires whenever an entry is unmapped; why_new=the branch condition is new; seam=none; no_claim=none test('a base policy or a user-owned policy keeps PREGATE_NO_LANE as a hard failure', () => { const withBase = fixture(); commit(withBase, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' }); @@ -98,14 +98,14 @@ describe('no policy anywhere: warn plus a printed starter (never PREGATE_NO_LANE commit(withUser, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' }); const userPolicy = path.join(withUser.home, 'projects', 'starterslug', 'pregate.json'); fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); - fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}' })); + fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } })); const u = lanes(withUser, [], { GSTACK_PROJECT_SLUG: 'starterslug' }); expect(u.status).toBe(EXIT.fail); expect(u.err).toContain('(PREGATE_NO_LANE)'); expect(u.out).not.toContain('PREGATE_STARTER'); // --policy-from head with a head policy (no base copy) is strict too. const withHead = fixture({ pregate: null }); - commit(withHead, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ runner: 'node --test {files}' }) + '\n' }); + commit(withHead, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } }) + '\n' }); const h = lanes(withHead, ['--policy-from', 'head']); expect(h.status).toBe(EXIT.fail); expect(h.err).toContain('(PREGATE_NO_LANE)');