feat(pregate): user-owned policy file and local lanes

A repo that keeps .gstack/ out of git can hold its pregate policy at
<GSTACK_HOME>/projects/<slug>/pregate.json. gstack-pregate run reads it only
under --policy-from base when the base carries no .gstack/pregate.json, so a
reviewed base copy always wins and a PR still cannot reach the policy. A
missing file falls back; any other read error, and a non-null sealed or
price_file (read only from the git copies), refuse with PREGATE_CONFIG_INVALID
and a fix that names the file.

A new `local` key lists <workflow.yml>/<job> lanes a local run satisfies: a
platform lane named there runs here (tier unit) instead of becoming a
requires-remote obligation. Lane ids share one LANE_ID check with selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
arlophoenixandClaude Opus 5.5 committed 2026-10-18 14:14:47 +13:00
1 parent c5c2f28994
commit 6fdec4c5a7
5 files changed
+182 -11

No files matched your search

+4 -1
View File
@@ -36,6 +36,8 @@ import { ensureTmpExcluded } from '../lib/restamp';
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
import { cancelActive } from '../lib/pregate/supervise';
import { slugFromEnvironment } from '../lib/bin-context';
import { resolveStateRoot } from '../lib/state-root';
import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate';
import { GATE_AHEAD_REL } from '../lib/restamp';
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard';
@@ -145,7 +147,8 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
remote = { spendCapUsd: cap };
} else if (flag(rest, '--spend-cap') !== undefined) { process.stderr.write('gstack-pregate: --spend-cap applies only with --remote\n'); return EXIT.usage; }
const base = resolveBaseBranch(root, flag(rest, '--base'));
const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2] });
const userConfigPath = path.join(resolveStateRoot(), 'projects', slugFromEnvironment(undefined, root), 'pregate.json');
const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2], userConfigPath });
if ('refused' in loaded) {
const refusal = describeRefusal(loaded.refused, base);
process.stderr.write(refusal.line + '\n');
+38 -5
View File
@@ -16,6 +16,9 @@
* warn check ids downgraded to warn (never `secrets`; `lanes` keeps
* its required-lane and requires-remote verdicts)
* strays.allow root files or new top-level directories that are intentional
* local `<workflow.yml>/<job>` lanes a local run satisfies (a
* platform-agnostic toolchain, or a repo whose CI is off):
* their tests run here instead of becoming requires-remote
* guards `{ id, when: [globs], command, fix?, timeout_s? }`: a repo
* command that must pass when a touched file matches `when`
* (manifest checks, CLI goldens); never downgradable
@@ -30,6 +33,10 @@
* 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=…`)
*
* A repo that keeps `.gstack/` out of git can hold the same file at
* `<GSTACK_HOME>/projects/<slug>/pregate.json`, honoured only when the base
* carries none. It lives outside the repo, so a PR still cannot reach it.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -52,6 +59,7 @@ export interface PregateConfig {
selection: Record<string, string>;
warn: string[];
strays: { allow: string[] };
local: string[];
guards: Guard[];
serial_patterns: string[];
price_file: string | null;
@@ -59,12 +67,13 @@ export interface PregateConfig {
remote_gate: string | null;
}
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, remote_gate: null };
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 };
export interface ConfigError { code: ResultCodeName; path: string; message: string }
const MAX_LIST = 256;
const MAX_COMMAND = 512;
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'remote_gate'];
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'];
@@ -153,7 +162,7 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
const sel = obj.selection;
if (!sel || typeof sel !== 'object' || Array.isArray(sel)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'selection', message: 'must be an object of <workflow.yml>/<job> → command' });
else for (const [lane, command] of Object.entries(sel as Record<string, unknown>)) {
if (!/^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be <workflow file>/<job id>' });
if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be <workflow file>/<job id>' });
const why = commandError(command);
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: why });
}
@@ -176,6 +185,10 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
}
}
}
if ('local' in obj) {
if (!Array.isArray(obj.local) || obj.local.some(l => typeof l !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'local', message: 'must be an array of <workflow file>/<job id>' });
else (obj.local as string[]).forEach((l, i) => { if (!LANE_ID.test(l)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `local[${i}]`, message: `${JSON.stringify(l)} must be <workflow file>/<job id>` }); });
}
return errors;
}
@@ -186,10 +199,10 @@ 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 ?? [] }, 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 }, 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, remote_gate: o.remote_gate ?? null }, errors: [] };
}
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string };
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none' } | { unavailable: true; base: string; detail: string };
function git(cwd: string, args: string[]): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
@@ -211,6 +224,23 @@ export function loadConfigFromHead(repoRoot: string, knownChecks: readonly strin
return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' };
}
/** The user-owned copy for a repo that keeps `.gstack/` out of git; the caller consults it only when the base has none. */
export function loadConfigFromUser(file: string, knownChecks: readonly string[] = []): ConfigLoad | null {
let text: string;
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
// An unreadable policy must refuse, never fall back to defaults (its guards would silently vanish).
return { ...parseConfig('{}', knownChecks), errors: [{ code: 'PREGATE_CONFIG_INVALID', path: file, message: `cannot read the user-owned policy: ${(e as Error).message}` }], present: true, label: `user ${file}`, kind: 'user' };
}
const load = parseConfig(text, knownChecks);
// `sealed` (the pre-push guard) and `price_file` (gstack-eval-plan) read only the git copies, so a non-null value in the user file would look active and do nothing.
let raw: unknown = null;
try { raw = JSON.parse(text); } catch { /* parseConfig reported it */ }
const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file'].filter(k => (raw as Record<string, unknown>)[k] != null) : [];
const errors = [...load.errors, ...ignored.map(k => ({ code: 'PREGATE_CONFIG_INVALID' as const, path: k, message: `the user-owned policy covers gstack-pregate run only; ${k} is read from ${PREGATE_CONFIG_PATH} on the base branch` }))];
return { ...load, errors, present: true, label: `user ${file}`, kind: 'user' };
}
export function isConfigUnavailable(load: ConfigLoad): load is { unavailable: true; base: string; detail: string } {
return (load as any).unavailable === true;
}
@@ -224,6 +254,7 @@ export function exampleConfigJson(): string {
selection: {},
warn: [],
strays: { allow: [] },
local: [],
};
return JSON.stringify(example, null, 2) + '\n';
}
@@ -236,11 +267,13 @@ export function explainConfig(): string {
'selection: <workflow.yml>/<job> → the runner-owned command that lists that lane’s test files when its matrix is planner-computed',
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`,
'strays.allow: root files or new top-level directories that are intentional',
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (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)',
'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)',
'no .gstack/pregate.json on the base: <GSTACK_HOME>/projects/<slug>/pregate.json is read instead (outside the repo; a PR cannot reach it)',
].join('\n') + '\n';
}
+6 -3
View File
@@ -15,7 +15,7 @@ import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
import { policyUnavailableLine } from '../ship-policy';
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, type PregateConfig } from './config';
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
import type { CheckResult, PregateContext, TouchedFile } from './types';
import { discoverLanes } from './workflows';
import type { RegistryError } from '../regen';
@@ -27,6 +27,8 @@ export interface BuildContextOptions {
stallTimeoutMs?: number; progress?: (line: string) => void; cores?: number; testBackend?: string; remote?: { spendCapUsd: number } | null;
/** How gstack's own bash bins launch (gstack-wtree, gstack-config); tests inject a Windows probe. */
invocation?: InvocationOptions;
/** `<GSTACK_HOME>/projects/<slug>/pregate.json`: read only when the base carries no pregate.json. */
userConfigPath?: string;
}
export const DEFAULT_STALL_TIMEOUT_S = 300;
@@ -110,9 +112,10 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
if (!baseSha.ok || !baseSha.out) return { refused: { 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;
const configLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
let configLoad: ConfigLoad = o.policyFrom === 'head' ? loadConfigFromHead(o.repoRoot, o.knownChecks) : loadConfigFromBase(o.repoRoot, base, o.knownChecks);
if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') configLoad = loadConfigFromUser(o.userConfigPath, o.knownChecks) ?? configLoad;
if (isConfigUnavailable(configLoad)) return { refused: { code: 'POLICY_SOURCE_UNAVAILABLE', message: configLoad.detail } };
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 } };
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 } };
if (registryLoad.errors.length) return { refused: { code: 'REGEN_REGISTRY_INVALID', message: `${registryLoad.source.label}: ${registryLoad.errors.map(e => `${e.message} at ${e.path}`).join('; ')}`, errors: registryLoad.errors } };
+3 -2
View File
@@ -122,12 +122,13 @@ export function selectLanes(ctx: PregateContext): Selection {
const set = laneFileSet(ctx, lane);
if (set !== 'all') laneSets[lane.id] = set;
const inLane = set === 'all' ? selected : set === '?' ? selected : selected.filter(t => set.includes(t));
if (lane.tier === 'platform') {
if (lane.tier === 'platform' && ctx.config.local.includes(lane.id)) ctx.explain.push(`lane ${lane.id}: ${lane.platform} lane satisfied locally (pregate.json local)`);
else if (lane.tier === 'platform') {
if (inLane.length) remote.push({ lane: lane.id, platform: lane.platform, workflow: lane.workflow, job: lane.job, tests: inLane, receipt: null });
continue;
}
if (set === 'all') anyLocalAll = true;
if (inLane.length) { tiers.add(lane.tier); for (const t of inLane) local.add(t); }
if (inLane.length) { tiers.add(lane.tier === 'platform' ? 'unit' : lane.tier); for (const t of inLane) local.add(t); }
}
if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t);
for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t);
+131
View File
@@ -0,0 +1,131 @@
/**
* The user-owned pregate policy and `local` lanes. A repo that keeps `.gstack/`
* out of git declares its policy at `<GSTACK_HOME>/projects/<slug>/pregate.json`;
* the base branch's copy always wins, and `--policy-from head` never reads the
* user file. A platform lane named in `local` runs here instead of becoming
* requires-remote. Every assertion holds on any host platform: each lane the
* fixture selects is either named in `local` or asserted only by label.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { loadConfigFromUser, validateConfig } from '../lib/pregate/config';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const SLUG = 'userpolicy';
const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard'];
/** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */
function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType<typeof runBin> } {
const repo = makeFixtureRepo({ pregate: null });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
const run = (extra: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...extra], { GSTACK_PROJECT_SLUG: SLUG });
return { repo, userPolicy, run };
}
const writePolicy = (file: string, policy: Record<string, unknown>) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy }));
describe('pregate.json local', () => {
// Value: protects=`local` accepts only <workflow file>/<job id> lane ids, shared with selection keys; fails_when=a bare job name or a
// string is accepted and silently matches no lane; why_new=the key is new; seam=none; no_claim=none beyond protects
test('takes <workflow>/<job> ids only', () => {
expect(validateConfig({ local: ['test.yml/unit'] })).toEqual([]);
expect(validateConfig({ local: ['unit'] }).map(e => e.path)).toEqual(['local[0]']);
expect(validateConfig({ local: 'test.yml/unit' }).map(e => e.path)).toEqual(['local']);
expect(validateConfig({ local: [7] }).map(e => e.path)).toEqual(['local']);
expect(validateConfig({ selection: { unit: 'true' } }).map(e => e.path)).toEqual(['selection["unit"]']);
});
});
describe('loadConfigFromUser', () => {
// Value: protects=a missing user file falls back (null) while any other read error refuses at the file's path, never defaults;
// fails_when=every read error is treated as absent and the user's guards silently vanish; why_new=the loader is new; seam=none;
// no_claim=EACCES specifically (a directory stands in for an unreadable file)
test('ENOENT is null; an unreadable file refuses at its path', () => {
const { userPolicy } = setup();
expect(loadConfigFromUser(userPolicy)).toBeNull();
fs.mkdirSync(userPolicy);
const load = loadConfigFromUser(userPolicy);
expect(load && 'errors' in load ? load.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]);
expect(load && 'kind' in load ? [load.kind, load.label] : null).toEqual(['user', `user ${userPolicy}`]);
});
});
describe('gstack-pregate with a user-owned policy', () => {
// Value: protects=with no base copy the user file is the policy and its `local` lanes run here, while an unnamed platform lane stays
// requires-remote; fails_when=the user fallback or the `local` branch in selectLanes is dropped; why_new=both are new;
// seam=none; no_claim=a real CI host (lanes are judged by the fixture's workflows)
test('the user file is read when the base has none, and a local lane runs here instead of requires-remote', () => {
const { userPolicy, run } = setup();
expect(run().out).toContain('(no .gstack/pregate.json)');
writePolicy(userPolicy, { local: ['free-tests.yml/unit'] });
const partial = run();
expect(partial.out).toContain(`policy user ${userPolicy}`);
expect(partial.out).toContain('requires-remote windows.yml/windows-shard (windows)');
expect(partial.out).not.toContain('requires-remote free-tests.yml/unit');
writePolicy(userPolicy, { local: LOCAL_ALL });
const all = run(['--explain']);
expect(all.status).toBe(EXIT.ok);
expect(all.out).toContain('Tiers ran: unit; requires-remote: none');
expect(all.out).toContain('lane windows.yml/windows-shard: windows lane satisfied locally (pregate.json local)');
expect(all.out).toMatch(/^tests {6}lanes {5}pass /m);
}, 60_000);
// Value: protects=an invalid, sealed-bearing or unreadable user file refuses with PREGATE_CONFIG_INVALID naming the file, with a fix
// that points at the file rather than the base branch; fails_when=user-file errors run on defaults, or sealed/price_file are accepted
// and look active while nothing reads them; why_new=the user source is new; seam=none; no_claim=the sealed guard itself
test('an invalid, sealed or unreadable user file refuses and names the file', () => {
const { userPolicy, run } = setup();
writePolicy(userPolicy, { local: ['not-a-lane'] });
const invalid = run();
expect(invalid.status).toBe(EXIT.refused);
expect(invalid.err).toContain('PREGATE_CONFIG_INVALID');
expect(invalid.err).toContain(`user ${userPolicy}`);
expect(invalid.err).toContain(`fix: edit ${userPolicy} directly`);
for (const [key, value] of [['sealed', { paths: ['evals/**'] }], ['price_file', 'evals/calls.json']] as const) {
writePolicy(userPolicy, { [key]: value });
const r = run();
expect(r.status).toBe(EXIT.refused);
expect(r.err).toContain(`covers gstack-pregate run only; ${key}`);
}
// A null price_file is the documented default, not a policy, so it is accepted.
writePolicy(userPolicy, { price_file: null, local: LOCAL_ALL });
expect(run().err).not.toContain('PREGATE_CONFIG_INVALID');
fs.rmSync(userPolicy);
fs.mkdirSync(userPolicy);
const unreadable = run();
expect(unreadable.status).toBe(EXIT.refused);
expect(unreadable.err).toContain('cannot read the user-owned policy');
}, 60_000);
// Value: protects=the user file is consulted only for --policy-from base with no base copy: head mode and a committed base copy never
// read it; fails_when=either guard on the fallback is dropped and an unreviewed user file overrides reviewed policy; why_new=the
// fallback is new; seam=none; no_claim=none beyond protects
test('--policy-from head and a base copy never read the user file', () => {
const { repo, userPolicy, run } = setup();
writePolicy(userPolicy, { local: ['not-a-lane'] });
const head = run(['--policy-from', 'head']);
expect(head.err).not.toContain('PREGATE_CONFIG_INVALID');
expect(head.out).not.toContain('policy user');
commit(repo, { '.gstack/pregate.json': JSON.stringify(BASE_PREGATE) + '\n' });
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin');
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' });
const based = run();
expect(based.err).not.toContain('PREGATE_CONFIG_INVALID');
expect(based.out).toContain('origin/main@');
expect(based.out).not.toContain('policy user');
}, 60_000);
});