Files
gstack/test/gstack-checks.test.ts
T
Garry Tanandcapy-ai[bot] 923ed3cddc v1.91.359.0 feat(verify): /verify skill and gstack-checks bin (GST-116 PR 1) (#3672)
* verify: add /verify skill and gstack-checks (GST-116 PR 1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (pass 2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: adversarial review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: win32 NUL discard for the Go build check; unique Health Stack names (rule 23 grant)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog fragment for /verify

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>
2026-10-31 09:54:03 +00:00

205 lines
14 KiB
TypeScript

/**
* gstack-checks (/verify's loop, GST-116): detection, the consent boundary,
* pregate-owned test selection and the no-false-green verdicts, end to end on
* the pregate fixture repo (bare origin + clone, node --test runner, a Windows
* lane whose file list comes from a selection manifest command).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { declaredVerify, healthStack, manifestChecks, testVerdict } from '../lib/checks';
import type { CheckResult } from '../lib/pregate/types';
import { BASE_PREGATE, ROOT, cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
const dirs: string[] = [];
afterEach(() => {
while (repos.length) cleanup(repos.pop()!);
while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true });
});
const TYPECHECK = "const s=require('fs').readFileSync('lib/util.js','utf8');if(s.includes('TYPE_ERROR')){console.error('lib/util.js: TYPE_ERROR');process.exit(2)}";
const LIST = "require('fs').writeFileSync(process.env.SELECTION_MARK||'/dev/null','ran');if(process.argv.includes('--matrix'))console.log(JSON.stringify({files:['test/windows-paths.test.js']}));else console.log('test/windows-paths.test.js');\n";
function setup(runner = BASE_PREGATE.runner): { repo: FixtureRepo; mark: string; checks: (args: string[], env?: Record<string, string>) => ReturnType<typeof runBin> } {
const repo = makeFixtureRepo({
prefix: 'gstack-checks-fx-',
baseFiles: {
'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'node --test test/*.test.js', typecheck: 'node scripts/typecheck.js' } }, null, 2) + '\n',
'scripts/typecheck.js': TYPECHECK + '\n',
'scripts/windows-list.js': LIST,
'.gstack/pregate.json': JSON.stringify({ ...BASE_PREGATE, runner, dependencies: { ...BASE_PREGATE.dependencies, 'scripts/**': [], 'package.json': [] } }, null, 2) + '\n',
},
});
repos.push(repo);
const mark = path.join(repo.root, 'selection-ran');
return { repo, mark, checks: (args, env = {}) => runBin(repo, 'gstack-checks', [...args, '--base', 'main'], { SELECTION_MARK: mark, ...env }) };
}
const run = (s: ReturnType<typeof setup>, ...extra: string[]) => s.checks(['run', ...extra]);
describe('detection', () => {
// Value: protects=/verify reads the same gstack:verify: command gstack-verify-gate runs; fails_when=the TS port and the hook's sed
// disagree on a comment wrapper, backticks or trailing space; why_new=the port is new; seam=the hook's own sed line; no_claim=none
test('declaredVerify matches gstack-verify-gate\'s sed on the same text', () => {
const hook = fs.readFileSync(path.join(ROOT, 'bin/gstack-verify-gate'), 'utf8');
const sed = /CMD="\$\(sed -n '([^']+)'/.exec(hook)![1];
for (const text of ['<!-- gstack:verify: bun test -->', ' gstack:verify: ' + '\u0060npm run check\u0060 ', 'intro\ngstack:verify: make test\ngstack:verify: second', 'no declaration here', 'gstack:verify: ']) {
const r = spawnSync('sh', ['-c', `sed -n '${sed}' | head -1`], { input: text + '\n', encoding: 'utf8', timeout: 10_000 });
const viaSed = r.stdout.replace(/\n$/, '').split('-->')[0].replace(/\u0060/g, '').replace(/\s+$/, '');
expect(declaredVerify(text)).toBe(viaSed || null);
}
});
// Value: protects=a Health Stack section replaces manifest detection and leaves tests to pregate; manifests never invent a tool;
// fails_when=test rows run twice or a tsc/eslint command appears for a project that does not declare the tool; why_new=detection is
// new; seam=none; no_claim=which commands succeed
test('Health Stack rows and manifest checks', () => {
expect(healthStack('# x\n## Health Stack\n\n- typecheck: tsc --noEmit\n- test: bun test\n- shell: shellcheck *.sh\n## Next\n- lint: nope\n').map(c => [c.name, c.command]))
.toEqual([['typecheck', 'tsc --noEmit'], ['shell', 'shellcheck *.sh']]);
// A repeated key keeps both checks under their own names, and a tests row is pregate's, so status never reads one verdict for two checks.
expect(healthStack('## Health Stack\n- lint: eslint .\n- lint: stylelint "**/*.css"\n- lint: oxlint\n- tests: npm test\n').map(c => c.name)).toEqual(['lint', 'lint-2', 'lint-3']);
expect(healthStack('## Health Stack\n- lint: eslint .\n- lint-2: oxlint\n- lint: stylelint x\n').map(c => c.name)).toEqual(['lint', 'lint-2', 'lint-3']);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-checks-manifest-'));
dirs.push(dir);
fs.writeFileSync(path.join(dir, 'tsconfig.json'), '{}');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ scripts: { build: 'vite build' } }));
expect(manifestChecks(dir).map(c => c.command)).toEqual(['npm run build']);
fs.writeFileSync(path.join(dir, 'bun.lock'), '');
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ scripts: { lint: 'eslint .' }, devDependencies: { typescript: '5' } }));
fs.writeFileSync(path.join(dir, 'go.mod'), 'module x\n');
expect(manifestChecks(dir).map(c => c.command)).toEqual(['bunx tsc --noEmit', 'bun run lint', 'go vet ./...', `go build -o ${process.platform === 'win32' ? 'NUL' : '/dev/null'} ./...`]);
// Clippy is a rustup component Cargo.toml does not declare: it runs only when the repo opts in.
const rs = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-checks-rs-'));
dirs.push(rs);
fs.writeFileSync(path.join(rs, 'Cargo.toml'), '[package]\nname = "x"\n');
expect(manifestChecks(rs).map(c => c.command)).toEqual(['cargo check --all-targets']);
fs.writeFileSync(path.join(rs, 'Cargo.toml'), '[package]\nname = "x"\n[lints.clippy]\npedantic = "warn"\n');
expect(manifestChecks(rs).map(c => c.command)).toEqual(['cargo check --all-targets', 'cargo clippy --all-targets']);
const py = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-checks-py-'));
dirs.push(py);
expect(manifestChecks(py)).toEqual([]);
fs.writeFileSync(path.join(py, 'pyproject.toml'), '[tool.ruff]\nline-length = 100\n[tool.mypy]\nstrict = true\n');
expect(manifestChecks(py).map(c => c.command)).toEqual(['ruff check .', 'mypy .']);
});
});
describe('testVerdict', () => {
// Value: protects=only a pregate pass is green; fails_when=an unmapped file, a zero-run selection, a stall or an incomplete run reads
// PASS; why_new=the mapping is new; seam=pregate's CheckResult codes; no_claim=pregate's own classification
test('maps pregate lanes results without a false green', () => {
const r = (x: Partial<CheckResult>): CheckResult => ({ id: 'lanes', stage: 'tests', status: 'pass', inputs: {}, ...x } as CheckResult);
expect(testVerdict(r({ status: 'fail', code: 'PREGATE_NO_LANE', detail: '1 touched file(s) with no test lane: a.js' })).verdict).toBe('UNMAPPED');
expect(testVerdict(r({ status: 'warn', code: 'PREGATE_NO_POLICY', detail: 'x' })).verdict).toBe('UNMAPPED');
expect(testVerdict(r({ status: 'fail', code: 'PREGATE_LANE_FAILED', detail: 'ZERO-RUN: the runner reported 0 tests' })).verdict).toBe('ZERO_RUN');
expect(testVerdict(r({ status: 'incomplete', code: 'PREGATE_STALLED', detail: 'stalled' })).verdict).toBe('TIMEOUT');
expect(testVerdict(r({ status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: 'no test runner detected' })).verdict).toBe('INCOMPLETE');
expect(testVerdict(r({ status: 'incomplete', cause: 'environment', detail: 'not on PATH' } as Partial<CheckResult>)).verdict).toBe('ENV');
const owed = testVerdict(r({ status: 'requires-remote', remote: [{ lane: 'windows.yml/windows-shard', receipt: null } as any] }));
expect([owed.verdict, owed.notCovered]).toEqual(['PASS', ['windows.yml/windows-shard']]);
});
});
describe('gstack-checks on a fixture repo', () => {
// Value: protects=nothing runs before consent, including pregate's selection manifest command, and any change to a check or a
// selection command needs consent again; fails_when=an untrusted or edited command executes; why_new=the boundary is new;
// seam=the fixture's selection command writes a marker when it runs; no_claim=the trust grant log's contents
test('consent covers the checks and the selection manifest commands', () => {
const s = setup();
commit(s.repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
const refused = run(s);
expect(refused.status).toBe(3);
expect(refused.err).toContain('CHECKS_CONSENT_REQUIRED');
expect(fs.existsSync(s.mark)).toBe(false);
expect(s.checks(['detect']).out).toContain('TRUST untrusted');
expect(fs.existsSync(s.mark)).toBe(false);
expect(s.checks(['trust']).status).toBe(0);
const green = run(s);
expect(green.out).toContain('CHECK typecheck PASS');
expect(green.out).toContain('CHECK tests PASS');
expect(green.status).toBe(0);
expect(s.checks(['status']).out).toMatch(/^READY_FOR_SHIP tree=\w+ checks=typecheck,tests not_covered=/);
// A changed check command needs consent again.
write(s.repo.work, 'package.json', JSON.stringify({ name: 'fixture', scripts: { typecheck: 'node scripts/typecheck.js --strict' } }) + '\n');
expect(run(s).status).toBe(3);
expect(s.checks(['status']).out).toStartWith('NOT_READY');
s.repo.git('checkout', '-q', '--', 'package.json');
// A selection manifest command changed on the base needs consent before anything executes.
s.repo.git('checkout', '-q', 'main');
write(s.repo.work, '.gstack/pregate.json', JSON.stringify({ ...BASE_PREGATE, selection: { 'windows.yml/windows-shard': 'node scripts/windows-list.js --changed' }, dependencies: { ...BASE_PREGATE.dependencies, 'scripts/**': [], 'package.json': [] } }, null, 2) + '\n');
s.repo.git('commit', '-q', '-am', 'policy');
s.repo.git('push', '-q', 'origin', 'main');
s.repo.git('checkout', '-q', 'feature');
fs.rmSync(s.mark, { force: true });
const again = run(s);
expect(again.status).toBe(3);
expect(fs.existsSync(s.mark)).toBe(false);
}, 60_000);
// Value: protects=a source change that breaks an untouched test is a red to fix, not a pre-existing one; an unmapped file and a
// zero-run selection are never green; status never reports ready from a red or stale run; fails_when=any of those reads green;
// why_new=the verdicts are new; seam=pregate's real selection and runner; no_claim=output wording beyond the verdict tokens
test('reds, unmapped and status', () => {
const s = setup();
expect(s.checks(['trust']).status).toBe(0);
expect(s.checks(['status']).out).toStartWith('NOT_READY');
commit(s.repo, { 'lib/util.js': 'exports.add = (a, b) => a - b;\n' });
const red = run(s);
expect(red.status).toBe(1);
expect(red.out).toContain('CHECK tests FAIL');
expect(s.checks(['status']).out).toContain('tests=FAIL');
write(s.repo.work, 'lib/util.js', 'exports.add = (a, b) => a + b; // TYPE_ERROR\n');
const typeRed = run(s, '--only', 'typecheck');
expect(typeRed.out).toContain('CHECK typecheck FAIL');
expect(typeRed.out).toContain('| lib/util.js: TYPE_ERROR');
write(s.repo.work, 'lib/util.js', 'exports.add = (a, b) => a + b;\n');
write(s.repo.work, 'lib/orphan.js', 'exports.lonely = () => 2;\n');
const unmapped = run(s);
expect(unmapped.out).toContain('CHECK tests UNMAPPED');
expect(unmapped.status).toBe(1);
s.repo.git('checkout', '-q', '--', 'lib/orphan.js');
expect(run(s).status).toBe(0);
expect(s.checks(['status']).status).toBe(0);
write(s.repo.work, 'README.md', '# changed after the run\n');
expect(s.checks(['status']).out).toContain('the last run was at');
}, 60_000);
// Value: protects=two --only runs at one tree add up to READY, and a tree that changes during a run is never green; bad arguments
// run nothing; fails_when=a partial rerun drops the other verdicts, a run that edited the tree reads PASS, or an unknown --only name
// is ignored; why_new=the ledger merge and tree re-check are new; seam=a declared check that writes a file; no_claim=signals
test('partial runs, a tree moved mid-run and argument errors', () => {
const s = setup();
expect(s.checks(['trust']).status).toBe(0);
commit(s.repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
expect(run(s, '--only', 'typecheck').status).toBe(0);
expect(s.checks(['status']).out).toContain('tests=not run');
expect(run(s, '--only', 'tests').status).toBe(0);
expect(s.checks(['status']).out).toStartWith('READY_FOR_SHIP');
expect(run(s, '--only', 'nope').status).toBe(64);
expect(run(s, '--timeout', '0').status).toBe(64);
expect(s.checks(['--base']).status).toBe(64);
write(s.repo.work, 'CLAUDE.md', 'gstack:verify: node -e "require(\'fs\').writeFileSync(\'touched.txt\', \'x\')"\n');
commit(s.repo, {});
expect(s.checks(['trust']).status).toBe(0);
const moved = run(s, '--only', 'verify');
expect(moved.out).toContain('CHECK verify INCOMPLETE');
expect(moved.status).toBe(1);
}, 60_000);
// Value: protects=a selection that runs zero tests is not green; fails_when=exit 0 with no test counted reads PASS; why_new=the
// verdict is new; seam=pregate's runner summary (a bats stub printing an empty TAP plan); no_claim=node --test, which counts
// each file as a test, so an empty file there is not a zero run
test('zero-run selection', () => {
const z = setup('bats {files}');
expect(z.checks(['trust']).status).toBe(0);
const stub = path.join(z.repo.root, 'bin-stub');
fs.mkdirSync(stub, { recursive: true });
fs.writeFileSync(path.join(stub, 'bats'), "#!/bin/sh\nprintf '1..0\\n'\n", { mode: 0o755 });
write(z.repo.work, 'test/empty.test.js', "require('../lib/util.js');\n");
const zero = z.checks(['run', '--only', 'tests'], { PATH: `${stub}:${process.env.PATH ?? ''}` });
expect(zero.out).toContain('CHECK tests ZERO_RUN');
expect(zero.status).toBe(1);
}, 60_000);
});