mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* fix(pregate): Rust crate lanes and nested JS package runners
lanes reported PREGATE_NO_LANE for every touched .rs file and ran a nested
pnpm+vitest package's tests as `bun test` from the repo root.
- Rust: a .rs file of a crate (nearest Cargo.toml with [package]) is a test
under <crate>/tests/ or when it holds a #[cfg(test)]; every .rs file of a
crate and its Cargo.toml select the crate's tests and those of every crate
depending on it (lib/pregate/rust.ts). Selected tests run per crate as
`cargo test -p <name>` from the workspace root, judged on the summed
`test result:` lines (doc-tests included); zero tests stays ZERO-RUN and a
missing cargo is incomplete, like a missing Dart SDK.
- JS/TS: a test owned by a nested package.json that declares vitest or jest
runs from that package with package-relative paths, through the package
manager its packageManager field or nearest lockfile names (pnpm exec,
yarn, bun x, else npx). Root detection and a declared runner are unchanged.
- init notes that Rust tests run per crate; docs/pregate.md updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): parse Cargo manifests as TOML; follow workspace membership
The line scan of Cargo.toml missed dependents (a false green) and picked the
wrong workspace:
- an inherited renamed dependency (`base = { workspace = true }` with
`[workspace.dependencies] base = { package = "alpha", path = ... }`);
- dotted keys (`alpha.path = "../a"`), `[dependencies.<name>]` tables and
target-specific tables, matched by key name only;
- a crate the workspace excludes ran `cargo test -p` from the workspace root.
Manifests are now parsed with Bun.TOML. A dependency resolves by path
(relative to the manifest declaring it, or to the workspace root when
inherited), else by package name. A crate runs from the workspace it is a
member of under Cargo's rules (package.workspace, the nearest [workspace],
members globs, exclude, literal members, path dependencies of members),
else from its own directory. Nothing runs cargo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(pregate): the Rust fixture's cargo shim wins PATH on Windows and beside a real cargo
windows-free-shard (5) failed 'a touched src file runs its crate...' and
'zero tests is ZERO-RUN...' deterministically: the shim PATH was joined with
':' so Git Bash never saw the shim dir, and the runner's real cargo ran
against the fixture (exit 1, no test result lines). On POSIX hosts where
Bun and cargo share a directory (/usr/local/bin on Capy's image), the lane
puts Bun's directory first and the real cargo wins the same way.
Join with path.delimiter, add cargo.cmd for Bun.which on Windows, and on
POSIX put the shim first again in the lane's own bash through BASH_ENV.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): quote the crate name in cargoRunner
The crate name is read from the repo's Cargo.toml and was interpolated into
'cargo test -p <name>', which the lane runs through bash. A name like
'evil;touch pwned' ran the second command. Quote it like every file and cwd
the lane already passes; a new end-to-end case checks the name reaches cargo
as one argument and runs nothing.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(pregate): the nested-package pnpm shim wins PATH beside a real pnpm
'a touched file in a nested pnpm+vitest package runs vitest there' ran the
host's real pnpm wherever Bun and pnpm share a directory (/usr/local/bin on
Capy's image): the lane puts Bun's directory first, so pnpm installed vitest
from the network, dirtied the fixture tree and the check came back
incomplete. Put the shim first again in the lane's bash through BASH_ENV,
as the Rust fixture does.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): pregate Rust crate lanes and nested package runners (#3344)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): a crate name Cargo would reject never reaches the lane's shell
Port review (outside voice): quoting alone was not enough, because the lane
expands {files} after the runner string is built, so a crate named
'evil{files}' spliced the quoted file list into the quoted name and a file
name like tests/$(cmd).rs then ran. A name outside Cargo's grammar
(letters, digits, _ and -) now gets no runner: its files are unclaimed and
the lane is incomplete, as Cargo itself would refuse the manifest. A valid
name, Unicode included, still runs quoted.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): match Cargo members globs per path component, without a backtracking regex
Port review (outside voice): members globs went through ship-policy's
matchesAny, which escapes [...] classes (crates/[ab] matched nothing, so the
crate was no member, lost its inherited [workspace.dependencies] path and its
dependents' tests were never selected: a false green) and backtracks
exponentially on a hostile glob such as '*a*a...*ab' (over 5 s for one crate,
before any runner deadline). A small dynamic-programming matcher handles *,
?, [..] with negation and ranges, and ** across components, in
O(pattern x path).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): a plain #[test] in src is a Rust test; bound the cfg attribute scan
Port review (outside voice): only #[cfg(test)] modules and tests/ files
counted, so a dependent crate whose tests are plain #[test] or #[tokio::test]
functions in src never ran when its dependency changed (a false green). The
cfg scan also went quadratic on a run of unclosed '#[cfg(' (about 3 s on
120 KB). Test attributes now count, and the cfg predicate and its strings are
length-bounded so the scan stays linear.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(pregate): Rust test attributes, workspace membership and rejected crate names; changelog for the port review fixes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): match members globs by code point in one pass, and admit every crate past a size cap
Second port review round (outside voice): the matcher counted UTF-16 units, so
'crates/?' missed a crate directory named with a supplementary character and
dropped its inherited dependencies (a false green); and each unmatched '['
rescanned the rest of the glob (5 s for one crate with a million of them).
Tokenize by code point with a precomputed next-']' table, and past 4096
characters of members globs admit every crate under the workspace: a wrong
member fails loudly in cargo, a dropped one is silent. The new supervised
probe kills a super-linear matcher at 30 s instead of hanging the file.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): scan cfg attributes once without truncating them; an unclosable one counts as a test
Second port review round (outside voice): the length bound from the first
round dropped a valid cfg(any(test, feature = "<257 chars>")), so its crate's
tests were no longer selected (a false green). Read each attribute once to
its closing ']' and resume after it, which keeps the scan linear without a
bound; an attribute the scan cannot close (an unterminated string, no ']')
counts as a test, which selects more, never fewer.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): second port review round (code-point member globs, unbounded linear cfg scan)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): past the members size cap, a crate's run root is undecided and its tests unclaimed
Third port review round (outside voice): admitting every crate past the cap
made a non-member run as 'cargo test -p <name>' from the workspace root,
where a same-named member answered and its passing tests stood in for the
orphan's failing one (a false green). Keep the conservative membership for
dependency edges, but leave such a crate's workspace undecided (null):
planRuns leaves its files unclaimed and the lane incomplete.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(pregate): the Rust end-to-end runs put the cargo shim under Windows' own Path key
windows-free-shard (5) still failed both end-to-end cases at fe99b200 with
the same symptom (the runner's real cargo, exit 1, no test result lines).
runBin merges process.env, whose Windows key is 'Path', with the shim's
'PATH', so the child got two path variables and read the original. Write the
shim under the key process.env actually uses, as test/helpers/unreachable-gbrain.ts
and sync-gbrain-readiness-fixture.ts do.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(pregate): run the cargo stand-in cases on POSIX only
windows-free-shard (5) still failed both end-to-end cargo-lane cases after the
delimiter, cargo.cmd and Path-key repairs: the lane's Git Bash exits 1 with an
empty log, and that log is not visible from CI. Skip the two cases that run the
bash stand-in on win32 (test.skipIf, as other POSIX-shim pregate fixtures are
handled) and drop the unproven cargo.cmd and Path-key changes. Selection,
planning and the hostile-name case still run on Windows.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.197.0 stamp
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: vilano93 <244097767+vilano93@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
650 lines
45 KiB
TypeScript
650 lines
45 KiB
TypeScript
/**
|
||
* C8 `lanes` selection and the mechanical preflight checks, pinned on tokens,
|
||
* exit codes and the pregate.json contract. Fixture repos live under
|
||
* os.tmpdir() (test/helpers/pregate-fixture.ts). Incident replays named in
|
||
* the plan's P0-4 section, each caught locally before any remote gate:
|
||
* p0-4-stale-goldens regen: a changed input, an unchanged output
|
||
* p0-4-scratch-script strays: zz-probe.sh at the repository root
|
||
* p0-4-heavy-old-constant literals: a nightly-only test keeps the old number
|
||
* p0-4-windows-lane-unrun lanes: a Windows-only test file changes on Linux
|
||
* p0-4-pipe-hidden-failure lanes: `false | tail` under pipefail is red
|
||
*/
|
||
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 { changedLiterals, constantsIn } from '../lib/pregate/checks/literals';
|
||
import { parseSummary, planRuns } from '../lib/pregate/checks/lanes';
|
||
import { DEFAULT_CONFIG, validateConfig } from '../lib/pregate/config';
|
||
import { isDocsEmptyLane } from '../lib/pregate/docs-lane';
|
||
import { buildGraph, testsFor } from '../lib/pregate/import-graph';
|
||
import { CHECK_IDS } from '../lib/pregate/index';
|
||
import type { PregateContext } from '../lib/pregate/types';
|
||
import { parseWorkflow } from '../lib/pregate/workflows';
|
||
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||
|
||
const repos: FixtureRepo[] = [];
|
||
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; };
|
||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||
const pregate = (repo: FixtureRepo, args: string[]) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', ...args]);
|
||
const readOut = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8'));
|
||
const check = (repo: FixtureRepo, id: string) => readOut(repo).checks.find((c: any) => c.id === id);
|
||
|
||
describe('workflow lanes (parser over .github/workflows)', () => {
|
||
test('platform from runs-on, planner-computed matrix kept as an expression, test files from run steps and package scripts, manual jobs flagged', () => {
|
||
const text = `name: CI\njobs:\n unit:\n runs-on: ubuntu-24.04\n steps:\n - run: bun test test/a.test.ts\n win:\n runs-on: windows-latest\n strategy:\n matrix: \${{ fromJSON(needs.plan.outputs.matrix) }}\n steps:\n - run: |\n bun run test:win\n record:\n if: \${{ inputs.record }}\n runs-on: windows-latest\n steps:\n - run: bun run test:win --record\n serial:\n runs-on: macos-latest\n strategy:\n max-parallel: 1\n steps:\n - run: npm test\n`;
|
||
const lanes = parseWorkflow('ci.yml', text, 'linux', { 'test:win': 'bun test test/win.test.ts' });
|
||
expect(lanes.map(l => [l.id, l.platform, l.tier, l.runsTests, l.manual])).toEqual([
|
||
['ci.yml/unit', 'linux', 'unit', true, false], ['ci.yml/win', 'windows', 'platform', true, false], ['ci.yml/record', 'windows', 'platform', true, true], ['ci.yml/serial', 'macos', 'platform', true, false],
|
||
]);
|
||
expect(lanes[0]!.testFiles).toEqual(['test/a.test.ts']);
|
||
expect(lanes[1]!.matrixExpression).toBe('${{ fromJSON(needs.plan.outputs.matrix) }}');
|
||
expect(lanes[1]!.testFiles).toEqual(['test/win.test.ts']);
|
||
expect(parseWorkflow('ci.yml', text, 'win32')[1]!.tier).toBe('unit');
|
||
expect(parseWorkflow('ci.yml', text, 'win32')[0]!.tier).toBe('platform');
|
||
});
|
||
|
||
test('a verify-only shard step is not a test lane', () => {
|
||
const lanes = parseWorkflow('w.yml', 'jobs:\n v:\n runs-on: ubuntu-24.04\n steps:\n - run: bun run scripts/test-free-shards.ts --windows-only --ci-verify x\n');
|
||
expect(lanes[0]!.runsTests).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('import graph (static imports ∪ repo-path literals ∪ bin convention ∪ declarations)', () => {
|
||
test('imports, a joined literal read, the bin/<name> convention, a declared dependency and a deleted file all map; an orphan does not', () => {
|
||
const repo = fixture();
|
||
commit(repo, { 'lib/gone.js': null, 'test/gone.test.js': "const test = require('node:test');\ntest('reads a path', () => { require('node:fs').existsSync('lib/gone.js'); });\n" });
|
||
const g = buildGraph(repo.work, { declarations: BASE_PREGATE.dependencies, extraFiles: ['lib/gone.js'] });
|
||
expect(testsFor(g, 'lib/retry.js').tests).toEqual(['test/nightly/retry-heavy.test.js', 'test/retry.test.js']);
|
||
expect(testsFor(g, 'lib/config.json').tests).toEqual(['test/config-read.test.js']);
|
||
expect(testsFor(g, 'bin/tool').tests).toEqual(['test/tool-cli.test.js']);
|
||
const declared = buildGraph(repo.work, { declarations: { 'lib/orphan.js': ['test/util.test.js'] } });
|
||
expect(testsFor(declared, 'lib/orphan.js').provenance).toEqual(['test/util.test.js ← lib/orphan.js (declared)']);
|
||
expect(testsFor(g, 'lib/gone.js').tests).toEqual(['test/gone.test.js']);
|
||
expect(testsFor(g, 'lib/orphan.js')).toMatchObject({ tests: [], declaredNone: false });
|
||
expect(testsFor(g, 'docs/README.md', BASE_PREGATE.dependencies)).toMatchObject({ tests: [], declaredNone: true });
|
||
expect(testsFor(g, 'test/util.test.js').tests).toEqual(['test/util.test.js']);
|
||
});
|
||
|
||
test('markdown a test reads maps through the same literal and join edges; an unreferenced markdown file does not', () => {
|
||
const repo = fixture();
|
||
commit(repo, {
|
||
'prompts/system.md': '# system\n',
|
||
'notes/scratch.md': '# scratch\n',
|
||
'test/fixtures/sample.md': '# sample\n',
|
||
'test/prompt-doc.test.js': "const test = require('node:test');\nconst fs = require('node:fs');\ntest('reads the prompt', () => { fs.readFileSync('prompts/system.md', 'utf8'); });\n",
|
||
'test/fixture-doc.test.js': "const test = require('node:test');\nconst fs = require('node:fs');\nconst path = require('node:path');\ntest('reads the fixture', () => { fs.readFileSync(path.join('test', 'fixtures', 'sample.md'), 'utf8'); });\n",
|
||
});
|
||
const g = buildGraph(repo.work);
|
||
expect(testsFor(g, 'prompts/system.md').tests).toEqual(['test/prompt-doc.test.js']);
|
||
expect(testsFor(g, 'prompts/system.md').provenance[0]).toContain('(literal)');
|
||
expect(testsFor(g, 'test/fixtures/sample.md').tests).toEqual(['test/fixture-doc.test.js']);
|
||
expect(testsFor(g, 'test/fixtures/sample.md').provenance[0]).toContain('(literal)');
|
||
expect(testsFor(g, 'notes/scratch.md')).toMatchObject({ tests: [], declaredNone: false });
|
||
});
|
||
|
||
test('#3340: an extensionless import of a .jsx, .mts or .cts file maps to that file', () => {
|
||
const repo = fixture();
|
||
commit(repo, {
|
||
'src/components/Foo.jsx': 'export default function Foo() { return null; }\n',
|
||
'src/lib/m.mts': 'export const m = 1;\n',
|
||
'src/lib/c.cts': 'export const c = 1;\n',
|
||
'test/foo.test.jsx': "import Foo from '../src/components/Foo';\nimport { m } from '../src/lib/m';\nimport { c } from '../src/lib/c';\n",
|
||
});
|
||
const g = buildGraph(repo.work);
|
||
for (const f of ['src/components/Foo.jsx', 'src/lib/m.mts', 'src/lib/c.cts']) {
|
||
expect(testsFor(g, f).tests).toEqual(['test/foo.test.jsx']);
|
||
expect(testsFor(g, f).provenance[0]).toContain('(import)');
|
||
}
|
||
});
|
||
|
||
test('#3340: a directory import resolves to its index.jsx, index.tsx or index.mjs, not only the dir/** fallback', () => {
|
||
const repo = fixture();
|
||
commit(repo, {
|
||
'src/Bar/index.jsx': 'export default 1;\n',
|
||
'src/Bar/other.js': 'export default 2;\n',
|
||
'src/Baz/index.tsx': 'export default 1;\n',
|
||
'src/Baz/other.js': 'export default 2;\n',
|
||
'src/Qux/index.mjs': 'export default 1;\n',
|
||
'src/Qux/other.js': 'export default 2;\n',
|
||
'test/dirs.test.js': "import Bar from '../src/Bar';\nimport Baz from '../src/Baz';\nimport Qux from '../src/Qux';\n",
|
||
});
|
||
const g = buildGraph(repo.work);
|
||
for (const f of ['src/Bar/index.jsx', 'src/Baz/index.tsx', 'src/Qux/index.mjs']) {
|
||
expect(testsFor(g, f).tests).toEqual(['test/dirs.test.js']);
|
||
expect(testsFor(g, f).provenance[0]).not.toContain('/**');
|
||
}
|
||
for (const f of ['src/Bar/other.js', 'src/Baz/other.js', 'src/Qux/other.js']) expect(testsFor(g, f).tests).toEqual([]);
|
||
});
|
||
|
||
test('#3340: an import through a jsconfig paths alias reaches a .jsx file; an existing .js sibling still wins', () => {
|
||
const repo = fixture();
|
||
commit(repo, {
|
||
'jsconfig.json': '{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }\n',
|
||
'src/ui/Button.jsx': 'export default function Button() { return null; }\n',
|
||
'src/ui/Card.js': 'export default 1;\n',
|
||
'src/ui/Card.jsx': 'export default 2;\n',
|
||
'test/ui.test.jsx': "import Button from '@/ui/Button';\nimport Card from '@/ui/Card';\n",
|
||
});
|
||
const g = buildGraph(repo.work);
|
||
expect(testsFor(g, 'src/ui/Button.jsx').tests).toEqual(['test/ui.test.jsx']);
|
||
expect(testsFor(g, 'src/ui/Card.js').tests).toEqual(['test/ui.test.jsx']);
|
||
expect(testsFor(g, 'src/ui/Card.jsx').tests).toEqual([]);
|
||
});
|
||
|
||
test('python: absolute, from-import submodule, relative and parenthesised imports and mock.patch targets map; stdlib, comments and docstrings do not', () => {
|
||
const repo = fixture();
|
||
commit(repo, {
|
||
'pkg/__init__.py': '',
|
||
'pkg/core.py': 'import os\nfrom pkg.util import helper\n',
|
||
'pkg/util.py': 'def helper():\n return 1\n',
|
||
'pkg/multi.py': 'X = 1\n',
|
||
'pkg/patched.py': 'class Thing:\n def run(self):\n return 1\n',
|
||
'pkg/ghost.py': 'G = 1\n',
|
||
'pkg/sub/__init__.py': '',
|
||
'pkg/sub/sibling.py': 'S = 1\n',
|
||
'pkg/sub/leaf.py': 'from .. import core\nfrom . import sibling\n',
|
||
'tests/test_core.py': 'import pkg.core as core\n',
|
||
'tests/test_leaf.py': 'from pkg.sub import leaf\n',
|
||
'tests/test_multi.py': 'from pkg import (\n multi, # the module\n util,\n)\nimport json, os.path\n',
|
||
'tests/test_patch.py': "from unittest import mock\n\n@mock.patch('pkg.patched.Thing.run')\ndef test_run(run):\n pass\n",
|
||
'tests/test_ghost.py': '"""\nimport pkg.ghost\n"""\n# from pkg import ghost\nX = "from pkg import ghost"\n',
|
||
});
|
||
const g = buildGraph(repo.work);
|
||
expect(testsFor(g, 'pkg/core.py').tests).toEqual(['tests/test_core.py', 'tests/test_leaf.py']);
|
||
expect(testsFor(g, 'pkg/util.py').tests).toEqual(['tests/test_core.py', 'tests/test_leaf.py', 'tests/test_multi.py']);
|
||
expect(testsFor(g, 'pkg/sub/sibling.py').tests).toEqual(['tests/test_leaf.py']);
|
||
expect(testsFor(g, 'pkg/multi.py').tests).toEqual(['tests/test_multi.py']);
|
||
expect(testsFor(g, 'pkg/patched.py').provenance).toEqual(['tests/test_patch.py ← pkg/patched.py (literal)']);
|
||
expect(testsFor(g, 'pkg/sub/__init__.py').tests).toEqual(['tests/test_leaf.py']);
|
||
expect(testsFor(g, 'pkg/__init__.py').tests).toEqual(['tests/test_core.py', 'tests/test_leaf.py', 'tests/test_multi.py', 'tests/test_patch.py']);
|
||
expect(testsFor(g, 'pkg/ghost.py').tests).toEqual([]);
|
||
expect([...g.reverse.keys()].filter(k => k.endsWith('.py')).sort()).toEqual([
|
||
'pkg/__init__.py', 'pkg/core.py', 'pkg/multi.py', 'pkg/patched.py', 'pkg/sub/__init__.py', 'pkg/sub/leaf.py', 'pkg/sub/sibling.py', 'pkg/util.py',
|
||
]);
|
||
});
|
||
|
||
test('python: a package and a same-named module both map, statements after ; and : map, escaped triple quotes and deep modules map, a path literal does not hide its patch target, and adversarial input stays linear', () => {
|
||
const repo = fixture();
|
||
commit(repo, {
|
||
'app.py': 'A = 1\n',
|
||
'app/__init__.py': '',
|
||
'pkg/__init__.py': '',
|
||
'pkg/core.py': 'C = 1\n',
|
||
'pkg/patched.py': 'class Thing:\n pass\n',
|
||
'pkg.patched.Thing.run': 'a file whose name is also a patch target\n',
|
||
'tests/test_app.py': 'import app\n',
|
||
'tests/test_semicolon.py': 'import os; import pkg.core\n',
|
||
'tests/test_inline.py': 'def test_core():\n if True: from pkg.core import C\n',
|
||
'tests/test_patch.py': "from unittest import mock\n\n@mock.patch('pkg.patched.Thing.run')\ndef test_run(run):\n pass\n",
|
||
'tests/test_adversarial.py': `from ${'.'.repeat(100_000)}x\nfrom ${' '.repeat(100_000)}x\nX = '${Array.from({ length: 20_000 }, () => 'pkg').join('.')}'\n${'\\"""'.repeat(20_000)}\n`,
|
||
'tests/test_escaped.py': 'DOC = """text \\"""\nstill doc"""\nimport pkg.core\nTAIL = """x"""\n',
|
||
[`${Array.from({ length: 33 }, (_, i) => `d${i}`).join('/')}/leaf.py`]: 'L = 1\n',
|
||
'tests/test_deep.py': `import ${Array.from({ length: 33 }, (_, i) => `d${i}`).join('.')}.leaf\n`,
|
||
});
|
||
const g = buildGraph(repo.work);
|
||
expect(testsFor(g, 'app/__init__.py').tests).toEqual(['tests/test_app.py']);
|
||
expect(testsFor(g, 'app.py').tests).toEqual(['tests/test_app.py']);
|
||
expect(testsFor(g, 'pkg/core.py').tests).toEqual(['tests/test_escaped.py', 'tests/test_inline.py', 'tests/test_semicolon.py']);
|
||
expect(testsFor(g, `${Array.from({ length: 33 }, (_, i) => `d${i}`).join('/')}/leaf.py`).tests).toEqual(['tests/test_deep.py']);
|
||
expect(testsFor(g, 'pkg/patched.py').tests).toEqual(['tests/test_patch.py']);
|
||
expect(testsFor(g, 'pkg.patched.Thing.run').tests).toEqual(['tests/test_patch.py']);
|
||
expect(testsFor(g, 'pkg/__init__.py').tests).toEqual(['tests/test_adversarial.py', 'tests/test_escaped.py', 'tests/test_inline.py', 'tests/test_patch.py', 'tests/test_semicolon.py']);
|
||
});
|
||
});
|
||
|
||
describe('literals (pure)', () => {
|
||
test('exported constants with literal values are extracted; a changed one pairs old and new; functions are ignored', () => {
|
||
expect([...constantsIn("export const MAX = 3;\nexport const NAME = 'x';\nexports.LIMIT = 10;\nexport const fn = () => 1;\nTIMEOUT_MS = 5000\n").entries()]).toEqual([['MAX', '3'], ['NAME', "'x'"], ['LIMIT', '10'], ['TIMEOUT_MS', '5000']]);
|
||
expect(changedLiterals('f.ts', 'export const MAX = 3;\nconst x = { timeout: 5000 };', 'export const MAX = 4;\nconst x = { timeout: 9000 };')).toEqual([
|
||
{ name: 'MAX', old: '3', new: '4', file: 'f.ts' }, { name: 'timeout', old: '5000', new: '9000', file: 'f.ts' },
|
||
]);
|
||
});
|
||
test('runner summaries: bun, node --test, vitest, jest, pytest, zero-run', () => {
|
||
expect(parseSummary(' 12 pass\n 1 fail\nRan 13 tests across 2 files.')).toEqual({ ran: 13, failed: 1 });
|
||
expect(parseSummary('# tests 4\n# pass 4\n# fail 0')).toEqual({ ran: 4, failed: 0 });
|
||
expect(parseSummary('Tests 2 failed | 8 passed (10)')).toEqual({ ran: 10, failed: 2 });
|
||
expect(parseSummary('Tests: 1 failed, 7 passed, 8 total')).toEqual({ ran: 8, failed: 1 });
|
||
expect(parseSummary('===== 3 passed in 0.1s =====')).toEqual({ ran: 3, failed: 0 });
|
||
expect(parseSummary('No tests found')).toEqual({ ran: 0, failed: 0 });
|
||
expect(parseSummary('ok')).toEqual({ ran: null, failed: null });
|
||
});
|
||
test('config: unknown keys, secrets in warn, a runner without {files} and an uncontained glob are PREGATE_CONFIG_INVALID', () => {
|
||
const codes = (c: unknown) => validateConfig(c, CHECK_IDS).map(e => `${e.code}:${e.path}`);
|
||
expect(codes(BASE_PREGATE)).toEqual([]);
|
||
expect(codes({ warn: ['secrets'] })).toEqual(['PREGATE_CONFIG_INVALID:warn']);
|
||
expect(codes({ warn: ['bogus'] })).toEqual(['PREGATE_CONFIG_INVALID:warn']);
|
||
expect(codes({ runner: 'bun test' })).toEqual(['PREGATE_CONFIG_INVALID:runner']);
|
||
expect(codes({ dependencies: { '../x': [] } })).toEqual(['PREGATE_CONFIG_INVALID:dependencies["../x"]']);
|
||
expect(codes({ empty_lanes: 'docs/**' })).toEqual(['PREGATE_CONFIG_INVALID:empty_lanes']);
|
||
expect(codes({ empty_lanes: ['../x'] })).toEqual(['PREGATE_CONFIG_INVALID:empty_lanes[0]']);
|
||
expect(codes({ empty_lanes: ['notes/**/*.md'] })).toEqual([]);
|
||
expect(codes({ selection: { 'ci/job': 'x' } })).toEqual(['PREGATE_CONFIG_INVALID:selection["ci/job"]']);
|
||
expect(codes({ extra: 1 })).toEqual(['PREGATE_CONFIG_INVALID:extra']);
|
||
});
|
||
});
|
||
|
||
describe('docs/no-tests empty lane', () => {
|
||
const docs = [
|
||
'.work/round-3-plan.md', 'pkg/.work/notes.md', 'notes/redproof.md', 'notes/REDPROOF.md',
|
||
'notes/round-3.red-proof.md', 'docs/evidence.md', 'docs/guide.md', 'Docs/Guide.MD',
|
||
'documentation/index.md', 'plans/2026-10-16.md', 'README.md', 'lib/README.md',
|
||
'CHANGELOG.md', 'LICENSE', 'notes/implementation-plan.md', 'notes/planning.md', 'src/plan.md',
|
||
'prompts/system.md', 'src/notes.md', 'src/explanation.md', 'planet.md', 'my-docs/guide.md', 'notes/scratch.md',
|
||
];
|
||
const blocked = [
|
||
'lib/orphan.js', 'src/app.ts', 'bin/tool', 'scripts/gen.sh', 'notes/scratch.txt',
|
||
'docs/example.ts', 'test/fixtures/plan.md', 'test/plans/plan.md', '__tests__/redproof.md',
|
||
];
|
||
test('markdown qualifies, including unmarked names; source, test trees and non-markdown do not', () => {
|
||
for (const file of docs) expect(isDocsEmptyLane(file)).toBe(true);
|
||
for (const file of blocked) expect(isDocsEmptyLane(file)).toBe(false);
|
||
});
|
||
test('empty_lanes adds documentation globs and cannot clear source code or a test-tree file', () => {
|
||
expect(isDocsEmptyLane('notes/scratch.txt')).toBe(false);
|
||
expect(isDocsEmptyLane('notes/scratch.txt', ['notes/**/*.txt'])).toBe(true);
|
||
expect(isDocsEmptyLane('lib/orphan.js', ['**', 'lib/**', '**/*.js'])).toBe(false);
|
||
expect(isDocsEmptyLane('docs/example.ts', ['docs/**'])).toBe(false);
|
||
expect(isDocsEmptyLane('bin/tool', ['bin/**'])).toBe(false);
|
||
expect(isDocsEmptyLane('test/fixtures/plan.md', ['**/*.md', 'test/**'])).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('gstack-pregate (bin)', () => {
|
||
test('--help prints the exit table and both stages; unknown commands and checks are usage (2)', () => {
|
||
const repo = fixture();
|
||
const help = runBin(repo, 'gstack-pregate', ['--help']);
|
||
expect(help.out).toContain('Exit codes: 0 ok · 1 fail · 2 usage · 3 refused or needs a flag');
|
||
expect(help.out).toContain('stage 1 mechanical preflight (regen, secrets, strays, literals, timing, guards)');
|
||
expect(help.out).toContain('stage 2 test execution (lanes)');
|
||
expect(runBin(repo, 'gstack-pregate', ['bogus']).status).toBe(EXIT.usage);
|
||
expect(pregate(repo, ['--only', 'hermetic']).status).toBe(EXIT.usage);
|
||
});
|
||
|
||
test('a clean branch passes every check, runs the selected local lane under pipefail and writes a valid pregate.json with both clocks', () => {
|
||
const repo = fixture();
|
||
commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||
const r = pregate(repo, []);
|
||
expect(r.status).toBe(EXIT.ok);
|
||
expect(r.out).toMatch(/^preflight {2}regen {5}pass /m);
|
||
expect(r.out).toMatch(/^preflight {2}secrets {3}pass /m);
|
||
expect(r.out).toMatch(/^preflight {2}strays {4}pass /m);
|
||
expect(r.out).toMatch(/^preflight {2}literals {2}pass /m);
|
||
expect(r.out).toMatch(/^preflight {2}guards {4}pass .*no guards declared/m);
|
||
expect(r.out).toMatch(/^tests {6}lanes {5}pass .*1 tests ran in 1 file\(s\), exit 0/m);
|
||
expect(r.out).toMatch(/^stage 1 \(mechanical preflight\): \d+ ms$/m);
|
||
expect(r.out).toMatch(/^stage 2 \(test execution\): \d+ ms$/m);
|
||
expect(r.out).toContain('Tiers ran: unit; requires-remote: none');
|
||
expect(r.out).toMatch(/GSTACK_RESULT: skill=pregate status=complete run=.*pregate\.json/);
|
||
const out = readOut(repo);
|
||
expect(out.schema_version).toBe(1);
|
||
expect(out.tree).toMatch(/^[0-9a-f]{40}$/);
|
||
expect(out.checks.map((c: any) => [c.id, c.status, c.stage])).toEqual([['regen', 'pass', 'preflight'], ['secrets', 'pass', 'preflight'], ['strays', 'pass', 'preflight'], ['literals', 'pass', 'preflight'], ['timing', 'pass', 'preflight'], ['guards', 'pass', 'preflight'], ['lanes', 'pass', 'tests']]);
|
||
for (const c of out.checks) { expect(c.inputs.tree).toBe(out.tree); expect(typeof c.ms).toBe('number'); }
|
||
expect(check(repo, 'lanes').inputs.command).toMatch(/^[0-9a-f]{64}$/);
|
||
expect(out.verdict).toBe('pass');
|
||
expect(runBin(repo, 'gstack-artifact', ['validate', path.join(repo.work, '.gstack/tmp/pregate.json'), '--as', 'pregate']).status).toBe(EXIT.ok);
|
||
});
|
||
|
||
test('p0-4-stale-goldens: regen fails with the exact write command; the tree is untouched', () => {
|
||
const repo = fixture();
|
||
commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n" });
|
||
const r = pregate(repo, ['--stage', 'preflight']);
|
||
expect(r.status).toBe(EXIT.fail);
|
||
expect(r.out).toMatch(/^preflight {2}regen {5}fail .*1 stale: generated\/digest\.md \(REGEN_STALE\)/m);
|
||
expect(r.out).toContain('regen: regen digest: stale');
|
||
expect(r.out).toContain('fix: node gen.js # then commit generated/**');
|
||
expect(r.err).toContain('(REGEN_STALE)');
|
||
expect(fs.readFileSync(path.join(repo.work, 'generated/digest.md'), 'utf8')).toBe('# digest v1.0.0\n');
|
||
expect(readOut(repo).stage2_ms).toBeUndefined();
|
||
});
|
||
|
||
test('missing dependencies: regen is incomplete with REGEN_DEPS_MISSING and the install command, not REGEN_STALE', () => {
|
||
const repo = fixture({
|
||
registry: { entries: [{ id: 'digest', command: 'node gen.js', outputs: ['generated/**'], inputs: ['src/version.js', 'gen.js'] }] },
|
||
baseFiles: {
|
||
'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'node --test test/*.test.js' }, devDependencies: { 'fixture-dep': '1.0.0' } }, null, 2) + '\n',
|
||
'gen.js': "const fs=require('fs');const {VERSION}=require('./src/version.js');let style='plain';try{style=require('fixture-dep').style;}catch{}fs.writeFileSync('generated/digest.md',`# digest v${VERSION} (${style})\\n`);\n",
|
||
'generated/digest.md': '# digest v1.0.0 (fancy)\n',
|
||
},
|
||
});
|
||
commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.0.0' };\n// touched\n" });
|
||
const r = pregate(repo, ['--stage', 'preflight', '--only', 'regen']);
|
||
expect(r.status).not.toBe(EXIT.ok);
|
||
expect(r.out).toMatch(/^preflight {2}regen {5}incomplete .*dependencies missing; run bun install, then rerun the check \(1 entry unverified: digest\) \(REGEN_DEPS_MISSING\)/m);
|
||
expect(r.err).toContain('(REGEN_DEPS_MISSING)');
|
||
expect(r.out + r.err).not.toContain('REGEN_STALE');
|
||
});
|
||
|
||
test('p0-4-scratch-script: zz-probe.sh at the root fails strays (downgraded to warn only when the base config says so); a conventional root file passes', () => {
|
||
const strict = fixture({ pregate: { ...BASE_PREGATE, warn: [] } });
|
||
commit(strict, { 'zz-probe.sh': '#!/bin/sh\necho hi\n', 'CONTRIBUTING.md': '# c\n', 'notes.txt': 'x\n', 'newtop/thing.js': 'module.exports = 1;\n' });
|
||
const r = pregate(strict, ['--stage', 'preflight', '--only', 'strays']);
|
||
expect(r.status).toBe(EXIT.fail);
|
||
expect(r.out).toMatch(/^preflight {2}strays {4}fail .*2 stray file\(s\): notes\.txt zz-probe\.sh \(PREGATE_STRAYS\)/m);
|
||
expect(r.out).toContain('strays: zz-probe.sh: scratch name (zz-probe.sh)');
|
||
expect(r.out).toContain('strays: notes.txt: new file at the repository root');
|
||
expect(r.out).toContain('newtop/thing.js: new top-level directory newtop/');
|
||
expect(r.out).not.toContain('CONTRIBUTING.md:');
|
||
const lenient = fixture();
|
||
commit(lenient, { 'zz-probe.sh': '#!/bin/sh\n' });
|
||
const w = pregate(lenient, ['--stage', 'preflight', '--only', 'strays']);
|
||
expect(w.status).toBe(EXIT.ok);
|
||
expect(w.out).toMatch(/^preflight {2}strays {4}warn .*downgraded to warn by origin\/main@/m);
|
||
});
|
||
|
||
test('p0-4-heavy-old-constant: a nightly-only test still asserting the old literal fails literals with file:line', () => {
|
||
const repo = fixture();
|
||
commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 9000;\nexports.retry = (fn) => fn();\n" });
|
||
const r = pregate(repo, ['--stage', 'preflight', '--only', 'literals']);
|
||
expect(r.status).toBe(EXIT.fail);
|
||
expect(r.out).toContain('literals: test/nightly/retry-heavy.test.js:3: TIMEOUT_MS still 5000 (now 9000 in lib/retry.js)');
|
||
expect(r.out).not.toContain('test/retry.test.js');
|
||
expect(r.err).toContain('(PREGATE_LITERALS)');
|
||
// Updating the consumer clears it.
|
||
commit(repo, { 'test/nightly/retry-heavy.test.js': "const test = require('node:test');\nconst assert = require('node:assert');\ntest('heavy', () => assert.strictEqual(require('../../lib/retry.js').TIMEOUT_MS, 9000));\n" });
|
||
expect(pregate(repo, ['--stage', 'preflight', '--only', 'literals']).status).toBe(EXIT.ok);
|
||
});
|
||
|
||
test('secrets: a credential in an added line fails and cannot be downgraded; a committed-then-removed line is clean', () => {
|
||
const repo = fixture({ pregate: { ...BASE_PREGATE, warn: ['secrets'] } });
|
||
const bad = pregate(repo, ['--stage', 'preflight', '--only', 'secrets']);
|
||
expect(bad.status).toBe(EXIT.refused);
|
||
expect(bad.err).toContain('secrets cannot be downgraded to warn');
|
||
const ok = fixture();
|
||
write(ok.work, 'lib/token.js', `exports.T = 'ghp_${'A'.repeat(36)}';\n`);
|
||
const r = pregate(ok, ['--stage', 'preflight', '--only', 'secrets']);
|
||
expect(r.status).toBe(EXIT.fail);
|
||
expect(r.out).toMatch(/^preflight {2}secrets {3}fail .*credential finding\(s\) in added lines \(github\.pat\) \(PREGATE_SECRETS\)/m);
|
||
expect(r.out).toContain('HIGH github.pat [secret]');
|
||
fs.rmSync(path.join(ok.work, 'lib/token.js'));
|
||
expect(pregate(ok, ['--stage', 'preflight', '--only', 'secrets']).status).toBe(EXIT.ok);
|
||
});
|
||
|
||
test('lanes: tracked planning and evidence markdown is docs/no-tests, and an unmapped source file still fails closed', () => {
|
||
// No .gstack/pregate.json on the base: documentation cannot be declared as [].
|
||
const docs = fixture({ pregate: null });
|
||
commit(docs, {
|
||
'.work/round-3-plan.md': '# plan\n\nImplementation notes, not a test subject.\n',
|
||
'notes/redproof.md': '# redproof\n\nThe lane failed, then this note recorded the green run.\n',
|
||
'docs/evidence.md': '# evidence\n',
|
||
});
|
||
const ok = pregate(docs, ['--only', 'lanes', '--explain']);
|
||
expect(ok.status).toBe(EXIT.ok);
|
||
expect(ok.err).not.toContain('PREGATE_NO_LANE');
|
||
expect(ok.out).toMatch(/docs\/no-tests: 3 documentation file\(s\)/);
|
||
expect(ok.out).toContain('.work/round-3-plan.md: docs/no-tests (empty lane)');
|
||
expect(ok.out).toContain('notes/redproof.md: docs/no-tests (empty lane)');
|
||
expect(ok.out).toContain('docs/evidence.md: docs/no-tests (empty lane)');
|
||
expect(ok.out).not.toContain('has no test lane');
|
||
|
||
const mixed = fixture({ pregate: null });
|
||
commit(mixed, {
|
||
'.work/round-3-plan.md': '# plan\n',
|
||
'lib/orphan.js': 'exports.lonely = () => 2;\n',
|
||
});
|
||
const bad = pregate(mixed, ['--only', 'lanes']);
|
||
expect(bad.status).toBe(EXIT.fail);
|
||
expect(bad.err).toContain('(PREGATE_NO_LANE)');
|
||
expect(bad.out).toContain('lanes: FAIL lanes — lib/orphan.js has no test lane');
|
||
expect(bad.out).toContain('docs/no-tests (empty lane): .work/round-3-plan.md');
|
||
expect(bad.out).not.toContain('.work/round-3-plan.md has no test lane');
|
||
|
||
const extra = fixture({ pregate: { empty_lanes: ['notes/**/*.txt'], runner: 'node --test {files}' } });
|
||
commit(extra, { 'notes/sketch.txt': 'a note that is not markdown\n' });
|
||
const declared = pregate(extra, ['--only', 'lanes']);
|
||
expect(declared.status).toBe(EXIT.ok);
|
||
expect(declared.err).not.toContain('PREGATE_NO_LANE');
|
||
expect(declared.out).toContain('docs/no-tests (empty lane): notes/sketch.txt');
|
||
});
|
||
|
||
test('lanes: unreferenced markdown is docs/no-tests; markdown a test reads selects that test', () => {
|
||
const unread = fixture({ pregate: null });
|
||
commit(unread, {
|
||
'prompts/system.md': '# system\n\nAn unmarked prompt, not a plan or a redproof.\n',
|
||
'notes/scratch.md': '# scratch\n',
|
||
});
|
||
const idle = pregate(unread, ['--only', 'lanes', '--explain']);
|
||
expect(idle.status).toBe(EXIT.ok);
|
||
expect(idle.err).not.toContain('PREGATE_NO_LANE');
|
||
expect(idle.out).toMatch(/docs\/no-tests: 2 documentation file\(s\)/);
|
||
expect(idle.out).toContain('prompts/system.md: docs/no-tests (empty lane)');
|
||
expect(idle.out).toContain('notes/scratch.md: docs/no-tests (empty lane)');
|
||
expect(idle.out).not.toContain('has no test lane');
|
||
|
||
const named = fixture({ pregate: null });
|
||
named.git('checkout', '-q', 'main');
|
||
write(named.work, 'prompts/system.md', '# system\n');
|
||
write(named.work, 'test/fixtures/sample.md', '# sample\n');
|
||
write(named.work, 'test/prompt-doc.test.js', "const test = require('node:test');\nconst fs = require('node:fs');\ntest('reads the prompt', () => { fs.readFileSync('prompts/system.md', 'utf8'); });\n");
|
||
write(named.work, 'test/fixture-doc.test.js', "const test = require('node:test');\nconst fs = require('node:fs');\nconst path = require('node:path');\ntest('reads the fixture', () => { fs.readFileSync(path.join('test', 'fixtures', 'sample.md'), 'utf8'); });\n");
|
||
named.git('add', '-A');
|
||
named.git('commit', '-q', '-m', 'tests read markdown');
|
||
named.git('push', '-q', 'origin', 'main');
|
||
named.git('checkout', '-q', 'feature');
|
||
named.git('reset', '--hard', 'main');
|
||
commit(named, {
|
||
'prompts/system.md': '# system\n\nupdated\n',
|
||
'test/fixtures/sample.md': '# sample\n\nupdated\n',
|
||
});
|
||
const r = pregate(named, ['--only', 'lanes', '--explain']);
|
||
expect(r.status).toBe(EXIT.ok);
|
||
expect(r.out).toContain('test/prompt-doc.test.js ← prompts/system.md');
|
||
expect(r.out).toContain('test/fixture-doc.test.js ← test/fixtures/sample.md');
|
||
expect(r.err).not.toContain('PREGATE_NO_LANE');
|
||
expect(r.out).not.toContain('docs/no-tests');
|
||
});
|
||
|
||
test('lanes: a test that names a markdown file still runs that test', () => {
|
||
const repo = fixture({ pregate: null });
|
||
repo.git('checkout', '-q', 'main');
|
||
write(repo.work, 'test/readme-doc.test.js', "const test = require('node:test');\nconst fs = require('node:fs');\ntest('reads the readme', () => { fs.readFileSync('docs/README.md', 'utf8'); });\n");
|
||
repo.git('add', '-A');
|
||
repo.git('commit', '-q', '-m', 'name the doc');
|
||
repo.git('push', '-q', 'origin', 'main');
|
||
repo.git('checkout', '-q', 'feature');
|
||
repo.git('reset', '--hard', 'main');
|
||
commit(repo, { 'docs/README.md': '# docs\n\nupdated evidence\n' });
|
||
const r = pregate(repo, ['--only', 'lanes', '--explain']);
|
||
expect(r.status).toBe(EXIT.ok);
|
||
expect(r.out).toContain('test/readme-doc.test.js ← docs/README.md');
|
||
expect(r.err).not.toContain('PREGATE_NO_LANE');
|
||
expect(r.out).not.toContain('docs/no-tests');
|
||
});
|
||
|
||
test('lanes: a touched file with no test lane is FAIL lanes — <file> has no test lane; a declaration or an explicit [] clears it', () => {
|
||
const repo = fixture();
|
||
commit(repo, { 'lib/orphan.js': "exports.lonely = () => 2;\n", 'docs/guide.md': '# guide\n' });
|
||
const r = pregate(repo, ['--stage', 'tests']);
|
||
expect(r.status).toBe(EXIT.fail);
|
||
expect(r.out).toContain('lanes: FAIL lanes — lib/orphan.js has no test lane');
|
||
expect(r.out).not.toContain('docs/guide.md has no test lane');
|
||
expect(r.err).toContain('(PREGATE_NO_LANE)');
|
||
// A declaration on the BASE branch maps it; a declaration only on the branch is unreviewed and ignored.
|
||
commit(repo, { '.gstack/pregate.json': JSON.stringify({ ...BASE_PREGATE, dependencies: { ...BASE_PREGATE.dependencies, 'lib/orphan.js': ['test/util.test.js'] } }) + '\n' });
|
||
expect(pregate(repo, ['--stage', 'tests']).status).toBe(EXIT.fail);
|
||
const head = pregate(repo, ['--stage', 'tests', '--policy-from', 'head']);
|
||
expect(head.status).toBe(EXIT.ok);
|
||
expect(head.out).toContain('policy head (unreviewed) .gstack/pregate.json');
|
||
expect(head.out).toMatch(/^tests {6}lanes {5}pass /m);
|
||
});
|
||
|
||
test('lanes: deleted and renamed files select the tests that named them; subprocess targets and computed reads map through declarations and joined literals', () => {
|
||
const repo = fixture();
|
||
repo.git('mv', 'lib/util.js', 'lib/maths.js');
|
||
commit(repo, { 'lib/config.json': '{"limit": 11}\n', 'bin/tool': "#!/usr/bin/env node\nconsole.log('tool');\n// touched\n" });
|
||
const r = pregate(repo, ['--stage', 'tests', '--explain']);
|
||
expect(r.out).toContain('test/util.test.js ← lib/util.js (import) [renamed to lib/maths.js]');
|
||
expect(r.out).toContain('test/config-read.test.js ← lib/config.json (literal) [touched]');
|
||
expect(r.out).toContain('test/tool-cli.test.js ← bin/tool (literal) [touched]');
|
||
expect(r.out).toMatch(/^tests {6}lanes {5}fail /m); // util.test.js still requires ../lib/util.js, which is gone: the lane ran and caught it
|
||
expect(r.out).toContain('(PREGATE_LANE_FAILED)');
|
||
const del = fixture();
|
||
commit(del, { 'lib/config.json': null });
|
||
const d = pregate(del, ['--stage', 'tests', '--explain']);
|
||
expect(d.out).toContain('test/config-read.test.js ← lib/config.json (literal) [deleted]');
|
||
});
|
||
|
||
test('p0-4-windows-lane-unrun: a Windows-only test changed on Linux is a persisted requires-remote obligation named by workflow/job, and unsupported expressions are explained', () => {
|
||
const repo = fixture();
|
||
commit(repo, { 'test/windows-paths.test.js': "const test = require('node:test');\ntest('windows paths', () => { /* touched */ });\n" });
|
||
const r = pregate(repo, ['--stage', 'tests', '--explain']);
|
||
expect(r.status).toBe(EXIT.ok);
|
||
expect(r.out).toMatch(/^tests {6}lanes {5}requires-remote .*1 requires-remote obligation\(s\)/m);
|
||
expect(r.out).toContain('requires-remote windows.yml/windows-shard (windows): 1 test(s) — test/windows-paths.test.js');
|
||
expect(r.out).toContain('Tiers ran: unit; requires-remote: windows');
|
||
expect(r.out).toContain('lane windows.yml/windows-shard: 1 files from manifest `node scripts/windows-list.js`');
|
||
expect(r.out).toContain('PREGATE: requires-remote (1 uncleared)');
|
||
const out = readOut(repo);
|
||
expect(out.requires_remote).toEqual([{ lane: 'windows.yml/windows-shard', platform: 'windows', workflow: 'windows.yml', job: 'windows-shard', tests: ['test/windows-paths.test.js'], receipt: null }]);
|
||
// Without a manifest the planner-computed matrix is an unsupported expression: every selected test is conservatively in the lane.
|
||
const bare = fixture({ pregate: { ...BASE_PREGATE, selection: {} } });
|
||
commit(bare, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||
const b = pregate(bare, ['--stage', 'tests', '--explain']);
|
||
expect(b.out).toContain('unsupported workflow expression "${{ fromJSON(needs.windows-plan.outputs.matrix) }}" and no selection manifest; treated as unknown');
|
||
expect(b.out).toContain('requires-remote windows.yml/windows-shard (windows): 1 test(s) — test/util.test.js');
|
||
});
|
||
|
||
test('p0-4-pipe-hidden-failure: a runner whose failure hides behind | tail is red under pipefail; a zero-run lane is red with exit 0', () => {
|
||
const piped = fixture({ pregate: { ...BASE_PREGATE, runner: 'node --test {files} 2>&1 | tail -n 1' } });
|
||
commit(piped, { 'lib/util.js': "exports.add = (a, b) => a - b;\n" });
|
||
const r = pregate(piped, ['--stage', 'tests']);
|
||
expect(r.status).toBe(EXIT.fail);
|
||
expect(r.out).toMatch(/^tests {6}lanes {5}fail .*local lane exited 1 .*pipefail\) \(PREGATE_LANE_FAILED\)/m);
|
||
expect(r.out).toMatch(/lanes: log: .*lanes\.log/);
|
||
const zero = fixture({ pregate: { ...BASE_PREGATE, runner: 'true {files} && echo "# tests 0"' } });
|
||
commit(zero, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||
const z = pregate(zero, ['--stage', 'tests']);
|
||
expect(z.status).toBe(EXIT.fail);
|
||
expect(z.out).toMatch(/^tests {6}lanes {5}fail .*ZERO-RUN: the runner reported 0 tests/m);
|
||
});
|
||
|
||
test('trust boundary: without --allow-repo-commands the pin-file commands and the runner do not run; regen and lanes are incomplete, never pass', () => {
|
||
const repo = fixture();
|
||
commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n", 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||
const r = runBin(repo, 'gstack-pregate', ['--base', 'main']);
|
||
expect(r.status).toBe(EXIT.fail);
|
||
expect(r.out).toMatch(/^preflight {2}regen {5}incomplete .*repo commands not executed \(pass --allow-repo-commands\).*\(REPO_COMMANDS_NOT_ALLOWED\)/m);
|
||
expect(r.out).toContain('regen: COMMAND: digest="node gen.js"');
|
||
expect(r.out).toMatch(/^tests {6}lanes {5}incomplete .*1 local test file\(s\) selected but not run/m);
|
||
expect(r.out).toContain('GSTACK_RESULT: skill=pregate status=incomplete');
|
||
expect(fs.readFileSync(path.join(repo.work, 'generated/digest.md'), 'utf8')).toBe('# digest v1.0.0\n');
|
||
expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--policy-from', 'head']).status).toBe(EXIT.refused);
|
||
const gone = runBin(repo, 'gstack-pregate', ['--base', 'nope']);
|
||
expect(gone.status).toBe(EXIT.refused);
|
||
expect(gone.err).toContain('(POLICY_SOURCE_UNAVAILABLE)');
|
||
});
|
||
|
||
test('a working-tree change during a check makes it incomplete (the fingerprint moved), and --json carries the file', () => {
|
||
const repo = fixture({ pregate: { ...BASE_PREGATE, runner: 'echo touched >> lib/util.js && node --test {files}' } });
|
||
commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" });
|
||
const r = pregate(repo, ['--stage', 'tests', '--json']);
|
||
expect(r.status).toBe(EXIT.fail);
|
||
const parsed = JSON.parse(r.out);
|
||
const lanes = parsed.checks.find((c: any) => c.id === 'lanes');
|
||
expect(lanes.status).toBe('incomplete');
|
||
expect(lanes.detail).toContain('working tree changed during the check');
|
||
expect(lanes.code).toBe('PREGATE_INCOMPLETE');
|
||
expect(parsed.ok).toBe(false);
|
||
});
|
||
|
||
test('lanes prints the lane list with platform tiers; validate and init cover the working-tree config', () => {
|
||
const repo = fixture();
|
||
const l = runBin(repo, 'gstack-pregate', ['lanes']);
|
||
expect(l.out).toContain('free-tests.yml/unit: tier=unit platform=linux runs-on=ubuntu-24.04 tests=yes (.github/workflows/free-tests.yml:4)');
|
||
expect(l.out).toContain('windows.yml/windows-shard: tier=platform platform=windows runs-on=windows-latest tests=yes matrix="${{ fromJSON(needs.windows-plan.outputs.matrix) }}"');
|
||
expect(l.out).toMatch(/^LANES: 3 jobs; tests 2; platform 1$/m);
|
||
expect(runBin(repo, 'gstack-pregate', ['validate']).out).toContain('PREGATE_CONFIG_VALID: .gstack/pregate.json');
|
||
write(repo.work, '.gstack/pregate.json', '{"warn": ["secrets"]}\n');
|
||
const v = runBin(repo, 'gstack-pregate', ['validate']);
|
||
expect(v.status).toBe(EXIT.fail);
|
||
expect(v.err).toContain('secrets cannot be downgraded to warn');
|
||
expect(runBin(repo, 'gstack-pregate', ['init']).status).toBe(EXIT.refused);
|
||
fs.rmSync(path.join(repo.work, '.gstack/pregate.json'));
|
||
expect(runBin(repo, 'gstack-pregate', ['init']).out).toContain('PREGATE_CONFIG_WRITTEN');
|
||
expect(runBin(repo, 'gstack-pregate', ['validate']).status).toBe(EXIT.ok);
|
||
});
|
||
});
|
||
|
||
describe('nested JS packages run their own runner from their own directory', () => {
|
||
const APP = {
|
||
'app/package.json': JSON.stringify({ name: 'app', scripts: { test: 'vitest run' }, devDependencies: { vitest: '^3.2.0' } }) + '\n',
|
||
'app/pnpm-lock.yaml': "lockfileVersion: '9.0'\n",
|
||
'app/src/sum.ts': 'export const sum = (a: number, b: number) => a + b;\n',
|
||
'app/src/sum.test.ts': "import { expect, test } from 'vitest';\nimport { sum } from './sum';\ntest('sum', () => expect(sum(1, 2)).toBe(3));\n",
|
||
};
|
||
/**
|
||
* A `pnpm` (and `yarn`) on PATH that reports its cwd and argv and prints a vitest summary for the files it is given.
|
||
* The lane puts its Bun's directory first on PATH, and that directory can hold a real pnpm (/usr/local/bin), which
|
||
* would install from the network; the lane's bash puts the shim first again through BASH_ENV.
|
||
*/
|
||
const pmShim = (repo: FixtureRepo): { PATH: string; BASH_ENV: string } => {
|
||
const dir = path.join(repo.root, 'shim');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
for (const tool of ['pnpm', 'yarn']) fs.writeFileSync(path.join(dir, tool), '#!/usr/bin/env bash\necho "ran in $(basename "$PWD"): $*"\necho " Test Files 1 passed (1)"\necho " Tests 3 passed (3)"\n', { mode: 0o755 });
|
||
fs.writeFileSync(path.join(dir, 'path.sh'), `PATH='${dir}':"$PATH"\n`);
|
||
return { PATH: `${dir}:${process.env.PATH ?? ''}`, BASH_ENV: path.join(dir, 'path.sh') };
|
||
};
|
||
const ctxFor = (repo: FixtureRepo, env: Record<string, string>) => ({ repoRoot: repo.work, config: DEFAULT_CONFIG, env }) as unknown as PregateContext;
|
||
|
||
// Value: protects=a test owned by a nested package that declares vitest runs from that package with its lockfile's package manager
|
||
// and package-relative paths, while root-owned tests keep the root runner; fails_when=the root package.json alone decides again
|
||
// (the reported `bun test` from the repo root: vi.stubGlobal missing); why_new=only root package.json detection was tested;
|
||
// seam=none; no_claim=the runners themselves (plan only)
|
||
test('a nested vitest package plans its own group with the lockfile\'s package manager; root tests keep the root runner', () => {
|
||
const repo = fixture({ pregate: null, baseFiles: APP });
|
||
const ctx = ctxFor(repo, pmShim(repo));
|
||
expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js'])).toEqual({ groups: [
|
||
{ runner: 'node --test {files}', cwd: '', files: ['test/util.test.js'] },
|
||
{ runner: 'pnpm exec vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] },
|
||
], unclaimed: [], missing: [], noPubspec: [] });
|
||
// The lockfile is found walking up, and a packageManager field outranks it.
|
||
fs.rmSync(path.join(repo.work, 'app/pnpm-lock.yaml'));
|
||
write(repo.work, 'yarn.lock', '# yarn lockfile v1\n');
|
||
expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'yarn vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }]);
|
||
write(repo.work, 'app/package.json', JSON.stringify({ name: 'app', packageManager: 'pnpm@9.15.0', devDependencies: { jest: '^29.0.0' } }) + '\n');
|
||
expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'pnpm exec jest {files}', cwd: 'app', files: ['src/sum.test.ts'] }]);
|
||
// Value: protects=a package manager missing from PATH is incomplete naming it, never exit 127 judged as a test failure;
|
||
// fails_when=the PATH check is dropped; why_new=new detection path; seam=none; no_claim=a real pnpm install
|
||
expect(planRuns(ctxFor(repo, { PATH: path.join(repo.root, 'empty-path') }), ['app/src/sum.test.ts'])).toEqual({ groups: [], unclaimed: ['app/src/sum.test.ts'], missing: ['pnpm'], noPubspec: [] });
|
||
fs.rmSync(path.join(repo.work, 'yarn.lock'));
|
||
write(repo.work, 'app/package.json', JSON.stringify({ name: 'app', devDependencies: { vitest: '^3.2.0' } }) + '\n');
|
||
expect(planRuns(ctx, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: 'app', files: ['src/sum.test.ts'] }]);
|
||
});
|
||
|
||
// Value: protects=repos where root detection works today are unchanged: a nested package that declares no test runner stays with the
|
||
// root runner from the root, a root vitest stays `npx vitest run` from the root, and a declared runner takes every file;
|
||
// fails_when=every nested package.json splits a group or the root command changes; why_new=negative controls for the new
|
||
// nested detection; seam=none; no_claim=the runners themselves (plan only)
|
||
test('negative controls: a nested package without a test runner, a root vitest repo and a declared runner keep today\'s plan', () => {
|
||
const repo = fixture({ pregate: null, baseFiles: { ...APP, 'app/package.json': JSON.stringify({ name: 'app', scripts: { build: 'tsc' } }) + '\n' } });
|
||
const ctx = ctxFor(repo, pmShim(repo));
|
||
write(repo.work, 'bun.lock', '{}\n');
|
||
expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js']).groups).toEqual([{ runner: 'bun test {files}', cwd: '', files: ['app/src/sum.test.ts', 'test/util.test.js'] }]);
|
||
fs.rmSync(path.join(repo.work, 'bun.lock'));
|
||
write(repo.work, 'package.json', JSON.stringify({ name: 'fixture', devDependencies: { vitest: '^3.2.0' } }) + '\n');
|
||
write(repo.work, 'pnpm-lock.yaml', "lockfileVersion: '9.0'\n");
|
||
expect(planRuns(ctx, ['app/src/sum.test.ts', 'test/util.test.js']).groups).toEqual([{ runner: 'npx vitest run {files}', cwd: '', files: ['app/src/sum.test.ts', 'test/util.test.js'] }]);
|
||
const declared = { ...ctx, config: { ...DEFAULT_CONFIG, runner: 'bun test {files}' } } as PregateContext;
|
||
write(repo.work, 'app/package.json', APP['app/package.json']);
|
||
expect(planRuns(declared, ['app/src/sum.test.ts']).groups).toEqual([{ runner: 'bun test {files}', cwd: '', files: ['app/src/sum.test.ts'] }]);
|
||
});
|
||
|
||
// Value: protects=the reported case end to end: a touched source file in a nested pnpm+vitest package runs `pnpm exec vitest run`
|
||
// in that package with the package-relative path and passes on vitest's summary; fails_when=the lane runs from the repo root or
|
||
// with the root runner; why_new=no nested package run existed; seam=none; no_claim=a real pnpm or vitest (PATH shim)
|
||
test('a touched file in a nested pnpm+vitest package runs vitest there', () => {
|
||
const repo = fixture({ pregate: null, baseFiles: APP });
|
||
// No workflows, so the Windows lane's unknown matrix cannot turn the selection into a requires-remote obligation.
|
||
commit(repo, { '.github/workflows/free-tests.yml': null, '.github/workflows/windows.yml': null }, 'no workflows');
|
||
repo.git('push', '-q', 'origin', 'HEAD:main');
|
||
repo.git('fetch', '-q', 'origin');
|
||
commit(repo, { 'app/src/sum.ts': 'export const sum = (a: number, b: number) => b + a;\n' });
|
||
const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--only', 'lanes'], pmShim(repo));
|
||
expect(r.out).toMatch(/^tests {6}lanes {5}pass .*3 tests ran in 1 file\(s\), exit 0/m);
|
||
expect(r.out).toContain("ran: (cd 'app' && pnpm exec vitest run 'src/sum.test.ts')");
|
||
expect(fs.readFileSync(/log: (\S+)/.exec(r.out)![1]!, 'utf8')).toContain('ran in app: exec vitest run src/sum.test.ts');
|
||
expect(r.status).toBe(EXIT.ok);
|
||
}, 60_000);
|
||
});
|