fix(pregate): lanes and guards validate the install verdict like admission and return incomplete (INSTALL_UNVERIFIED, BUN_RESOLUTION_DRIFT) before launching; the checkout binds the 'none' verdict (integration item 8)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-15 01:32:46 +00:00
1 parent 79604e718f
commit 63c068a412
6 files changed
+120 -28

No files matched your search

+3 -2
View File
@@ -130,9 +130,10 @@ function supportedBun(gstackRoot: string): string {
return text.match(/^GSTACK_BUN_TESTED="([0-9.]+)"/m)?.[1] ?? '0.0.0';
}
interface VerdictProblem { detail: string; code: ResultCodeName; fix?: string }
export interface VerdictProblem { detail: string; code: ResultCodeName; fix?: string }
function verdictProblem(skill: string, gstackRoot: string, env: Env, bunPath: string, bunVersion: string, cwd?: string): VerdictProblem | null {
/** Why the install verdict for (checkout, project of `cwd`) does not admit `skill`; null when it does. `bunPath` '' skips the running-Bun check. */
export function verdictProblem(skill: string, gstackRoot: string, env: Env, bunPath: string, bunVersion: string, cwd?: string): VerdictProblem | null {
const src = sourceRoot(gstackRoot);
const project = installProject(src, cwd);
const file = findInstallVerdict(src, project, env);
+1
View File
@@ -45,6 +45,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', lines: listed, detail: `${selected.length} guard(s) not run (pass --allow-repo-commands): ${selected.map(g => g.id).join(', ')}`, fix: 'rerun with --allow-repo-commands (what a lane runs before reporting done, and what /ship passes)' };
}
const runtime = launchRuntime(ctx.repoRoot, ctx.env);
if (runtime.refusal) return { ...base, ...runtime.refusal, lines: [`runtime: ${runtime.label}`] };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-guards-'));
const runs: GuardRun[] = [];
for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir));
+1
View File
@@ -184,6 +184,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
const runner = detectRunner(ctx, sel.localTests);
if (!runner) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `no test runner detected for ${sel.localTests.length} selected file(s)`, fix: 'declare `runner` (with {files}) in .gstack/pregate.json' };
const runtime = launchRuntime(ctx.repoRoot, ctx.env);
if (runtime.refusal) return { ...base, ...runtime.refusal, lines: [`runtime: ${runtime.label}`] };
const r = await runLocal(ctx, runtime.env, runner, sel.localTests);
const runLines = [...lines, `runtime: ${runtime.label}`, `progress: ${adapterFor(runner) ?? 'none (no progress adapter for this runner; serial files and heartbeats only)'}`, `ran: ${r.command.length > 200 ? `${r.command.slice(0, 200)}…` : r.command}`, `log: ${r.log}`, ...r.tail.map(l => ` ${l}`)];
const withRun = { ...base, lines: runLines, inputs: { ...inputs, command: sha256(r.command), runtime: runtime.label } };
+44 -26
View File
@@ -12,14 +12,17 @@
*
* The install verdict for (checkout, project) is the one record install,
* the shim and admission share. Under the admission condition (GSTACK_CAPY=1,
* or a verdict exists for this checkout and project) the lanes run the Bun it
* recorded, and a recorded Bun that is gone or changed is reported as drift;
* otherwise the verdict check is skipped with an explain line (contributors, CI).
* or a verdict exists for this checkout and project) it is validated as
* workflow admission validates it and the lanes run the Bun it recorded; an
* invalid verdict (INSTALL_UNVERIFIED) or a recorded Bun that is gone, changed
* or no longer satisfies the pins (BUN_RESOLUTION_DRIFT) makes the lanes and
* guards incomplete before anything launches. Otherwise the verdict check is
* skipped with an explain line (contributors, CI).
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { findInstallVerdict, readInstallVerdict, sourceRoot } from '../install-verdict';
import { findInstallVerdict, installProject, readInstallVerdict, sourceRoot, verdictProblem } from '../install-verdict';
import { resultCodeSuffix } from '../result-codes';
import { collectPins, evaluatePins, exactPlan, parseVersion, satisfies } from '../runtime-pins';
import { resolveStateRoot } from '../state-root';
@@ -133,40 +136,55 @@ export interface LaunchRuntime {
version: string | null;
env: NodeJS.ProcessEnv;
label: string;
/** The install-verdict check: ok, drift (recorded Bun gone or changed), missing (GSTACK_CAPY=1 without one) or skipped. */
verdict: 'ok' | 'drift' | 'missing' | 'skipped';
/**
* The install-verdict check: ok, drift (recorded Bun gone, changed or no
* longer satisfying the pins), unverified (no verdict under GSTACK_CAPY=1,
* or one that is not ok, for another HEAD, planning-only, another project or
* too narrow) or skipped (no GSTACK_CAPY=1 and no verdict: contributors, CI).
*/
verdict: 'ok' | 'drift' | 'unverified' | 'skipped';
verdictLine: string;
/** Set for drift and unverified: the lanes and guards return it before launching anything. */
refusal: { status: 'incomplete'; code: 'BUN_RESOLUTION_DRIFT' | 'INSTALL_UNVERIFIED'; detail: string; fix: string } | null;
}
/** The Bun the lanes and guards run under, its dir first on PATH; the env is unchanged when nothing qualifies. */
/**
* The Bun the lanes and guards run under, its dir first on PATH; the env is
* unchanged when nothing qualifies. Under the admission condition the install
* verdict for (checkout, project) is validated as workflow admission does
* (verdictProblem: ok, HEAD, pins, project, coverage, for /ship) and its
* recorded Bun is the one launched; the gstack checkout itself binds the
* 'none' verdict (installProject).
*/
export function launchRuntime(repoRoot: string, env: NodeJS.ProcessEnv): LaunchRuntime {
let project: string | null = null;
try { project = fs.realpathSync(repoRoot); } catch { /* unresolvable: no project */ }
const checkout = sourceRoot(GSTACK_ROOT);
const project = installProject(checkout, repoRoot);
const file = findInstallVerdict(checkout, project, env);
const recorded = readInstallVerdict(file);
const bound = recorded && (recorded.schema_version === 1 || (recorded.project ?? '') === (project ?? '')) ? recorded : null;
const launch = (bin: string, version: string, why: string, verdict: LaunchRuntime['verdict'], verdictLine: string): LaunchRuntime => {
const dir = path.dirname(bin);
return { dir, version, env: { ...env, PATH: [dir, env.PATH ?? ''].filter(Boolean).join(path.delimiter) }, label: `bun ${version} at ${bin} (${why})${verdictLine ? `; ${verdictLine}` : ''}`, verdict, verdictLine };
};
let pinsRoot: string | null = null;
try { pinsRoot = fs.realpathSync(repoRoot); } catch { /* unresolvable: no pins */ }
const { pinExact, accept } = projectBunPins(pinsRoot, env);
const result = (dir: string | null, version: string | null, label: string, verdict: LaunchRuntime['verdict'], verdictLine: string, refusal: LaunchRuntime['refusal'] = null): LaunchRuntime =>
({ dir, version, env: dir ? { ...env, PATH: [dir, env.PATH ?? ''].filter(Boolean).join(path.delimiter) } : env, label: `${label}${verdictLine ? `; ${verdictLine}` : ''}`, verdict, verdictLine, refusal });
const refuse = (code: 'BUN_RESOLUTION_DRIFT' | 'INSTALL_UNVERIFIED', detail: string, fix: string) =>
result(null, null, 'not launched', code === 'BUN_RESOLUTION_DRIFT' ? 'drift' : 'unverified', `install verdict: ${detail}; fix: ${fix} ${resultCodeSuffix(code)}`, { status: 'incomplete', code, detail: `install verdict: ${detail}`, fix });
const installer = 'run the Capy skill installer here (scripts/install.sh --for ship), then rerun gstack-pregate with the GSTACK_LAUNCH line it prints';
let verdict: LaunchRuntime['verdict'] = 'skipped';
let verdictLine = '';
if (env.GSTACK_CAPY !== '1' && !bound) {
if (env.GSTACK_CAPY !== '1' && !recorded) {
verdictLine = 'install verdict: not checked (no GSTACK_CAPY=1 and no verdict for this checkout and project)';
} else if (!bound) {
verdict = 'missing';
verdictLine = `install verdict: none for ${project ?? 'this directory'} at ${file}; fix: run the Capy skill installer (scripts/install.sh --check --for review) here ${resultCodeSuffix('INSTALL_UNVERIFIED')}`;
} else if (bound.bun_path) {
const now = fs.existsSync(bound.bun_path) ? bunVersionOf(bound.bun_path, env) : '';
if (now && now === bound.bun_version) return launch(bound.bun_path, now, 'recorded by the install verdict', 'ok', '');
verdict = 'drift';
verdictLine = `install verdict: recorded bun ${bound.bun_version} at ${bound.bun_path} ${now ? `now reports ${now}` : 'is gone'}; fix: re-run the Capy skill installer with --check (scripts/install.sh --check --for review) ${resultCodeSuffix('BUN_RESOLUTION_DRIFT')}`;
} else {
const problem = verdictProblem('ship', GSTACK_ROOT, env, '', '', repoRoot);
if (problem) return refuse('INSTALL_UNVERIFIED', problem.detail, problem.fix ?? installer);
verdict = 'ok';
if (recorded!.bun_path) {
const now = fs.existsSync(recorded!.bun_path) ? bunVersionOf(recorded!.bun_path, env) : '';
const why = !now ? 'is gone' : now !== recorded!.bun_version ? `now reports ${now}` : !accept(now) ? 'no longer satisfies the project pins' : '';
if (why) return refuse('BUN_RESOLUTION_DRIFT', `recorded bun ${recorded!.bun_version} at ${recorded!.bun_path} ${why}`, 're-run the Capy skill installer with --check (scripts/install.sh --check --for ship) and follow its fix');
return result(path.dirname(recorded!.bun_path), now, `bun ${now} at ${recorded!.bun_path} (recorded by the install verdict)`, 'ok', '');
}
}
const { pinExact, accept } = projectBunPins(project, env);
const { resolution } = resolveBun({ store: bunStore(resolveStateRoot(env)), env, pinExact, accept });
if (resolution) return launch(resolution.bin, resolution.version, `resolved: ${resolution.rule}`, verdict, verdictLine);
return { dir: null, version: null, env, label: `PATH (no eligible Bun in the runtime store, $BUN_INSTALL/bin, ~/.bun/bin or PATH)${verdictLine ? `; ${verdictLine}` : ''}`, verdict, verdictLine };
if (resolution) return result(path.dirname(resolution.bin), resolution.version, `bun ${resolution.version} at ${resolution.bin} (resolved: ${resolution.rule})`, verdict, verdictLine);
return result(null, null, 'PATH (no eligible Bun in the runtime store, $BUN_INSTALL/bin, ~/.bun/bin or PATH)', verdict, verdictLine);
}
+15
View File
@@ -116,4 +116,19 @@ describe('one Bun resolution contract, two twins', () => {
expect(bashResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
expect(tsResolve({ ...l, env, state: env.GSTACK_STATE_ROOT }, null)).toBe(want);
});
// Integration item 8: the gstack checkout itself binds the 'none' verdict.
test('launchRuntime on the gstack checkout reads the none verdict and launches its recorded Bun', () => {
const l = layout({ storeVersions: ['1.4.3'] });
const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict');
const checkout = fs.realpathSync(ROOT);
const file = installVerdictPath(checkout, l.state, null);
const head = spawnSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ schema_version: 2, checkout, project: '', head, for: 'ship', required: ['claude', 'codex', 'patch'], pins_planning_only: false, ok: true, trailer: 'gstack: ok', min_revision: '', launch_path: '', time: '', bun_path: REAL_BUN, bun_version: Bun.version }));
const r = launchRuntime(ROOT, l.env);
expect(r.refusal).toBeNull();
expect(r.verdict).toBe('ok');
expect(r.label).toBe(`bun ${Bun.version} at ${REAL_BUN} (recorded by the install verdict)`);
});
});
+56
View File
@@ -127,6 +127,62 @@ describe('guards (CD1)', () => {
});
});
// Integration item 8: under the admission condition (GSTACK_CAPY=1, or a
// verdict exists for this checkout and project) the guards and lanes validate
// the install verdict as workflow admission does and return incomplete before
// launching anything; with neither, contributors and CI run as before.
describe('guards and lanes: install verdict admission (integration item 8)', () => {
const GSTACK = path.resolve(import.meta.dir, '..');
const { installVerdictPath } = require('../lib/install-verdict') as typeof import('../lib/install-verdict');
const head = () => require('node:child_process').spawnSync('git', ['-C', GSTACK, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim();
const guard = { id: 'marker', when: ['lib/**'], command: 'touch ../guard-ran' };
const verdict = (repo: FixtureRepo, fields: Record<string, unknown>) => {
const project = fs.realpathSync(repo.work);
const file = installVerdictPath(fs.realpathSync(GSTACK), repo.home, project);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ schema_version: 2, checkout: fs.realpathSync(GSTACK), project, head: head(), for: 'ship', required: ['claude', 'codex', 'patch'], pins_planning_only: false, ok: true, trailer: 'gstack: ok', min_revision: '', launch_path: '', time: '', ...fields }));
};
const run = (repo: FixtureRepo, env: Record<string, string> = {}) => pregate(repo, ['--allow-repo-commands', '--stage', 'preflight', '--only', 'guards'], { GSTACK_STATE_ROOT: repo.home, ...env });
const ran = (repo: FixtureRepo) => fs.existsSync(path.join(repo.root, 'guard-ran'));
test('GSTACK_CAPY=1 without a verdict: guards incomplete INSTALL_UNVERIFIED and nothing launched; without GSTACK_CAPY the guard runs', () => {
const repo = guardedFixture({ ...BASE_PREGATE, guards: [guard] });
commit(repo, { 'lib/util.js': 'exports.x = 1;\n' });
const r = run(repo, { GSTACK_CAPY: '1' });
expect(r.status).toBe(EXIT.fail);
expect(checkRow(repo, 'guards')).toMatchObject({ status: 'incomplete', code: 'INSTALL_UNVERIFIED' });
expect(checkRow(repo, 'guards').detail).toContain('install verdict: no install verdict at');
expect(ran(repo)).toBe(false);
expect(run(repo, { GSTACK_CAPY: '' }).status).toBe(EXIT.ok);
expect(ran(repo)).toBe(true);
});
test('an existing verdict is enforced without GSTACK_CAPY: ok:false and a stale HEAD are INSTALL_UNVERIFIED, a gone recorded Bun is BUN_RESOLUTION_DRIFT, a valid one launches its Bun', () => {
const repo = guardedFixture({ ...BASE_PREGATE, guards: [guard] });
commit(repo, { 'lib/util.js': 'exports.x = 1;\n' });
verdict(repo, { ok: false, trailer: 'gstack: fail pins' });
expect(run(repo).status).toBe(EXIT.fail);
expect(checkRow(repo, 'guards')).toMatchObject({ status: 'incomplete', code: 'INSTALL_UNVERIFIED' });
expect(checkRow(repo, 'guards').detail).toContain('the install verdict says: gstack: fail pins');
verdict(repo, { head: '0'.repeat(40) });
expect(run(repo).status).toBe(EXIT.fail);
expect(checkRow(repo, 'guards').detail).toContain('the install verdict is for another HEAD');
verdict(repo, { pins_planning_only: true, for: 'autoplan' });
expect(run(repo).status).toBe(EXIT.fail);
expect(checkRow(repo, 'guards').detail).toContain('planning-only');
verdict(repo, { bun_path: path.join(repo.root, 'gone/bun'), bun_version: '1.4.3' });
expect(run(repo).status).toBe(EXIT.fail);
expect(checkRow(repo, 'guards')).toMatchObject({ status: 'incomplete', code: 'BUN_RESOLUTION_DRIFT' });
expect(checkRow(repo, 'guards').detail).toContain(`recorded bun 1.4.3 at ${repo.root}/gone/bun is gone`);
expect(ran(repo)).toBe(false);
verdict(repo, { bun_path: fs.realpathSync(process.execPath), bun_version: Bun.version });
const ok = run(repo);
expect(ok.status, ok.out).toBe(EXIT.ok);
expect(ok.out).toContain(`runtime: bun ${Bun.version} at ${fs.realpathSync(process.execPath)} (recorded by the install verdict)`);
expect(ran(repo)).toBe(true);
});
});
describe('pregate config: sealed (CD4)', () => {
test('`gstack-pregate validate` accepts a sealed policy and names each bad field', () => {
expect(codes({ sealed: { paths: ['heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], answer_markers: ['SEALED ANSWER:'], seed_patterns: ['seed=\\d+'], local_paths: true, visibility: 'private' } })).toEqual([]);