pregate: --policy-from head only adds lanes; timeout_s policy default; preflight forwards --policy-from (GST-419, GST-420)

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-31 08:50:17 +00:00
1 parent 047dad78ba
commit ff71dec209
17 files changed
+423 -59

No files matched your search

+6 -5
View File
@@ -344,7 +344,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
const killed = r.runs.find(x => x.outcome === 'exited' && x.exit === null);
return { code: 'PREGATE_INCOMPLETE', detail: `the runner process ended without an exit code (killed by ${killed?.signal ?? 'a signal'}; ${total} files; last file started: ${killed?.lastFile ?? 'none reported'}${rest})`, fix: `read ${r.log}; a runner killed by a signal (out of memory, an outside kill) never passes; rerun, or split the selection` };
}
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (--timeout applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (${ctx.timeoutSource ?? '--timeout'} applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
}
function describe(sel: Selection, noPolicy = false): string[] {
@@ -401,7 +401,8 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string
/**
* `--remote`: run the declared remote gate now, under the spend cap. The
* gate gets PREGATE_TREE, PREGATE_FILES_FILE and PREGATE_SPEND_CAP_USD and
* gate gets PREGATE_TREE, PREGATE_FILES_FILE, PREGATE_SPEND_CAP_USD and
* PREGATE_TIMEOUT_S (this run's per-process timeout, whole seconds) and
* must print `PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]`;
* the obligation clears only when that tree is the tested tree, tests > 0,
* failed = 0 and the gate exited 0. Anything else leaves it uncleared and
@@ -417,7 +418,7 @@ async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult)
const filesFile = path.join(dir, 'files.txt');
fs.writeFileSync(log, '');
fs.writeFileSync(filesFile, sel.localTests.join('\n') + '\n');
const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress });
const r = await supervise({ command: obligation.command!, cwd: ctx.repoRoot, env: { ...ctx.env, PREGATE_TREE: ctx.wtree, PREGATE_FILES_FILE: filesFile, PREGATE_SPEND_CAP_USD: String(cap), PREGATE_TIMEOUT_S: String(Math.floor(ctx.timeoutMs / 1000)) }, log, deadline: Date.now() + ctx.timeoutMs, stallMs: ctx.stallTimeoutMs, label: `remote gate ${obligation.lane}`, progress: ctx.progress });
const output = readRange(log, r.start, r.end);
const reported = parseRemoteReport(output);
const spent = reported?.spent;
@@ -527,10 +528,10 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
* routes; it never switches to the head policy on its own.
*/
function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string {
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : '';
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) on ${ctx.base} and let CI run them` : '';
const fix = `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`;
if (ctx.configKind === 'head' || !ctx.touched.some(t => t.path === PREGATE_CONFIG_PATH && t.status !== 'D')) return fix;
return `this branch edits ${PREGATE_CONFIG_PATH}, but the policy comes from ${ctx.configLabel} so a branch cannot loosen its own gate, and a lane it adds counts only once it is on ${ctx.base}: land the policy change in its own PR first, or rerun with --policy-from head --allow-repo-commands, which records the run as policy head (unreviewed); otherwise ${fix}`;
return `this branch edits ${PREGATE_CONFIG_PATH}, but the policy comes from ${ctx.configLabel} so a branch cannot loosen its own gate, and a lane it adds counts only once it is on ${ctx.base}: land the policy change in its own PR first, or rerun with --policy-from head --allow-repo-commands (gstack-ship-preflight forwards it; a head policy may only add test lanes, and a [] declaration must land on ${ctx.base} first), which records the run as policy head (unreviewed); otherwise ${fix}`;
}
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };
+11 -3
View File
@@ -30,6 +30,9 @@
* a paid gate from (GSTACK_EVAL_CALLS v1; never executed)
* stall_timeout_s seconds without test output before the lanes check kills
* the runner's process group (default 300; 0 disables)
* timeout_s seconds each runner process may take (the unit-file group,
* each serial file, each guard without its own timeout_s, the
* remote gate) when `--timeout` is not passed (default 600)
* remote_gate the repo's remote test gate command: under 8 cores the test
* tier is a requires-remote obligation naming it, and
* `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`)
@@ -78,17 +81,18 @@ export interface PregateConfig {
serial_patterns: string[];
price_file: string | null;
stall_timeout_s: number | null;
timeout_s: number | null;
remote_gate: string | null;
allowlist: AllowlistEntry[];
}
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
export interface AllowlistEntry { finding: string; paths?: string[]; pattern?: string; reason: string }
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null, allowlist: [] };
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, timeout_s: null, remote_gate: null, allowlist: [] };
export interface ConfigError { code: ResultCodeName; path: string; message: string }
export const MAX_LIST = 256;
const MAX_COMMAND = 512;
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate', 'allowlist'];
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'timeout_s', 'remote_gate', 'allowlist'];
const LANE_ID = /^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/;
const MAX_GUARDS = 32;
const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s'];
@@ -172,6 +176,9 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
if ('stall_timeout_s' in obj && obj.stall_timeout_s !== null && (!Number.isInteger(obj.stall_timeout_s) || (obj.stall_timeout_s as number) < 0 || (obj.stall_timeout_s as number) > 86_400)) {
errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'stall_timeout_s', message: 'must be an integer number of seconds from 0 (disabled) to 86400, or null' });
}
if ('timeout_s' in obj && obj.timeout_s !== null && (!Number.isInteger(obj.timeout_s) || (obj.timeout_s as number) < 1 || (obj.timeout_s as number) > 3600)) {
errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'timeout_s', message: 'must be an integer number of seconds from 1 to 3600 (preflight and finish allow gstack-pregate one hour), or null' });
}
if ('remote_gate' in obj && obj.remote_gate !== null) {
const why = commandError(obj.remote_gate);
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'remote_gate', message: why });
@@ -244,7 +251,7 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []):
const errors = validateConfig(raw, knownChecks);
if (errors.length) return { config: DEFAULT_CONFIG, errors };
const o = raw as Partial<PregateConfig>;
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [] }, errors: [] };
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [] }, errors: [] };
}
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string };
@@ -324,6 +331,7 @@ export function explainConfig(): string {
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'timeout_s: seconds each runner process may take (the unit-file group, each serial file, each guard without its own timeout_s, and the remote gate\'s deadline) when --timeout is not passed; 1 to 3600, default 600',
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
`allowlist: [{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted. finding is a gstack-redact id (env.kv, google.api_key, pii.phone.e164, …) or a category (${ALLOWLISTABLE_CATEGORIES.join(', ')}); paths are globs; pattern is a regex matched at the finding's position in the added line; one of the two is required, reason always. Never ${NON_ALLOWLISTABLE.join(' or ')} or engine.* (docs/pregate.md#allowlist)`,
'no .gstack/pregate.json on the base: <state root>/projects/<slug>/pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)',
+13 -6
View File
@@ -18,7 +18,8 @@ import { resolveStateRoot } from '../state-root';
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
import { headPolicy } from './head-policy';
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
import type { CheckResult, PregateContext, TouchedFile } from './types';
import { discoverLanes } from './workflows';
import { createScratch, scratchRunId } from './run-scratch';
@@ -47,7 +48,7 @@ export function readTestBackend(env: NodeJS.ProcessEnv, invocation?: InvocationO
return (r.status === 0 && (r.stdout ?? '').trim()) || 'local';
}
export interface ContextRefusal {
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
message: string;
/** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair; so does a `--base` that is no origin branch). */
fix?: string;
@@ -211,17 +212,23 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
let configLoad: ConfigLoad = loadConfigFromBase(o.repoRoot, base, o.knownChecks);
const explain: string[] = [];
let absentUserPolicy: string | undefined;
if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
if (o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') {
const rejected = userConfigRejection(o.repoRoot, o.userConfigPath);
const user = rejected ? null : loadConfigFromUser(o.userConfigPath, o.knownChecks);
if (user) configLoad = user;
else explain.push(`user policy: ${o.userConfigPath} ${rejected ? `ignored (${rejected})` : '(absent)'}`);
if (!user && !rejected) absentUserPolicy = o.userConfigPath;
if (!user && !rejected && o.policyFrom === 'base') absentUserPolicy = o.userConfigPath;
}
if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } };
if (o.policyFrom === 'head' && !configLoad.errors.length) {
const head = headPolicy(o.repoRoot, configLoad, mergeBase, base, o.knownChecks ?? []);
if ('loosens' in head) return { refused: { code: 'PREGATE_HEAD_POLICY_LOOSENS', message: head.loosens.join('; ') } };
configLoad = head.load;
explain.push(...head.explain);
}
if (configLoad.errors.length) return { refused: { code: 'PREGATE_CONFIG_INVALID', message: `${configLoad.label}: ${configLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: configLoad.errors, ...(configLoad.kind === 'user' ? { fix: `edit ${o.userConfigPath} directly at each path the message names, then rerun gstack-pregate; it lives outside the repository, so a base-branch change does not reach it` } : {}) } };
const registryLoad = o.policyFrom === 'head' ? loadRegistryFromHead(o.repoRoot) : loadRegistryFromBase(o.repoRoot, base);
if (isRegistryUnavailable(registryLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: registryLoad.detail } };
@@ -240,7 +247,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
ctx: {
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
config, configLabel: configLoad.label, configKind: configLoad.kind, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.kind === 'user' && configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000,
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? (config.timeout_s ?? 600) * 1000, timeoutSource: o.timeoutMs !== undefined ? '--timeout' : config.timeout_s ? `timeout_s from ${configLoad.label}` : 'the 600 s default',
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
cores: o.cores ?? os.availableParallelism(), testBackend: o.testBackend ?? readTestBackend(env, o.invocation), policyFrom: o.policyFrom, remote: o.remote ?? null,
+72
View File
@@ -0,0 +1,72 @@
/**
* pregate/head-policy — `--policy-from head` (GST-419): a branch's own
* `.gstack/pregate.json` may only add test lanes over the trusted policy (the
* base branch's copy, or the user-owned file when the base has none). A lane
* is a `dependencies` glob with a non-empty test list, or extra tests on a glob
* the trusted policy already maps. Everything else (a removed or shrunk glob, a
* new `[]` declaration, the runner, guards, warn, allowlist, timeouts, the
* remote gate, ...) must equal the trusted copy, or the run is refused with
* PREGATE_HEAD_POLICY_LOOSENS. A branch behind its base is compared with the
* copy at its merge base and runs the base tip's policy plus its additions, so
* a lane the base gained later never reads as a removal. With no trusted policy
* at all, the head file is a bootstrap and runs as written. `sealed` is not
* compared: its guard reads the base branch's copy itself.
*/
import { spawnSync } from 'node:child_process';
import { PREGATE_CONFIG_PATH, loadConfigFromHead, parseConfig, type ConfigLoad, type PregateConfig } from './config';
type Loaded = Exclude<ConfigLoad, { unavailable: true }>;
const FROZEN = ['runner', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'stall_timeout_s', 'timeout_s', 'remote_gate', 'allowlist'] as const;
const canonical = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])]));
return v;
};
const same = (a: unknown, b: unknown) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
/** Each way `head` loosens `ref`, and the tests it adds per glob. */
export function headPolicyDiff(ref: PregateConfig, head: PregateConfig, base: string): { loosens: string[]; adds: Record<string, string[]> } {
const loosens: string[] = FROZEN.filter(k => !same(ref[k], head[k])).map(k => `${k} differs from the trusted policy`);
const adds: Record<string, string[]> = {};
for (const [glob, tests] of Object.entries(ref.dependencies)) {
const now = head.dependencies[glob];
if (!now) { loosens.push(`dependencies[${JSON.stringify(glob)}] is removed`); continue; }
const dropped = tests.filter(t => !now.includes(t));
if (dropped.length) loosens.push(`dependencies[${JSON.stringify(glob)}] drops ${dropped.join(', ')}`);
const extra = now.filter(t => !tests.includes(t));
if (extra.length) adds[glob] = extra;
}
for (const [glob, tests] of Object.entries(head.dependencies)) {
if (glob in ref.dependencies) continue;
if (!tests.length) loosens.push(`dependencies[${JSON.stringify(glob)}] declares no lane needed; a [] declaration must land on ${base} first`);
else adds[glob] = tests;
}
return { loosens, adds };
}
/** The policy at the merge base when it carries a valid copy; the branch's additions are measured from there. */
function forkCopy(repoRoot: string, mergeBase: string, knownChecks: readonly string[]): PregateConfig | null {
const r = spawnSync('git', ['show', `${mergeBase}:${PREGATE_CONFIG_PATH}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
if (r.status !== 0 || r.error) return null;
const parsed = parseConfig(r.stdout, knownChecks);
return parsed.errors.length ? null : parsed.config;
}
/** The policy a `--policy-from head` run uses over `trusted`, or what the head file loosens. */
export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string, base: string, knownChecks: readonly string[]): { load: Loaded; explain: string[] } | { loosens: string[] } {
const head = loadConfigFromHead(repoRoot, knownChecks) as Loaded;
if (trusted.kind === 'none' || head.errors.length) return { load: head, explain: [] };
if (head.kind === 'none') return { loosens: [`${PREGATE_CONFIG_PATH} is absent at head while ${trusted.label} declares a policy`] };
const ref = (trusted.kind === 'base' && forkCopy(repoRoot, mergeBase, knownChecks)) || trusted.config;
const { loosens, adds } = headPolicyDiff(ref, head.config, base);
if (loosens.length) return { loosens };
const dependencies = { ...trusted.config.dependencies };
for (const [glob, tests] of Object.entries(adds)) dependencies[glob] = [...new Set([...(dependencies[glob] ?? []), ...tests])];
const n = Object.keys(adds).length;
return {
load: { ...trusted, config: { ...trusted.config, dependencies }, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` },
explain: Object.entries(adds).map(([glob, tests]) => `head policy adds: dependencies[${JSON.stringify(glob)}] → ${tests.join(', ')}`),
};
}
+2
View File
@@ -49,6 +49,8 @@ export interface PregateContext {
platform: NodeJS.Platform;
allowRepoCommands: boolean;
timeoutMs: number;
/** Where timeoutMs came from: `--timeout`, `timeout_s from <policy>`, or the 600 s default. */
timeoutSource?: string;
/** No output for this long kills a test-tier process group (`--stall-timeout`, `stall_timeout_s`); 0 disables. */
stallTimeoutMs: number;
/** Live stderr lines: per-file progress, heartbeats, the stage result. */
+5
View File
@@ -341,4 +341,9 @@ export const RELEASE_RESULT_CODES = {
summary: 'the watcher did not start, or a local error stopped it before a verdict',
fix: 'pass a PR number or URL the current gh login can read (--repo owner/name when the checkout is not that repository), stop the watcher the line names before starting another for the same PR, or fix the named local error',
},
PREGATE_HEAD_POLICY_LOOSENS: {
anchor: 'pregate-head-policy-loosens',
summary: 'under --policy-from head, the branch’s .gstack/pregate.json removes, shrinks or loosens something the trusted policy declares; a head policy may only add test lanes',
fix: 'keep every key of the trusted policy as it is and only add dependencies globs with tests (or tests on an existing glob); land any other change, a [] declaration included, in its own PR on the base branch first',
},
} as const satisfies Record<string, ResultCode>;
+16 -6
View File
@@ -15,11 +15,14 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste
gstack-ship-preflight --base <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>... Step 5 only (after a repair)
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
(a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS
gstack-ship-preflight --audit --base <base> --out <dir> the single reads and Steps 7-8's deterministic audits
(CONTEXT, TODOS, COVERAGE_MAP, Coverage, PLAN_*, READER: coverage=needed|none plan=needed|none)
Prints at most 30 lines: BRANCH, STATUS, DIFF, PR/CARRY, APPLE, SCOPE, MERGE, DISTRIBUTION, POLICY,
RUNTIME_LINES, PREGATE, one LANE line per lane, then NEXT: lines. STOP: names what the agent must
RUNTIME_LINES, PREGATE (with up to three PREGATE_DETAIL lines when it does not pass), one LANE line per lane,
then NEXT: lines. STOP: names what the agent must
decide or repair; nothing after it ran. Exit codes: 0 ok · 2 usage · 3 STOP`;
const DIST = /(^|\/)(cmd\/[^/]+\/main\.go|bin\/[^/]+|Cargo\.toml|setup\.py|package\.json)$/;
@@ -100,11 +103,16 @@ function merge(cwd: string, base: string, r: Report): boolean {
return true;
}
async function tests(cwd: string, base: string, list: { label: string; command: string }[], r: Report): Promise<void> {
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, r: Report): Promise<void> {
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--policy-from', policyFrom], 3_600_000);
const refusal = pg.status === 3 ? /\((PREGATE_[A-Z_]+|POLICY_[A-Z_]+)[;)]/.exec(pg.stderr)?.[1] : undefined;
const verdict = refusal ? `refused (${refusal})` : /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
r.add(`PREGATE: ${verdict}`);
if (/^(fail|incomplete|unreadable)/.test(verdict)) { r.stop(`pregate ${verdict}: fix the failing checks it lists (gstack-pregate --allow-repo-commands), then rerun --tests-only`); return; }
if (/^(fail|incomplete|unreadable|refused)/.test(verdict)) {
for (const line of pg.stderr.split('\n').filter(l => l.startsWith('gstack-pregate: ')).slice(0, 3)) r.add(`PREGATE_DETAIL: ${line.length > 300 ? `${line.slice(0, 220)} … ${line.slice(-80)}` : line}`);
r.stop(`pregate ${verdict}: fix what PREGATE_DETAIL names (gstack-pregate --allow-repo-commands${policyFrom === 'head' ? ' --policy-from head' : ''}), then rerun --tests-only`);
return;
}
if (/uncleared/.test(verdict)) r.next('pregate requires-remote: gstack-pregate clear, or --remote --spend-cap <usd> under a user cap, before Step 18');
const results = (await runLanes(cwd, list)).map(laneLine);
for (const l of results) r.add(l.line);
@@ -123,11 +131,13 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
if (!base || !out) return { text: 'gstack-ship-preflight: --audit needs --base <base> --out <dir>\n', exit: 2 };
return { text: audit(cwd, base, out).render(), exit: 0 };
}
const policyFrom = flag('--policy-from') ?? 'base';
if (!['base', 'head'].includes(policyFrom)) return { text: 'gstack-ship-preflight: --policy-from needs base|head\n', exit: 2 };
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
const r = new Report();
if (has('--tests-only') || integrate(cwd, base, r)) {
if (list.length) await tests(cwd, base, list, r);
if (list.length) await tests(cwd, base, list, policyFrom, r);
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
}
return { text: r.render(), exit: r.exit };