Files
gstack/test/evidence-reuse.test.ts
T
Garry Tanandcapy-ai[bot] d2e63181d7 v1.94.17.0 ci-carry: guard carry, jobs-mapped lanes, aliases, per-file carry of runner-skipped files, path-skipped jobs (GST-1186, GST-1189, GST-1190) (#3929)
* feat(pregate): a guard that already passed at this tree carries from CI or the ledger (GST-1186)

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

* feat(ci-carry): jobs-mapped lanes, aliases, narrow-lane counts, --simulate proof, policy-base check on carried (GST-1189, GST-1190)

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

* fix(ci-carry): a file whose every test skipped on CI is not carried; tree.json records skipped_files (GST-1190)

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

* feat(ci-carry): path-skipped jobs count only under an unchanged gate; find --policy-from head --simulate (GST-1190)

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

* ship: name the pre-push guards in Step 17 and the early push (moved from #3923 per GSTA-258)

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

* ci-carry: per-file lane carry for runner-skipped files; review fixes (gate inputs, passed ignores CI rows, simulate consent, pull_request-only skips, guard not-owed)

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

* ship: Step 17 names the pre-push guards without a code this branch cannot print

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

* pregate: gstack's bun run test lane runs its runner-skipped files with per_file

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

* ci-carry: a per_file remainder must run tests here and the carried runs must still stand (l6c-p2)

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

* ci-carry: read record-ci from its own last record line; the remainder's count must come from this run's row (l6c-p2 red-team)

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

* test(ci-tree-reuse): type the skipped_files fixture for the test typecheck ratchet

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

* Revert "pregate: gstack's bun run test lane runs its runner-skipped files with per_file"

This reverts commit fafbae5b9b.

* ci-carry: ship the skipped_files reader inert; gstack's writer and per_file move to the adoption PR; Step 17 keeps its refs/heads marker

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

* docs: guard carry, per_file and the too-old message in pregate and troubleshooting (docs audit l6cdocs2)

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

* gstack-evidence record-ci: recheck the working tree after the post-remainder run recheck (#3929 second fix-check)

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

* windows-curation: exclude test/ship-ci-carry.test.ts like its ship-bin-fixture siblings (the path-scoped Windows check runs it at its first spawn)

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-11-03 14:14:50 +00:00

143 lines
9.2 KiB
TypeScript

/**
* gstack-evidence reuse and CI-sourced rows (GSTA-258 pieces 1-2, design fixes A4 and A5).
*
* run --reuse: a lane that passed at this exact working tree, command and execution identity never runs again;
* the newest matching row decides (a newer failure vetoes), the reused row keeps its original timestamp and the
* max age applies to it. record-ci: a lane CI carried is an explicit CI-sourced row for a run it verified itself, never a fictitious local run.
*/
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { findFilesBySuffix, gitIn } from './helpers/scratch-repo';
import { runBin } from './helpers/run-bin';
setDefaultTimeout(60_000);
const EVIDENCE = path.join(import.meta.dir, '..', 'bin', 'gstack-evidence');
let home: string;
let repo: string;
let counter: string;
const run = (args: string[]) => runBin(EVIDENCE, args, { cwd: repo, env: { GSTACK_HOME: home } });
const ledger = () => findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')[0]!;
const rows = (): any[] => fs.readFileSync(ledger(), 'utf8').trim().split('\n').map(l => JSON.parse(l));
const rewrite = (edit: (r: any[]) => any[]) => fs.writeFileSync(ledger(), edit(rows()).map(r => JSON.stringify(r)).join('\n') + '\n');
const executions = () => (fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8').length : 0);
const lane = (pass: number, fail = 0) => `printf x >> '${counter}'; printf ' ${pass} pass\\n ${fail} fail\\nRan ${pass + fail} tests across 1 file. [1.00ms]\\n' >&2; exit ${fail ? 1 : 0}`;
const wtree = () => runBin(path.join(import.meta.dir, '..', 'bin', 'gstack-wtree'), [], { cwd: repo }).stdout.trim();
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-reuse-home-'));
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-reuse-repo-'));
counter = path.join(home, 'executions');
gitIn(repo, 'init -q -b main');
fs.writeFileSync(path.join(repo, 'src.txt'), 'v1\n');
gitIn(repo, 'add src.txt');
gitIn(repo, 'commit -q -m init');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
});
describe('gstack-evidence run --reuse (A5)', () => {
// Value: protects=a lane that passed at this tree never reruns, and the reuse is visible; fails_when=--reuse runs the suite again, or reports a pass it never recorded;
// why_new=GSTA-258 piece 2; seam=the ledger and the wtree fingerprint; no_claim=cross-machine reuse (CI carries that)
test('a pass at the same tree, command and identity is reused with its original timestamp and count', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
const first = rows().at(-1);
const again = run(['run', '--reuse', '--label', 'unit', '--', lane(4)]);
expect(again.status).toBe(0);
expect(executions()).toBe(1);
expect(again.stderr).toContain(`gstack-evidence: reused label=unit tree=${wtree().slice(0, 12)} from=${first.ts}`);
expect(again.stderr).toMatch(/gstack-evidence: recorded label=unit exit=0 tests_ran=4 log=\S+/);
const copy = rows().at(-1);
expect(copy.ts).toBe(first.ts);
expect(copy.reused_from).toBe(first.ts);
expect(copy.identity).toBe(first.identity);
expect(typeof first.identity).toBe('string');
expect(run(['check', '--label', 'unit']).stdout).toContain('EVIDENCE: FRESH label=unit exit=0 tests_ran=4');
});
// Value: protects=only the newest matching row decides; fails_when=an older pass is reused under a newer failure on the same tree
test('a newer failure on the same tree vetoes an older pass; without --reuse nothing is reused', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--label', 'unit', '--', lane(3, 1)]).status).toBe(1);
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(3, 1)]).status).toBe(1);
expect(executions()).toBe(3);
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(5);
});
// Value: protects=reuse binds to the exact command, tree, count and identity; fails_when=a changed command, an edited file, a zero-run or unknown row, or another runtime reuses
test('another command, an edited tree, a zero-run or unknown count, or another identity runs again', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(2);
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(3);
rewrite(r => r.map(x => ({ ...x, tests_ran: 0 })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(4);
rewrite(r => r.map(x => ({ ...x, tests_ran: 'unknown' })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(5);
rewrite(r => r.map(x => ({ ...x, identity: 'f'.repeat(64) })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(6);
});
// Value: protects=the max age applies to the original pass, never to a refreshed copy; fails_when=reuse makes an old pass look new
test('a pass older than --max-age is not reused, and a reused copy keeps the age check honest', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 30 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(2);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 2 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--max-age', '1', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(3);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 3 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(3);
expect(run(['check', '--label', 'unit', '--max-age', '2']).stdout).toContain('EVIDENCE: STALE label=unit');
});
});
describe('gstack-evidence record-ci and carried (A4)', () => {
const ciRow = () => {
const tree = wtree();
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'], policy: 'origin/main@000000000000:.gstack/pregate.json' };
};
// Value: protects=a carried lane is an explicit CI row the FRESH check accepts by source, and the receipt lists it only at its own tree;
// fails_when=a check cannot tell a CI row from a local run, or a stale carry from an earlier head reaches ci_carried
test('a CI-sourced row is FRESH for its exact command, and carried lists it only at its tree', () => {
expect(run(['run', '--label', 'unit', '--', lane(2)]).status).toBe(0);
fs.appendFileSync(ledger(), JSON.stringify(ciRow()) + '\n');
const chk = run(['check', '--label', 'free', '--expect-cmd', 'bun run test', '--max-age', '24']);
expect(chk.status).toBe(0);
expect(chk.stdout).toContain('EVIDENCE: FRESH label=free exit=0 tests_ran=900');
expect(chk.stdout).toContain('source=ci runs=41/2');
expect(JSON.parse(run(['carried', '--base', 'main', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
expect(JSON.parse(run(['carried', '--base', 'release', '--json']).stdout)).toEqual([]);
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
gitIn(repo, 'commit -qam two');
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([]);
});
// Value: protects=record-ci finds and verifies the CI run itself, so no caller supplies run ids, a test count or a tree (/ship pass 1 red-team#1);
// fails_when=the forged call from that finding writes a FRESH carried row; seam=no origin, so the base's ship_lanes_from is unreadable
test('record-ci takes no run facts from its caller and records nothing without a verified run', () => {
const forged = run(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', wtree(), '--tests', '999', '--finished', new Date().toISOString(), '--', 'bun run test']);
expect(forged.status).toBe(2);
expect(forged.stderr).toContain('usage: gstack-evidence record-ci --label <L> --base <base> -- <command>');
const unverified = run(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
expect(unverified.status).toBe(1);
expect(unverified.stderr).toMatch(/^CARRY_NONE: config_unavailable: /m);
expect(findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')).toEqual([]);
});
});