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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-30 03:29:41 +00:00
1 parent 9184ffd16c
commit de53aef03f
8 files changed
+143 -4

No files matched your search

+10
View File
@@ -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"
]
}
+11
View File
@@ -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 `<test> (always_run, .gstack/always-run.json)` and a glob that matches nothing as `<glob> 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.
+1 -1
View File
@@ -1 +1 @@
1.91.328.0
1.91.329.0
+1 -1
View File
@@ -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,
+6
View File
@@ -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 `<test> (always_run, .gstack/always-run.json)`, and a glob that matches no test as `<glob> 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`
```
+29 -1
View File
@@ -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<string>): { 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<string>();
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<string, string[] | '?'> = {};
const local = new Set<string>();
const remote: RemoteObligation[] = [];
+1 -1
View File
@@ -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",
+84
View File
@@ -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');
});
});