From de53aef03fbd0811d51cb111c86f8c7ecaf7e57e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 29 Oct 2026 20:29:41 -0700 Subject: [PATCH] v1.91.329.0 pregate: always run the whole-tree tests listed in .gstack/always-run.json (GST-167 PR 1, GST-98) (#3626) * pregate: always run the whole-tree tests listed in .gstack/always-run.json A diff-selected run never maps a touched file to a scanner that reads the whole tree, so tripwires, ratchets and conformance tests passed the selected gate and failed CI after review (#3592, #3593). The lanes selection now adds every tracked test the list's globs name whenever the branch selects a test, owes a lane or edits the list. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate): restore the truncated pregate.md tail; cite seeded durations for the always-run list Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): [Unreleased] entry for the pregate always-run list 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> --- .gstack/always-run.json | 10 ++++ CHANGELOG.md | 11 +++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/pregate.md | 6 +++ lib/pregate/lane-selection.ts | 30 +++++++++++- package.json | 2 +- test/pregate-always-run.test.ts | 84 +++++++++++++++++++++++++++++++++ 8 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 .gstack/always-run.json create mode 100644 test/pregate-always-run.test.ts diff --git a/.gstack/always-run.json b/.gstack/always-run.json new file mode 100644 index 000000000..40e122e7b --- /dev/null +++ b/.gstack/always-run.json @@ -0,0 +1,10 @@ +{ + "tests": [ + "test/*tripwire*.test.ts", + "test/*ratchet*.test.ts", + "test/env-host-fence-conformance.test.ts", + "test/free-fixtures.test.ts", + "test/bun-entry-isolation.test.ts", + "test/egress-receipt-wiring.test.ts" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 63eb9ec49..188ec66c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.329.0] - 2026-10-30 + +**A selected pregate now runs the whole-tree tripwires and ratchets on every code branch, so they fail before review instead of in CI.** #3592 and #3593 passed their selected gate and then failed CI on exactly these scanners, because no touched file maps to a test that reads the whole tree. + +### Added +- **`.gstack/always-run.json`** (`{ "tests": [globs] }`): whole-tree tests that join the `lanes` selection whenever a branch selects any test, owes a lane, or edits the list. `--explain` prints each as ` (always_run, .gstack/always-run.json)` and a glob that matches nothing as ` matches no test file`. A docs-only branch adds nothing. A missing file means an empty list; a malformed one stops the check with a message naming the file. gstack's list names 15 tests (tripwires, ratchets, env-host fence conformance, free-fixtures, bun-entry-isolation, egress wiring); `host-conformance`, which runs `./setup`, stays off it. +- docs/pregate.md has a section on the file and why it is separate from `pregate.json` (older readers reject unknown keys there). + +### Tests +- `test/pregate-always-run.test.ts`: a code change adds the scanner the import graph never selects, and not without the file; a docs-only branch adds nothing; an edit to the list runs it and owes no lane; malformed lists stop; gstack's own list names only tracked tests and includes the GST-98 scanners. + ## [1.91.328.0] - 2026-10-30 **Most PRs no longer pay for a solo paid screen before the merge train runs the same cases.** `gstack-ship-queue ready` used to recommend `--screen` whenever a diff touched a skill's own cases, which is almost every PR, and the screens (16 to 19 a day at about $32 each) cost more than the train gates that rerun all 137 cases a few hours later. Of 41 full screens between Oct 20 and 29, the 3 that caught a real bug all came from diffs the selector can't narrow (an unknown dependency or a shared runtime input). The recommendation is now `--screen` only in that case; every other PR rides unscreened and the train gate's full census covers it. Because most PRs now ride unscreened, a train also stopped holding them back, so unscreened PRs fill the seats a screened PR leaves. diff --git a/VERSION b/VERSION index 084921575..b82bbbbd3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.328.0 +1.91.329.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 8a81b87b0..e159029f0 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.328.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.329.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 09a7ee76c..1cab42ce0 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -135,6 +135,12 @@ writes `[]`, which would clear a file without a test. A file with no nearby test, and a lane whose matrix a planner computes, is printed as a `TODO`. Review each entry before committing the file on the base branch. +## `.gstack/always-run.json` + +`{ "tests": [globs] }` names whole-tree tests that scan every file of a kind (tripwires, ratchets, conformance and fixture inventories). A diff-selected run never maps a touched file to them, so they used to pass the selected gate and then fail CI (GST-98). When a branch selects any test, owes a lane, or edits this file, the `lanes` selection adds every tracked test file a glob matches. `--explain` prints each one as ` (always_run, .gstack/always-run.json)`, and a glob that matches no test as ` matches no test file`. A docs-only branch adds nothing. + +The list lives in its own file, not in `pregate.json`, because older pregate readers reject unknown keys there. A repository that ignores `.gstack/` (gstack does) tracks the file with `git add -f`, as it does `pregate.json`. A missing file means an empty list. A file that is not JSON, or whose `tests` is not a list of non-empty strings, stops the check with that message. gstack's own list matches 15 test files; the 13 with a seed in `scripts/free-test-durations.json` (recorded 2026-10-19) total about 13 s there, and `bun-entry-isolation` and `launch-env-echo-ratchet` have no seed yet. + ## `gstack-pregate` ``` diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index ae0c68126..5ca134179 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -20,6 +20,29 @@ import { stampFileKind, versionStampOnly } from './stamp-only'; import type { PregateContext, RemoteObligation } from './types'; import type { Lane } from './workflows'; +/** Whole-tree tests every selection that runs a test must include (GST-98). Kept out of pregate.json so older readers, which reject unknown keys, keep working. */ +export const ALWAYS_RUN_PATH = '.gstack/always-run.json'; + +/** The tracked test files the `tests` globs in `.gstack/always-run.json` name, and the globs that name none. No file: none. */ +export function alwaysRunTests(repoRoot: string, testFiles: Iterable): { tests: string[]; unmatched: string[] } { + const file = path.join(repoRoot, ALWAYS_RUN_PATH); + if (!fs.existsSync(file)) return { tests: [], unmatched: [] }; + let parsed: unknown; + try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } + catch (error) { throw new Error(`${ALWAYS_RUN_PATH}: not valid JSON (${(error as Error).message})`); } + const globs = (parsed as { tests?: unknown } | null)?.tests; + if (!Array.isArray(globs) || globs.some(g => typeof g !== 'string' || !g)) throw new Error(`${ALWAYS_RUN_PATH}: "tests" must be a list of non-empty glob strings`); + const all = [...testFiles]; + const tests = new Set(); + const unmatched: string[] = []; + for (const glob of globs as string[]) { + const hits = all.filter(t => matchesAny([glob], t)); + if (!hits.length) unmatched.push(glob); + for (const t of hits) tests.add(t); + } + return { tests: [...tests].sort(), unmatched }; +} + /** A path the branch removed (deleted, or a rename's old name) that owes no lane, with why (`note`) and the files that lost their last test. */ export interface Removed { file: string; note: string; lostCoverage: string[] } @@ -210,7 +233,7 @@ export function selectLanes(ctx: PregateContext): Selection { const generator = ctx.registry.entries.find(e => e.command && matchesAny(e.inputs, file)); if (generator) { provenance.push(`${file}: covered by regen ${generator.id} (registry input)`); continue; } // The pin files are validated by pregate itself (validateConfig / validateRegistry) on every run. - if (file === PREGATE_CONFIG_PATH || file === GENERATED_PATH) { provenance.push(`${file}: pin file validated by pregate`); continue; } + if (file === PREGATE_CONFIG_PATH || file === GENERATED_PATH || file === ALWAYS_RUN_PATH) { provenance.push(`${file}: pin file validated by pregate`); continue; } // Reached only when testsFor mapped nothing, so a Markdown file a test reads never lands here. if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) { emptyLane.push(file); @@ -246,6 +269,11 @@ export function selectLanes(ctx: PregateContext): Selection { } noLane.push(file); } + if (tests.size || noLane.length || seeds.has(ALWAYS_RUN_PATH)) { + const always = alwaysRunTests(ctx.repoRoot, [...graph.tests].filter(t => !goneSet.has(t))); + for (const t of always.tests) if (!tests.has(t)) { tests.add(t); provenance.push(`${t} (always_run, ${ALWAYS_RUN_PATH})`); } + for (const glob of always.unmatched) provenance.push(`${ALWAYS_RUN_PATH}: ${glob} matches no test file`); + } const laneSets: Record = {}; const local = new Set(); const remote: RemoteObligation[] = []; diff --git a/package.json b/package.json index e4bc156c0..8e7981db2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.328", + "version": "1.91.329", "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-always-run.test.ts b/test/pregate-always-run.test.ts new file mode 100644 index 000000000..4d45043e9 --- /dev/null +++ b/test/pregate-always-run.test.ts @@ -0,0 +1,84 @@ +/** + * GST-167 PR 1 (GST-98): whole-tree tests named in .gstack/always-run.json join + * every selection that runs a test, because no touched file maps to a scanner + * that reads the whole tree. #3592 and #3593 passed their selected gate and + * then failed CI on exactly these tests. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { ALWAYS_RUN_PATH, alwaysRunTests } from '../lib/pregate/lane-selection'; +import { listRepoFiles, isTestFile } from '../lib/pregate/import-graph'; +import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SCANNER = "const test = require('node:test');\ntest('scans the tree', () => {});\n"; +const repos: FixtureRepo[] = []; +const fixture = (alwaysRun: unknown) => { + const r = makeFixtureRepo({ baseFiles: { 'test/tree-tripwire.test.js': SCANNER, ...(alwaysRun === undefined ? {} : { [ALWAYS_RUN_PATH]: typeof alwaysRun === 'string' ? alwaysRun : JSON.stringify(alwaysRun) }) } }); + repos.push(r); + return r; +}; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const lanes = (repo: FixtureRepo) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--only', 'lanes', '--explain']); + +describe('always_run selection', () => { + test('a code change adds the listed scanner the import graph never selects; without the file it is not added', () => { + const repo = fixture({ tests: ['test/*-tripwire.test.js', 'test/missing-*.test.js'] }); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }); + const r = lanes(repo); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('test/util.test.js ← lib/util.js'); + expect(r.out).toContain(`test/tree-tripwire.test.js (always_run, ${ALWAYS_RUN_PATH})`); + expect(r.out).toContain(`${ALWAYS_RUN_PATH}: test/missing-*.test.js matches no test file`); + + const bare = fixture(undefined); + commit(bare, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }); + const b = lanes(bare); + expect(b.status).toBe(EXIT.ok); + expect(b.out).toContain('test/util.test.js ← lib/util.js'); + expect(b.out).not.toContain('always_run'); + }); + + test('a docs-only branch adds nothing; editing the list itself runs it and owes no lane', () => { + const docs = fixture({ tests: ['test/*-tripwire.test.js'] }); + commit(docs, { 'docs/README.md': '# docs\n\nupdated\n' }); + const d = lanes(docs); + expect(d.status).toBe(EXIT.ok); + expect(d.out).not.toContain('always_run'); + + const list = fixture({ tests: ['test/*-tripwire.test.js'] }); + commit(list, { [ALWAYS_RUN_PATH]: JSON.stringify({ tests: ['test/tree-*.test.js'] }) }); + const l = lanes(list); + expect(l.status).toBe(EXIT.ok); + expect(l.out).toContain(`${ALWAYS_RUN_PATH}: pin file validated by pregate`); + expect(l.out).toContain(`test/tree-tripwire.test.js (always_run, ${ALWAYS_RUN_PATH})`); + expect(l.err).not.toContain('PREGATE_NO_LANE'); + }); + + test('a malformed list stops with a message naming the file and the fix', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-always-run-')); + try { + fs.mkdirSync(path.join(dir, '.gstack')); + fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), '{ not json'); + expect(() => alwaysRunTests(dir, [])).toThrow(`${ALWAYS_RUN_PATH}: not valid JSON`); + fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), JSON.stringify({ tests: ['ok', ''] })); + expect(() => alwaysRunTests(dir, [])).toThrow('"tests" must be a list of non-empty glob strings'); + fs.writeFileSync(path.join(dir, ALWAYS_RUN_PATH), JSON.stringify({ tests: 'test/*.ts' })); + expect(() => alwaysRunTests(dir, [])).toThrow('"tests" must be a list of non-empty glob strings'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test("gstack's own list: every glob names a tracked test, and the GST-98 scanners are on it", () => { + const tracked = listRepoFiles(ROOT).filter(isTestFile); + const own = alwaysRunTests(ROOT, tracked); + expect(own.unmatched).toEqual([]); + for (const t of ['test/spawnsync-timeout-tripwire.test.ts', 'test/script-invocation-tripwire.test.ts', 'test/egress-receipt-wiring.test.ts', + 'test/env-host-fence-conformance.test.ts', 'test/free-fixtures.test.ts', 'test/bun-entry-isolation.test.ts', 'test/module-size-ratchet.test.ts']) { + expect(own.tests).toContain(t); + } + expect(own.tests).not.toContain('test/host-conformance.test.ts'); + }); +});