Merge remote-tracking branch 'origin/main' into fix/review-fix-range-rereview

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

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
garrytan committed 2026-10-25 15:16:16 +00:00
commit 6cf3c51a45
32 files changed
+1100 -136

No files matched your search

+73
View File
@@ -26,6 +26,79 @@
- letting Step 5.0 read this invocation's records.
- No threshold, budget, timeout, trial count or case kind changed. Size pins only went down.
## [1.91.211.0] - 2026-10-25
**`gstack-diff-scope` sees Python tests and nested test directories (#3335).** A branch that changed `components/orch/services/shared/tests/test_native_store_custody.py` reported `SCOPE_TESTS=false`, because the Tests rule only knew `*.test.*`-style suffixes and `test/`, `tests/`, `spec/` and `__tests__/` at the repository root. /ship and /review use that flag to route the testing specialist, so a backend change whose tests follow pytest's `test_*.py` convention skipped it. The test-bootstrap resolver already counted these files, so the two detectors disagreed.
### Fixed
- **`bin/gstack-diff-scope`**: a changed path is a test when it matches `TEST_PATH_RE`, the same regex the test-bootstrap resolver uses for its TESTFILES count (`scripts/resolvers/testing.ts`): test dirs at any depth, `test_*.py`, `tests.py`, `_test.<ext>`, `.test.`/`.spec.` JS and TS, `_spec.rb` and `*Test.java`/`.kt`. `conftest.py` counts too. The existing globs stay.
### Tests
- `test/diff-scope.test.ts` adds the issue's three paths, `pkg/test_store.py`, Django's `tests.py`, `conftest.py`, and nested `__tests__/` and `spec/` dirs; all eight fail on main. Controls that stay `SCOPE_TESTS=false`: `latest.py`, `contest_rules.py`, `attest_signer.py` and a `testing/` dir. A new test checks that `TEST_PATH_RE` is byte-identical to the resolver's regex, so the two can't drift again.
## [1.91.210.0] - 2026-10-25
**/autoplan works when Claude Code's project directory is a subfolder of the repository (#3296).** When the git root sits above the project (for example the root is `$HOME` and the session starts in `$HOME/projects/app`), a worktree session's journal lives in `.claude/worktrees/<name>` under that subfolder. The publication guard only looked for `<project dir>/.git/worktrees`, found nothing, and denied every phase Read with `foreign_cwd`, so the reporter had to restart the session from the repository root.
### Fixed
- **`autoplan/bin/phase-publication-hook.ts`**: `linkedWorktrees` walks up from the project directory to the first `.git` and reads that repository's worktree registry. A `.git` file (the project is itself a linked worktree or a submodule) stops the walk with no owners, as before. Git's two-way link is still required: the registry entry must name the worktree's `.git` file and that file must name the same entry back. No `git` process is started inside the hook.
### Tests
- `test/autoplan-publication-hook.test.ts` builds the reporter's layout with real `git worktree add`: a subfolder project directory (and a deeper one) owns the worktree under it, and the root still does. It fails on main. Controls that still get no owners: a nested independent repository (its own `.git` stops the walk), a project directory inside a linked worktree, a registry entry whose worktree names another repository, and a directory with no `.git` above it.
### Gate integrity
- One accepted-input change to review: from a project directory below the repository root, the guard now accepts the journal of a linked worktree of that same repository, which it already accepted when the project directory was the root. The seven existing `foreign_cwd` refusals in `test/autoplan-publication-guard.test.ts` (no link, one-sided links, another repository, a submodule gitdir, a project that is itself a worktree, a bare repository) still deny. No threshold, budget, timeout, trial count or case kind changed.
## [1.91.209.0] - 2026-10-25
**The free test suite no longer rewrites a contributor's global git pre-push hook (#3353).** On a machine with a global `core.hooksPath`, `test/redact-prepush-target.test.ts` and `test/fs-utils.test.ts` ran `gstack-redact install-prepush-hook` with the parent environment. Git resolved the hooks directory to the global one, so the install moved the developer's own `pre-push` to `pre-push.local` and left a managed wrapper pointing into the test checkout, which breaks every later push once that checkout is gone. The same config also failed 4 tests in the target file, 5 in `test/redact-prepush-hook.test.ts` and 1 in `fs-utils`. CI has no global hooks path, so it stayed green.
### Fixed
- **`test/helpers/git-env.ts`** (new): `gitEnv()` is the one fixture environment for git and hook installs: `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_COUNT=0` and no `GIT_CONFIG_PARAMETERS`. Config written inside the fixture repo still applies.
- `test/redact-prepush-target.test.ts`, `test/redact-prepush-hook.test.ts` and `test/fs-utils.test.ts` pass it to every fixture git, install and hook spawn. `test/redact-prepush-scan-range.test.ts` and `test/redact-prepush-rebase-force-push.test.ts` drop their private copies (#2967) and import it.
### Tests
- A new case in `test/redact-prepush-target.test.ts` sets a global, system and environment `core.hooksPath` to a hook that fails, builds the fixture, and checks the developer's hooks directory is untouched, the managed hook landed in the fixture repo, and a push never runs the developer's hook. It fails without the shared environment.
- Rerun of the issue's repro with `HOME` pointing at a gitconfig with a global hooks dir: on main, two files rewrote it and three files had failures; with this change all six `redact-prepush-*` and `fs-utils` files pass and the directory is unchanged.
## [1.91.208.0] - 2026-10-25
**The Capy install guide says that the team-mode session-start updater also waits for live workflow leases.** With #3385 (#3363), `bin/gstack-session-update` skips its background fast-forward while another session holds a live workflow lease, the same leases `bin/gstack-upgrade` already honors. `docs/capy-install.md` described only the upgrade side.
### Documentation
- **`docs/capy-install.md`**: the workflow-lease paragraph now says the team-mode SessionStart hook (`bin/gstack-session-update`) honors the same leases. It logs `SKIP leased pid=<p>/<age>m` to `analytics/session-update.log` and tries again at the next session start, without writing its hourly throttle stamp.
## [1.91.207.0] - 2026-10-25
**Per-repository session counts include sessions from removed Claude Code worktrees (ported from #3334 by @Shweta-Mishra-ai).** Claude Code creates worktrees at `<repo>/.claude/worktrees/<name>` and removes them once their branch merges, but their session history stays. `gstack-global-discover` resolved each session's recorded directory with git, so a removed worktree resolved to nothing: its sessions counted in the tool total and in no repository. Reproduced on main: 2 sessions in a repo plus 3 in a removed worktree gave `total_sessions 5` but 2 for the repo.
### Fixed
- **`bin/gstack-global-discover.ts`**: when a session's directory no longer exists and sits inside `<repo>/.claude/worktrees/<name>` (or a subdirectory of it), the session counts with `<repo>`. Any other missing directory, and a worktree whose repository is gone too, still count in no repository. A live worktree keeps its own path, as before.
### Tests
- `test/global-discover.test.ts` gains six end-to-end cases built with real `git worktree add` and `remove` (removed worktree, a subdirectory of one, a repo with no remote, plus the live-worktree, gone-repo and plain-missing-directory controls) and table tests for the path matcher. The three positive cases and the matcher tests fail on main.
## [1.91.206.0] - 2026-10-25
**A repo that keeps `.gstack/` out of git can give pregate a policy, and name the CI lanes a local run satisfies (#3286).** On a shared repo where gstack is one contributor's tool, pregate fell back to defaults, so every platform lane the machine could not run (an `ubuntu-latest` suite on a Mac, say) became a `requires-remote` obligation that never cleared, even when that suite runs the same here. Ported from #3286 by arlophoenix, whose 13 commits carry the feature (merged with their history kept); our commits on top close two findings from a security-focused review of the port.
### Added
- **`lib/pregate/context.ts`, `lib/pregate/config.ts`** (arlophoenix): when the base branch carries no `.gstack/pregate.json`, `gstack-pregate run` reads `<state root>/projects/<slug>/pregate.json`. Never under `--policy-from head`, never a relative path or one inside the checkout, and never chosen by a checkout's `.env` (the bin ignores it, and a bare `bun gstack-pregate` beside one refuses). An unreadable file, a dangling link on its path, `sealed` or `price_file` refuses with `PREGATE_CONFIG_INVALID`; a base copy git cannot read refuses with `POLICY_SOURCE_UNAVAILABLE` unless `git ls-tree` confirms it is absent.
- **`local`** (arlophoenix): `<workflow.yml>/<job>` lanes a local run satisfies run here instead of becoming `requires-remote`. Under 8 cores with a `remote_gate` or remote `test_backend`, the small-machine obligation still stands.
- **`lib/pregate/index.ts`** (arlophoenix): the record names the user file and its digest, and `verify` goes `PREGATE_STALE` when it is edited, deleted, or appears after a run under the defaults. Fix texts for missing lanes, strays, stalls and `remote_gate` name the active policy file.
### Fixed
- **`lib/pregate/context.ts`**: a user policy whose path reaches the checkout through any symlink is ignored. Before, only the lexical path and the final target were checked, so a state root linked into the checkout, holding a link back out to another existing policy, let a branch choose a more permissive file without changing its bytes.
- **`lib/pregate/index.ts`**: `verify` goes `PREGATE_STALE` when a link on the way to the absent user path starts pointing nowhere. A fresh run refuses that state, but verify looked only at the leaf, so an old defaults pass stayed publishable. The loader and verify now share one absence check.
### Tests
- `test/pregate-user-policy.test.ts` (new, arlophoenix) covers the user file, `local`, the refusals, `.env` isolation and stale receipts. New cases: a link chain through the checkout is refused for both a linked state root and a single in-repo hop, while a direct outside link is still read; and verify goes stale on a dangling parent, with the plain-absent path as the control.
- `test/pregate-rust.test.ts`: the two cargo-lane cases skipped on Windows carry a `TODO(#3380)` naming the gap.
### Gate integrity
- No threshold, budget, timeout, trial count, case kind or skip changed. The user file can relax lanes only where the base has no policy at all, and only from outside the repository; the two fixes make that containment stricter.
- No new dependency, no network call, no workflow change.
## [1.91.205.0] - 2026-10-25
**/ship's Step 9 can re-review just the fixes after a fixing pass, and a narrowed pass can never be the review that ships (#3339).** Before, every fix cycle reran every specialist and the Red Team over the whole branch. On a large branch each full pass tends to find something new in code earlier passes already read, so the loop often ran to the three-cycle stop without converging. Now the pass after a fixing pass can review only what changed since that pass. One full pass still has to come last: a fix-range record is never CURRENT, and Step 11.5 can't bind it. Garry approved this on 2026-10-24 ("only with a mandatory final full pass").
+1 -1
View File
@@ -1 +1 @@
1.91.205.0
1.91.211.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.205.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.211.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+13 -4
View File
@@ -627,12 +627,21 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[
* A worktree session gets the repository root as CLAUDE_PROJECT_DIR while its
* journal is rooted in the linked worktree. Only git's own two-way link makes
* a directory that worktree: <root>/.git/worktrees/<name>/gitdir names
* <worktree>/.git, and that file names the same admin entry back.
* <worktree>/.git, and that file names the same admin entry back. A project
* directory below the root (#3296) uses the first .git found walking up; a
* .git file there means a linked worktree or submodule, which has no registry.
*/
export function linkedWorktrees(projectDir: string): string[] {
let entries: fs.Dirent[];
const admin = path.join(projectDir, '.git', 'worktrees');
try { entries = fs.readdirSync(admin, { withFileTypes: true }); } catch { return []; }
let entries: fs.Dirent[], admin: string | undefined;
try {
for (let dir = path.resolve(projectDir); ; dir = path.dirname(dir)) {
const dotGit = fs.statSync(path.join(dir, '.git'), { throwIfNoEntry: false });
if (dotGit) { if (dotGit.isDirectory()) admin = path.join(dir, '.git', 'worktrees'); break; }
if (path.dirname(dir) === dir) break;
}
if (!admin) return [];
entries = fs.readdirSync(admin, { withFileTypes: true });
} catch { return []; }
return entries.filter(entry => entry.isDirectory()).slice(0, 256).flatMap(entry => {
try {
const link = path.join(admin, entry.name);
+8
View File
@@ -42,6 +42,12 @@ MIGRATIONS=false
API=false
AUTH=false
# Test-file paths, the same rule the test-bootstrap resolver counts as
# TESTFILES (scripts/resolvers/testing.ts; test/diff-scope.test.ts keeps the two
# identical). Nested test dirs and Python's test_*.py / tests.py were missed by
# the root-anchored globs alone (#3335).
TEST_PATH_RE='(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$'
_print_flags() {
echo "SCOPE_FRONTEND=$FRONTEND"
echo "SCOPE_BACKEND=$BACKEND"
@@ -126,7 +132,9 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
case "$f" in
*.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;;
test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;;
conftest.py|*/conftest.py) m_tests=true ;;
esac
if [[ "$f" =~ $TEST_PATH_RE ]]; then m_tests=true; fi
# Docs
case "$f" in
+15
View File
@@ -186,11 +186,26 @@ function gitProject(cwd: string): GitProject | null {
const up = prefix.split("/").filter(Boolean).map(() => "..");
project = { root: resolve(cwd, ...up), main: basename(common) === ".git" ? dirname(common) : common };
}
} else {
// A merged branch's worktree is removed but its session history stays:
// count those sessions with the repository the worktree was created from.
const parent = parentRepoOfClaudeWorktree(cwd);
if (parent) project = gitProject(parent);
}
PROJECT_MEMO.set(cwd, project);
return project;
}
/**
* Claude Code creates worktrees at `<repo>/.claude/worktrees/<name>`. Given a
* cwd inside one (the worktree root or any subdirectory), the repository root
* it was created from; null for any other path.
*/
export function parentRepoOfClaudeWorktree(cwd: string): string | null {
const m = cwd.match(/^(.+?)[\\/]\.claude[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/);
return m ? m[1] : null;
}
function getGitRemote(root: string): string | null {
const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], {
encoding: "utf-8",
+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);
+5 -1
View File
@@ -109,7 +109,11 @@ and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after
a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start
self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy)
while a lease held by a live process outside its own chain exists, naming each
holder as `pid <p> (<age>m <command>)`. A lease whose pid now belongs to another
holder as `pid <p> (<age>m <command>)`. The team-mode SessionStart hook
(`bin/gstack-session-update`) honors the same leases: it logs
`SKIP leased pid=<p>/<age>m` to `analytics/session-update.log` and tries again at
the next session start, without writing its hourly throttle stamp. A lease whose
pid now belongs to another
process, or an identity-less lease from an older gstack past 120 minutes, is
stale: `bin/gstack-lease live` reclaims it (`gstack-lease: reclaimed stale
lease …`). Stale needs proof: two well-formed identities from the same backend
+41 -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,36 @@ 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 (or a
link on its path that starts pointing nowhere) after a run under the defaults,
as `PREGATE_STALE`. A path that is relative, resolves inside the repository, or
reaches it through any symlink on the way (a state root linked into the
checkout, or a link hop inside it, even when the last hop points back outside)
is ignored, since a PR could commit it or choose the file; `--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 +145,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:**
@@ -181,7 +213,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
@@ -194,7 +226,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).
@@ -260,8 +292,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.
+28 -8
View File
@@ -2474,7 +2474,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`;
@@ -2877,7 +2883,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`
@@ -2932,10 +2942,16 @@ 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, or
reached through a symlink into it, is ignored rather than read (`--explain`
names it).
<a id="pregate-secrets"></a>
### `PREGATE_SECRETS`
@@ -2980,7 +2996,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
@@ -3019,7 +3035,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.
@@ -3047,7 +3063,10 @@ 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,
or a symlink on its path started pointing nowhere, 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.
@@ -3276,8 +3295,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` };
}
+61 -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,37 @@ 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;
}
/** Nothing at `file` and no dangling link on the way to it: the only state that means "no user policy", for the loader and verify alike. */
export function userPolicyAbsent(file: string): boolean {
try { fs.statSync(file); return false; } catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT' && !danglingLink(file); }
}
/** 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' && userPolicyAbsent(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 +275,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';
}
+79 -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,61 @@ 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); }
}
/**
* Every location the path to `p` passes through, each symlink followed one hop at a time: a link inside the checkout picks
* the file even when the last hop points back outside it. A missing component ends the walk with the rest joined lexically.
*/
function resolutionChain(p: string): string[] {
const chain: string[] = [];
let cur = path.parse(path.resolve(p)).root;
let rest = path.relative(cur, path.resolve(p)).split(path.sep).filter(Boolean);
for (let hops = 0; rest.length && hops <= 40;) {
const next = path.join(cur, rest.shift()!);
let st: fs.Stats | undefined;
try { st = fs.lstatSync(next, { throwIfNoEntry: false }); } catch { st = undefined; }
chain.push(st ? next : path.join(next, ...rest));
if (!st) break;
if (!st.isSymbolicLink()) { cur = next; continue; }
hops++;
const target = path.resolve(cur, fs.readlinkSync(next));
cur = path.parse(target).root;
rest = [...path.relative(cur, target).split(path.sep).filter(Boolean), ...rest];
}
return chain;
}
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';
const repo = path.resolve(repoRoot), real = realish(repoRoot);
if (within(repo, path.resolve(file)) || [file, ...resolutionChain(file)].some(p => within(real, realish(p)))) 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 +190,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 +211,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 +231,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 };
}
/**
+32 -6
View File
@@ -22,8 +22,8 @@ import { regenCheck } from './checks/regen';
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 { NON_DOWNGRADABLE, userPolicyAbsent } from './config';
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,27 @@ 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;
if (userPolicyAbsent(absent)) return null;
let leaf: fs.Stats | undefined;
try { leaf = fs.lstatSync(absent, { throwIfNoEntry: false }); } catch { leaf = undefined; }
if (!leaf) return `cannot confirm ${absent} is still absent (a link on the way to it is dangling or unreadable); 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: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.205",
"version": "1.91.211",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+61 -1
View File
@@ -4,7 +4,7 @@ import * as path from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
import { linkedWorktrees, runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
import { OWNED_READ_APPROVAL } from '../autoplan/bin/owned-read';
const ROOT = path.join(import.meta.dir, '..');
@@ -192,3 +192,63 @@ describe('Autoplan hook journal-root verdicts', () => {
for (const detail of ['unrecognized_shape:cwd_spelling', 'analytics/autoplan-guard.jsonl']) expect(guide).toContain(detail);
});
});
describe('linkedWorktrees from a project directory below the repository root (#3296)', () => {
const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0' };
const git = (cwd: string, ...args: string[]) => {
const child = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, env: gitEnv });
expect(child.status).toBe(0);
};
function repo(): string {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(dir);
git(dir, 'init', '-q', '-b', 'main');
git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed');
return dir;
}
const real = (dir: string) => fs.realpathSync(dir);
test.skipIf(process.platform === 'win32')('the reporter layout: a subfolder project directory owns its worktree under that subfolder', () => {
const root = repo(), sub = path.join(root, 'sub'); fs.mkdirSync(path.join(sub, '.claude', 'worktrees'), { recursive: true });
const worktree = path.join(sub, '.claude', 'worktrees', 'w');
git(root, 'worktree', 'add', '-q', worktree);
expect(linkedWorktrees(sub)).toEqual([real(worktree)]);
expect(linkedWorktrees(path.join(sub, 'deeper', 'still'))).toEqual([real(worktree)]);
expect(linkedWorktrees(root)).toEqual([real(worktree)]);
});
test.skipIf(process.platform === 'win32')('a nested independent repository stops the walk at its own .git', () => {
const outer = repo(), inner = path.join(outer, 'vendor', 'lib');
fs.mkdirSync(inner, { recursive: true });
git(inner, 'init', '-q', '-b', 'main');
git(inner, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed');
git(outer, 'worktree', 'add', '-q', path.join(outer, 'outer-wt'));
expect(linkedWorktrees(inner)).toEqual([]);
expect(linkedWorktrees(path.join(inner, 'src'))).toEqual([]);
});
test.skipIf(process.platform === 'win32')('a project directory inside a linked worktree has no registry of its own', () => {
const root = repo(), worktree = path.join(root, 'wt');
git(root, 'worktree', 'add', '-q', worktree);
fs.mkdirSync(path.join(worktree, 'pkg'));
expect(linkedWorktrees(worktree)).toEqual([]);
expect(linkedWorktrees(path.join(worktree, 'pkg'))).toEqual([]);
});
test('a registry entry whose worktree names another repository is still refused from a subfolder', () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(root);
const other = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-other-'))); dirs.push(other);
const worktree = path.join(root, 'sub', 'wt'); fs.mkdirSync(worktree, { recursive: true });
const entry = path.join(root, '.git', 'worktrees', 'wt'); fs.mkdirSync(entry, { recursive: true });
const foreign = path.join(other, '.git', 'worktrees', 'wt'); fs.mkdirSync(foreign, { recursive: true });
fs.writeFileSync(path.join(entry, 'gitdir'), path.join(worktree, '.git') + '\n');
fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${foreign}\n`);
expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([]);
fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${entry}\n`);
expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([worktree]);
});
test('a directory with no .git anywhere above it has no worktrees', () => {
expect(linkedWorktrees(path.parse(tmpdir()).root)).toEqual([]);
});
});
+27 -1
View File
@@ -5,7 +5,7 @@
* the correct SCOPE_* variables are output.
*/
import { describe, test, expect, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { spawnSync } from 'child_process';
@@ -245,6 +245,20 @@ describe('glob classes (table-driven, #2526 + #2455)', () => {
{ name: 'plain backend', file: 'server.go', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_FRONTEND'] },
{ name: 'docs', file: 'docs/guide.md', expects: ['SCOPE_DOCS'] },
{ name: 'prompts', file: 'app/services/prompt_builder.rb', expects: ['SCOPE_PROMPTS', 'SCOPE_BACKEND'] },
// Python tests and nested test dirs (#3335): the issue's three paths.
{ name: 'nested tests/test_*.py (#3335)', file: 'components/orch/services/shared/tests/test_native_store_custody.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'second nested tests/test_*.py (#3335)', file: 'components/orch/services/shared/tests/test_infra_promoted_first_apply.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'hyphenated service tests/test_*.py (#3335)', file: 'components/orch/services/api-gateway/tests/test_native_chat_checkpoint_store.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'test_*.py outside a tests dir', file: 'pkg/test_store.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'Django tests.py', file: 'shop/tests.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'pytest conftest.py', file: 'services/api/conftest.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'nested __tests__ dir', file: 'packages/ui/__tests__/render.ts', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
{ name: 'nested spec dir', file: 'engines/billing/spec/invoice_checker.rb', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] },
// Controls: a name that only contains "test" is not a test file.
{ name: 'latest.py is not a test', file: 'pkg/latest.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
{ name: 'contest_rules.py is not a test', file: 'pkg/contest_rules.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
{ name: 'testing/ dir is not a test dir', file: 'pkg/testing/fixtures_loader.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
{ name: 'attest_*.py is not a test', file: 'pkg/attest_signer.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] },
];
for (const row of TABLE) {
@@ -262,6 +276,18 @@ describe('glob classes (table-driven, #2526 + #2455)', () => {
}
});
describe('one test-path rule (#3335)', () => {
test('TEST_PATH_RE is byte-identical to the test-bootstrap TESTFILES regex', () => {
const script = readFileSync(SCRIPT, 'utf8');
const scriptRe = script.match(/^TEST_PATH_RE='([^']+)'$/m)?.[1];
const resolver = readFileSync(join(import.meta.dir, '..', 'scripts', 'resolvers', 'testing.ts'), 'utf8');
const resolverRe = resolver.match(/git ls-files \| grep -cE '([^']+)'/)?.[1]?.replace(/\\\\/g, '\\');
expect(scriptRe).toBeDefined();
expect(resolverRe).toBeDefined();
expect(scriptRe).toBe(resolverRe);
});
});
describe('exit-code contract (#2526)', () => {
test('clean tree, no changes → all false, exit 0, no SCOPE_ERROR', () => {
const dir = createRepo([]);
+3 -1
View File
@@ -14,6 +14,7 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
import { gitEnv } from "./helpers/git-env";
import { mkdirpSync } from "../lib/fs-utils";
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
@@ -93,7 +94,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", (
const base = tmpdir();
try {
const repo = path.join(base, "repo");
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
spawnSync("git", ["init", "-q", repo], { timeout: 30_000, env: gitEnv() });
const hookDir = path.join(repo, ".git", "hooks");
fs.mkdirSync(hookDir, { recursive: true });
const hookPath = path.join(hookDir, "pre-push");
@@ -106,6 +107,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", (
cwd: repo,
encoding: "utf8",
timeout: 30_000,
env: gitEnv(),
});
expect(r.status).toBe(0);
expect(r.stderr ?? "").not.toContain("EEXIST");
+99
View File
@@ -644,5 +644,104 @@ describe("gstack-global-discover", () => {
expect(json.repos).toHaveLength(1);
expect(json.repos[0].sessions.claude_code).toBe(1);
});
// Claude Code worktrees live at <repo>/.claude/worktrees/<name> and are removed once
// their branch merges; the session history under ~/.claude/projects stays behind.
function removedWorktree(main: string, name = "feat-x"): string {
const wt = join(main, ".claude", "worktrees", name);
git(main, "worktree", "add", "-q", wt);
return wt;
}
test("sessions of a removed Claude worktree count with the repository it was created from", () => {
const main = repo("work/ai-blogger-lab", "https://github.com/acme/ai-blogger-lab.git");
const wt = removedWorktree(main);
session(main, "main-1");
session(main, "main-2");
session(wt, "wt-1");
session(wt, "wt-2");
session(wt, "wt-3");
git(main, "worktree", "remove", "--force", wt);
expect(existsSync(wt)).toBe(false);
const json = discover();
expect(json.tools.claude_code.total_sessions).toBe(5);
expect(json.repos).toHaveLength(1);
expect(json.repos[0].sessions.claude_code).toBe(5);
expect(json.repos[0].paths).toEqual([main]);
});
test("a session started in a subdirectory of a removed Claude worktree counts too", () => {
const main = repo("work/app", "git@github.com:acme/app.git");
const wt = removedWorktree(main);
mkdirSync(join(wt, "packages", "web"), { recursive: true });
session(join(wt, "packages", "web"), "sub-1");
git(main, "worktree", "remove", "--force", wt);
const r = discover().repos.find((x) => x.remote === "https://github.com/acme/app")!;
expect(r.sessions.claude_code).toBe(1);
expect(r.paths).toEqual([main]);
});
test("a removed Claude worktree of a repository with no remote counts with its repository", () => {
const main = repo("work/local-only");
const wt = removedWorktree(main);
session(main, "l-1");
session(wt, "l-2");
git(main, "worktree", "remove", "--force", wt);
const local = discover().repos.filter((x) => x.remote.startsWith("local:"));
expect(local).toHaveLength(1);
expect(local[0].sessions.claude_code).toBe(2);
});
test("a live Claude worktree keeps its own path next to its repository's", () => {
const main = repo("work/live", "https://github.com/acme/live.git");
const wt = removedWorktree(main);
session(main, "m-1");
session(wt, "w-1");
const r = discover().repos.find((x) => x.remote === "https://github.com/acme/live")!;
expect(r.sessions.claude_code).toBe(2);
expect(r.paths.sort()).toEqual([main, wt].sort());
});
test("a removed Claude worktree whose repository is also gone is not counted as a repository", () => {
const gone = join(home, "work", "gone-repo");
session(join(gone, ".claude", "worktrees", "feat-x"), "g-1");
const json = discover();
expect(json.tools.claude_code.total_sessions).toBe(1);
expect(json.repos).toHaveLength(0);
});
test("a removed directory that is not a Claude worktree is not counted as a repository", () => {
const main = repo("work/real", "https://github.com/acme/real.git");
session(join(main, "some", "deleted-dir"), "d-1");
session(join(main, ".claude", "worktrees"), "d-2");
session(main, "r-1");
const json = discover();
expect(json.tools.claude_code.total_sessions).toBe(3);
expect(json.repos).toHaveLength(1);
expect(json.repos[0].sessions.claude_code).toBe(1);
});
describe("parentRepoOfClaudeWorktree", () => {
let parentRepoOfClaudeWorktree: (cwd: string) => string | null;
beforeEach(async () => {
parentRepoOfClaudeWorktree = (await import("../bin/gstack-global-discover.ts")).parentRepoOfClaudeWorktree;
});
test("returns the repository root for a worktree path, with or without a subdirectory", () => {
expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x")).toBe("/Users/me/code/app");
expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x/src/lib")).toBe("/Users/me/code/app");
expect(parentRepoOfClaudeWorktree("C:\\code\\app\\.claude\\worktrees\\feat-x")).toBe("C:\\code\\app");
});
test("returns null for any other path", () => {
for (const p of [
"/Users/me/code/app",
"/Users/me/code/app/.claude/worktrees",
"/Users/me/code/app/.claude/worktrees/",
"/Users/me/code/not.claude/worktrees/x",
"/.claude/worktrees/x",
]) expect(parentRepoOfClaudeWorktree(p)).toBeNull();
});
});
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Environment for fixture git commands and `gstack-redact install-prepush-hook`.
*
* With the parent environment, a developer's global or system core.hooksPath
* (or the same key via GIT_CONFIG_COUNT / GIT_CONFIG_PARAMETERS) reaches the
* fixture: `git rev-parse --git-path hooks` resolves to that directory, so the
* install rewrites the developer's own pre-push (#3353), and their hook runs
* on fixture pushes and rejects the local remote (#2967). /dev/null drops the
* global file on every platform: Git for Windows rejects NUL (see
* EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts). Config written inside the
* fixture repo still applies.
*/
export function gitEnv(extra: Record<string, string | undefined> = {}): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, ...extra };
env.GIT_CONFIG_GLOBAL = "/dev/null";
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_CONFIG_COUNT = "0";
delete env.GIT_CONFIG_PARAMETERS;
return env;
}
+2
View File
@@ -301,6 +301,7 @@ describe('gstack-pregate on a Rust workspace', () => {
// Value: protects=the reported case end to end: a touched src file of a workspace crate is not PREGATE_NO_LANE, its crate and the
// dependent crates run with `cargo test -p` from the workspace root, and the summed count is reported; fails_when=any link of
// graph, plan or summary regresses; why_new=no Rust run existed; seam=none; no_claim=a real cargo (PATH shim reports cwd and argv)
// TODO(#3380): Windows gap. This case is skipped on win32 until the cargo stand-in runs under the lane's Git Bash there; https://github.com/garrytan/gstack/pull/3380
posixShim('a touched src file runs its crate and the crates depending on it from the workspace root', () => {
const repo = rustRepo();
commit(repo, { 'engine/crates/alpha/src/util.rs': 'pub fn two() -> u32 { 1 + 1 }\n' });
@@ -315,6 +316,7 @@ describe('gstack-pregate on a Rust workspace', () => {
// Value: protects=a crate whose test binaries all report 0 tests is ZERO-RUN (exit 0 is not a pass), and a failing cargo run is
// PREGATE_LANE_FAILED with its counts; fails_when=cargo's summary is unparsed (count unavailable passes) or the exit is ignored;
// why_new=the pure parser test does not run the lane; seam=none; no_claim=a real cargo (PATH shim)
// TODO(#3380): Windows gap. This case is skipped on win32 until the cargo stand-in runs under the lane's Git Bash there; https://github.com/garrytan/gstack/pull/3380
posixShim('zero tests is ZERO-RUN; a failing cargo run is PREGATE_LANE_FAILED', () => {
const repo = rustRepo();
commit(repo, { 'engine/crates/gamma/src/lib.rs': 'pub fn g() { }\n' });
+407
View File
@@ -0,0 +1,407 @@
/**
* 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, verifyPregate } 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=a policy reached through the checkout is refused even when the last link points back outside it, so a
// branch cannot steer which existing out-of-repo policy governs it; fails_when=containment checks only the lexical path and
// the final target, and a state root or a link hop inside the checkout picks the file; why_new=security review of #3286;
// seam=none; no_claim=case-only path differences
test('a policy whose link chain passes through the checkout is inside, even when its final target is outside', () => {
const { repo } = setup();
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-chain-'));
try {
const permissive = path.join(outside, 'permissive.json');
fs.writeFileSync(permissive, '{}');
fs.mkdirSync(path.join(repo.work, '.st', 'projects', 'x'), { recursive: true });
fs.symlinkSync(permissive, path.join(repo.work, '.st', 'projects', 'x', 'pregate.json'));
fs.symlinkSync(path.join(repo.work, '.st'), path.join(outside, 'root'));
expect(userConfigRejection(repo.work, path.join(outside, 'root', 'projects', 'x', 'pregate.json'))).toContain('inside the repository');
fs.symlinkSync(permissive, path.join(repo.work, '.st', 'hop.json'));
fs.symlinkSync(path.join(repo.work, '.st', 'hop.json'), path.join(outside, 'leaf.json'));
expect(userConfigRejection(repo.work, path.join(outside, 'leaf.json'))).toContain('inside the repository');
fs.symlinkSync(permissive, path.join(outside, 'direct.json'));
expect(userConfigRejection(repo.work, path.join(outside, 'direct.json'))).toBeNull();
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
});
// Value: protects=a defaults pass stops verifying once a link on the way to the absent user path dangles, the state a fresh
// run refuses; fails_when=verify checks only the leaf and reads the broken parent as continued absence; why_new=security
// review of #3286; seam=verifyPregate with the recorded tree passed in, so only the policy check differs; no_claim=the
// bin's verify wiring (the stale-policy test covers it)
test('verify goes stale when a parent of the absent user path becomes a dangling link', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pregate-dangle-'));
try {
const userPolicy = path.join(dir, 'projects', 's', 'pregate.json');
const record = { schema_version: 1, tree: 'T', checks: [], policy: 'defaults', policy_user_path: userPolicy, verdict: 'pass' } as any;
expect(verifyPregate(dir, record, { tree: 'T' }).ok).toBe(true);
fs.symlinkSync(path.join(dir, 'not-yet-cloned'), path.join(dir, 'projects'));
expect((loadConfigFromUser(userPolicy) as { errors: { code: string }[] } | null)?.errors.map(e => e.code)).toEqual(['PREGATE_CONFIG_INVALID']);
const v = verifyPregate(dir, record, { tree: 'T' });
expect([v.ok, v.code]).toEqual([false, 'PREGATE_STALE']);
expect(v.message).toContain(`cannot confirm ${userPolicy} is still absent`);
} finally { fs.rmSync(dir, { 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);
});
+17 -14
View File
@@ -13,6 +13,7 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
import { gitEnv } from "./helpers/git-env";
const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush");
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
@@ -21,7 +22,7 @@ let repo: string;
let stateHome: string;
function git(args: string[], cwd = repo): string {
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 });
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
return r.stdout?.trim() ?? "";
}
@@ -40,7 +41,7 @@ function runHook(
cwd: repo,
input: Buffer.from(stdinLines),
encoding: "utf8",
env: { ...process.env, ...env },
env: gitEnv(env),
timeout: 30_000,
});
return { code: r.status ?? 0, stderr: r.stderr ?? "" };
@@ -439,7 +440,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
const r = spawnSync("bash", ["-c", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], // stdin not a TTY
env: { ...process.env, GSTACK_HOME: home },
env: gitEnv({ GSTACK_HOME: home }),
timeout: 15_000,
});
expect(r.status).toBe(0);
@@ -467,7 +468,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => {
const r = spawnSync("bash", ["-c", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, GSTACK_HOME: home },
env: gitEnv({ GSTACK_HOME: home }),
timeout: 15_000,
});
expect(r.status).toBe(0);
@@ -502,7 +503,7 @@ describe("install / chaining", () => {
const existing = path.join(hookDir, "pre-push");
fs.writeFileSync(existing, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 });
const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(r.status).toBe(0);
const installed = fs.readFileSync(existing, "utf8");
expect(installed).toContain("gstack-redact pre-push (managed)");
@@ -516,7 +517,7 @@ describe("install / chaining", () => {
test("chained pre-push.local receives the final ref line (trailing newline preserved)", () => {
const hookDir = path.join(repo, ".git", "hooks");
fs.mkdirSync(hookDir, { recursive: true });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
const seen = path.join(repo, "seen.txt");
fs.writeFileSync(
@@ -531,7 +532,7 @@ describe("install / chaining", () => {
cwd: repo,
input: Buffer.from(line),
encoding: "utf8",
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
timeout: 30_000,
});
expect(r.status).toBe(0);
@@ -545,7 +546,7 @@ describe("install / chaining", () => {
test("a blocking pre-push.local still short-circuits the push", () => {
const hookDir = path.join(repo, ".git", "hooks");
fs.mkdirSync(hookDir, { recursive: true });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
fs.writeFileSync(
path.join(hookDir, "pre-push.local"),
"#!/usr/bin/env bash\nwhile read -r _a _b _c _d || [ -n \"${_a:-}\" ]; do exit 1; done\nexit 0\n",
@@ -555,7 +556,7 @@ describe("install / chaining", () => {
cwd: repo,
input: Buffer.from(`refs/heads/main ${"b".repeat(40)} refs/heads/main ${ZERO}\n`),
encoding: "utf8",
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
timeout: 30_000,
});
expect(r.status).toBe(1);
@@ -603,6 +604,7 @@ describe("install / chaining", () => {
timeout: 30_000,
cwd: repo,
encoding: "utf8",
env: gitEnv(),
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("refreshed stale managed pre-push hook");
@@ -618,7 +620,7 @@ describe("install / chaining", () => {
cwd: repo,
input: Buffer.from(`refs/heads/main ${sha} refs/heads/main ${ZERO}\n`),
encoding: "utf8",
env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome },
env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }),
});
expect(run.status).toBe(0);
expect(fs.readFileSync(path.join(stateHome, "security", "prepush-skip.jsonl"), "utf8")).toContain("env-skip");
@@ -630,13 +632,14 @@ describe("install / chaining", () => {
fs.mkdirSync(hookDir, { recursive: true });
const hook = path.join(hookDir, "pre-push");
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, env: gitEnv() });
const first = fs.readFileSync(hook, "utf8");
const stamp = fs.statSync(hook).mtimeMs;
const again = spawnSync("bun", [REDACT, "install-prepush-hook"], {
cwd: repo,
encoding: "utf8",
env: gitEnv(),
});
expect(again.status).toBe(0);
expect(again.stdout).toContain("already installed");
@@ -651,8 +654,8 @@ describe("install / chaining", () => {
fs.writeFileSync(path.join(hookDir, "pre-push"), "#!/usr/bin/env bash\necho mine\n", {
mode: 0o755,
});
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 });
spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000 });
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() });
const restored = fs.readFileSync(path.join(hookDir, "pre-push"), "utf8");
expect(restored).toContain("echo mine");
expect(restored).not.toContain("managed");
@@ -671,7 +674,7 @@ describe("base resolution when the default branch is neither main nor master", (
// blocks the push having scanned NOTHING — the "scans more, never less"
// fallback inverting into "scans nothing".
const bare = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-remote-"));
spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000 });
spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000, env: gitEnv() });
git(["branch", "-M", "trunk"]);
const old = commit("legacy.txt", FAKE_AWS_KEY + "\n", "secret already on the remote");
+1 -11
View File
@@ -31,23 +31,13 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
import { gitEnv } from "./helpers/git-env";
const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush");
let repo: string;
let remote: string;
// Same blanking as test/redact-prepush-scan-range.test.ts (#2967). A
// hooksPath written into the fixture repo still runs.
function gitEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
env.GIT_CONFIG_GLOBAL = "/dev/null";
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_CONFIG_COUNT = "0";
delete env.GIT_CONFIG_PARAMETERS;
return env;
}
function git(args: string[], cwd = repo): string {
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
if (r.status !== 0) throw new Error(`git ${args.join(" ")}\n${r.stderr}`);
+1 -15
View File
@@ -17,25 +17,11 @@
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { spawnSync } from "child_process";
import { gitEnv } from "./helpers/git-env";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
// Fixture git was spawned with the parent environment. A global or system
// core.hooksPath, or the same key via GIT_CONFIG_COUNT /
// GIT_CONFIG_PARAMETERS, runs on these pushes and rejects the local remote
// (#2967). /dev/null drops the global file on every platform: Git for
// Windows rejects NUL (see EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts).
// Config written inside the fixture repo still applies.
function gitEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
env.GIT_CONFIG_GLOBAL = "/dev/null";
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_CONFIG_COUNT = "0";
delete env.GIT_CONFIG_PARAMETERS;
return env;
}
let dir: string;
const run = (args: string[], cwd = dir): string => {
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
+70 -31
View File
@@ -4,13 +4,14 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { scan } from "../lib/redact-engine";
import { gitEnv } from "./helpers/git-env";
const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact");
const roots: string[] = [];
const key = ["AKIA", "1234567890ABCDEF"].join("");
function git(cwd: string, ...args: string[]): string {
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 });
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() });
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
return result.stdout.trim();
}
@@ -35,7 +36,7 @@ function fixture(initialSecret = true, sha256 = false): { repo: string; origin:
git(repo, "remote", "add", "origin", origin);
git(repo, "push", "-q", "-u", "origin", "main");
git(repo, "remote", "add", "publish", publish);
const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(install.status).toBe(0);
return { repo, origin, publish, head };
}
@@ -58,11 +59,49 @@ afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
describe("fixture installs ignore the developer's hook config (#3353)", () => {
test("a global, system or environment core.hooksPath is neither rewritten nor run", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-target-global-"));
roots.push(root);
const hooks = path.join(root, "global-hooks");
fs.mkdirSync(hooks);
const original = "#!/bin/sh\necho developer-hook >&2\nexit 1\n";
fs.writeFileSync(path.join(hooks, "pre-push"), original, { mode: 0o755 });
const config = path.join(root, "gitconfig");
fs.writeFileSync(config, `[core]\n\thooksPath = ${JSON.stringify(hooks)}\n`);
const keys = ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_COUNT",
"GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "GIT_CONFIG_PARAMETERS"] as const;
const prev = Object.fromEntries(keys.map(key => [key, process.env[key]]));
try {
process.env.GIT_CONFIG_GLOBAL = config;
process.env.GIT_CONFIG_SYSTEM = config;
delete process.env.GIT_CONFIG_NOSYSTEM;
process.env.GIT_CONFIG_COUNT = "1";
process.env.GIT_CONFIG_KEY_0 = "core.hooksPath";
process.env.GIT_CONFIG_VALUE_0 = hooks;
process.env.GIT_CONFIG_PARAMETERS = `'core.hooksPath=${hooks}'`;
const { repo } = fixture(false);
expect(fs.readdirSync(hooks)).toEqual(["pre-push"]);
expect(fs.readFileSync(path.join(hooks, "pre-push"), "utf8")).toBe(original);
const installed = git(repo, "rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-push");
expect(fs.readFileSync(installed, "utf8")).toContain("gstack-redact pre-push (managed)");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status, push.stderr).toBe(0);
expect(push.stderr).not.toContain("developer-hook");
} finally {
for (const [key, value] of Object.entries(prev)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
});
describe("installed pre-push guard uses the actual destination", () => {
test("origin tracking at HEAD cannot excuse the same credential on first push to publish", () => {
const { repo, publish, head } = fixture();
expect(git(repo, "rev-parse", "origin/main")).toBe(head);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -71,7 +110,7 @@ describe("installed pre-push guard uses the actual destination", () => {
test("a configured remote's different push URL cannot reuse its fetch tracking tip", () => {
const { repo, publish } = fixture();
git(repo, "remote", "set-url", "--push", "origin", publish);
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -92,7 +131,7 @@ describe("installed pre-push guard uses the actual destination", () => {
git(repo, "update-ref", "refs/heads/publish/main", tip);
expect(git(repo, "rev-parse", "publish/main")).toBe(tip);
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(base);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -108,7 +147,7 @@ describe("installed pre-push guard uses the actual destination", () => {
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(git(repo, "rev-parse", "HEAD"));
git(repo, "remote", "set-url", "--add", "publish", second);
expect(git(repo, "remote", "get-url", "publish")).toBe(publish);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(second, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -116,7 +155,7 @@ describe("installed pre-push guard uses the actual destination", () => {
test("direct URL push cannot borrow origin's tracking history", () => {
const { repo, publish } = fixture();
const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
@@ -125,8 +164,8 @@ describe("installed pre-push guard uses the actual destination", () => {
test("an advertised remote tip absent from local objects cannot justify a guessed base", () => {
const { repo, publish } = fixture();
const remoteTip = seedIndependentRemote(repo, publish);
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(remoteTip);
@@ -135,21 +174,21 @@ describe("installed pre-push guard uses the actual destination", () => {
test("an absent advertised tip permits a clean forced update after conservative scanning", () => {
const { repo, publish, head } = fixture(false);
const remoteTip = seedIndependentRemote(repo, publish);
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(head);
});
test("new SHA-256 branch scans the correct empty tree and allows a clean push", () => {
const { repo, publish } = fixture(false, true);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(git(publish, "rev-parse", "refs/heads/main")).toHaveLength(64);
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
git(repo, "add", "leak.txt");
git(repo, "commit", "-qm", "new secret");
const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(blocked.status).toBe(1);
expect(blocked.stderr).toContain("aws.access_key");
});
@@ -164,7 +203,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n");
git(repo, "add", "clean.txt");
git(repo, "commit", "-qm", "clean branch");
const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
@@ -183,7 +222,7 @@ describe("installed pre-push guard uses the actual destination", () => {
git(repo, "add", "clean.txt");
git(repo, "commit", "-qm", "clean update");
const head = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/disposable")).toBe("");
@@ -193,7 +232,7 @@ describe("installed pre-push guard uses the actual destination", () => {
const { repo, origin, head } = fixture(false);
const hook = git(repo, "rev-parse", "--git-path", "hooks/pre-push");
const result = spawnSync("bash", [hook, "origin", origin], {
cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000,
cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000, env: gitEnv(),
});
expect(result.status, result.stderr || result.error?.message).toBe(1);
expect(result.stderr).toContain("could not parse a pre-push ref line");
@@ -208,7 +247,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "near seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.secret_key");
});
@@ -221,7 +260,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "unlabeled seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("aws.secret_key");
});
@@ -232,7 +271,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "one finding at seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
});
@@ -247,7 +286,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "supplementary normalization seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
@@ -264,7 +303,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "mid-line control");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("MEDIUM finding");
});
@@ -277,7 +316,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "embedded key control");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).not.toBe(head);
@@ -294,7 +333,7 @@ describe("installed pre-push guard uses the actual destination", () => {
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "long bearer advisory");
const tip = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).toContain("MEDIUM finding");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);
@@ -308,7 +347,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "invisible seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.secret_key");
});
@@ -320,7 +359,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "unscannable context");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
@@ -339,7 +378,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "normalization seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(expected ? 1 : 0);
expect(push.stderr.includes("aws.secret_key")).toBe(expected);
});
@@ -350,19 +389,19 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "large.txt"), "ordinary content\n".repeat(160_000));
git(repo, "add", "large.txt");
git(repo, "commit", "-qm", "large clean diff");
let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
expect(push.status).toBe(0);
fs.writeFileSync(path.join(repo, "long.txt"), "x".repeat(900_000) + "\n");
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "long clean line");
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
expect(push.status).toBe(0);
const priorTip = git(origin, "rev-parse", "refs/heads/main");
expect(priorTip).not.toBe(head);
fs.writeFileSync(path.join(repo, "too-long.txt"), "x".repeat(1_100_000) + "\n");
git(repo, "add", "too-long.txt");
git(repo, "commit", "-qm", "over cap line");
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(priorTip);
@@ -373,7 +412,7 @@ describe("installed pre-push guard uses the actual destination", () => {
fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)} key ${key}\n`);
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "long line with key");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
@@ -385,7 +424,7 @@ describe("installed pre-push guard uses the actual destination", () => {
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "neighboring long lines");
const tip = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);