mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
9184ffd16c
commit
de53aef03f
8 files changed
+143
-4
No files matched your search
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# 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
|
## [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.
|
**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,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
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
@@ -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`.
|
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.
|
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`
|
## `gstack-pregate`
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -20,6 +20,29 @@ import { stampFileKind, versionStampOnly } from './stamp-only';
|
|||||||
import type { PregateContext, RemoteObligation } from './types';
|
import type { PregateContext, RemoteObligation } from './types';
|
||||||
import type { Lane } from './workflows';
|
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. */
|
/** 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[] }
|
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));
|
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; }
|
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.
|
// 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.
|
// Reached only when testsFor mapped nothing, so a Markdown file a test reads never lands here.
|
||||||
if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) {
|
if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) {
|
||||||
emptyLane.push(file);
|
emptyLane.push(file);
|
||||||
@@ -246,6 +269,11 @@ export function selectLanes(ctx: PregateContext): Selection {
|
|||||||
}
|
}
|
||||||
noLane.push(file);
|
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 laneSets: Record<string, string[] | '?'> = {};
|
||||||
const local = new Set<string>();
|
const local = new Set<string>();
|
||||||
const remote: RemoteObligation[] = [];
|
const remote: RemoteObligation[] = [];
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"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.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user