Merge PR #3286 (arlophoenix): user-owned pregate policy file and local lanes

Ports arlophoenix's branch as is (13 commits, their two upstream merges
included). It merges cleanly onto main and onto #3380.

Co-authored-by: arlophoenix <arlo.phoenix@gmail.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
committed 2026-10-24 23:36:47 +00:00
commit 2bc9f9facb
14 files changed
+605 -53

No files matched your search

+6 -3
View File
@@ -29,7 +29,7 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
import { buildContext, describeRefusal } from '../lib/pregate/context';
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
@@ -140,13 +140,14 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
if (stallTimeoutMs !== undefined && (!Number.isFinite(stallTimeoutMs) || stallTimeoutMs < 0)) { process.stderr.write('gstack-pregate: --stall-timeout needs a number of seconds (0 disables)\n'); return EXIT.usage; }
let remote: { spendCapUsd: number } | null = null;
if (rest.includes('--remote')) {
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the base branch\'s remote_gate and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
if (!allow || policyFrom === 'head') { process.stderr.write('gstack-pregate: --remote runs the active policy\'s remote_gate (the base branch\'s copy, or the user-owned file when the base carries none) and needs --allow-repo-commands with --policy-from base\n'); return EXIT.refused; }
const cap = Number(flag(rest, '--spend-cap') ?? process.env.GSTACK_SPEND_CAP_USD ?? NaN);
if (!Number.isFinite(cap) || cap <= 0) { process.stderr.write('gstack-pregate: --remote spends money; pass --spend-cap <usd> (or set GSTACK_SPEND_CAP_USD)\n'); return EXIT.usage; }
remote = { spendCapUsd: cap };
} else if (flag(rest, '--spend-cap') !== undefined) { process.stderr.write('gstack-pregate: --spend-cap applies only with --remote\n'); return EXIT.usage; }
const base = resolveBaseBranch(root, flag(rest, '--base'));
const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2] });
const userConfigPath = defaultUserConfigPath(root);
const loaded = buildContext({ repoRoot: root, base, policyFrom, allowRepoCommands: allow, timeoutMs, stallTimeoutMs, remote, knownChecks: [...CHECK_IDS, ...PREGATE_TIER2], userConfigPath });
if ('refused' in loaded) {
const refusal = describeRefusal(loaded.refused, base);
process.stderr.write(refusal.line + '\n');
@@ -281,6 +282,8 @@ async function main(argv: string[]): Promise<number> {
if (bad) { process.stderr.write(`gstack-pregate: ${bad}; see gstack-pregate --help\n`); return EXIT.usage; }
const root = repoRoot();
if (!root) { process.stderr.write('gstack-pregate: not inside a git repository\n'); return EXIT.usage; }
const dotenv = loadedDotenvFiles(process.cwd(), process.execArgv);
if (dotenv.length) { process.stderr.write(resultLine('gstack-pregate', 'POLICY_SOURCE_UNAVAILABLE', `bun loaded ${dotenv.join(', ')} from the working directory, and a checkout's .env must not choose the state root or slug behind the policy; run gstack-pregate directly (its shebang passes --no-env-file) or as bun --no-env-file`) + '\n'); return EXIT.refused; }
if (cmd === 'run') return await cmdRun(root, rest, json);
if (cmd === 'verify') return cmdVerify(root, rest, json);
if (cmd === 'clear') return cmdClear(root, rest);
+38 -9
View File
@@ -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/<base>` (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
@@ -65,6 +66,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 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)) |
@@ -72,8 +74,33 @@ 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/<base>` copy is honoured, so a downgrade is reviewed before it
takes effect. `gstack-pregate validate` / `init` cover the working-tree file.
Only the `origin/<base>` 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 `<state root>/projects/<slug>/pregate.json`. The
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)); `<slug>` 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 <path>`) and fingerprints
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
ship receipt treat an edited or deleted user file, or one that appears after a
run under the defaults, as `PREGATE_STALE`. 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. The slug and state root come from
the caller's environment, never the checkout's `.env`: the bin's shebang passes
`--no-env-file`, and a bare `bun gstack-pregate` launched beside any `.env` file
refuses (`POLICY_SOURCE_UNAVAILABLE`). `verify` reads both paths from the
record, not from its own environment.
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, 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 init` writes a draft built from the repo, not a fixed example.
It prints the languages it found and the runner each language's tests would
@@ -115,13 +142,15 @@ Two stages with separate clocks, both printed:
`TIMING_ASSERTIONS: found=<n> escaped=<n> files=<n>` 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:**
@@ -177,7 +206,7 @@ Two stages with separate clocks, both printed:
failure, a `| tail`-hidden exit is still an exit; cargo's `test result:` lines, one per
test binary and doc-tests included, are summed); 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
@@ -190,7 +219,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).
@@ -256,8 +285,8 @@ misses a descendant that was reparented or started detached.
<a id="remote-gate"></a>
## 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
+3
View File
@@ -104,6 +104,9 @@ lives in `projects/<slug>/`. `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.
+26 -8
View File
@@ -2472,7 +2472,13 @@ 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/<base>`,
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. It also refuses when Bun loaded a `.env` file
from the working directory (a bare `bun gstack-pregate`), since a branch could
then pick the user policy: run `gstack-pregate` directly or as
`bun --no-env-file`.
**Fix.** `git fetch origin <base> --depth=1`, then rerun. To run against the
branch's own file deliberately, pass `--policy-from head --allow-repo-commands`;
@@ -2875,7 +2881,11 @@ 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/<base>`, 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
`<state root>/projects/<slug>/pregate.json` instead (outside the repository; see
docs/pregate.md for the state root and slug).
Details: docs/pregate.md.
<a id="regen-registry-invalid"></a>
### `REGEN_REGISTRY_INVALID`
@@ -2930,10 +2940,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 `<state root>/projects/<slug>/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 (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
base branch's copy.
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).
<a id="pregate-secrets"></a>
### `PREGATE_SECRETS`
@@ -2978,7 +2993,7 @@ allowlisted tool config no test names, are the assets-and-tool-config empty lane
not this error.
**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.
Dart files under `integration_test/` or `test_driver/` need a device, so pregate
never runs them: declare `"**/integration_test/**": []` (and
@@ -3017,7 +3032,7 @@ declare `runner`), raise `--timeout`, or 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.
@@ -3045,7 +3060,9 @@ file with `--receipt`, or let `gstack-pregate --stage tests --remote --spend-cap
### `PREGATE_STALE`
**Meaning.** `pregate.json` was recorded against a different working-tree
fingerprint than the one being published (the final stamp, a regen, any edit).
fingerprint than the one being published (the final stamp, a regen, any edit),
or the user-owned policy it ran under was edited or deleted (or one appeared
after a run under the defaults).
**Fix.** Rerun `gstack-pregate` after the final generation and stamp; Step 18
refuses to write the ship receipt otherwise.
@@ -3274,8 +3291,9 @@ next `./setup` prints no CRLF warning.
<a id="pregate-guard-failed"></a>
### `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.
+5 -1
View File
@@ -73,6 +73,10 @@ export interface PregateFile {
schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number;
// C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict.
head?: string; base?: string; generated_at?: string; policy?: string; registry?: string; requires_remote?: PregateRemote[]; verdict?: string;
/** sha256 of the user-owned policy when `policy` is `user <path>`; verify re-checks it. */
policy_digest?: string;
/** Under the defaults, the user-owned policy path that was looked for and absent; verify checks it is still absent. */
policy_user_path?: string;
}
export interface ShipReceipt {
schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string;
@@ -189,7 +193,7 @@ export const ARTIFACT_SCHEMAS = {
properties: {
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList } } },
head: str, base: str, generated_at: str, policy: str, registry: str, verdict: str,
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str,
requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str } } },
},
},
+7 -7
View File
@@ -220,8 +220,8 @@ function planGap(ctx: PregateContext, plan: RunPlan, total: number): Pick<CheckR
const { unclaimed, missing, noPubspec } = plan;
const detail = `no test runner detected for ${unclaimed.length} of ${total} selected file(s): ${unclaimed.slice(0, 8).join(' ')}${unclaimed.length > 8 ? ' …' : ''}${missing.length ? `; not on PATH: ${missing.join(', ')}` : ''}${noPubspec.length ? `; no pubspec.yaml above ${noPubspec.length} .dart file(s)` : ''}`;
const fix = missing.length ? `install ${missing.join(' and ')}${fvmPinnedFor(ctx.repoRoot, unclaimed) ? ' (or fvm, which this repo pins)' : ''} and rerun`
: noPubspec.length ? 'commit the Dart package\'s pubspec.yaml, or declare `runner` (with {files}) in .gstack/pregate.json'
: 'declare `runner` (with {files}) in .gstack/pregate.json';
: noPubspec.length ? `commit the Dart package's pubspec.yaml, or declare \`runner\` (with {files}) in ${ctx.configFile}`
: `declare \`runner\` (with {files}) in ${ctx.configFile}`;
return { code: 'PREGATE_INCOMPLETE', detail, fix };
}
@@ -318,7 +318,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
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' };
@@ -379,7 +379,7 @@ export function remoteReportProblem(report: RemoteReport, tree: string): string
*/
async function runRemote(ctx: PregateContext, sel: Selection, base: CheckResult): Promise<CheckResult> {
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-'));
@@ -411,7 +411,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
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: noLaneFix(sel.noLane) };
return { ...base, status: 'fail', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) };
}
if (sel.tests.length === 0) {
const inert = sel.inert.length ? `${sel.inert.length} asset or tool-config file(s)` : '';
@@ -449,9 +449,9 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
return { ...withRun, status: sel.remote.length ? 'requires-remote' : 'pass', detail };
}
function noLaneFix(noLane: readonly string[]): string {
function noLaneFix(ctx: PregateContext, noLane: readonly string[]): string {
const device = noLane.some(isDeviceDart) ? `; pregate does not run device tests, so declare "**/integration_test/**": [] (and "**/test_driver/**": []) and let CI run them` : '';
return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${PREGATE_CONFIG_PATH}${device}`;
return `add a test that names the file, or declare its lane (or []) under dependencies — or empty_lanes for other documentation — in ${ctx.configFile}${device}`;
}
export const lanesCheck: CheckDef = { id: 'lanes', stage: 'tests', tier: 1, downgradable: true, needsRepoCommands: true, run };
+2 -2
View File
@@ -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` };
}
+56 -7
View File
@@ -16,6 +16,9 @@
* warn check ids downgraded to warn (never `secrets`; `lanes` keeps
* its required-lane and requires-remote verdicts)
* strays.allow root files or new top-level directories that are intentional
* local `<workflow.yml>/<job>` lanes a local run satisfies (a
* platform-agnostic toolchain, or a repo whose CI is off):
* their tests run here instead of becoming requires-remote
* guards `{ id, when: [globs], command, fix?, timeout_s? }`: a repo
* command that must pass when a touched file matches `when`
* (manifest checks, CLI goldens); never downgradable
@@ -30,10 +33,16 @@
* 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
* `<state root>/projects/<slug>/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';
@@ -52,6 +61,7 @@ export interface PregateConfig {
selection: Record<string, string>;
warn: string[];
strays: { allow: string[] };
local: string[];
guards: Guard[];
serial_patterns: string[];
price_file: string | null;
@@ -59,12 +69,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 }
export 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 +164,7 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
const sel = obj.selection;
if (!sel || typeof sel !== 'object' || Array.isArray(sel)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'selection', message: 'must be an object of <workflow.yml>/<job> → command' });
else for (const [lane, command] of Object.entries(sel as Record<string, unknown>)) {
if (!/^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be <workflow file>/<job id>' });
if (!LANE_ID.test(lane)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: 'key must be <workflow file>/<job id>' });
const why = commandError(command);
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `selection[${JSON.stringify(lane)}]`, message: why });
}
@@ -176,6 +187,10 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
}
}
}
if ('local' in obj) {
if (!Array.isArray(obj.local) || obj.local.some(l => typeof l !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'local', message: 'must be an array of <workflow file>/<job id>' });
else (obj.local as string[]).forEach((l, i) => { if (!LANE_ID.test(l)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: `local[${i}]`, message: `${JSON.stringify(l)} must be <workflow file>/<job id>` }); });
}
return errors;
}
@@ -186,14 +201,15 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []):
const errors = validateConfig(raw, knownChecks);
if (errors.length) return { config: DEFAULT_CONFIG, errors };
const o = raw as Partial<PregateConfig>;
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, remote_gate: o.remote_gate ?? null }, errors: [] };
}
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'none' } | { unavailable: true; base: string; detail: string };
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** 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 });
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 {
@@ -201,7 +217,12 @@ 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 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' };
}
@@ -211,6 +232,32 @@ 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) {
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' };
}
const load = parseConfig(text, knownChecks);
// `sealed` (the pre-push guard) and `price_file` (gstack-eval-plan) read only the git copies, so a non-null value in the user file would look active and do nothing.
let raw: unknown = null;
try { raw = JSON.parse(text); } catch { /* parseConfig reported it */ }
const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file'].filter(k => (raw as Record<string, unknown>)[k] != null) : [];
// 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 } {
return (load as any).unavailable === true;
}
@@ -223,11 +270,13 @@ export function explainConfig(): string {
'selection: <workflow.yml>/<job> → the runner-owned command that lists that lane’s test files when its matrix is planner-computed',
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`,
'strays.allow: root files or new top-level directories that are intentional',
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
'no .gstack/pregate.json on the base: <state root>/projects/<slug>/pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)',
].join('\n') + '\n';
}
+56 -7
View File
@@ -9,13 +9,16 @@
*/
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 { slugFromEnvironment } from '../bin-context';
import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type Registry } from '../regen';
import { resolveStateRoot } from '../state-root';
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead, type ConfigError, 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';
@@ -27,6 +30,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;
/** `<state root>/projects/<slug>/pregate.json`: read only when the base carries no pregate.json, and never from a relative path or one inside the repository. */
userConfigPath?: string;
}
export const DEFAULT_STALL_TIMEOUT_S = 300;
@@ -123,6 +128,38 @@ 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 (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.native(head), ...tail); } catch { return path.resolve(p); }
}
const within = (root: string, p: string) => { const rel = path.relative(root, p); return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); };
/** Dotenv files Bun loads from the launch directory unless started with --no-env-file. */
const DOTENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local', '.env.production', '.env.production.local', '.env.test', '.env.test.local'];
/**
* The dotenv files in `cwd` that Bun loaded (no --no-env-file in `execArgv`). Any of them could set the slug or state
* root that picks the user policy, and Bun's dotenv syntax is looser than a key scan can follow, so any one refuses.
*/
export function loadedDotenvFiles(cwd: string, execArgv: readonly string[]): string[] {
if (execArgv.includes('--no-env-file')) return [];
return DOTENV_FILES.filter(f => fs.existsSync(path.join(cwd, f)));
}
/** `<state root>/projects/<slug>/pregate.json` for the repository at `root`, from the caller's environment. */
export function defaultUserConfigPath(root: string): string {
return path.join(resolveStateRoot(), 'projects', slugFromEnvironment(undefined, root), 'pregate.json');
}
/** 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}`;
@@ -130,9 +167,18 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
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);
const explain: string[] = [];
let absentUserPolicy: string | undefined;
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 (!user && !rejected) absentUserPolicy = o.userConfigPath;
}
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 } };
@@ -142,15 +188,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 workflow lane; ignored`);
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,
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 } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), 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,
},
};
}
@@ -159,7 +208,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<string, string> = {}): Record<string, string> {
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 };
}
/**
+28 -5
View File
@@ -23,7 +23,7 @@ import { secretsCheck } from './checks/secrets';
import { straysCheck } from './checks/strays';
import { timingCheck } from './checks/timing';
import { NON_DOWNGRADABLE } from './config';
import { withFingerprint } from './context';
import { sha256, withFingerprint } from './context';
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
@@ -57,7 +57,7 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<Pr
const remote: PregateRemote[] = checks.flatMap(c => c.remote ?? []).map(r => ({ ...r }));
const file: PregateFile = {
schema_version: 1, tree: ctx.wtree, head: ctx.head, base: `${ctx.baseRef}@${ctx.baseSha.slice(0, 12)}`, generated_at: new Date().toISOString(),
policy: ctx.configLabel, registry: ctx.registryLabel, run: o.run,
policy: ctx.configLabel, ...(ctx.configDigest ? { policy_digest: ctx.configDigest } : {}), ...(ctx.absentUserPolicy ? { policy_user_path: ctx.absentUserPolicy } : {}), registry: ctx.registryLabel, run: o.run,
stage1_ms: o.stage === 'tests' ? undefined : clocks.preflight, stage2_ms: o.stage === 'preflight' ? undefined : clocks.tests,
checks: checks.map(c => { const { remote: _r, ...rest } = c; return rest; }),
requires_remote: remote,
@@ -122,12 +122,17 @@ export interface Verification { ok: boolean; code?: 'PREGATE_STALE' | 'PREGATE_R
/**
* Publication check: the recorded tree must equal the tree now (results
* current after final generation and stamping), no check may be fail or
* incomplete, and every requires-remote obligation needs its receipt.
* incomplete, and every requires-remote obligation needs its receipt. The
* user-owned policy behind the run (or its absence, under the defaults) must
* still hold, since it lives outside the tree.
*/
export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: WtreeResult = wtreeFingerprint({ cwd: repoRoot })): Verification {
const uncleared = (file.requires_remote ?? []).filter(r => !r.receipt);
const current = 'tree' in treeNow && treeNow.tree === file.tree;
if (!current) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current, uncleared };
const treeCurrent = 'tree' in treeNow && treeNow.tree === file.tree;
if (!treeCurrent) return { ok: false, code: 'PREGATE_STALE', message: `pregate.json was recorded against tree ${file.tree.slice(0, 12)}; ${'tree' in treeNow ? `the tree is now ${treeNow.tree.slice(0, 12)}` : `the tree cannot be fingerprinted now (${treeNow.error})`}`, current: false, uncleared };
const policyChange = userPolicyChange(file);
if (policyChange) return { ok: false, code: 'PREGATE_STALE', message: policyChange, current: false, uncleared };
const current = true;
const failed = file.checks.filter(c => c.status === 'fail');
if (failed.length) return { ok: false, code: 'PREGATE_LANE_FAILED', message: `${failed.length} check(s) failed: ${failed.map(c => c.id).join(', ')}`, current, uncleared };
const incomplete = file.checks.filter(c => c.status === 'incomplete' || c.status === 'not_installed');
@@ -136,6 +141,24 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}`, current, uncleared };
}
/**
* Why the user-owned policy a run recorded no longer holds, or null. Both paths come from the record, never the verifier's
* environment: the policy file and its digest, or under the defaults the path that was looked for and absent.
*/
function userPolicyChange(file: PregateFile): string | null {
const recorded = file.policy?.startsWith('user ') ? file.policy.slice('user '.length) : null;
if (recorded) {
if (!file.policy_digest) return `pregate.json names the user-owned policy ${recorded} without its digest; rerun gstack-pregate`;
let text: string;
try { text = fs.readFileSync(recorded, 'utf8'); } catch { return `the user-owned policy ${recorded} that pregate.json was recorded under is gone or unreadable; rerun gstack-pregate`; }
return sha256(text) === file.policy_digest ? null : `the user-owned policy ${recorded} changed after pregate.json was recorded; rerun gstack-pregate`;
}
const absent = file.policy_user_path;
if (!absent) return null;
try { if (!fs.lstatSync(absent, { throwIfNoEntry: false })) return null; } catch (e) { return `cannot confirm ${absent} is still absent (${(e as Error).message}); rerun gstack-pregate`; }
return `${absent} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`;
}
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string }
export type ClearDecision = { receipt: string; attested: boolean } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string };
+3 -2
View File
@@ -130,12 +130,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);
+6
View File
@@ -33,6 +33,12 @@ 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;
/** 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;
/** The user-owned policy path looked for and absent under the defaults; recorded so verify can tell when one appears. */
absentUserPolicy?: string;
registry: Registry;
registryLabel: string;
lanes: Lane[];
+2 -2
View File
@@ -166,7 +166,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/<base> is not a resolvable commit, so the reviewed .gstack/ship-policy.json cannot be read',
summary: 'policy source unavailable: origin/<base> is not a resolvable commit, or a pin file committed on it cannot be read, so the reviewed .gstack/ship-policy.json cannot be read',
fix: 'git fetch origin <base> --depth=1 (the working-tree copy is never used silently)',
},
POLICY_INVALID: {
@@ -364,7 +364,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: {
+367
View File
@@ -0,0 +1,367 @@
/**
* The user-owned pregate policy and `local` lanes. A repo that keeps `.gstack/`
* out of git declares its policy at `<GSTACK_HOME>/projects/<slug>/pregate.json`;
* the base branch's copy always wins, and `--policy-from head` never reads the
* user file. A platform lane named in `local` runs here instead of becoming
* requires-remote. Every assertion holds on any host platform: each lane the
* fixture selects is either named in `local` or asserted only by label.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
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, userConfigRejection } from '../lib/pregate/context';
import { runPregate } from '../lib/pregate/index';
import { BASE_PREGATE, BIN, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
import { runScriptSync } from '../lib/script-invocation';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const SLUG = 'userpolicy';
const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard'];
/** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */
function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType<typeof runBin> } {
const repo = makeFixtureRepo({ pregate: null });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json');
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
const run = (extra: string[] = []) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', ...extra], { GSTACK_PROJECT_SLUG: SLUG });
return { repo, userPolicy, run };
}
/** setup() with a base that commits `.gstack/pregate.json` (raw text, or a policy object). */
function setupWithBase(base: string | Record<string, unknown>): { 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<string, unknown>) => fs.writeFileSync(file, JSON.stringify({ runner: 'node --test {files}', ...policy }));
describe('pregate.json local', () => {
// Value: protects=`local` accepts only <workflow file>/<job id> lane ids, shared with selection keys; fails_when=a bare job name or a
// string is accepted and silently matches no lane; why_new=the key is new; seam=none; no_claim=none beyond protects
test('takes <workflow>/<job> ids only', () => {
expect(validateConfig({ local: ['test.yml/unit'] })).toEqual([]);
expect(validateConfig({ local: ['unit'] }).map(e => e.path)).toEqual(['local[0]']);
expect(validateConfig({ local: 'test.yml/unit' }).map(e => e.path)).toEqual(['local']);
expect(validateConfig({ local: [7] }).map(e => e.path)).toEqual(['local']);
expect(validateConfig({ selection: { unit: 'true' } }).map(e => e.path)).toEqual(['selection["unit"]']);
});
});
describe('loadConfigFromUser', () => {
// Value: protects=a missing user file falls back (null) while any other read error refuses at the file's path, never defaults;
// fails_when=every read error is treated as absent and the user's guards silently vanish; why_new=the loader is new; seam=none;
// no_claim=EACCES specifically (a directory, 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();
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}`]);
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);
// 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);
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]);
});
});
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 { repo, userPolicy, run } = setup();
expect(run().out).toContain('(no .gstack/pregate.json)');
// The lane left off `local` must be foreign to this host: on Windows the windows lane is native and runs anyway.
const [named, foreign] = process.platform === 'win32'
? [['windows.yml/windows-shard', 'windows'], ['free-tests.yml/unit', 'linux']]
: [['free-tests.yml/unit', 'linux'], ['windows.yml/windows-shard', 'windows']];
writePolicy(userPolicy, { local: [named[0]] });
const partial = run();
expect(partial.out).toContain(`policy user ${userPolicy}`);
expect(partial.out).toContain(`requires-remote ${foreign[0]} (${foreign[1]})`);
expect(partial.out).not.toContain(`requires-remote ${named[0]}`);
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 ${foreign[0]}: ${foreign[1]} lane satisfied locally (pregate.json local)`);
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
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`);
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 });
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');
// 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
// 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<string, string | null>) => {
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);
// 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 (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');
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=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, 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 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`
// 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 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 workflow 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);
// Value: protects=a branch's committed .env cannot choose the slug or state root behind the user policy, whether pregate is
// launched through scriptInvocation, by its shebang, or as a bare `bun <bin>` (which refuses on any loaded dotenv file,
// since Bun's syntax admits `KEY: value` and bare-CR lines a key scan misses); fails_when=Bun's dotenv
// loading reaches slugFromEnvironment and a branch picks which pregate.json governs it; why_new=maintainer review of #3286;
// seam=none; no_claim=a .env in a parent of the working directory (Bun reads the launch directory only)
test('a committed .env cannot redirect the user policy', () => {
const { repo } = setup();
const env: Record<string, string | undefined> = { ...process.env, ...repo.env };
delete env.GSTACK_PROJECT_SLUG;
const slug = /^SLUG=(.+)$/m.exec(runScriptSync(BIN('gstack-slug'), [], { cwd: repo.work, encoding: 'utf8', env }).stdout)?.[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 });
commit(repo, { '.env': 'GSTACK_PROJECT_SLUG=elsewhere\n' });
const args = ['--base', 'main', '--allow-repo-commands', '--stage', 'tests'];
expect(runScriptSync(BIN('gstack-pregate'), args, { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }).stdout).toContain(`policy user ${userPolicy}`);
if (process.platform !== 'win32') expect(spawnSync(BIN('gstack-pregate'), args, { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 }).stdout).toContain(`policy user ${userPolicy}`);
const bare = spawnSync(process.execPath, [BIN('gstack-pregate'), ...args], { cwd: repo.work, encoding: 'utf8', env, timeout: 120_000 });
expect(bare.status).toBe(EXIT.refused);
expect(bare.stderr).toContain('POLICY_SOURCE_UNAVAILABLE');
expect(bare.stderr).toContain('bun loaded .env from the working directory');
}, 120_000);
// Value: protects=a recorded pass stops verifying once the user-owned policy behind it is edited or deleted, or once a user
// file appears where the run used the defaults; fails_when=verify checks only the tree and a weakened or removed policy
// keeps an old pass publishable; why_new=maintainer review of #3286; seam=none; no_claim=a base-branch copy that changes
// (pinned by the recorded base, not re-read); both paths come from the record, so the verifier's environment is not read
test('verify goes stale when the user-owned policy changes, disappears, or appears after a defaults run', () => {
const { repo, userPolicy, run } = setup();
const verify = () => JSON.parse(runBin(repo, 'gstack-pregate', ['verify', '--json'], { GSTACK_PROJECT_SLUG: SLUG }).out);
writePolicy(userPolicy, { local: LOCAL_ALL });
expect(run().status).toBe(EXIT.ok);
expect(verify().ok).toBe(true);
writePolicy(userPolicy, { local: ['free-tests.yml/unit'] });
expect([verify().code, verify().message]).toEqual(['PREGATE_STALE', `the user-owned policy ${userPolicy} changed after pregate.json was recorded; rerun gstack-pregate`]);
fs.rmSync(userPolicy);
expect(verify().message).toContain('is gone or unreadable');
run();
expect(verify().code).toBe('PREGATE_REMOTE_UNCLEARED');
writePolicy(userPolicy, { local: LOCAL_ALL });
expect([verify().code, verify().message]).toEqual(['PREGATE_STALE', `${userPolicy} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`]);
}, 120_000);
});