mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
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:
1 parent
79604e718f
commit
63c068a412
6 files changed
+120
-28
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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)`);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
|
||||
Reference in new issue
Block a user