From 63c068a41200501a97f422b6897ff56054d24f9e Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 15 Oct 2026 01:32:46 +0000 Subject: [PATCH] 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> --- lib/install-verdict.ts | 5 +-- lib/pregate/checks/guards.ts | 1 + lib/pregate/checks/lanes.ts | 1 + lib/pregate/runtime.ts | 70 ++++++++++++++++++++++-------------- test/bun-resolve.test.ts | 15 ++++++++ test/pregate-guards.test.ts | 56 +++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 28 deletions(-) diff --git a/lib/install-verdict.ts b/lib/install-verdict.ts index 8492c2eac..a6973cb9c 100644 --- a/lib/install-verdict.ts +++ b/lib/install-verdict.ts @@ -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); diff --git a/lib/pregate/checks/guards.ts b/lib/pregate/checks/guards.ts index b0ea168ee..dc28c8144 100644 --- a/lib/pregate/checks/guards.ts +++ b/lib/pregate/checks/guards.ts @@ -45,6 +45,7 @@ async function run(ctx: PregateContext): Promise { 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)); diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 7b155af59..8e9b9a6f0 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -184,6 +184,7 @@ async function run(ctx: PregateContext): Promise { 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 } }; diff --git a/lib/pregate/runtime.ts b/lib/pregate/runtime.ts index 9d692809b..c8a46ff54 100644 --- a/lib/pregate/runtime.ts +++ b/lib/pregate/runtime.ts @@ -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); } diff --git a/test/bun-resolve.test.ts b/test/bun-resolve.test.ts index ff46c3165..bf04922e4 100644 --- a/test/bun-resolve.test.ts +++ b/test/bun-resolve.test.ts @@ -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)`); + }); }); diff --git a/test/pregate-guards.test.ts b/test/pregate-guards.test.ts index 4a6703ef0..14ee0ec8d 100644 --- a/test/pregate-guards.test.ts +++ b/test/pregate-guards.test.ts @@ -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) => { + 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 = {}) => 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([]);