From 6fdec4c5a72a4e09aac86ab9c2de94f82ab9887a Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Sun, 18 Oct 2026 14:14:47 +1300 Subject: [PATCH 01/13] feat(pregate): user-owned policy file and local lanes A repo that keeps .gstack/ out of git can hold its pregate policy at /projects//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 / 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 --- bin/gstack-pregate | 5 +- lib/pregate/config.ts | 43 ++++++++-- lib/pregate/context.ts | 9 ++- lib/pregate/lane-selection.ts | 5 +- test/pregate-user-policy.test.ts | 131 +++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 test/pregate-user-policy.test.ts diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 9eab2d38c..a49319b5f 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -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/` 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 + * `/projects//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; 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 / → command' }); else for (const [lane, command] of Object.entries(sel as Record)) { - 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 /' }); + if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be /' }); 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 /' }); + 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 /` }); }); + } 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; - 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)[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: / → 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: / 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= tests= failed=) (docs/pregate.md#remote-gate)', + 'no .gstack/pregate.json on the base: /projects//pregate.json is read instead (outside the repo; a PR cannot reach it)', ].join('\n') + '\n'; } diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 0238261b1..966fa2d39 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -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; + /** `/projects//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 } }; diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 9a6844d39..06dd9a934 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -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); diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts new file mode 100644 index 000000000..9055a4a4a --- /dev/null +++ b/test/pregate-user-policy.test.ts @@ -0,0 +1,131 @@ +/** + * The user-owned pregate policy and `local` lanes. A repo that keeps `.gstack/` + * out of git declares its policy at `/projects//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 } { + 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) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy })); + +describe('pregate.json local', () => { + // Value: protects=`local` accepts only / 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 / 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); +}); From 47173fccfdd3c53285344ca46e8a522feb281012 Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Sun, 18 Oct 2026 14:14:47 +1300 Subject: [PATCH 02/13] docs(pregate): the user-owned policy file and local lanes Co-Authored-By: Claude Opus 5.5 --- docs/pregate.md | 12 +++++++++++- docs/troubleshooting.md | 11 +++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/pregate.md b/docs/pregate.md index 6e9e4f0e0..42a336c8c 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -63,6 +63,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) | | `warn` | check ids downgraded to warn; `secrets` and required lanes never | | `strays.allow` | intentional root files or new top-level directories | +| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote` | | `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) | | `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) | | `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) | @@ -71,7 +72,16 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) | Only the `origin/` copy is honoured, so a downgrade is reviewed before it -takes effect. `gstack-pregate validate` / `init` cover the working-tree file. +takes effect. A repo that keeps `.gstack/` out of git (a shared repo where gstack +is one contributor's tool) can hold the same file at +`/projects//pregate.json`. It is read only when the base +carries none (never under `--policy-from head`), it lives outside the repository +so a PR still cannot reach it, and the record names it (`policy user `). +It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the +base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`) +rather than holding a policy nothing reads. A user file that exists but cannot +be read refuses the same way instead of falling back to defaults. +`gstack-pregate validate` / `init` cover the working-tree file. ## `gstack-pregate` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d802e845b..269efc2a6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2751,7 +2751,10 @@ or `REALITY: surface-check finding bun upgrade --version absent on 1.3.14 bin/gs Both tools read their pin file (`.gstack/generated.json`, `.gstack/pregate.json`) from `origin/`, never the working tree, and run repo-declared commands only -under `--allow-repo-commands` or inside `/ship`. Details: docs/pregate.md. +under `--allow-repo-commands` or inside `/ship`. When the base carries no +`.gstack/pregate.json`, `gstack-pregate run` reads +`/projects//pregate.json` instead (outside the repository). +Details: docs/pregate.md. ### `REGEN_REGISTRY_INVALID` @@ -2794,10 +2797,14 @@ reference the repository's (gstack's entries do through `bun run`). **Meaning.** `.gstack/pregate.json` has an unknown key, a wrong type, a glob that escapes the repository, a runner without `{files}`, or `secrets` in `warn`. +The user-owned `/projects//pregate.json` also refuses when it +sets `sealed` or `price_file` (both are read only from the base branch's copy) +or exists but cannot be read. **Fix.** `gstack-pregate validate`. `secrets` and required-lane obligations cannot be downgraded; every other check id in `warn` is honoured only from the -base branch's copy. +base branch's copy. For the user-owned file, edit it directly at the path the +message names. ### `PREGATE_SECRETS` From 580a76f40bad07ce3e24546e96c7c73e6e4a71bf Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Sun, 18 Oct 2026 14:19:28 +1300 Subject: [PATCH 03/13] fix(pregate): policy fixes name the user-owned file when it is the active policy With no .gstack/pregate.json on the base, an edit to that path is never read, so the no-lane, runner, stall_timeout_s, remote_gate and strays fixes now name ctx.configFile: the user-owned path when it is the active policy. Co-Authored-By: Claude Opus 5.5 --- lib/pregate/checks/lanes.ts | 9 +++++---- lib/pregate/checks/strays.ts | 4 ++-- lib/pregate/context.ts | 4 ++-- lib/pregate/types.ts | 2 ++ test/pregate-user-policy.test.ts | 13 +++++++++++++ 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 7b55324a4..2fa286344 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -15,6 +15,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { baseInputs, sha256 } from '../context'; +import { PREGATE_CONFIG_PATH } from '../config'; import { selectLanes, type Selection } from '../lane-selection'; import { launchRuntime } from '../runtime'; import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise'; @@ -103,7 +104,7 @@ function unfinished(ctx: PregateContext, r: Awaited> const timedOut = r.runs.filter(x => x.outcome === 'timeout'); const rest = r.failed ? `; ${r.failed} failed` : ''; if (stalled.length) { - return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in .gstack/pregate.json; 0 disables) for a test that is quiet for longer` }; + return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that is quiet for longer` }; } if (!timedOut.length) return { code: 'PREGATE_INCOMPLETE', detail: `the runner could not be launched (${total} files${rest})`, fix: 'read the lane log, fix the runner command, then rerun gstack-pregate --stage tests' }; 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' }; @@ -163,7 +164,7 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string */ async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise { const lines = base.lines ?? []; - if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: 'declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in .gstack/pregate.json on the base branch, or run the tests locally without --remote' }; + if (!ctx.config.remote_gate) return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `--remote runs only a declared remote gate; ${ctx.configLabel} has no remote_gate`, fix: `declare remote_gate (a command that prints PREGATE_REMOTE: tree=… tests=… failed=…) in ${ctx.configFile === PREGATE_CONFIG_PATH ? `${PREGATE_CONFIG_PATH} on the base branch` : ctx.configFile}, or run the tests locally without --remote` }; const obligation = remoteObligation(ctx, sel); const cap = ctx.remote!.spendCapUsd; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pregate-remote-')); @@ -195,7 +196,7 @@ async function run(ctx: PregateContext): Promise { const lines = describe(sel); const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines, remote: sel.remote }; if (sel.noLane.length) { - return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: 'add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in .gstack/pregate.json' }; + return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}` }; } if (sel.tests.length === 0) { const detail = sel.emptyLane.length @@ -212,7 +213,7 @@ async function run(ctx: PregateContext): Promise { if (!ctx.allowRepoCommands) return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `${sel.localTests.length} local test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' }; if (sel.localTests.length === 0) return { ...base, status: sel.remote.length ? 'requires-remote' : 'pass', detail: `no local lane; ${sel.remote.length} requires-remote obligation(s)` }; 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' }; + 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 ${ctx.configFile}` }; 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); diff --git a/lib/pregate/checks/strays.ts b/lib/pregate/checks/strays.ts index b92b8c664..ab56ec935 100644 --- a/lib/pregate/checks/strays.ts +++ b/lib/pregate/checks/strays.ts @@ -31,8 +31,8 @@ function run(ctx: PregateContext): CheckResult { if (!baseDirs.has(top)) warnings.push(`${file}: new top-level directory ${top}/ (not on ${ctx.baseRef})`); } ctx.explain.push(`strays: base directories ${[...baseDirs].join(',')}; allow ${allow.join(',') || '(none)'}`); - if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: 'delete or move the file; an intentional root file goes in .gstack/pregate.json strays.allow' }; - if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: 'confirm the directory is intended, or allow it in .gstack/pregate.json strays.allow' }; + if (lines.length) return { id: 'strays', stage: 'preflight', status: 'fail', inputs, lines: [...lines, ...warnings], code: 'PREGATE_STRAYS', detail: `${lines.length} stray file(s): ${lines.map(l => l.split(':')[0]).join(' ')}`, fix: `delete or move the file; an intentional root file goes in ${ctx.configFile} strays.allow` }; + if (warnings.length) return { id: 'strays', stage: 'preflight', status: 'warn', inputs, lines: warnings, code: 'PREGATE_STRAYS', detail: `${warnings.length} file(s) in a new top-level directory`, fix: `confirm the directory is intended, or allow it in ${ctx.configFile} strays.allow` }; return { id: 'strays', stage: 'preflight', status: 'pass', inputs, detail: `${ctx.touched.filter(t => t.status === 'A').length} added file(s), none stray` }; } diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index 966fa2d39..f953f78fd 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -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, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config'; +import { PREGATE_CONFIG_PATH, 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'; @@ -128,7 +128,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad { return { ctx: { repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase), - config, configLabel: configLoad.label, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, + config, configLabel: configLoad.label, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000, stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000, progress: o.progress ?? (line => process.stderr.write(`${line}\n`)), diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index 37512dee4..c294c3154 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -33,6 +33,8 @@ export interface PregateContext { touched: TouchedFile[]; config: PregateConfig; configLabel: string; + /** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */ + configFile: string; registry: Registry; registryLabel: string; lanes: Lane[]; diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index 9055a4a4a..b6929d185 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -79,6 +79,19 @@ describe('gstack-pregate with a user-owned policy', () => { expect(all.out).toMatch(/^tests {6}lanes {5}pass /m); }, 60_000); + // Value: protects=a fix that says where to declare policy names the user file when it is the active policy, since an edit to the + // repo's .gstack/pregate.json is never read on a base that carries none; fails_when=the fix hardcodes .gstack/pregate.json; + // why_new=the user source is new; seam=none; no_claim=every fix string (strays, runner, remote_gate share ctx.configFile) + test('the no-lane fix names the user file when it is the active policy', () => { + const { repo, userPolicy, run } = setup(); + writePolicy(userPolicy, { local: LOCAL_ALL }); + commit(repo, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' }); + expect(run().status).not.toBe(EXIT.ok); + const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes'); + expect(lanes.code).toBe('PREGATE_NO_LANE'); + expect(lanes.fix).toContain(`under dependencies — or empty_lanes for other documentation — in ${userPolicy}`); + }, 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 From 28de1e72c903a14dee1c7c50f875829a58143d4e Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Sun, 18 Oct 2026 21:20:26 +1300 Subject: [PATCH 04/13] test(pregate): the remote_gate, strays and stall fixes name the active policy file Co-Authored-By: Claude Opus 5.5 --- test/pregate-user-policy.test.ts | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index b6929d185..decf19b9d 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -11,6 +11,8 @@ 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 { buildContext } from '../lib/pregate/context'; +import { runPregate } from '../lib/pregate/index'; import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; @@ -54,6 +56,11 @@ describe('loadConfigFromUser', () => { 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}`]); + // Malformed JSON reaches the sealed/price_file probe too; it must still come back as a refusal, never a throw. + fs.rmSync(userPolicy, { recursive: true }); + fs.writeFileSync(userPolicy, '{"local": ['); + const bad = loadConfigFromUser(userPolicy); + expect(bad && 'errors' in bad ? bad.errors.map(e => e.message.startsWith('not valid JSON')) : null).toEqual([true]); }); }); @@ -141,4 +148,59 @@ describe('gstack-pregate with a user-owned policy', () => { expect(based.out).toContain('origin/main@'); expect(based.out).not.toContain('policy user'); }, 60_000); + + // Value: protects=the --remote refusal for a policy with no remote_gate tells the user where to declare it: the repo copy "on the + // base branch", the user file by its path (never "on the base branch", which the user file is not on); fails_when=the + // configFile ternary is dropped or inverted and the user is sent to edit a file nothing reads; why_new=no test reaches the + // no-remote_gate branch and the user source is new; seam=none (userConfigPath and remote are existing buildContext options); + // no_claim=the remote gate run itself + test('--remote with no remote_gate names the file to declare it in', async () => { + const lanesFix = async (repo: FixtureRepo, userConfigPath?: string) => { + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', remote: { spendCapUsd: 1 }, userConfigPath }); + if (!('ctx' in loaded)) throw new Error('fixture context refused'); + const lanes = (await runPregate(loaded.ctx, { stage: 'tests' })).checks.find(c => c.id === 'lanes')!; + expect([lanes.status, lanes.code]).toEqual(['incomplete', 'PREGATE_INCOMPLETE']); + return lanes.fix!; + }; + const { repo, userPolicy } = setup(); + writePolicy(userPolicy, { local: LOCAL_ALL }); + const user = await lanesFix(repo, userPolicy); + expect(user).toContain(`in ${userPolicy}, or run the tests locally`); + expect(user).not.toContain('on the base branch'); + + const based = makeFixtureRepo({ pregate: { ...BASE_PREGATE, local: LOCAL_ALL } }); + repos.push(based); + commit(based, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); + expect(await lanesFix(based)).toContain('in .gstack/pregate.json on the base branch, or run the tests locally'); + }, 60_000); + + // Value: protects=the strays fail fix, the strays warn fix and the PREGATE_STALLED fix each name the user file when it is the active + // policy, so the user edits the file that is read; fails_when=any of the three reverts to a hardcoded .gstack/pregate.json, which a + // base carrying none never reads; why_new=existing strays and stall tests assert detail and lines, never the fix, and only with a + // repo copy; seam=none; no_claim=the stall timing itself (pregate-supervise covers it) + test('strays (fail and warn) and stall fixes name the user file', () => { + const { repo, userPolicy, run } = setup(); + writePolicy(userPolicy, { local: LOCAL_ALL }); + const check = (id: string) => JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === id); + const strays = (files: Record) => { + commit(repo, files); + runBin(repo, 'gstack-pregate', ['--base', 'main', '--stage', 'preflight', '--only', 'strays'], { GSTACK_PROJECT_SLUG: SLUG }); + return check('strays'); + }; + + const fail = strays({ 'notes.txt': 'x\n', 'newtop/thing.js': 'module.exports = 1;\n' }); + expect([fail.status, fail.code]).toEqual(['fail', 'PREGATE_STRAYS']); + expect(fail.fix).toContain(`goes in ${userPolicy} strays.allow`); + + const warn = strays({ 'notes.txt': null }); + expect([warn.status, warn.code]).toEqual(['warn', 'PREGATE_STRAYS']); + expect(warn.fix).toContain(`allow it in ${userPolicy} strays.allow`); + + commit(repo, { 'newtop/thing.js': null, 'test/hang.test.js': 'setInterval(() => {}, 1000);\n' }); + run(['--stall-timeout', '2']); + const stalled = check('lanes'); + expect(stalled.code).toBe('PREGATE_STALLED'); + expect(stalled.fix).toContain(`stall_timeout_s in ${userPolicy}; 0 disables`); + }, 60_000); }); + From 06531746109422b3a0891a8910524e359566532a Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Sun, 18 Oct 2026 21:54:55 +1300 Subject: [PATCH 05/13] fix(pregate): pre-landing review fixes for the user-owned policy Ignore a user policy path that is relative or inside the checkout, fingerprint the active user file into check inputs, name the user file in its JSON errors and the refusal lead, add --explain lines for an absent user file and an unknown local id, and document the state-root location and slug lookup. Co-Authored-By: Claude Opus 5.5 --- bin/gstack-pregate | 2 +- docs/pregate.md | 21 ++++++++---- docs/troubleshooting.md | 10 +++--- lib/pregate/config.ts | 15 +++++---- lib/pregate/context.ts | 37 ++++++++++++++++---- lib/pregate/types.ts | 2 ++ lib/result-codes.ts | 2 +- test/pregate-user-policy.test.ts | 58 ++++++++++++++++++++++++++++++-- 8 files changed, 119 insertions(+), 28 deletions(-) diff --git a/bin/gstack-pregate b/bin/gstack-pregate index a49319b5f..a57d421c3 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -141,7 +141,7 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; } remote = { spendCapUsd: cap }; diff --git a/docs/pregate.md b/docs/pregate.md index 652c770f4..cfad85a34 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -65,7 +65,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) | | `warn` | check ids downgraded to warn; `secrets` and required lanes never | | `strays.allow` | intentional root files or new top-level directories | -| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote` | +| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no platform lane | | `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) | | `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) | | `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) | @@ -73,12 +73,19 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) | | `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) | -Only the `origin/` copy is honoured, so a downgrade is reviewed before it -takes effect. A repo that keeps `.gstack/` out of git (a shared repo where gstack -is one contributor's tool) can hold the same file at -`/projects//pregate.json`. It is read only when the base -carries none (never under `--policy-from head`), it lives outside the repository -so a PR still cannot reach it, and the record names it (`policy user `). +Only the `origin/` copy or the user-owned file below is honoured, so a +downgrade is reviewed (or is the user's own) before it takes effect. A repo that +keeps `.gstack/` out of git (a shared repo where gstack is one contributor's +tool) can hold the same file at `/projects//pregate.json`. The +state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `~/.gstack` (full +chain in [docs/state-root.md](state-root.md)); `` is what +`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository +(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none +(never under `--policy-from head`), it lives outside the repository so a PR still +cannot reach it, and the record names it (`policy user `) and fingerprints +its bytes in each check's `inputs.policy`. A path that is relative or resolves +inside the repository is ignored, since a PR could commit it; `--explain` says +so, or says `(absent)` when no file is there. It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`) rather than holding a policy nothing reads. A user file that exists but cannot diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7d30ad07d..b47ead752 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2753,7 +2753,8 @@ Both tools read their pin file (`.gstack/generated.json`, `.gstack/pregate.json` from `origin/`, never the working tree, and run repo-declared commands only under `--allow-repo-commands` or inside `/ship`. When the base carries no `.gstack/pregate.json`, `gstack-pregate run` reads -`/projects//pregate.json` instead (outside the repository). +`/projects//pregate.json` instead (outside the repository; see +docs/pregate.md for the state root and slug). Details: docs/pregate.md. @@ -2809,14 +2810,15 @@ repository's lockfile), then rerun `gstack-regen check` or the pre-gate. **Meaning.** `.gstack/pregate.json` has an unknown key, a wrong type, a glob that escapes the repository, a runner without `{files}`, or `secrets` in `warn`. -The user-owned `/projects//pregate.json` also refuses when it +The user-owned `/projects//pregate.json` also refuses when it sets `sealed` or `price_file` (both are read only from the base branch's copy) or exists but cannot be read. **Fix.** `gstack-pregate validate`. `secrets` and required-lane obligations cannot be downgraded; every other check id in `warn` is honoured only from the -base branch's copy. For the user-owned file, edit it directly at the path the -message names. +base branch's copy or the user-owned file. For the user-owned file, edit it +directly at the path the message names. A user file inside the repository is +ignored rather than read (`--explain` names it). ### `PREGATE_SECRETS` diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index 074471da4..0fc24b78c 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -35,12 +35,14 @@ * `--remote` runs it (it prints `PREGATE_REMOTE: tree=… tests=… failed=…`) * * A repo that keeps `.gstack/` out of git can hold the same file at - * `/projects//pregate.json`, honoured only when the base - * carries none. It lives outside the repo, so a PR still cannot reach it. + * `/projects//pregate.json` (lib/state-root.ts), honoured + * only when the base carries none. It must sit outside the repo, so a PR + * cannot reach it; a path inside the checkout is ignored (pregate/context). */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import type { ResultCodeName } from '../result-codes'; import { sealedErrors } from '../sealed-guard'; import { containedRelative, globToRegExp } from '../ship-policy'; @@ -202,7 +204,7 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []): 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' | 'user' | 'none' } | { unavailable: true; base: string; detail: string }; +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 }; function git(cwd: string, args: string[]): { ok: boolean; out: string } { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); @@ -237,8 +239,9 @@ export function loadConfigFromUser(file: string, knownChecks: readonly string[] 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)[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' }; + // parseConfig names the repo copy for a JSON error; this file is not it. + const errors = [...load.errors.map(e => e.path === PREGATE_CONFIG_PATH ? { ...e, path: file } : e), ...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', digest: createHash('sha256').update(text).digest('hex') }; } export function isConfigUnavailable(load: ConfigLoad): load is { unavailable: true; base: string; detail: string } { @@ -274,6 +277,6 @@ export function explainConfig(): string { '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= tests= failed=) (docs/pregate.md#remote-gate)', - 'no .gstack/pregate.json on the base: /projects//pregate.json is read instead (outside the repo; a PR cannot reach it)', + 'no .gstack/pregate.json on the base: /projects//pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else ~/.gstack; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)', ].join('\n') + '\n'; } diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index f953f78fd..db8d48e1a 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -9,6 +9,7 @@ */ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen'; @@ -27,7 +28,7 @@ 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; - /** `/projects//pregate.json`: read only when the base carries no pregate.json. */ + /** `/projects//pregate.json`: read only when the base carries no pregate.json, and never from a relative path or one inside the repository. */ userConfigPath?: string; } @@ -105,6 +106,21 @@ export function touchedFiles(repoRoot: string, mergeBase: string): TouchedFile[] return [...out.values()].filter(t => t.path !== 'node_modules' && !t.path.startsWith('node_modules/')).sort((a, b) => a.path.localeCompare(b.path)); } +/** The deepest existing ancestor realpathed, the rest re-joined: a symlinked state root inside the checkout still counts as inside. */ +function realish(p: string): string { + let head = path.resolve(p); const tail: string[] = []; + while (!fs.existsSync(head) && path.dirname(head) !== head) { tail.unshift(path.basename(head)); head = path.dirname(head); } + try { return path.join(fs.realpathSync(head), ...tail); } catch { return path.resolve(p); } +} +const within = (root: string, p: string) => { const rel = path.relative(root, p); return !rel.startsWith('..') && !path.isAbsolute(rel); }; + +/** Why a user policy path must not be read, or null: a relative path (no HOME) or one a PR could commit is never trusted. */ +export function userConfigRejection(repoRoot: string, file: string): string | null { + if (!path.isAbsolute(file)) return 'not an absolute path'; + if (within(path.resolve(repoRoot), path.resolve(file)) || within(realish(repoRoot), realish(file))) return 'inside the repository; a PR could supply it'; + return null; +} + export function buildContext(o: BuildContextOptions): ContextLoad { const base = o.base.replace(/^origin\//, ''); const baseRef = `origin/${base}`; @@ -113,7 +129,13 @@ export function buildContext(o: BuildContextOptions): ContextLoad { 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); - if (o.policyFrom === 'base' && o.userConfigPath && !isConfigUnavailable(configLoad) && configLoad.kind === 'none') configLoad = loadConfigFromUser(o.userConfigPath, o.knownChecks) ?? configLoad; + const explain: string[] = []; + if (o.policyFrom === 'base' && 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 (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, ...(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); @@ -125,15 +147,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad { const wtree = fp.tree; const platform = o.platform ?? process.platform; const config = configLoad.config as PregateConfig; + const lanes = discoverLanes(o.repoRoot, platform); + // A lane that exists but is not platform-tier on this host already runs here, so only an unknown id is worth a line. + for (const id of config.local) if (!lanes.some(l => l.id === id)) explain.push(`local: ${id} matches no platform lane; ignored`); return { ctx: { repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase), - config, configLabel: configLoad.label, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, - lanes: discoverLanes(o.repoRoot, platform), platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000, + config, configLabel: configLoad.label, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.kind === 'user' && configLoad.digest ? { configDigest: configLoad.digest } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label, + lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? 600_000, 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, - env, explain: [], + env, explain, }, }; } @@ -142,7 +167,7 @@ export function sha256(text: string): string { return createHash('sha256').updat /** The common input identity every check records; checks add their own keys. */ export function baseInputs(ctx: PregateContext, extra: Record = {}): Record { - return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...extra }; + return { tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, touched: sha256(ctx.touched.map(t => `${t.status} ${t.path}`).join('\n')), ...(ctx.configDigest ? { policy: ctx.configDigest } : {}), ...extra }; } /** diff --git a/lib/pregate/types.ts b/lib/pregate/types.ts index c294c3154..b98f105a5 100644 --- a/lib/pregate/types.ts +++ b/lib/pregate/types.ts @@ -35,6 +35,8 @@ export interface PregateContext { configLabel: string; /** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */ configFile: string; + /** sha256 of the user-owned policy's bytes when it is active; recorded as `inputs.policy` (a base copy is pinned by `inputs.base`). */ + configDigest?: string; registry: Registry; registryLabel: string; lanes: Lane[]; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index d7adaf05e..23a3e1ddb 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -363,7 +363,7 @@ export const RESULT_CODES = { }, PREGATE_CONFIG_INVALID: { anchor: 'pregate-config-invalid', - summary: '.gstack/pregate.json has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob', + summary: 'the pregate policy (.gstack/pregate.json, or the user-owned copy) has an unknown key, a wrong type, a non-downgradable check in `warn`, or an uncontained glob', fix: 'run `gstack-pregate validate` and fix the file on the base branch (secrets and required lanes cannot be downgraded)', }, PREGATE_SECRETS: { diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index decf19b9d..f969c2b0a 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -11,7 +11,7 @@ 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 { buildContext } from '../lib/pregate/context'; +import { buildContext, sha256 } from '../lib/pregate/context'; import { runPregate } from '../lib/pregate/index'; import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; @@ -61,6 +61,8 @@ describe('loadConfigFromUser', () => { fs.writeFileSync(userPolicy, '{"local": ['); const bad = loadConfigFromUser(userPolicy); expect(bad && 'errors' in bad ? bad.errors.map(e => e.message.startsWith('not valid JSON')) : null).toEqual([true]); + // The JSON error names this file, not the repo copy parseConfig assumes. + expect(bad && 'errors' in bad ? bad.errors.map(e => e.path) : null).toEqual([userPolicy]); }); }); @@ -69,7 +71,7 @@ describe('gstack-pregate with a user-owned policy', () => { // 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(); + const { repo, userPolicy, run } = setup(); expect(run().out).toContain('(no .gstack/pregate.json)'); writePolicy(userPolicy, { local: ['free-tests.yml/unit'] }); @@ -81,6 +83,9 @@ describe('gstack-pregate with a user-owned policy', () => { writePolicy(userPolicy, { local: LOCAL_ALL }); const all = run(['--explain']); expect(all.status).toBe(EXIT.ok); + // The record pins the user file's bytes, since no commit does. + const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes'); + expect(lanes.inputs.policy).toBe(sha256(fs.readFileSync(userPolicy, 'utf8'))); 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); @@ -110,6 +115,7 @@ describe('gstack-pregate with a user-owned policy', () => { expect(invalid.err).toContain('PREGATE_CONFIG_INVALID'); expect(invalid.err).toContain(`user ${userPolicy}`); expect(invalid.err).toContain(`fix: edit ${userPolicy} directly`); + expect(invalid.err).not.toContain('.gstack/pregate.json has'); for (const [key, value] of [['sealed', { paths: ['evals/**'] }], ['price_file', 'evals/calls.json']] as const) { writePolicy(userPolicy, { [key]: value }); @@ -147,6 +153,10 @@ describe('gstack-pregate with a user-owned policy', () => { expect(based.err).not.toContain('PREGATE_CONFIG_INVALID'); expect(based.out).toContain('origin/main@'); expect(based.out).not.toContain('policy user'); + // A base copy is pinned by inputs.base; no user digest is recorded. + const lanes = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes'); + expect(lanes.inputs.base).toContain('origin/main@'); + expect('policy' in lanes.inputs).toBe(false); }, 60_000); // Value: protects=the --remote refusal for a policy with no remote_gate tells the user where to declare it: the repo copy "on the @@ -202,5 +212,47 @@ describe('gstack-pregate with a user-owned policy', () => { expect(stalled.code).toBe('PREGATE_STALLED'); expect(stalled.fix).toContain(`stall_timeout_s in ${userPolicy}; 0 disables`); }, 60_000); -}); + // Value: protects=a user path inside the checkout (GSTACK_HOME under the repo, or a relative state root when HOME is unset) is + // never read, so a PR cannot commit its own "user" policy; fails_when=userConfigRejection is dropped and the in-repo file + // becomes the policy; why_new=the trust check is new; seam=none (userConfigPath is an existing buildContext option); + // no_claim=a symlinked state root (realish covers it, untested here) + test('a user path inside the repository or relative is ignored, with an explain line', () => { + const { repo } = setup(); + const inside = path.join(repo.work, '.gstack', 'projects', 'x', 'pregate.json'); + fs.mkdirSync(path.dirname(inside), { recursive: true }); + fs.writeFileSync(inside, JSON.stringify({ local: ['not-a-lane'] })); + for (const [file, why] of [[inside, 'inside the repository'], [path.join('.gstack', 'projects', 'x', 'pregate.json'), 'not an absolute path']] as const) { + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: file }); + if (!('ctx' in loaded)) throw new Error(`refused: ${loaded.refused.message}`); + expect(loaded.ctx.configLabel).not.toStartWith('user '); + expect(loaded.ctx.explain).toContain(`user policy: ${file} ignored (${why}${why === 'inside the repository' ? '; a PR could supply it' : ''})`); + } + }, 60_000); + + // Value: protects=--explain says when the user file was looked for and absent, and names a `local` id that matches no platform lane; + // fails_when=either line is dropped and a typo'd slug or lane id silently does nothing; why_new=both lines are new; seam=none; + // no_claim=the line wording beyond its path and id + test('--explain names an absent user file and a local id that matches no platform lane', () => { + const { userPolicy, run } = setup(); + expect(run(['--explain']).out).toContain(`user policy: ${userPolicy} (absent)`); + writePolicy(userPolicy, { local: [...LOCAL_ALL, 'ci.yml/nope'] }); + const r = run(['--explain']); + expect(r.out).toContain('local: ci.yml/nope matches no platform lane; ignored'); + expect(r.out).not.toContain('local: free-tests.yml/unit matches'); + }, 60_000); + + // Value: protects=with no GSTACK_PROJECT_SLUG the bin finds the user file under the slug gstack-slug resolves for the repo; + // fails_when=the bin computes the slug some other way (a different cwd or state root) and the documented path is never read; + // why_new=every other test pins GSTACK_PROJECT_SLUG; seam=none; no_claim=slug rules themselves (state-root tests cover them) + test('the default slug path is the one gstack-slug prints', () => { + const { repo } = setup(); + const noSlug = { GSTACK_PROJECT_SLUG: '' }; + const slug = /^SLUG=(.+)$/m.exec(runBin(repo, 'gstack-slug', [], noSlug).out)?.[1]; + expect(slug).toBeTruthy(); + const userPolicy = path.join(repo.home, 'projects', slug!, 'pregate.json'); + fs.mkdirSync(path.dirname(userPolicy), { recursive: true }); + writePolicy(userPolicy, { local: LOCAL_ALL }); + expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'], noSlug).out).toContain(`policy user ${userPolicy}`); + }, 60_000); +}); From fbad6926b7a9a893484fdac426a6a93916635f96 Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Mon, 19 Oct 2026 09:21:45 +1300 Subject: [PATCH 06/13] fix(pregate): containment edges and explain wording for the user policy A state root under an in-repo directory named ..something passed the inside-the-repo check, and realpath kept the caller's casing on macOS. The unknown-local explain line now says workflow lane, and the state-root chain in explain and docs names every source. Co-Authored-By: Claude Opus 5.5 --- docs/pregate.md | 6 +++--- lib/pregate/config.ts | 2 +- lib/pregate/context.ts | 8 ++++---- test/pregate-user-policy.test.ts | 27 ++++++++++++++++++++++----- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/docs/pregate.md b/docs/pregate.md index cfad85a34..58314a53d 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -65,7 +65,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`). | `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) | | `warn` | check ids downgraded to warn; `secrets` and required lanes never | | `strays.allow` | intentional root files or new top-level directories | -| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no platform lane | +| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane | | `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) | | `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) | | `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) | @@ -77,8 +77,8 @@ Only the `origin/` copy or the user-owned file below is honoured, so a downgrade is reviewed (or is the user's own) before it takes effect. A repo that keeps `.gstack/` out of git (a shared repo where gstack is one contributor's tool) can hold the same file at `/projects//pregate.json`. The -state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `~/.gstack` (full -chain in [docs/state-root.md](state-root.md)); `` is what +state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`, +else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `` is what `~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository (`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none (never under `--policy-from head`), it lives outside the repository so a PR still diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index 0fc24b78c..e3348f252 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -277,6 +277,6 @@ export function explainConfig(): string { '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= tests= failed=) (docs/pregate.md#remote-gate)', - 'no .gstack/pregate.json on the base: /projects//pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else ~/.gstack; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)', + 'no .gstack/pregate.json on the base: /projects//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)', ].join('\n') + '\n'; } diff --git a/lib/pregate/context.ts b/lib/pregate/context.ts index db8d48e1a..480d00b8b 100644 --- a/lib/pregate/context.ts +++ b/lib/pregate/context.ts @@ -106,13 +106,13 @@ export function touchedFiles(repoRoot: string, mergeBase: string): TouchedFile[] return [...out.values()].filter(t => t.path !== 'node_modules' && !t.path.startsWith('node_modules/')).sort((a, b) => a.path.localeCompare(b.path)); } -/** The deepest existing ancestor realpathed, the rest re-joined: a symlinked state root inside the checkout still counts as inside. */ +/** The deepest existing ancestor realpathed (on-disk casing), the rest re-joined: a symlinked state root inside the checkout still counts as inside. */ function realish(p: string): string { let head = path.resolve(p); const tail: string[] = []; while (!fs.existsSync(head) && path.dirname(head) !== head) { tail.unshift(path.basename(head)); head = path.dirname(head); } - try { return path.join(fs.realpathSync(head), ...tail); } catch { return path.resolve(p); } + try { return path.join(fs.realpathSync.native(head), ...tail); } catch { return path.resolve(p); } } -const within = (root: string, p: string) => { const rel = path.relative(root, p); return !rel.startsWith('..') && !path.isAbsolute(rel); }; +const within = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); }; /** Why a user policy path must not be read, or null: a relative path (no HOME) or one a PR could commit is never trusted. */ export function userConfigRejection(repoRoot: string, file: string): string | null { @@ -149,7 +149,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad { const config = configLoad.config as PregateConfig; const lanes = discoverLanes(o.repoRoot, platform); // A lane that exists but is not platform-tier on this host already runs here, so only an unknown id is worth a line. - for (const id of config.local) if (!lanes.some(l => l.id === id)) explain.push(`local: ${id} matches no platform lane; ignored`); + for (const id of config.local) if (!lanes.some(l => l.id === id)) explain.push(`local: ${id} matches no workflow lane; ignored`); return { ctx: { repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase), diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index f969c2b0a..461cd88c4 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -8,10 +8,11 @@ */ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { loadConfigFromUser, validateConfig } from '../lib/pregate/config'; -import { buildContext, sha256 } from '../lib/pregate/context'; +import { buildContext, sha256, userConfigRejection } from '../lib/pregate/context'; import { runPregate } from '../lib/pregate/index'; import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; @@ -216,7 +217,7 @@ describe('gstack-pregate with a user-owned policy', () => { // Value: protects=a user path inside the checkout (GSTACK_HOME under the repo, or a relative state root when HOME is unset) is // never read, so a PR cannot commit its own "user" policy; fails_when=userConfigRejection is dropped and the in-repo file // becomes the policy; why_new=the trust check is new; seam=none (userConfigPath is an existing buildContext option); - // no_claim=a symlinked state root (realish covers it, untested here) + // no_claim=a symlinked state root (the next test) test('a user path inside the repository or relative is ignored, with an explain line', () => { const { repo } = setup(); const inside = path.join(repo.work, '.gstack', 'projects', 'x', 'pregate.json'); @@ -230,15 +231,31 @@ describe('gstack-pregate with a user-owned policy', () => { } }, 60_000); - // Value: protects=--explain says when the user file was looked for and absent, and names a `local` id that matches no platform lane; + // Value: protects=the containment check sees through a symlinked state root and does not mistake an in-repo `..name` + // directory for a parent; fails_when=the realpath half or the `..`-segment test regresses and a file a PR committed is read + // as the user policy; why_new=both edges are new; seam=none; no_claim=case-only path differences (realpathSync.native) + test('a state root symlinked into the checkout, or under an in-repo ..name directory, is inside', () => { + const { repo } = setup(); + fs.mkdirSync(path.join(repo.work, '.st')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-sr-')); + try { + const link = path.join(outside, 'root'); + fs.symlinkSync(path.join(repo.work, '.st'), link); + expect(userConfigRejection(repo.work, path.join(link, 'projects', 'x', 'pregate.json'))).toContain('inside the repository'); + expect(userConfigRejection(repo.work, path.join(repo.work, '..st', 'projects', 'x', 'pregate.json'))).toContain('inside the repository'); + expect(userConfigRejection(repo.work, path.join(outside, 'projects', 'x', 'pregate.json'))).toBeNull(); + } finally { fs.rmSync(outside, { recursive: true, force: true }); } + }); + + // Value: protects=--explain says when the user file was looked for and absent, and names a `local` id that matches no workflow lane; // fails_when=either line is dropped and a typo'd slug or lane id silently does nothing; why_new=both lines are new; seam=none; // no_claim=the line wording beyond its path and id - test('--explain names an absent user file and a local id that matches no platform lane', () => { + test('--explain names an absent user file and a local id that matches no workflow lane', () => { const { userPolicy, run } = setup(); expect(run(['--explain']).out).toContain(`user policy: ${userPolicy} (absent)`); writePolicy(userPolicy, { local: [...LOCAL_ALL, 'ci.yml/nope'] }); const r = run(['--explain']); - expect(r.out).toContain('local: ci.yml/nope matches no platform lane; ignored'); + expect(r.out).toContain('local: ci.yml/nope matches no workflow lane; ignored'); expect(r.out).not.toContain('local: free-tests.yml/unit matches'); }, 60_000); From 181c6f4942984ee7469413f289711dd4ddc8c1f5 Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Mon, 19 Oct 2026 09:34:30 +1300 Subject: [PATCH 07/13] fix(pregate): a base copy git cannot read, or a dangling user symlink, refuses Any git show failure read as no base policy, which now hands the run to the unreviewed user file; it is absent only when cat-file says so, and a spawn error (stdout cut at the buffer limit) counts as a failure. A user pregate.json that is a dangling symlink refuses instead of reading as absent. Co-Authored-By: Claude Opus 5.5 --- lib/pregate/config.ts | 12 +++++++++--- test/pregate-user-policy.test.ts | 23 +++++++++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index e3348f252..0c188c51f 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -208,7 +208,8 @@ export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present function git(cwd: string, args: string[]): { ok: boolean; out: string } { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); - return { ok: r.status === 0, out: (r.stdout ?? '').trim() }; + // r.error covers ENOBUFS, where stdout is cut at the buffer limit even with status 0. + return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() }; } export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad { @@ -216,7 +217,11 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` }; const show = git(repoRoot, ['show', `${sha.out}:${PREGATE_CONFIG_PATH}`]); - if (!show.ok) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; + if (!show.ok) { + // Absent only when git says so: a timeout, an oversized blob or a corrupt object must refuse, not hand the run to the user file. + if (git(repoRoot, ['cat-file', '-e', `${sha.out}:${PREGATE_CONFIG_PATH}`]).ok) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} exists but git show could not read it` }; + return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; + } return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' }; } @@ -230,7 +235,8 @@ export function loadConfigFromHead(repoRoot: string, knownChecks: readonly strin 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; + // A dangling symlink is a policy the user meant to have, not an absent one. + if ((e as NodeJS.ErrnoException).code === 'ENOENT' && !fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink()) 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' }; } diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index 461cd88c4..91bbcff7b 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -49,7 +49,7 @@ describe('pregate.json local', () => { 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) + // no_claim=EACCES specifically (a directory, and a dangling symlink, stand in for an unreadable file) test('ENOENT is null; an unreadable file refuses at its path', () => { const { userPolicy } = setup(); expect(loadConfigFromUser(userPolicy)).toBeNull(); @@ -57,8 +57,12 @@ describe('loadConfigFromUser', () => { 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}`]); - // Malformed JSON reaches the sealed/price_file probe too; it must still come back as a refusal, never a throw. fs.rmSync(userPolicy, { recursive: true }); + fs.symlinkSync(path.join(path.dirname(userPolicy), 'not-yet-cloned.json'), userPolicy); + const dangling = loadConfigFromUser(userPolicy); + expect(dangling && 'errors' in dangling ? dangling.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]); + fs.unlinkSync(userPolicy); + // Malformed JSON reaches the sealed/price_file probe too; it must still come back as a refusal, never a throw. fs.writeFileSync(userPolicy, '{"local": ['); const bad = loadConfigFromUser(userPolicy); expect(bad && 'errors' in bad ? bad.errors.map(e => e.message.startsWith('not valid JSON')) : null).toEqual([true]); @@ -231,6 +235,21 @@ describe('gstack-pregate with a user-owned policy', () => { } }, 60_000); + // Value: protects=a base copy that exists but git show cannot read (here over spawnSync's 1 MB buffer) refuses instead of + // letting the user file replace it; fails_when=every git show failure is read as "no base copy" and the unreviewed user + // file governs a repo that commits its policy; why_new=the user-file fallback is new; seam=none; no_claim=timeouts and + // corrupt objects (same branch, not separately driven) + test('a base copy git show cannot read refuses rather than reading the user file', () => { + const repo = makeFixtureRepo({ pregate: null, baseFiles: { '.gstack/pregate.json': `{"runner": "node --test {files}"${' '.repeat(1_200_000)}}\n` } }); + 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 }); + writePolicy(userPolicy, { local: LOCAL_ALL }); + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy }); + expect('refused' in loaded ? [loaded.refused.code, loaded.refused.message] : null).toEqual(['POLICY_SOURCE_UNAVAILABLE', 'origin/main:.gstack/pregate.json exists but git show could not read it']); + }, 60_000); + // Value: protects=the containment check sees through a symlinked state root and does not mistake an in-repo `..name` // directory for a parent; fails_when=the realpath half or the `..`-segment test regresses and a file a PR committed is read // as the user policy; why_new=both edges are new; seam=none; no_claim=case-only path differences (realpathSync.native) From 55b73dbdc79da220f01c9e2718ee77e440cc71ac Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Mon, 19 Oct 2026 09:37:52 +1300 Subject: [PATCH 08/13] fix(pregate): decide a missing base policy from the tree, and catch dangling parent links cat-file -e checks the blob, so a missing or corrupt object still read as no base copy; ls-tree reads the tree entry, and any failure refuses. The dangling-symlink check now covers every path component, not just the file. Co-Authored-By: Claude Opus 5.5 --- lib/pregate/config.ts | 16 +++++++++--- test/pregate-user-policy.test.ts | 43 ++++++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index 0c188c51f..9b7f2bd4b 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -218,8 +218,9 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` }; const show = git(repoRoot, ['show', `${sha.out}:${PREGATE_CONFIG_PATH}`]); if (!show.ok) { - // Absent only when git says so: a timeout, an oversized blob or a corrupt object must refuse, not hand the run to the user file. - if (git(repoRoot, ['cat-file', '-e', `${sha.out}:${PREGATE_CONFIG_PATH}`]).ok) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} exists but git show could not read it` }; + // Absent only when the tree says so: a timeout, an oversized blob or a missing/corrupt object must refuse, not hand the run to the user file. + const entry = git(repoRoot, ['ls-tree', '--name-only', sha.out, '--', PREGATE_CONFIG_PATH]); + if (!entry.ok || entry.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} could not be read and git cannot confirm it is absent` }; return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; } return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' }; @@ -231,12 +232,19 @@ 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' }; } +/** A symlink to nothing on the way to `file` (a dotfiles link not yet cloned): a policy the user meant to have, not an absent one. */ +function danglingLink(file: string): boolean { + for (let p = file; path.dirname(p) !== p; p = path.dirname(p)) { + try { if (fs.lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() && !fs.existsSync(p)) return true; } catch { return true; } + } + return false; +} + /** 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) { - // A dangling symlink is a policy the user meant to have, not an absent one. - if ((e as NodeJS.ErrnoException).code === 'ENOENT' && !fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink()) return null; + if ((e as NodeJS.ErrnoException).code === 'ENOENT' && !danglingLink(file)) 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' }; } diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index 91bbcff7b..2abdf5fa9 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -32,6 +32,15 @@ function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[ const run = (extra: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...extra], { GSTACK_PROJECT_SLUG: SLUG }); return { repo, userPolicy, run }; } +/** setup() with a base that commits `.gstack/pregate.json` (raw text, or a policy object). */ +function setupWithBase(base: string | Record): { repo: FixtureRepo; userPolicy: string } { + const repo = makeFixtureRepo({ pregate: null, baseFiles: { '.gstack/pregate.json': typeof base === 'string' ? base : JSON.stringify(base) } }); + 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 }); + return { repo, userPolicy }; +} const writePolicy = (file: string, policy: Record) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy })); describe('pregate.json local', () => { @@ -62,6 +71,13 @@ describe('loadConfigFromUser', () => { const dangling = loadConfigFromUser(userPolicy); expect(dangling && 'errors' in dangling ? dangling.errors.map(e => [e.code, e.path]) : null).toEqual([['PREGATE_CONFIG_INVALID', userPolicy]]); fs.unlinkSync(userPolicy); + // A dangling link one level up (the project directory) is the same intent. + const dir = path.dirname(userPolicy); + fs.rmSync(dir, { recursive: true }); + fs.symlinkSync(path.join(path.dirname(dir), 'not-yet-cloned'), dir); + expect(loadConfigFromUser(userPolicy)).not.toBeNull(); + fs.unlinkSync(dir); + fs.mkdirSync(dir); // Malformed JSON reaches the sealed/price_file probe too; it must still come back as a refusal, never a throw. fs.writeFileSync(userPolicy, '{"local": ['); const bad = loadConfigFromUser(userPolicy); @@ -235,19 +251,26 @@ describe('gstack-pregate with a user-owned policy', () => { } }, 60_000); - // Value: protects=a base copy that exists but git show cannot read (here over spawnSync's 1 MB buffer) refuses instead of - // letting the user file replace it; fails_when=every git show failure is read as "no base copy" and the unreviewed user - // file governs a repo that commits its policy; why_new=the user-file fallback is new; seam=none; no_claim=timeouts and - // corrupt objects (same branch, not separately driven) + // Value: protects=a base copy that exists but git show cannot read (over spawnSync's 1 MB buffer, or its blob missing) + // refuses instead of letting the user file replace it; fails_when=a git show failure is read as "no base copy" and the + // unreviewed user file governs a repo that commits its policy; why_new=the user-file fallback is new; seam=none; + // no_claim=timeouts (they take the same failure branch) test('a base copy git show cannot read refuses rather than reading the user file', () => { - const repo = makeFixtureRepo({ pregate: null, baseFiles: { '.gstack/pregate.json': `{"runner": "node --test {files}"${' '.repeat(1_200_000)}}\n` } }); - 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 { repo, userPolicy } = setupWithBase(`{"runner": "node --test {files}"${' '.repeat(1_200_000)}}\n`); writePolicy(userPolicy, { local: LOCAL_ALL }); const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy }); - expect('refused' in loaded ? [loaded.refused.code, loaded.refused.message] : null).toEqual(['POLICY_SOURCE_UNAVAILABLE', 'origin/main:.gstack/pregate.json exists but git show could not read it']); + expect('refused' in loaded ? [loaded.refused.code, loaded.refused.message] : null).toEqual(['POLICY_SOURCE_UNAVAILABLE', 'origin/main:.gstack/pregate.json could not be read and git cannot confirm it is absent']); + }, 60_000); + + // Value: protects=absence is decided from the tree, not the blob; fails_when=cat-file -e (a blob check) stands in for absence + // and a missing or corrupt blob lets the user file govern; why_new=the fallback is new; seam=none; no_claim=partial clones + test('a base copy whose blob is missing refuses rather than reading the user file', () => { + const { repo, userPolicy } = setupWithBase({ runner: 'node --test {files}' }); + const blob = repo.git('rev-parse', 'origin/main:.gstack/pregate.json'); + fs.rmSync(path.join(repo.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2))); + writePolicy(userPolicy, { local: LOCAL_ALL }); + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: false, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 16, testBackend: 'local', userConfigPath: userPolicy }); + expect('refused' in loaded ? loaded.refused.code : null).toBe('POLICY_SOURCE_UNAVAILABLE'); }, 60_000); // Value: protects=the containment check sees through a symlinked state root and does not mistake an in-repo `..name` From 4cfe49fd94c3db91202192f8d601edf19337e940 Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Mon, 19 Oct 2026 09:50:31 +1300 Subject: [PATCH 09/13] fix(result-codes): POLICY_SOURCE_UNAVAILABLE names an unreadable committed pin file Co-Authored-By: Claude Opus 5.5 --- lib/result-codes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 23a3e1ddb..9b168aa71 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -165,7 +165,7 @@ export const RESULT_CODES = { // C-stamp (plan C1, C2, C3, C5, C7): ship policy, restamp, tree receipt, ship receipt. POLICY_SOURCE_UNAVAILABLE: { anchor: 'policy-source-unavailable', - summary: 'policy source unavailable: origin/ is not a resolvable commit, so the reviewed .gstack/ship-policy.json cannot be read', + summary: 'policy source unavailable: origin/ is not a resolvable commit, or a pin file committed on it cannot be read, so the reviewed .gstack/ship-policy.json (or .gstack/pregate.json) cannot be read', fix: 'git fetch origin --depth=1 (the working-tree copy is never used silently)', }, POLICY_INVALID: { From dfb7092e7bd514e1af1fae28f34c28083380e540 Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Mon, 19 Oct 2026 09:50:31 +1300 Subject: [PATCH 10/13] docs(pregate): the active policy may be the user-owned file; new refusals Guards, remote_gate, stall timeouts and lane fixes name the user-owned file when it is the active policy; the trust boundary says run alone reads it; the dangling-symlink and unreadable-base-copy refusals are documented, and state-root lists the bucket's pregate.json. Co-Authored-By: Claude Opus 5.5 --- docs/pregate.md | 22 ++++++++++++++-------- docs/state-root.md | 3 +++ docs/troubleshooting.md | 16 ++++++++++------ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/pregate.md b/docs/pregate.md index 58314a53d..859ba1407 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -19,7 +19,8 @@ Three bins, all over `lib/`: A pin file names commands. Its presence never authorizes execution: both tools read the committed copy on `origin/` (a PR cannot smuggle a command into -its own gate) and run commands only with `--allow-repo-commands`, which `/ship` +its own gate; `gstack-pregate run` alone also reads a user-owned file outside +the repository when the base has none, see [pregate v1](#pregate-v1)) and run commands only with `--allow-repo-commands`, which `/ship` passes. Standalone, `gstack-regen check` lists the commands and exits 3 (`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes` `incomplete`, never `pass`. `--policy-from head` (a PR that introduces or @@ -89,7 +90,10 @@ so, or says `(absent)` when no file is there. It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`) rather than holding a policy nothing reads. A user file that exists but cannot -be read refuses the same way instead of falling back to defaults. +be read, or a symlink on its path that points nowhere, refuses the same way +instead of falling back to defaults. A base copy git cannot read (and +`git ls-tree` cannot confirm is absent) refuses with `POLICY_SOURCE_UNAVAILABLE` +instead of handing the run to the user file. `gstack-pregate validate` / `init` cover the working-tree file. ## `gstack-pregate` @@ -123,13 +127,15 @@ Two stages with separate clocks, both printed: `TIMING_ASSERTIONS: found= escaped= files=` summary. It warns (`PREGATE_TIMING_ASSERTION`); it does not block. See [timing](#timing). -- `guards`: each guard in `.gstack/pregate.json` whose `when` globs match a +- `guards`: each guard in the active policy (`.gstack/pregate.json`, or the + user-owned file) whose `when` globs match a touched file (renames included) runs its `command` in its own process, under the verified launch Bun, with its own `timeout_s` (default `--timeout`). A non-zero exit fails pregate naming the guard and its `fix` (`PREGATE_GUARD_FAILED`); a timeout is `incomplete`; without `--allow-repo-commands` the selected guards are listed and the check is - `incomplete`. Guards are read from the base branch's copy and cannot be + `incomplete`. Guards are read from the base branch's copy (or the + user-owned file when the base carries none) and cannot be downgraded to warn. See [guards](#guards). **Stage 2, test execution:** @@ -150,7 +156,7 @@ Two stages with separate clocks, both printed: `set -o pipefail`, judged on the runner's summary line (zero tests ran is a failure, a `| tail`-hidden exit is still an exit); a platform lane this machine cannot run becomes a `requires-remote` obligation naming - `workflow.yml/job`. `--explain` prints each test's provenance and every + `workflow.yml/job`, unless `local` names that lane. `--explain` prints each test's provenance and every uncertain mapping (a planner-computed matrix without a `selection` manifest selects conservatively). Test files matching `serial_patterns` (default `**/*.serial.test.*`) run one process per file after the unit files. The @@ -163,7 +169,7 @@ Two stages with separate clocks, both printed: runner process (the unit-file group, each serial file, each guard without its own `timeout_s`), not their total; the stall timeout catches a hung or silent one sooner. -- Under 8 cores, a base-branch `remote_gate` or a remote `test_backend` +- Under 8 cores, a `remote_gate` in the active policy or a remote `test_backend` (`gstack-config set test_backend ubicloud`) records the local test tier as a `requires-remote` obligation instead of running it: see [remote gate](#remote-gate). @@ -229,8 +235,8 @@ misses a descendant that was reparented or started detached. ## Small machines and the remote gate -Under 8 cores (`os.availableParallelism()`), when the base branch's -`.gstack/pregate.json` declares `remote_gate` or `gstack-config get +Under 8 cores (`os.availableParallelism()`), when the active policy (the +base branch's `.gstack/pregate.json`, or the user-owned file) declares `remote_gate` or `gstack-config get test_backend` is not `local`, the lanes check runs nothing locally: it records the selected local files as a `requires-remote` obligation with the command to run, `pregate.json/remote_gate` (the declared command) or diff --git a/docs/state-root.md b/docs/state-root.md index 5cd662ee5..45c246ec6 100644 --- a/docs/state-root.md +++ b/docs/state-root.md @@ -104,6 +104,9 @@ lives in `projects//`. `bin/gstack-slug` picks the slug from the git and the ssh, https and ssh-with-port spellings of one repository share one. - Without a remote, the project root's directory name is used. +A bucket can also hold a user-owned `pregate.json` policy, for a repo that keeps +`.gstack/` out of git (see [pregate v1](pregate.md#pregate-v1)). + The first slug gstack resolves in a directory sticks (it is cached under `slug-cache/`), so a project keeps its bucket when it adds a remote later. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b47ead752..aa1652f35 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2354,7 +2354,10 @@ file is described in [docs/ship-policy.md](ship-policy.md); the queue flow in **Meaning.** The policy is read from the committed copy on `origin/`, and that ref does not resolve here (a shallow or detached gate checkout, or a fetch that never happened). The tool exits 3 and never falls back to the -working-tree file silently. +working-tree file silently. For `gstack-pregate` the same refusal covers a base +`.gstack/pregate.json` git cannot read (a timeout, an oversized blob, a missing +or corrupt object) that `git ls-tree` cannot confirm is absent; it never falls +through to the user-owned file. **Fix.** `git fetch origin --depth=1`, then rerun. To run against the branch's own file deliberately, pass `--policy-from head --allow-repo-commands`; @@ -2812,7 +2815,7 @@ repository's lockfile), then rerun `gstack-regen check` or the pre-gate. that escapes the repository, a runner without `{files}`, or `secrets` in `warn`. The user-owned `/projects//pregate.json` also refuses when it sets `sealed` or `price_file` (both are read only from the base branch's copy) -or exists but cannot be read. +or exists but cannot be read (a dangling symlink on its path counts). **Fix.** `gstack-pregate validate`. `secrets` and required-lane obligations cannot be downgraded; every other check id in `warn` is honoured only from the @@ -2859,7 +2862,7 @@ test. An unmapped source or code file still fails. A file under a test directory is not this empty lane. **Fix.** Add a test that names the file, or declare its lane (an explicit `[]` -for files with no behaviour) under `dependencies` in `.gstack/pregate.json`. +for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or the user-owned file the message names). Other non-Markdown documentation goes in `empty_lanes` on the base branch. `--explain` prints every mapping and why. For a file with no lane it also prints the import specifiers in test files that resolved to no repo file (for example a @@ -2892,7 +2895,7 @@ keep the tree still, then rerun. **Meaning.** A test command the lanes check (or a guard, or the `--remote` gate) launched wrote nothing to its log for the stall timeout (default 300 s, -`stall_timeout_s` in the base branch's `.gstack/pregate.json`, or +`stall_timeout_s` in the base branch's `.gstack/pregate.json` or the user-owned file, or `--stall-timeout`). pregate killed its process group and names the last test file started. The check is incomplete, never pass. @@ -3149,8 +3152,9 @@ next `./setup` prints no CRLF warning. ### `PREGATE_GUARD_FAILED` -**Meaning.** A guard from the base branch's `.gstack/pregate.json` (a repo -command such as a serial-test manifest check or a CLI golden test) matched a +**Meaning.** A guard (a repo command such as a serial-test manifest check or a +CLI golden test) from the base branch's `.gstack/pregate.json`, or from the +user-owned file when the base carries none, matched a file the branch touched and exited non-zero. Guards cover what the import graph cannot see: manifests, subprocess goldens, registries. They cannot be downgraded to warn. From 5247ac61af1e3a8c632731e379c20f663cd96745 Mon Sep 17 00:00:00 2001 From: arlophoenix Date: Tue, 20 Oct 2026 11:27:08 +1300 Subject: [PATCH 11/13] fix(pregate): a checkout's .env cannot pick the user policy; verify re-checks it Bun loads .env from the working directory, so a branch could set GSTACK_PROJECT_SLUG or a state root and choose which pregate.json governs it. The shebang now passes --no-env-file, scriptInvocation carries that flag over when it launches the bin as bun