mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(pregate): recognise bats test files so lanes does not fail in bats repos (#3161) gstack-pregate never treated a .bats file as a test, so in a repository whose tests are bats files the lanes check failed every run with PREGATE_NO_LANE: a .gstack/pregate.json dependency pointing at tests/foo.bats selected nothing, a selection manifest printing .bats paths yielded 0 files, and a bats run that executed no tests could not trip the zero-run rule. Recognise .bats in the test-file pattern and in the manifest-output parser, and read the bats TAP plan and pretty summary (as a last resort, after every existing runner's summary) so a run that reports zero tests is a failure. Fixes #3161. Cherry-picked from #3177 (d355c116b). Conflicts resolved as in the author's own merges (b09476beb,444f4e768): lane-selection.ts keeps main's isTestFile-based manifest parser, and lanes.ts carries the bats summary on main's supervised runner. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): a nested .bats file is selected and run end to end (#3161) Reviewer test for #3177: touched itself with no declaration, and through a declared dependency; a failing nested bats run reports the bats counts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): a wtree failure is WTREE_UNAVAILABLE, not an unresolvable base (#3197) The pregate consumers of the working-tree fingerprint (buildContext, withFingerprint, verifyPregate) move to lib/script-invocation.ts's {tree}|{error} form, and lib/regen.ts drops its string-returning wrapper. - A fingerprint failure was reported as "policy source unavailable: origin/<base> is not a resolvable commit" with git fetch as the fix. It is now WTREE_UNAVAILABLE: a launch failure lists each Windows bash candidate with its rejection reason and names GSTACK_GIT_BASH; a gstack-wtree exit keeps the repair it printed (set TMPDIR to a writable directory). - A fingerprint failing after a check is incomplete with that cause, not "working tree changed"; verify says the tree cannot be fingerprinted. - #3190's gstack-config read (test_backend) goes through runScriptSync, so Windows reaches it through Git Bash. The tripwire learns the bin-constant shape that let it through. - Under --json every context refusal is one envelope on stdout ({ok:false,status:'refused',code,detail,fix,docs}, exit 3); the run object carries ok and status too, and GSTACK_RESULT goes to stderr. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(pregate): gstack-pregate joins the Windows pregate job; pregate tests return to the Windows lane (#3197) - windows-pregate-e2e runs gstack-pregate run (preflight: regen, secrets through the gstack-redact bun bin, literals, guards) in both journeys, and asserts a missing GSTACK_GIT_BASH refuses with one WTREE_UNAVAILABLE --json envelope. - test/helpers/pregate-fixture.ts and restamp-fixture.ts runBin launch bins through lib/script-invocation.ts (bun bins as Bun argv, bash bins through Git Bash on Windows), which removes the "shebang via CreateProcess" reason for the pregate tests; they move from KNOWN_WINDOWS_INCOMPATIBLE to KNOWN_WINDOWS_SAFE. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor: list rescue refs and warn about CLAUDE.md skill lists gstack-doctor's plain report gains two rows. 'rescue refs' lists each refs/gstack/rescue/* commit in the install checkout, newest first, with its date, a needs-manual-resolution label when the rescue held unmerged files, and the shell-quoted inspect and delete commands bin/gstack-ff-update's banner prints. 'CLAUDE.md' warns when a gstack section in ~/.claude/CLAUDE.md or the project's CLAUDE.md enumerates skills (#3169) and prints the short block to paste instead; docs/troubleshooting.md#claude-md-skill-list explains it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor: name the Windows bash gstack resolves and what chose it On Windows (OSTYPE msys/cygwin) gstack-doctor adds a 'windows bash' row from lib/script-invocation.ts resolveWindowsBash, run through the new thin bin/gstack-windows-bash.ts with the bun on PATH: the resolved bash and the override or discovery step that chose it, a warn when GSTACK_GIT_BASH and GSTACK_BASH differ, and a fail carrying the BASH_UNAVAILABLE line (every candidate with its rejection reason) when none qualifies. Tests inject the platform (GSTACK_DOCTOR_PLATFORM) and the discovery inputs (GSTACK_DOCTOR_BASH_PROBE) so the row runs on every OS. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor --check: name the held-install recovery on Windows, not bin/gstack-upgrade bin/gstack-upgrade compares a pwd -P path (/c/...) with git's toplevel (C:/...) and refuses on every Git Bash install, so on Windows the revision row's upgrade fix (REVISION_BELOW_MINIMUM, the Capy-wrapper variant and REVISION_UNMET) names the held-install recovery forms from docs/troubleshooting.md#auto-update-held-install-recovery instead: the Git Bash form and the Windows PowerShell 5.1 form, each labelled by shell with the install path quoted for that shell. Other platforms keep bin/gstack-upgrade. A test pins both printed forms to the documented ones. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(skills): omit the plan-mode section on hosts without plan mode (#3168) generatePlanModeInfo now honours capabilities.planMode. Hosts that declare planMode:false (codex, agy, hermes, openclaw, and define-host's default: factory, kiro, slate, cursor, gbrain) no longer render the '## Plan Mode Safe Operations' and '## Skill Invocation During Plan Mode' preamble sections. Claude, copilot and opencode are unchanged. The codex, factory and agy ship goldens are regenerated. The clarity test that pinned the plan-mode entry-order prose on every host now pins it on planMode hosts and asserts its absence elsewhere (intentional contract change). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(skills): guard generated SKILL.md size at 100,000 chars on every host (#3199) A free test renders every host and fails when a generated SKILL.md exceeds 100,000 characters, naming host, skill, actual size, limit and the fix. The 89 current offenders are pinned at their exact size in test/fixtures/skill-md-size-allowlist.json: growth past a pin fails, and a shrink fails until the pin is lowered. The shared-preamble hoist is deferred (owner Garry, revisit 2026-11-15). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): keep #3177's bats tests beside the nested-directory test Moves #3177's bats describe block verbatim from test/pregate-selection.test.ts into test/pregate-bats-nested.test.ts, so pregate-selection.test.ts is unchanged on this branch and the in-flight field-list-2 pregate lane (capy/fl2-L6-pregate, which edits that file's imports and adds cases) merges without a conflict. No assertion changes. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor --check: keep bin/gstack-upgrade as the Windows fix (path spelling fixed on main) Reverts37bac07f8. Plan item D named the held-install recovery forms on Windows only until bin/gstack-upgrade stopped refusing Git Bash installs. bin/gstack-upgrade:63-67 now compares with gstack_path_same from bin/gstack-path-spelling.sh, pinned by test/gstack-upgrade-path-spelling.test.ts, so bin/gstack-upgrade is the right fix on every platform again. * test(skills): measure the size-guard pins on v1.91.109.0 #3225 (merged into this branch) grew 53 pinned skills: 3 characters each from the Aside driver resolver, and about 340 more in plan-design-review and plan-devex-review from the #3219 summary sections. The pins are this PR's starting measurement, so they are taken on the merged base; no skill this PR renders grew. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows): only the pregate tests that pass on windows-latest join the lane windows-free-tests run 38984824731 (this branch at8ed6da402) ran the moved files. gstack-regen and the new pregate-wtree-refusal passed. pregate-guards, -remote, -regate, -invalidation, -selection and -bats-nested launched gstack-pregate fine but fail on Linux-host expectations: their fixture lanes are Linux workflow jobs, which a Windows host records as requires-remote obligations. They go back to KNOWN_WINDOWS_INCOMPATIBLE with that reason instead of "shebang via CreateProcess". Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * windows curation: keep CI run ids out of reason prose (pii.phone false positive) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * skill-md size pins: follow #3231's review-army line (+21 chars on five inlining hosts) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor test: drop the reverted Windows upgrade-fix wording from item D's describe Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * skill-md size pins: follow #3226 (plan-devex-review +3324) and #3234 (autoplan +59) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * WTREE_UNAVAILABLE moves to lib/result-codes-release.ts (#3235 split; result-codes.ts stays at 796 of 800 lines); doctor fixture copies the release table; size pins follow #3235's review +1 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): fix wave PR 5 release notes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.117.0 fix: fix wave PR 5 (pregate bats + WTREE_UNAVAILABLE, doctor rows, SKILL.md size guard, planMode hosts) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(qa): an invalid-input probe breaks only the rule it tests The paid gate's qa-functional-cli-report failed again on #3236 (run 39001107574): the agent probed the digits-only amount rule with -5, which the positivity check rejected first, so the planted parseInt defect stayed hidden and it reported no defect. The contract map's invalid-input row now asks for each input to break only the rule it tests, so another check cannot reject it first. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: harjothkhara <48686985+harjothkhara@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
136 lines
9.2 KiB
TypeScript
136 lines
9.2 KiB
TypeScript
/**
|
|
* Pregate recognises .bats tests (#3161). The first block is #3177's own
|
|
* tests by @harjothkhara, moved here verbatim from
|
|
* test/pregate-selection.test.ts so that file stays untouched; the second is
|
|
* the reviewer test: a .bats file in a nested directory is selected and run
|
|
* end to end through bin/gstack-pregate, both when it is touched itself (no
|
|
* declaration) and through a declared dependency, and a failing nested bats
|
|
* run is a lane failure with the bats counts.
|
|
*/
|
|
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 { parseSummary } from '../lib/pregate/checks/lanes';
|
|
import { buildGraph, isTestFile, testsFor } from '../lib/pregate/import-graph';
|
|
import { laneFileSet } from '../lib/pregate/lane-selection';
|
|
import { parseWorkflow } from '../lib/pregate/workflows';
|
|
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, 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 check = (repo: FixtureRepo, id: string) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === id);
|
|
|
|
describe('bats tests (#3161)', () => {
|
|
const bats = (run: string) => parseWorkflow('b.yml', `jobs:\n t:\n runs-on: ubuntu-24.04\n steps:\n - run: ${run}\n`, 'linux')[0]!;
|
|
|
|
test('isTestFile and testsFor recognise .bats, and only a terminal .bats', () => {
|
|
for (const f of ['tests/foo.bats', 'test/a/b.bats', 'foo.bats']) expect(isTestFile(f)).toBe(true);
|
|
for (const f of ['tests/foo.bats.md', 'x.bats.sh', 'batsfile', 'tests/foo.bats.xml']) expect(isTestFile(f)).toBe(false);
|
|
const repo = fixture({ baseFiles: { 'scripts/foo.sh': '#!/bin/sh\ntrue\n', 'tests/foo.bats': '@test "x" { true; }\n' } });
|
|
const g = buildGraph(repo.work, { declarations: { 'scripts/**': ['tests/foo.bats'] } });
|
|
expect(testsFor(g, 'scripts/foo.sh').tests).toEqual(['tests/foo.bats']);
|
|
});
|
|
|
|
test('a selection manifest printing .bats paths yields them; .bats.md and .bats.xml are not taken', () => {
|
|
const ctx: any = { config: { selection: { 'b.yml/t': "printf 'tests/foo.bats\\ntests/x/bar.bats\\ntests/foo.bats.md\\ntests/foo.bats.xml\\ntests/foo.test.js.bats\\ntests/foo.spec.ts.bats\\n'" } }, allowRepoCommands: true, repoRoot: os.tmpdir(), timeoutMs: 30_000, env: process.env, explain: [] };
|
|
expect((laneFileSet(ctx, bats('bats tests/')) as string[]).sort()).toEqual(['tests/foo.bats', 'tests/foo.spec.ts.bats', 'tests/foo.test.js.bats', 'tests/x/bar.bats']);
|
|
});
|
|
|
|
test('parseSummary reads the bats TAP plan and the pretty summary', () => {
|
|
expect(parseSummary('1..1\nok 1 x')).toEqual({ ran: 1, failed: 0 });
|
|
expect(parseSummary('1..0')).toEqual({ ran: 0, failed: 0 });
|
|
expect(parseSummary('1..2\nok 1 a\nnot ok 2 b')).toEqual({ ran: 2, failed: 1 });
|
|
expect(parseSummary('3 tests, 1 failure')).toEqual({ ran: 3, failed: 1 });
|
|
expect(parseSummary('1 test, 0 failures')).toEqual({ ran: 1, failed: 0 });
|
|
// A bats-looking line inside output that ends with another runner's summary must not mask it.
|
|
expect(parseSummary('1..0\n===== 1 passed in 0.1s =====')).toEqual({ ran: 1, failed: 0 });
|
|
expect(parseSummary('0 tests, 0 failures\n===== 1 passed in 0.1s =====')).toEqual({ ran: 1, failed: 0 });
|
|
expect(parseSummary('1..0\n===== 1 passed in 0.1s =====', 'pytest {files}')).toEqual({ ran: 1, failed: 0 });
|
|
// With a bats runner the bats summary wins over stray words from test output.
|
|
for (const runner of ['bats {files}', 'CI=1 bats {files}', './node_modules/.bin/bats {files}']) {
|
|
expect(parseSummary('1..1\n# No tests found\nok 1 works', runner)).toEqual({ ran: 1, failed: 0 });
|
|
expect(parseSummary('1..0\n# 1 passed', runner)).toEqual({ ran: 0, failed: 0 });
|
|
}
|
|
for (const runner of ['npx bats {files}', 'pnpm exec bats {files}', 'bats {files}', 'CI=1 bats {files}', './node_modules/.bin/bats {files}', 'make bats']) expect(parseSummary('1..1\nok 1 No tests found', runner)).toEqual({ ran: 1, failed: 0 });
|
|
// Runners that merely contain the letters keep the generic behaviour.
|
|
for (const runner of ['bats-core {files}', 'node scripts/run-bats.js {files}', 'pytest {files}']) expect(parseSummary('1..1\nok 1 No tests found', runner)).toEqual({ ran: 0, failed: 0 });
|
|
});
|
|
|
|
test('end to end: a declared dependency selects the .bats file and runs it with the bats runner; a runner reporting 0 tests is ZERO-RUN', () => {
|
|
const repo = fixture({
|
|
pregate: { ...BASE_PREGATE, runner: 'bats {files}', dependencies: { ...BASE_PREGATE.dependencies, 'scripts/**': ['tests/foo.bats'] } },
|
|
baseFiles: {
|
|
'tests/foo.bats': '@test "x" { true; }\n',
|
|
'scripts/foo.sh': '#!/bin/sh\ntrue\n',
|
|
},
|
|
});
|
|
commit(repo, { 'scripts/foo.sh': '#!/bin/sh\n# touched\ntrue\n' });
|
|
expect(repo.git('diff', '--name-only', 'main...HEAD')).toBe('scripts/foo.sh');
|
|
const stub = (tap: string) => { const dir = path.join(repo.root, 'bin-stub'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'bats'), `#!/bin/sh\nprintf '%s\\n' '${tap}'\n`, { mode: 0o755 }); return { PATH: `${dir}:${process.env.PATH ?? ''}` }; };
|
|
const ok = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain'], stub('1..1\nok 1 x'));
|
|
expect(ok.status).toBe(EXIT.ok);
|
|
expect(ok.err).not.toContain('PREGATE_NO_LANE');
|
|
// The fixture's own Linux lane is remote on a non-Linux host, so the verdict may be requires-remote; the selected file and runner are what matter here.
|
|
expect(['pass', 'requires-remote']).toContain(check(repo, 'lanes').status);
|
|
expect(ok.out).toContain("ran: bats 'tests/foo.bats'");
|
|
const zero = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'], stub('1..0'));
|
|
expect(zero.status).toBe(EXIT.fail);
|
|
expect(zero.out).toMatch(/^tests {6}lanes {5}fail .*ZERO-RUN/m);
|
|
const masked = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'], stub('1..0\n# 1 passed'));
|
|
expect(masked.status).toBe(EXIT.fail);
|
|
expect(masked.out).toMatch(/^tests {6}lanes {5}fail .*ZERO-RUN/m);
|
|
});
|
|
});
|
|
|
|
const NESTED = 'tests/integration/cli/deep.bats';
|
|
|
|
function batsRepo(dependencies: Record<string, string[]> = {}): FixtureRepo {
|
|
return fixture({
|
|
pregate: { ...BASE_PREGATE, runner: 'bats {files}', dependencies: { ...BASE_PREGATE.dependencies, ...dependencies } },
|
|
baseFiles: { 'scripts/deep.sh': '#!/bin/sh\necho deep\n', [NESTED]: '@test "deep" { run bash scripts/deep.sh; [ "$output" = deep ]; }\n' },
|
|
});
|
|
}
|
|
|
|
/** A `bats` on PATH that records its argv and prints `tap`, exiting `exit`. */
|
|
function stubBats(repo: FixtureRepo, tap: string, exit = 0): { env: Record<string, string>; argv: string } {
|
|
const dir = path.join(repo.root, 'bin-stub');
|
|
const argv = path.join(repo.root, 'bats-argv.txt');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'bats'), `#!/bin/sh\nprintf '%s\\n' "$@" > '${argv}'\nprintf '%s\\n' '${tap}'\nexit ${exit}\n`, { mode: 0o755 });
|
|
return { env: { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ''}` }, argv };
|
|
}
|
|
|
|
const lanes = (repo: FixtureRepo) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: any) => c.id === 'lanes');
|
|
const run = (repo: FixtureRepo, env: Record<string, string>) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain'], env);
|
|
|
|
describe('nested .bats files end to end (#3161, reviewer test for #3177)', () => {
|
|
test('a touched nested .bats file selects itself with no declaration and runs under the bats runner', () => {
|
|
const repo = batsRepo();
|
|
commit(repo, { [NESTED]: '@test "deep" { run bash scripts/deep.sh; [ "$output" = deep ]; }\n@test "deeper" { true; }\n' });
|
|
const stub = stubBats(repo, '1..2\nok 1 deep\nok 2 deeper');
|
|
const r = run(repo, stub.env);
|
|
expect(r.err).not.toContain('PREGATE_NO_LANE');
|
|
expect(r.status).toBe(EXIT.ok);
|
|
expect(r.out).toContain(`${NESTED} (itself a test)`);
|
|
expect(r.out).toContain(`ran: bats '${NESTED}'`);
|
|
expect(fs.readFileSync(stub.argv, 'utf8').trim()).toBe(NESTED);
|
|
expect(lanes(repo).detail).toMatch(/^2 tests ran in 1 file\(s\)/);
|
|
});
|
|
|
|
test('a declared dependency on a nested .bats file selects it; a failing run reports the bats counts', () => {
|
|
const repo = batsRepo({ 'scripts/**': [NESTED] });
|
|
commit(repo, { 'scripts/deep.sh': '#!/bin/sh\necho deeper\n' });
|
|
const stub = stubBats(repo, '1..2\nok 1 a\nnot ok 2 deep', 1);
|
|
const r = run(repo, stub.env);
|
|
expect(r.err).not.toContain('PREGATE_NO_LANE');
|
|
expect(r.status).toBe(EXIT.fail);
|
|
expect(r.out).toContain(`${NESTED} ← scripts/deep.sh (declared)`);
|
|
expect(fs.readFileSync(stub.argv, 'utf8').trim()).toBe(NESTED);
|
|
expect(lanes(repo)).toMatchObject({ status: 'fail', code: 'PREGATE_LANE_FAILED' });
|
|
expect(lanes(repo).detail).toContain('1 failed of 2 ran');
|
|
});
|
|
});
|