Files
gstack/lib/pregate/head-policy.ts
T
Garry Tanandcapy-ai[bot] 05114c8049 ship accept: a lanes-only head policy gates the final tree; owner-granted test-lane exception (GST-1595) (#4061)
* ship accept: a lanes-only head policy gates the final tree; name bun-hidden JUnit suites; owner-granted test-lane exception (GST-1595)

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

* fix: pre-landing review fixes

Each excepted file is preflighted on its own and must run a counted test; the preflighted files count toward the lane's tests_ran; grants are kept per head and file set; lanesOnlyHeadPolicy compares .gstack/generated.json with the fork copy.

Review-Finding: v1/outside-voice#1 reader=outside-voice
Review-Finding: v1/outside-voice#2 reader=outside-voice
Review-Finding: v1/outside-voice#3 reader=outside-voice
Review-Finding: v1/adversarial-native#2 reader=adversarial-native
Review-Finding: v1/adversarial-native#4 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: lanesOnlyHeadPolicy compares generated.json with the base tip again

A --policy-from head run loads the regen registry from the branch (lib/pregate/context.ts), so a branch whose .gstack/generated.json differs from main's tip keeps the base policy; comparing with the fork copy would gate under a stale registry.

Review-Finding: p2/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: troubleshooting entries for the accept head policy and test-lane exceptions (GST-1595)

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-06 10:16:00 +00:00

107 lines
8.1 KiB
TypeScript

/**
* 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 * as fs from 'node:fs';
import * as path from 'node:path';
import { GENERATED_PATH } from '../regen';
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 }, declaresLanes: trusted.declaresLanes === true || head.declaresLanes === true, 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(', ')}`),
};
}
/**
* Whether a /ship run on the final tree (gstack-ship-release accept, and finish's full pregate rerun) gates under the
* branch's own policy (GST-1595): only when the base branch carries a policy and the branch's file differs from the copy
* it forked from (else the base tip's) in nothing but added test lanes, with GENERATED_PATH equal to the base tip's (a
* head-policy run loads the regen registry from the branch, context.ts), so the gate only gets stricter. Any other difference keeps the base policy, as before. `line` names the policy and why; it is absent
* when the branch leaves the policy alone.
*/
export function lanesOnlyHeadPolicy(repoRoot: string, base: string): { head: boolean; line?: string } {
const ref = `origin/${base.replace(/^origin\//, '')}`;
const show = (rev: string, file: string) => { const r = spawnSync('git', ['show', `${rev}:${file}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); return r.status === 0 && !r.error ? r.stdout : null; };
const read = (file: string) => { try { return fs.readFileSync(path.join(repoRoot, file), 'utf8'); } catch { return null; } };
const json = (text: string | null): Record<string, unknown> | null => { try { const v = text === null ? null : JSON.parse(text); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } };
const tip = show(ref, PREGATE_CONFIG_PATH), headText = read(PREGATE_CONFIG_PATH);
const fork = spawnSync('git', ['merge-base', ref, 'HEAD'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }).stdout?.trim();
const trusted = json((fork && show(fork, PREGATE_CONFIG_PATH)) || tip), head = json(headText);
if (headText === tip || (trusted && head && same(trusted, head))) return { head: false };
const keep = (why: string) => ({ head: false, line: `POLICY: base ${ref}:${PREGATE_CONFIG_PATH} (the branch's copy ${why}, so it does not count here; land it on ${ref.slice(7)} first)` });
if (!tip || !trusted) return keep(`has no reviewed copy on ${ref} to add lanes to`);
if (!head) return keep(headText === null ? 'is deleted' : 'does not parse as a JSON object');
const { dependencies: refDeps = {}, ...refRest } = trusted, { dependencies: headDeps = {}, ...headRest } = head;
const other = [...new Set([...Object.keys(refRest), ...Object.keys(headRest)])].filter(k => !same(refRest[k], headRest[k])).sort();
if (other.length) return keep(`also changes ${other.join(', ')}`);
const lists = (v: unknown): v is Record<string, string[]> => !!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v).every(t => Array.isArray(t) && t.every(x => typeof x === 'string'));
if (!lists(refDeps) || !lists(headDeps)) return keep('has a dependencies entry that is not a list of test files');
const { loosens, adds } = headPolicyDiff({ dependencies: refDeps } as PregateConfig, { dependencies: headDeps } as PregateConfig, ref.slice(7));
if (loosens.length) return keep(loosens.join('; '));
if (show(ref, GENERATED_PATH) !== read(GENERATED_PATH)) return keep(`comes with a changed ${GENERATED_PATH}`);
const n = Object.keys(adds).length;
return { head: true, line: `POLICY: head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} test lane${n === 1 ? '' : 's'} over ${ref} and changes nothing else, so the gate is only stricter` };
}