Merge commit '1ccab0d8bccba50e69587672fb82ac0574638a65' into capy/gst-1088-lane-a-followup

This commit is contained in:
garrytan committed 2026-11-03 15:44:01 +00:00
commit 1c5c0efa43
213 files changed
+5553 -550

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
name: Windows Scoped Tests
# The path-scoped Windows free shard (ship-90-90 plan D). windows-smoke.yml is
# still the required Windows check on every pull request, and the full
# curated suite (windows-free-tests.yml) still runs nightly on main. This
# workflow sits between them: when a pull request touches path, file-name or
# process code, one windows-latest job runs the test files the pull request
# changed that are in the curated Windows-safe subset.
#
# "Path, file-name or process code" (the windows-scope filter step):
# - file names: anything under a bin/ directory (shell entry points and
# skill hooks such as freeze/bin/check-freeze.sh, #3758), shell and
# PowerShell scripts, hosts/*/hooks, setup, bun.lock, the Windows
# curation (scripts/test-free-shards.ts, scripts/lib/windows-curation.ts,
# the Windows duration seed, windows-free-tests.yml), and code files whose
# names say path, spawn, exec, process, shell, launch, win32, windows,
# tmp, temp, symlink, state-root, slug, claude-bin, bun-isolation or
# script-invocation;
# - added or removed code lines that spawn processes or spell paths:
# child_process, spawn/exec calls, Bun.spawn, Bun.$, path.win32 and the
# other separator-sensitive path calls, process.platform/execPath, win32,
# tmpdir(), /dev/null, /tmp/, symlink, realpath, chmod, cygpath,
# USERPROFILE, homedir(), fileURLToPath and .exe. #3829's Windows break was
# one such line in a test it added: spawnSync(process.execPath, [...,
# '--config=/dev/null']), which native Windows Bun cannot open.
# test/windows-path-scope-workflow.test.ts proves the filter catches #3758
# and #3829 and passes over a docs-only and a pure-logic change.
#
# The windows-scoped-tests job is the check to read: it passes when nothing
# is in scope (no Windows job ran) and otherwise requires the Windows job, so
# a skipped run reports success. It is not one of the required checks, but
# gstack-ship-queue ready and land refuse a head with any failed check, so a
# red Windows job blocks the pull request it runs on: in a pull request that
# touches path, file-name or process code, a changed curated test file has to
# pass on Windows. A change to path or process code with no changed curated
# test selects nothing; the nightly suite covers it.
# gstack-pregate reads a job keyed on a dispatch input as manual, never a PR
# obligation: this check runs on the pull request itself. A workflow_dispatch
# run with the files input reruns chosen Windows-safe files on a branch.
on:
pull_request:
branches: [main]
workflow_dispatch:
inputs:
files:
description: Space-separated Windows-safe free test files to run (from bun run test:windows --list)
type: string
required: true
concurrency:
group: windows-scoped-tests-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
# Test-only lane — no token writes.
permissions:
contents: read
jobs:
windows-scope:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
files: ${{ steps.select.outputs.files }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'pull_request'
with:
fetch-depth: 2
persist-credentials: false
- id: filter
name: Does the diff touch path, file-name or process code?
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git diff --name-only --no-renames HEAD^1 HEAD > "$RUNNER_TEMP/changed-files"
code_re='\.(ts|tsx|js|mjs|cjs|sh|bash|ps1|cmd|bat)$|(^|/)bin/[^/.]+$|(^|/)setup$'
path_re='(^|/)bin/|\.(sh|bash|ps1|cmd|bat)$|(^|/)hosts/[^/]+/hooks/|(^|/)setup$|^bun\.lock$|^scripts/test-free-shards\.ts$|^scripts/lib/windows-curation\.ts$|^scripts/free-test-durations-windows\.json$|^\.github/workflows/windows-free-tests\.yml$'
name_re='(path|spawn|exec|process|shell|launch|win32|windows|tmp|temp|symlink|state-root|slug|claude-bin|bun-isolation|script-invocation)[^/]*$'
content_re='child_process|\b(spawn|spawnSync|execSync|execFile|execFileSync)[[:space:]]*\(|Bun\.(spawn|spawnSync|\$)|\bpath\.(win32|posix|sep|delimiter|relative|isAbsolute|normalize)\b|process\.(platform|execPath)|\bwin32\b|tmpdir\(|/dev/null|/tmp/|symlink|realpath|chmod|cygpath|USERPROFILE|homedir\(|fileURLToPath|\.exe\b'
{
grep -E "$path_re" "$RUNNER_TEMP/changed-files" | sed 's/^/path: /' || true
grep -E "$code_re" "$RUNNER_TEMP/changed-files" | grep -E "$name_re" | sed 's/^/name: /' || true
{ grep -E "$code_re" "$RUNNER_TEMP/changed-files" || true; } | while IFS= read -r file; do
git diff -U0 --no-renames HEAD^1 HEAD -- "$file" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' | grep -m1 -E "$content_re" | awk -v f="$file" '{ print "content: " f ": " $0 }' || true
done
} > "$RUNNER_TEMP/windows-scope"
if [ -s "$RUNNER_TEMP/windows-scope" ]; then
echo 'windows=true' >> "$GITHUB_OUTPUT"
{ echo 'This pull request touches path, file-name or process code:'; echo '~~~'; head -n 40 "$RUNNER_TEMP/windows-scope"; echo '~~~'; } >> "$GITHUB_STEP_SUMMARY"
else
echo 'windows=false' >> "$GITHUB_OUTPUT"
echo 'This pull request touches no path, file-name or process code, so no Windows job runs. windows-smoke still runs on every pull request.' >> "$GITHUB_STEP_SUMMARY"
fi
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
if: steps.filter.outputs.windows == 'true'
with:
bun-version: 1.4.2
- id: select
name: Pick the changed test files in the curated Windows-safe subset
if: steps.filter.outputs.windows == 'true'
run: |
set -euo pipefail
GITHUB_STEP_SUMMARY=/dev/null bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/curated.json" --shards 1 > /dev/null
jq -r '.shards[].files[]' "$RUNNER_TEMP/curated.json" | LC_ALL=C sort -u > "$RUNNER_TEMP/curated"
LC_ALL=C sort -u "$RUNNER_TEMP/changed-files" | LC_ALL=C comm -12 - "$RUNNER_TEMP/curated" > "$RUNNER_TEMP/selected"
if [ ! -s "$RUNNER_TEMP/selected" ]; then
echo 'No changed test file is in the curated Windows-safe subset, so no Windows job runs.' >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
{ echo 'files<<GSTACK_SCOPED_FILES'; cat "$RUNNER_TEMP/selected"; echo 'GSTACK_SCOPED_FILES'; } >> "$GITHUB_OUTPUT"
{ echo "The Windows job runs $(wc -l < "$RUNNER_TEMP/selected" | tr -d ' ') changed Windows-safe test file(s):"; sed 's/^/- /' "$RUNNER_TEMP/selected"; } >> "$GITHUB_STEP_SUMMARY"
windows-scoped-shard:
needs: windows-scope
if: ${{ (github.event_name == 'workflow_dispatch' && inputs.files != '') || needs.windows-scope.outputs.files != '' }}
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.18.0
# The same inputs as a windows-free-tests.yml shard, with no bun install
# cache (a cold install beats restoring it on windows-latest).
- name: Configure git identity (required by tests that init temp repos)
run: |
git config --global user.email "windows-ci@gstack.test"
git config --global user.name "Windows CI"
git config --global init.defaultBranch main
shell: bash
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build server-node.mjs (required by Windows browse path)
run: bash browse/scripts/build-node-server.sh
shell: bash
- name: Generate host SKILL.md outputs (.agents, .factory)
run: bun run gen:skill-docs --host all
shell: bash
- name: Install Chromium for the Node worker smoke
run: bunx playwright install chromium
- name: Run the scoped Windows-safe test files
env:
FILES: ${{ inputs.files || needs.windows-scope.outputs.files }}
GSTACK_FREE_RETRY_FLAKY: '1'
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
TEMP: ${{ runner.temp }}
TMP: ${{ runner.temp }}
run: |
set -euo pipefail
printf '%s\n' "$FILES" | tr -d '\r' | tr ' ' '\n' | sed '/^$/d' > "$RUNNER_TEMP/windows-scoped-files.txt"
bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-scoped-files.txt"
shell: bash
- name: Upload full test logs
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: windows-scoped-test-logs
path: |
.context/free-test-logs/gstack-free-test-*.log
${{ runner.temp }}/gstack-free-test-*.log
include-hidden-files: true
if-no-files-found: ignore
- name: Upload flake ledger
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: flake-ledger-windows-scoped
path: ${{ runner.temp }}/flake-ledger.jsonl
if-no-files-found: ignore
retention-days: 90
windows-scoped-tests:
needs: [windows-scope, windows-scoped-shard]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Pass when nothing is in scope, else require the Windows job
env:
SCOPE_RESULT: ${{ needs.windows-scope.result }}
SHARD_RESULT: ${{ needs.windows-scoped-shard.result }}
FILES: ${{ inputs.files || needs.windows-scope.outputs.files }}
run: |
set -eu
test "$SCOPE_RESULT" = success
if [ -z "$FILES" ]; then
echo 'Nothing in scope (no changed Windows-safe test file in a pull request that touches path, file-name or process code); passing without a Windows run.' >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
test "$SHARD_RESULT" = success
+109
View File
@@ -32,6 +32,13 @@ name: Windows Setup E2E
# Separate from windows-free-tests.yml because that one runs a curated
# unit-test subset; this one exercises the install path itself.
#
# Runs by path (ship-90-90 plan D): the paths below are the install files
# this workflow runs or ./setup sources or runs. The windows-scope job then
# drops a pull request whose only install change is package.json's version
# line, which is what the merge-train stamp commit puts on every PR head.
# windows-scope's list must match the paths list (pinned by
# test/windows-path-scope-workflow.test.ts).
#
# Runner: GitHub-hosted free windows-latest, two parallel jobs. ~10-25 min.
on:
@@ -39,6 +46,7 @@ on:
branches: [main]
paths:
- 'package.json'
- 'bun.lock'
- 'scripts/build.sh'
- 'scripts/build-cso.sh'
- 'scripts/build-cso-windows.ps1'
@@ -64,6 +72,18 @@ on:
- 'bin/gstack-claude-root.sh'
- 'bin/gstack-browser-ensure'
- 'bin/gstack-path-spelling.sh'
- 'bin/gstack-config'
- 'bin/gstack-patch-names'
- 'bin/gstack-state-root.sh'
- 'bin/gstack-skill-name.sh'
- 'bin/gstack-install-registry.sh'
- 'bin/gstack-render-claude.sh'
- 'bin/gstack-host-renders.sh'
- 'bin/gstack-migrate-claude-code'
- 'bin/gstack-settings-hook'
- 'bin/gstack-relink'
- 'bin/gstack-harness'
- 'scripts/preflight-codex-overlap.ts'
- 'lib/path-spelling.ts'
- 'hosts/claude/hooks/**'
- 'gstack-upgrade/**'
@@ -81,7 +101,94 @@ permissions:
contents: read
jobs:
windows-scope:
# The paths filter above starts this workflow; this job decides whether
# the Windows jobs run. A workflow_dispatch run always runs them.
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
install: ${{ steps.scope.outputs.install }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: github.event_name == 'pull_request'
with:
fetch-depth: 2
persist-credentials: false
- id: scope
name: Run unless the only install change is package.json's version line
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
echo 'install=true' >> "$GITHUB_OUTPUT"
exit 0
fi
pkg_version_only=true
if git diff -U0 --no-renames HEAD^1 HEAD -- package.json | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' \
| grep -vE '^[+-] "version": "[^"]*",?$' > /dev/null; then
pkg_version_only=false
fi
git diff --name-only --no-renames HEAD^1 HEAD | while IFS= read -r file; do
if [ "$file" = package.json ] && [ "$pkg_version_only" = true ]; then continue; fi
while IFS= read -r glob; do
# shellcheck disable=SC2254
case "$file" in ${glob//\*\*/*}) echo "$file"; break ;; esac
done <<'PATHS'
package.json
bun.lock
scripts/build.sh
scripts/build-cso.sh
scripts/build-cso-windows.ps1
scripts/write-version-files.sh
setup
setup.ps1
test/setup-ps1.test.ts
docs/troubleshooting.md
browse/src/cli.ts
browse/src/find-browse.ts
browse/src/server.ts
browse/src/meta-commands.ts
browse/scripts/build-node-server.sh
design/src/cli.ts
make-pdf/src/cli.ts
bin/gstack-global-discover.ts
bin/gstack-paths
bin/gstack-doctor
bin/gstack-launch-probe.sh
bin/gstack-hook-check
bin/gstack-session-update
bin/gstack-ff-update
bin/gstack-claude-root.sh
bin/gstack-browser-ensure
bin/gstack-path-spelling.sh
bin/gstack-config
bin/gstack-patch-names
bin/gstack-state-root.sh
bin/gstack-skill-name.sh
bin/gstack-install-registry.sh
bin/gstack-render-claude.sh
bin/gstack-host-renders.sh
bin/gstack-migrate-claude-code
bin/gstack-settings-hook
bin/gstack-relink
bin/gstack-harness
scripts/preflight-codex-overlap.ts
lib/path-spelling.ts
hosts/claude/hooks/**
gstack-upgrade/**
.github/workflows/windows-setup-e2e.yml
PATHS
done > "$RUNNER_TEMP/install-files"
if [ -s "$RUNNER_TEMP/install-files" ]; then
echo 'install=true' >> "$GITHUB_OUTPUT"
{ echo 'Install files changed, so the Windows jobs run:'; sed 's/^/- /' "$RUNNER_TEMP/install-files"; } >> "$GITHUB_STEP_SUMMARY"
else
echo 'install=false' >> "$GITHUB_OUTPUT"
echo "The only install change is package.json's version line (the merge-train stamp), so the Windows jobs do not run." >> "$GITHUB_STEP_SUMMARY"
fi
windows-setup:
needs: windows-scope
if: needs.windows-scope.outputs.install == 'true'
runs-on: windows-latest
timeout-minutes: 35
@@ -377,6 +484,8 @@ jobs:
# docs/troubleshooting.md, against the runner's real ~/.claude install
# path ($HOME in PowerShell). GSTACK_E2E_POWERSHELL and
# GSTACK_E2E_UPDATER_PATH_PREFIX exist only to run these steps off Windows.
needs: windows-scope
if: needs.windows-scope.outputs.install == 'true'
runs-on: windows-latest
timeout-minutes: 50
env:
+168
View File
@@ -1,5 +1,173 @@
# Changelog
## [1.94.24.0] - 2026-11-03
### Fixed
- **The OpenClaw and Hermes start hook no longer breaks a fresh harness.** When the harness has no config yet, gstack runs none of its commands: `openclaw config set` and `hermes plugins enable` would have created a config holding only gstack's plugin setting, with no model, provider or terminal settings. The hook reports `written` with the setup to run first (`openclaw onboard`, `hermes setup`), and the next `./setup` or `gstack-harness refresh` turns it on, changing only the plugin entry. Turning the hook off or unregistering in such a profile also leaves the config alone. If v1.92.23.0 or a later release before this fix already created a config holding only the plugin entry, move it aside, run the harness's own setup, then `gstack-harness refresh`.
- **`OPENCLAW_CONFIG_PATH` naming the profile's own `openclaw.json` keeps the start hook on.** gstack compares the path after resolving symlinks, so only a config outside the profile turns the hook off. When `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` asked for the hook and it ends up off, `gstack-harness register` and `./setup --host` now exit 4 with the reason and the fix instead of exiting 0.
- **`gstack-harness status` reads the hook from the harness's config when the harness CLI is not on `PATH`**, as inside Hermes's or OpenClaw's own terminal tool. Hermes's `config.yaml` settles it (`active`, `disabled` or `written`); otherwise status says `unknown` instead of a wrong `written`.
## [1.94.23.0] - 2026-11-03
**Two `gstack-pregate` preflight false positives fixed (GST-1211).** Both stopped /ship on real work in gbrain-evals.
### Fixed
- **`strays`:** the scratch-name check matched three prefixes the docs never listed (`temp`, `debug-` and `probe`), so real files like `probe.py` and `temporal-graph-shipped.test.ts` failed preflight. It now matches only the documented names: `zz-*`, `tmp*`, `scratch*`, plus editor leftovers (`*.orig`, `*.rej`, `*.swp`, `*.swo`, `*.bak`, `*.tmp`, `*~`, `.DS_Store`, `Thumbs.db`), which docs/pregate.md and docs/troubleshooting.md now list too.
- **`literals`:** a named literal the branch only adds, such as `limit: 300` in a new object, was read as an existing `limit: 10` elsewhere in the file changing to 300, and every test that mentioned 10 was flagged. The check now compares every value under a name, so an added literal isn't a change. Editing an existing value still fails, and so do two cases the old last-value check could miss: changing one of two equal values under the same name, and deleting one site while editing another.
### Tests
- `test/pregate-selection.test.ts`: `probe.py` and `temporal-*.test.ts` pass `strays` while `zz-notes.md`, `tmp-x.ts`, `scratch.py` and `util.js.orig` still fail; a new object literal beside `limit: 10` passes `literals` while editing that value still fails; the pure `changedLiterals` case keeps an exported constant change.
## [1.94.22.0] - 2026-11-03
**/investigate, /review, /qa and /verify now say when to use them, and /investigate and /review declare what "done" means (GST-1088 lane B).** Untold, Opus 5.5 never opened /investigate in 252 trials (Run D, gstack-evals#140). A when-to-use description raised GPT-6.1 Sol's open rate from 9.5% to 27.0% in the same run. Claude Code's catalog shows only a description's first sentence, so each of the four now opens with a "Use when ..." sentence. The routing hook and the contract check that read the new frontmatter landed in v1.94.6.0 (lane A, off by default; see docs/skill-routing-and-contracts.md), so the "Done means" list now also names `gstack-skill-contract check` and `waive`.
### Changed
- **Descriptions**: the first sentence of each is now a when-clause, for example /investigate's "Use when a test fails, code errors or output is wrong: find the root cause, then fix it." Hosts that show the full description also get the longer list of situations. The catalog stays within its existing budget.
- **Triggers**: /investigate, /review and /verify list the prompts people actually type, such as "test fails", "look over my diff" and "make it green". /qa keeps its three triggers because its size cap had 9 bytes of room.
### Added
- **`contract:` frontmatter** (frozen v1 schema) on /investigate (the DEBUG REPORT labels, and a test or repro run after the last edit) and /review (one Agent dispatch per selected specialist, the adversarial pass, the Pre-Landing Review report). `{{CONTRACT_DONE_MEANS}}` (`scripts/resolvers/contract-done-means.ts`) renders each block as a short "Done means" list at the top of the skill, so the list and the block can't drift.
### Tests
- `test/skill-descriptions.test.ts`: each of the four opens with a when-clause within the catalog, lead and host caps; triggers are short and don't overlap; ten sample prompts match exactly the intended skill; every template's contract block passes the v1 schema and names markers the skill text actually prints; the renderer covers each item kind; and malformed blocks fail with the file and the fix.
## [1.94.21.0] - 2026-11-03
- **A stacked PR's /ship receipt now carries even when the child merged main before its receipt was written (GST-1214).** After the parent squash-landed and main was merged in again, `gstack-ship-receipt carry` compared the range from the parent's head, which still held every file the child's first main merge brought in, and refused with `RECEIPT_CARRY_PRODUCT_CHANGED` naming those files. The carry now leaves out paths whose content at the reviewed head is main's, unless HEAD changed them. A real edit to one of those files still refuses.
## [1.94.20.0] - 2026-11-03
### Changed
- **The review pins and the harness pins are one file per pin, so PRs that repin different files no longer conflict (GST-1084).** The reviewer-brief pins that lived in one table in `test/review-packets-removed.test.ts` now live under `test/fixtures/review-pins/`: `files/<pinned path>.json` holds a file's sha and the replay that admits it, `replays/<slug>.json` holds one replay's written no-loss justification, and `brief-bodies/<name>.json` holds one brief body's sha. The harness pins in `scripts/harness-version.json` moved to `scripts/harness-pins/<path>.json`; the manifest keeps only the version and the patterns, so `HARNESS_VERSION` and its readers are unchanged. Before, two PRs that repinned adjacent entries conflicted on the shared table (at least five main-merge conflicts on 2026-11-02, two of them on #3852). Every entry is ported unchanged, including the combined GST-508 entries from #3852.
### For contributors
- `/ship`'s post-review route still counts a non-behavioral harness re-pin as release metadata (GST-1031): it now reads the pin files under `scripts/harness-pins/` and refuses a manifest that regains a `files` table.
- The checks are at least as strict as before. Every pin still needs its sha and a replay whose written justification names a no-loss replay or re-score, and the brief-body check still guards the bodies. A malformed or misnamed pin file, a stray file in a pin directory, or a `files` block back in `scripts/harness-version.json` now fails too. New tests prove that a stale pin still fails, and that two branches repinning different, adjacent pins merge cleanly with a real `git merge-tree`, while two repins of one pin still conflict.
- `bun run scripts/bump-harness-version.ts` writes and deletes pin files itself. Repin a review file by editing its `files/` pin and adding the replay file it names.
## [1.94.19.0] - 2026-11-03
### Fixed
- **A finished /ship no longer blocks `gstack-upgrade` for two hours (GST-1205).** Each `gstack-skill-start` writes a workflow lease that `gstack-upgrade` checks before moving a shared checkout. Nothing released it when the workflow ended, and on Capy every session's lease names the machine's one `capy-server`. So after a /ship finished, its own lease kept refusing the upgrade with UPGRADE_BUSY until it was 120 minutes old, unless you set `GSTACK_UPGRADE_FORCE=1`. Now `gstack-skill-start` records its `SESSION_ID` as an open run on the lease, and `gstack-skill-end` closes it with the new `gstack-lease release`. Once every run on a lease is closed, the lease is idle and blocks nothing (`gstack-lease list` shows `idle`). A run another session still has open keeps blocking. So does a holder that records no run (an older gstack, or `gstack-lease touch` without `--run`, which now leaves a `hold` entry that no release removes). Each run counts for 120 minutes from its own start, so one that never reaches `gstack-skill-end` stops blocking on time even while other sessions keep starting. A nested skill's own run covers it while it runs, but other sessions' starts no longer extend a run past its 120 minutes.
## [1.94.18.0] - 2026-11-03
**The Windows-safe test subset is green again on current main.** `test/qa-report-dir.test.ts` expected `ENOTDIR` when `.gstack` is a file, but Windows reports `ENOENT` for the same mistake, so the test failed on every Windows run (windows-free-tests run 41607809007 at f6d6215). The test now accepts either code on Windows and still requires `ENOTDIR` everywhere else. With Windows checks now running by path, a red subset test would block any pull request that touches it.
## [1.94.17.0] - 2026-11-03
- A pregate guard whose exact command already passed at this tree no longer runs again (GST-1186). When the base branch's `ship_lanes_from` maps the command, it is carried from the verified CI run through the same `gstack-evidence record-ci` path /ship lanes use (`GUARD: <id> carried runs=…`); otherwise a counted local ledger pass of that command at the same working tree stands (CI rows count only through a live `record-ci` check) (`GUARD: <id> reused …`, and the new read-only `gstack-evidence passed -- <command>`). Repos whose suites are guards, like founderstar, stop rerunning about six minutes of suites per /ship. See docs/pregate.md#guard-carry.
- Known limit: `ship_lanes_from` is still read from `origin/<base>` only, so the PR that adds a mapping cannot carry from it.
- A `ship_lanes_from` entry can name the CI jobs that run a lane instead of an inventory command: `{ "profiles": [...], "jobs": ["verify", "typecheck"] }`, exactly one of the two (GST-1189). It carries only when every named job succeeded in the newest attempt of the verified run at HEAD's tree, through the same `verifyRun` job check. A missing job refuses as `job_missing` and a skipped one as the new `job_skipped`. Lanes with no test files, like gbrain's verify and typecheck (run 41564913688) or harness-evals' `bunx tsc --noEmit`, can now carry, and they record `tests_ran=n/a`. Fake `check:<command>` entries in `tree.json` can move to this form. A repo declaring `jobs` should raise its `min_gstack_version` to this release first, since older gstack rejects the key. See docs/pregate.md#ship-lanes-jobs.
- A `ship_lanes_from` entry can declare `aliases`, other exact spellings of the same lane such as `env -u ANTHROPIC_API_KEY bun test tests/` (GST-1190). gstack never strips env prefixes on its own, since an `env -i` run is a different environment.
- A carried lane no longer reports the whole run's test count. An inventory lane reports how many of its files the run covered, labeled as a file count (alphaclaw's 46-test guards lane recorded 9677 before).
- `gstack-ship-preflight --tests-only --policy-from head --simulate` proves a new mapping before it lands: every line starts `SIMULATION:`, no lane runs, no evidence row is written and nothing clears. An inventory-mapped lane runs the branch's inventory command, so the dry run needs `--allow-repo-commands`. A branch's own map never carries, so the first pull request that adopts a mapping pays its full lanes.
- `gstack-evidence carried`, which the ship receipt reads, lists only rows the receipt base's policy mapped, so a run against another base never lands in a receipt.
- An unknown `.gstack/pregate.json` key under a `min_gstack_version` above the running gstack now says `needs gstack >= <version>; upgrade` (`PREGATE_GSTACK_TOO_OLD`). It still refuses.
- docs/pregate.md#prove-a-mapping documents the proof route, the version floors, and that `tree.json` writers using Bun's JUnit must take the root `testsuite` count.
- A test file whose every test skipped on the CI runner no longer counts as carried (GST-1190, harness-evals: 75 tests). A `tree.json` that lists such files in `skipped_files` makes the pregate tests stage run them here. A /ship lane or guard whose inventory names one carries the covered files and runs only the skipped ones here, through the `per_file` runner its `ship_lanes_from` entry declares. The CI row is written only after that run passes with a test count above zero and the carried runs still stand (`skipped_failed`, `skipped_zero_run` or `run_changed` otherwise), and an entry without `per_file` refuses as `inventory_skipped`, naming the files. A partly skipped file still counts. An older `tree.json` without the list is read as before. gstack itself changes nothing yet: its own writer and a `per_file` entry for its `bun run test` lane switch on together in a follow-up pull request, after the pull requests now queued on older gstack have landed, because an older gstack rejects the new key in main's policy.
- A run profile can declare `skippable: { jobs, gate, scripts }` for jobs a path filter skips (GST-1190, truther #1169). A skipped job counts only in a `pull_request` run whose gate job succeeded, with the workflow, the declared `scripts` (every file the gate reads), the paths the gate job names (also under its `working-directory`) and `package.json` when it runs a package script matching `origin/<base>` byte for byte; the lane then prints `not owed: CI skipped at this tree (gate job <name>, workflow unchanged)`. If the branch changed any of them, it controlled the skip, so the lane runs here.
- `gstack-ci-carry find --policy-from head --simulate` is the same dry run for one lane command, every line starting `SIMULATION:`.
### Tests
- `test/ship-ci-carry.test.ts`: in founderstar's shape (guards only, no lanes), `check-quick` carries from CI, `play-through` reuses a same-tree ledger pass, `assets` runs, and a dirty tree carries nothing. It fails without the guard carry.
- `test/ship-ci-carry.test.ts`: a lane mapped by `jobs` carries when every named job passed at the same tree with no test files, and refuses for a missing job, a skipped job, a rerun whose newest attempt skipped the job, and another tree's run. Those five fail without the change.
- `test/ship-ci-carry.test.ts` (GST-1190): an alias carries and an undeclared env prefix does not; a narrow lane reports its covered file count, not 9677; a jobs-mapped `bunx tsc --noEmit` guard carries with `n/a`; the newer-gstack message; `--simulate` writes and clears nothing and the branch's own map never carries; `carried` drops a row another base mapped. Six of these fail without the change.
- `test/ship-ci-carry.test.ts` (GST-1190 skipped files): a fully skipped inventory file stops the lane carry, an empty list or older `tree.json` carries, and the tests stage runs only the skipped selected file. Both fail without the change.
- `test/ship-ci-carry.test.ts` (path-skipped jobs and `find --simulate`): a skip under an unchanged, green gate is not owed; a changed workflow, a changed gate script or a failed gate job runs the lane here; `find --policy-from head --simulate` prints only SIMULATION lines and writes nothing. All five fail without the change.
- `test/ship-ci-carry.test.ts` (review fixes): a gate script behind `${{ github.workspace }}`, under `working-directory` or behind a package script is compared; the gate is the job, not a same-named step; only a `pull_request` run's skip counts; a skippable profile must declare `scripts`; `passed` never counts a CI row; a path-skipped guard says not owed; `--simulate` runs a branch inventory only with `--allow-repo-commands`. Eight fail without the fixes; a newer-failure veto test covers existing behavior.
- `test/ship-ci-carry.test.ts` (per-file lane carry): the covered file carries and only the skipped file runs through `per_file`, a failing or zero-test remainder, or a run that moved to a new attempt meanwhile, records no CI row and runs the lane whole, and `per_file` needs `{files}` and an inventory entry. All five fail without the change.
## [1.94.16.0] - 2026-11-03
**A declared placeholder restamped to the new version is a stamp, and a carried receipt names the head's version (GST-1026, GST-1030).** gbrain writes a PR's own BEHAVIOR_CHANGES row as `since: 'TBD'` and its `release:restamp` fills in the version. Finish refused that receipt ("behavior, tests or build inputs changed since review"), and the receipt carry and the tree receipt would have refused the same row (gbrain #8444). Separately, a receipt carried after a main merge and a restamp kept the version of the head it was written at (GGST-1).
### Added
- **`stamp_placeholders` policy key** (default `[]`, so nothing changes for a repo that does not set it): literal placeholders such as `["TBD"]`, at most 16, one line and 32 characters each, never `{version}` and never a version. It needs `min_gstack_version` at the release that introduced it.
### Fixed
- **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version, alongside any stamp-form version move on the same line. The built-in forms, a bare `{version}` template, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it.
- **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. A head with no version source keeps the version `write --version` recorded.
- **`bin/gstack-ship-receipt` carry, pin-bump PRs:** a `package.json` the branch changed as product (a dependency pin) that both main and a restamp moved since review is compared with only its `version` field masked, at all four sides. The receipt carries when nothing else in the file moved, and still refuses when the pin or any other field changed (GBEV-3, gbrain-evals #306).
### Tests
- `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches.
- `test/ship-rereview-triggers.test.ts`: finish routes the declared row as metadata and stops on the same controls.
- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product; a pin-bump PR's receipt carries after a main merge and a restamp, and refuses when the pin moved since review.
- `test/gstack-ship-policy.test.ts`: the key's default, floor and validation.
The EVIDENCE.md refusal (GST-1030's second point) reproduces on main and is already fixed by the GST-980 own-row carry this branch builds on, so this change adds only the test for it.
## [1.94.15.0] - 2026-11-03
- **A stacked PR keeps its /ship receipt after its parent squash-merges, even when the receipt was written against main (GST-1199).** `gstack-ship-receipt carry` used to refuse with `RECEIPT_CARRY_PRODUCT_CHANGED` once the parent's hunks moved into main, forcing a full re-review of unchanged code. The carry now finds the newest reviewed commit that main already holds (`git merge-tree` into main changes no product path) and compares from there: an unchanged child carries as `stack parent <sha12> landed`, base edits next to the child's hunks go to `RECEIPT_CARRY_SCOPED_REVIEW`, and any other change still refuses. A parent that never landed never counts, even if the child's merge dropped its change.
## [1.94.14.0] - 2026-11-03
**`review_exclude_paths` keeps code it used to exclude, and the docs say exactly what the receipt check stops (GST-972).** Three ways to get code or a credential out of review under a base pattern are closed, each with a failing-first test in `test/review-exclude.test.ts`, and the receipt docs no longer over-claim.
### Fixed
- **Native binaries are code.** A `.node`, `.so`, `.dylib` or `.dll` file under a base pattern was excluded as binary content, and so was a compiled executable with a data extension. A binary file that starts with ELF, Mach-O (universal binaries included), PE, WebAssembly or `#!` magic is now kept as `code` whatever its extension. Binary here is git's own verdict on the bytes, read with no attributes from the tree as well as with the branch's, so a branch's `.gitattributes` (`*.png diff -binary`) cannot make an ELF read as text and skip the check (#3910).
- **A NUL byte no longer makes code excludable.** A binary file was content unless its extension was on a short source list, so `.gradle`, `.sql`, `.tf`, `.groovy`, a `.p12` key store or an extensionless private key with a NUL byte was excluded. A binary file is now content only under a known content extension (images, fonts, media, documents, compressed archives, `.bin`, `.dat` and data blobs); every other binary file is kept as `code`.
- **A branch's `.gitattributes` cannot get a file with a credential line excluded.** Marking `receipts/**` binary left the collector's added lines empty, so the secret-line keep rule saw nothing and the file was excluded. For a file git calls binary only because of its attributes, the rule now reads `git diff --text --no-textconv --no-ext-diff`, so the file stays in review and the plan lists it as `REVIEW_EXCLUDE_KEPT: secret-line`. Readers' own diffs still follow the branch's attributes and show that file as `Binary files differ`; reading such files as text in every reader's diff is a separate follow-up.
- **`gstack-ship-receipt write --review-exclude` docs are exact.** The help, `docs/troubleshooting.md`, `docs/ship-policy.md` and the queue's refusal line said a receipt write alone cannot clear `field=review_exclude`. Write checks the digest against the base policy and does not read the review ledger, so a write given the refusal's `expected=` digest passes without a review. A mismatch proves only that the `--review-exclude` argument (or the digest the receipt recorded from it) differs from the head's selection, not what the readers read. The text now says that, and that the review rerun is the fix.
## [1.94.13.0] - 2026-11-03
- **A bare `--base main` reads origin/main in every review and ship bin (GST-1036).** `gstack-review-plan` now prints `REVIEW_BASE: <ref> <sha>` and names a lagging local base on stderr. `gstack-banned-terms`, `gstack-prose-lint --base`, `gstack-docs-candidate`, `gstack-design-detect.ts scan --changed` and `gstack-pr-shots --base` no longer merge against a stale local main and report lines the branch only inherited. They all use the one resolver gstack-gate-diff already used.
- **`gstack-diff-scope` stops treating authorship files as auth (GST-1065).** `author-spec.md`, `AUTHORS`, `authority.ts` and `coauthors.ts` no longer raise a docs diff to gate class. Every other name with auth in it still does, including `auth.ts`, `/auth/`, `oauth`, `nextauthmiddleware.ts` and camelCase names like `useAuth.ts`.
## [1.94.12.0] - 2026-11-03
### Fixed
- **Six markup tricks no longer pass a remote-loading or scripted SVG off as a chart (GST-940).** `&#60;/style>` or `&#60;!--` inside a `<style>` element, a comment that only an HTML parser ends early, a `>` inside a quoted attribute on the `<style>` tag, or a Unicode space before the `>` of `</style>` let CSS that loads a remote image read as a plain drawing, and a non-ASCII namespace prefix such as `<gé:script>` hid a script. Any of these could get the SVG a light review. The style walker now reads the `<style>` tag as quoted pairs with ASCII whitespace and its text as written, refuses any `&` in it, and reads its comments as CSS; a non-ASCII character in an element or attribute name keeps the SVG code. Over 62,805 unique SVG files, no other verdict changed.
## [1.94.11.0] - 2026-11-03
**A granted fix-check's clean read now counts at Step 16.** On gstack-evals#168 the owner granted one scoped fix-check, the reader read `9b0c56c..7ea1907` and found nothing, and `gstack-ship-release finish` still stopped with `STOP: behavior, tests or build inputs changed since review (tests/panel-kit-v3-1.test.ts)`, sending the test file the fix-check had read back through Steps 5 to 11.5. The review-loop exception could not help, because it is the owner accepting unreviewed edits and it never accepts a gate-edited test. The fix-check row also read `UNVERIFIED`, because it was logged without its `completed` and `converged` fields (GST-1130, GEVA-218).
### Fixed
- **`gstack-ship-release finish`** reads the newest `review-fix-check` row. When the row is a completed clean read on committed content, its `range_base` is a branch commit holding the reviewed tree, and the owner's recorded `review_loop_grant` names a stop head inside that range, every code or test path inside that range that is unchanged since routes as `fix_checked=` on the `ROUTE:` line, with a `FIX_CHECK: reviewed <paths>` line. That includes a gate-edited test. Authored docs still get the plan-item recheck, and a path edited after the fix-check still stops. A row that does not qualify prints `FIX_CHECK: not counted (<reason>)` and changes nothing.
- **`gstack-review-log`** refuses a `review-fix-check` row that does not carry `completed` and `converged` as true or false, and keeps the start token for the retry. The refusal names the missing fields, so the row is fixed when it is written instead of reading `UNVERIFIED` at Step 11.5.
### Tests
- `test/ship-rereview-triggers.test.ts`: a fixture shaped like gstack-evals#168 (a round-2 pass, then a fix commit that edits the source and a gate-edited test) stops on main and passes finish once a clean fix-check row with its range base is logged through the real `gstack-review-log`. Negative controls: an edit after the fix-check still stops, and an ungranted row, a grant for another stop head, an incomplete row, a row with no range base, a range base that does not hold the reviewed tree, or a row logged on uncommitted content is not counted.
- `test/review-log-findings.test.ts`: a `review-fix-check` row missing either field, or with a non-boolean value, is refused and keeps its start token; the complete row binds as verified.
## [1.94.10.0] - 2026-11-03
**Windows checks now run by path.** Windows Setup E2E, the 10-to-25-minute install test, no longer starts its Windows jobs on a pull request whose only install change is `package.json`'s version line, which the merge-train stamp puts on every pull request before it merges. Of the 61 pull requests in its last 100 runs (as of 2026-11-03), 37 changed no install file besides `package.json`. Its path list also gains `bun.lock` and the `bin/` scripts that the test or `./setup` runs. A new `Windows Scoped Tests` workflow runs the Windows-safe test files a pull request changed, on one Windows job, when the pull request touches path, file-name or process code, such as a hook script or a new `spawnSync` call. On the last 120 commits to main (through 923fc0a) it would have run on 50, with a median of one test file. It catches both Windows breaks the plan names: /freeze's path check (#3758) and a test that started Bun with `--config=/dev/null` (#3829). A red run blocks its pull request like any failed check, so this release also fixes #3829's test, which has failed every nightly Windows run since it landed. `windows-smoke` still runs on every pull request, and the full Windows suite still runs nightly.
### CI
- `.github/workflows/windows-setup-e2e.yml`: a Linux `windows-scope` job runs first and skips both Windows jobs when the only install change is `package.json`'s `"version"` line. Paths add `bun.lock`, `bin/gstack-config`, `bin/gstack-patch-names` and the scripts `./setup` sources or runs.
- `.github/workflows/windows-scoped-tests.yml` (new): a Linux scope job reads the diff's file names and changed code lines, picks the changed files in the curated Windows-safe subset, and a `windows-latest` job runs them. The `windows-scoped-tests` check passes when nothing is in scope, so a skipped run still reports success. A `workflow_dispatch` with a `files` input reruns chosen Windows-safe files on a branch.
### Tests
- `test/review-risk-linear.test.ts`: the rules-hash case starts Bun with `bunIsolationArgs()` (`--config=NUL` on native Windows) instead of `--config=/dev/null`, which native Windows Bun cannot open (nightly Windows Free Tests run 41420490350, EINVAL).
- `scripts/test-pr-profile.ts`: the new workflow is listed as free-lane only beside the other Windows workflows (test/free-fixtures.test.ts requires every workflow to be classified).
- `test/windows-path-scope-workflow.test.ts`: runs both scope steps in a two-commit git repository. The filter catches #3758's and #3829's changed files and passes over a docs-and-stamp change and a pure-logic change. The Setup E2E scope skips a version-only `package.json` bump and runs on a dependency change. `windows-smoke` stays unfiltered, and no job that can skip is a required check.
## [1.94.9.0] - 2026-11-03
**A pull request can clear its remote gate obligation from a run with named flaky failures, once the owner grants it (GST-1096).** A timing flake in a test file the pull request never touched used to keep the pregate red with no way through but another paid run. `gstack-pregate clear --remote <lane> --run <id|url> --flaky-exception "<file>::<case>" --pr <n> --grant <approval id>` now accepts such a run when every failure is named from the run's own shard results and logs, at most three cases, each file outside the pull request's diff and its import closure, none paid and none a security scan test, and a gstack-approvals row in the owner's words binds the repository, PR, head, tree, lane, run attempt and cases. It covers the pregate only, and a red required CI check still needs its own green run.
### Added
- **`--flaky-exception`, `--pr` and `--grant` on `gstack-pregate clear`** (`lib/pregate/flaky-exception.ts`). Every job of the run attempt is reconciled (only test-shard jobs and their aggregate may fail), every planned shard needs a complete result, and each failed shard's log must name no failure its result leaves out. Without `--grant` the clear checks everything and prints the exact `gstack-approvals add` command, whose scope is a digest of everything the grant binds, so a grant can never be moved to another head or case list.
- **`pass-with-exception`.** pregate.json records `exceptions`; `gstack-pregate verify` re-checks the binding offline and prints `PREGATE: pass-with-exception (<cases>; grant <id>)`; `gstack-ship-queue ready` accepts it and prints a `FLAKY_EXCEPTION:` line; the ship receipt records `flaky_exception`.
- **docs/pregate.md#flaky-exception**, including why `base_red` is not used: it proves a failure by reproducing it on the base, and a timing flake passes there.
### Tests
- `test/pregate-flaky-exception.test.ts`: a red run with one named flake clears once the bound grant exists, and verify, ready and the receipt report it; refusals for an unnamed or extra failure, a case that did not fail, more than three cases, an unattributed failure, a failed non-shard job, a missing result or log, a timed-out shard, a log failure the result omits, a file in the diff or its import closure, a paid or security test, another head, another PR head, a newer run, a run from another repository or fork, a stale or unfinished run, a moved attempt (also after the inventory runs), and a grant with another scope, outcome or speaker; ready still refuses red CI, and refuses an exception granted for another PR or head. The printed grant command quotes case names for the shell. These tests failed before the change.
## [1.94.8.0] - 2026-11-03
**`gstack-approvals render` no longer puts the owner's exact words, autoplan run ids or plan hashes into PR bodies on public repositories (GST-1160).** On a public repo, /ship pasted the full `## Approvals` table into the PR description. That table carries the owner's verbatim quote, the run id and the plan sha256, and GitHub keeps every earlier version of a description in its edit history, which no API can delete.
+2 -1
View File
@@ -193,7 +193,8 @@ the new defaults.
| Run the full free suite from a small machine | `bun run test:ubicloud` | Free suite on a billed 16-vCPU VM, about 5 minutes | `UBICLOUD_API_KEY`, `UBI_OWNER` |
| Clear pregate's test tier on a machine under 8 cores from CI, the default on gstack ([CI carry](docs/pregate.md#ci-carry)) | push, `gstack-ci-carry wait --base main`, then `gstack-pregate --allow-repo-commands` (a branch behind main first runs `gh workflow run free-tests.yml --ref <branch>`) | Free; reuses the green Free Tests run at your pushed tree | Pushed branch, `gh` |
| Clear pregate's test tier on a machine under 8 cores when no CI run covers your tree ([remote gate](docs/pregate.md#remote-gate), opt-in) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`, `UBI_OWNER`; keyless: nothing |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check) | Free | Windows, Git Bash |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check, and a PR that touches path, file-name or process code also runs its changed Windows-safe test files in `Windows Scoped Tests`) | Free | Windows, Git Bash |
| Rerun chosen Windows-safe test files on Windows CI | `gh workflow run windows-scoped-tests.yml --ref <branch> -f files='<file> <file>'` (pick files from `bun run test:windows --list`) | Free CI runners | Pushed branch, `gh` with workflow rights |
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref <branch> -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights |
| Run a native Windows qualification campaign | `gh workflow run native-qualification.yml --ref <branch> -f mode=<mode>` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights |
| Preview which paid cases my diff selects | `bun run eval:select` (PR profile; `--profile full` for the plain touchfile selection) | Free | — |
+1 -1
View File
@@ -1 +1 @@
1.94.8.0
1.94.24.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.94.8.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.94.24.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,
+2 -1
View File
@@ -16,6 +16,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { BANNED_TERMS_PATH, addedLines, exampleBannedTermsJson, loadBannedTerms, renderBannedTerms, scanBannedTerms, validateBannedTerms, wholeFiles } from '../lib/banned-terms';
import { baseArg } from '../lib/gate-diff/git';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { resolveBaseBranch } from '../lib/ship-policy';
@@ -75,7 +76,7 @@ function main(argv: string[]): number {
let files;
if (filesFlag) files = wholeFiles(repoRoot, filesFlag.split(',').map(s => s.trim()).filter(Boolean));
else {
const diffBase = flag(rest, '--diff-base') ?? (() => { const mb = spawnSync('git', ['merge-base', base, 'HEAD'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); return mb.status === 0 ? mb.stdout.trim() : undefined; })();
const diffBase = flag(rest, '--diff-base') ?? (() => { const mb = spawnSync('git', ['merge-base', baseArg('gstack-banned-terms', base, repoRoot), 'HEAD'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); return mb.status === 0 ? mb.stdout.trim() : undefined; })();
if (!diffBase) { process.stderr.write(`gstack-banned-terms: no merge base with ${base}; pass --diff-base <sha> or --files <csv>\n`); return EXIT.usage; }
const diff = spawnSync('git', ['diff', '--unified=0', '--no-color', diffBase], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
if (diff.status !== 0) { process.stderr.write(`gstack-banned-terms: git diff ${diffBase} failed\n`); return EXIT.fail; }
+18 -11
View File
@@ -3,6 +3,7 @@
* gstack-ci-carry — CI as the one full test run (GSTA-258; docs/pregate.md#ci-carry).
*
* gstack-ci-carry find [--base <branch>] [--profile <name>]... [--command <exact lane command> --allow-repo-commands] [--json]
* [--policy-from head --simulate]
* gstack-ci-carry wait [--base <branch>] [--profile <name>] [--timeout <s>] [--interval <s>]
*
* find: the newest verified run of each profile (default: every profile the base branch's
@@ -14,10 +15,9 @@
* carried, 3 still running or no run. Logic: lib/ci-carry.ts; the run checks are lib/run-evidence.ts.
*/
import { spawnSync } from 'node:child_process';
import { findCarry, waitForRun } from '../lib/ci-carry';
import { isConfigUnavailable, loadConfigFromBase } from '../lib/pregate/config';
import { findCarry, laneMapping, mappingCoverage, waitForRun } from '../lib/ci-carry';
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
import { buildRunCheck } from '../lib/pregate/run-check';
import { runInventory } from '../lib/run-evidence';
import { wtreeFingerprint } from '../lib/script-invocation';
import { resolveBaseBranch } from '../lib/ship-policy';
@@ -25,11 +25,13 @@ const HELP = `gstack-ci-carry — use a verified CI run at HEAD's exact tree ins
find [--base <branch>] [--profile <name>]... [--command <exact lane command> --allow-repo-commands] [--json]
CI_CARRY: accepted <receipt> (exit 0) or CI_CARRY: none (<reason>) (exit 1)
--policy-from head --simulate: the same check under the branch's own map and profiles, every line starting
SIMULATION:; a dry run that proves a new mapping before it lands (find never writes evidence; nothing clears)
wait [--base <branch>] [--profile <name>] [--timeout <s>] [--interval <s>]
poll the newest run at HEAD (default 1800 s, every 20 s): CI_WAIT: green|red|running|none, then find on green;
exit 0 carried, 1 red or not carried, 3 still running or no run
Profiles and ship_lanes_from come from origin/<base>'s .gstack/pregate.json only. Only a clean, committed tree
Profiles and ship_lanes_from come from origin/<base>'s .gstack/pregate.json only (a simulation reads the branch's). Only a clean, committed tree
carries; fork runs, stale runs, a newer red or running run on HEAD and a tree.json that misses an inventory
file never do. Exit 2 is usage.`;
@@ -37,30 +39,35 @@ const argv = process.argv.slice(2);
const cmd = argv[0];
const all = (n: string) => argv.flatMap((a, i) => (a === n && argv[i + 1] !== undefined ? [argv[i + 1]!] : []));
const flag = (n: string) => all(n).at(-1);
const out = (s: string) => process.stdout.write(`${s}\n`);
const simulate = argv.includes('--simulate');
const out = (s: string) => process.stdout.write(`${simulate ? 'SIMULATION: ' : ''}${s}\n`);
const usage = (m: string) => { process.stderr.write(`gstack-ci-carry: ${m}\n`); process.exit(2); };
if (!cmd || argv.includes('--help') || argv.includes('-h')) { out(HELP); process.exit(cmd ? 0 : 2); }
if (cmd !== 'find' && cmd !== 'wait') usage(`unknown command ${cmd}; run gstack-ci-carry --help`);
const policyFrom = flag('--policy-from') ?? 'base';
if (policyFrom !== 'base' && policyFrom !== 'head') usage('--policy-from needs base or head');
if (simulate !== (policyFrom === 'head') || (simulate && cmd !== 'find')) usage('find --policy-from head --simulate go together: a branch\'s own map is only ever simulated, never carried');
const git = (args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : ''; };
const root = git(['rev-parse', '--show-toplevel']);
if (!root) usage('not inside a git repository');
const base = resolveBaseBranch(root, flag('--base'));
const loaded = loadConfigFromBase(root, base);
if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: origin/${base}'s .gstack/pregate.json is unreadable or invalid)`); process.exit(1); }
const loaded = simulate ? loadConfigFromHead(root) : loadConfigFromBase(root, base);
if (simulate) out(`policy ${isConfigUnavailable(loaded) ? 'unavailable' : loaded.label}; a dry run: nothing is written and nothing clears (a carry reads origin/${base}'s map only)`);
if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: ${simulate ? 'the branch\'s' : `origin/${base}'s`} .gstack/pregate.json is unreadable or invalid)`); process.exit(1); }
const command = flag('--command');
const mapping = command !== undefined ? loaded.config.ship_lanes_from[command] : undefined;
if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is not a key of origin/${base}'s ship_lanes_from)`); process.exit(1); }
const mapping = command !== undefined ? laneMapping(loaded.config.ship_lanes_from, command) : undefined;
if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is neither a key nor a declared alias in origin/${base}'s ship_lanes_from)`); process.exit(1); }
if (mapping && !argv.includes('--allow-repo-commands')) usage('--command runs the mapping\'s inventory, a pin-file command: pass --allow-repo-commands');
const profiles = all('--profile').length ? all('--profile') : mapping ? mapping.profiles : [...new Set(Object.values(loaded.config.ship_lanes_from).flatMap(m => m.profiles))];
if (!profiles.length) { out(`CI_CARRY: none (no_profile: origin/${base} maps no lane in ship_lanes_from; pass --profile <name>)`); process.exit(1); }
const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base);
const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base, simulate ? 'head' : 'base');
if ('error' in check) { out(`CI_CARRY: none (profiles_unavailable: ${check.error})`); process.exit(1); }
const head = git(['rev-parse', 'HEAD']);
const tree = git(['rev-parse', 'HEAD^{tree}']);
function find(): number {
const wtree = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
const v = findCarry(check as Exclude<typeof check, { error: string }>, { profiles, head, tree, wtree, ...(mapping ? { inventory: () => runInventory(mapping.inventory, root) } : {}) });
const v = findCarry(check as Exclude<typeof check, { error: string }>, { profiles, head, tree, wtree, ...(mapping ? mappingCoverage(mapping, root) : {}) });
if (argv.includes('--json')) out(JSON.stringify(v));
else out(v.ok ? `CI_CARRY: accepted ${v.receipt}` : `CI_CARRY: none (${v.reason}: ${v.detail})`);
return v.ok ? 0 : 1;
+2 -1
View File
@@ -97,6 +97,7 @@ import { writeReceipt, writeOutcome } from '../lib/egress-receipt';
import { readConfigKey, resolveStateRoot } from '../lib/state-root';
import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog';
import { isFrontendPath } from '../lib/frontend-scope';
import { baseArg } from '../lib/gate-diff/git';
// ── Environment ──────────────────────────────────────────────────────────────
@@ -821,7 +822,7 @@ function resolveTargets(args: ScanArgs, p: Probe): { targets: string[]; refusedB
for (const rel of r.stdout.toString('utf-8').split('\0')) if (rel) files.add(rel);
return true;
};
if (!runZ(['diff', '-z', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`])) {
if (!runZ(['diff', '-z', '--name-only', '--diff-filter=ACMR', `${baseArg('gstack-design-detect', base, top)}...HEAD`])) {
// A base that does not resolve must not read as "no frontend changes".
refuse(base, 'git diff against this base failed (unknown ref or unfetched base)');
return { targets: out, refusedBase: true };
+22 -1
View File
@@ -128,6 +128,26 @@ _session_data() {
return "$r"
}
# An auth path holds a token, split the way lib/review-risk.ts tokens() splits
# paths (camelCase, then every non-alphanumeric run), that contains auth
# (GST-1065). It fails safe: every such token raises the class except the
# authorship words below, so author-spec.md, AUTHORS, authority.ts and
# coauthors.ts do not, while auth.ts, /auth/, nextauthmiddleware.ts,
# noauthrequired.ts, unauthenticated.ts and HTTPAuthorization.ts do.
_auth_path() {
local s="$1" t r=1
while [[ "$s" =~ ^(.*[a-z0-9])([A-Z].*)$ ]]; do s="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"; done
shopt -s nocasematch
for t in ${s//[^a-zA-Z0-9]/ }; do
case "$t" in
author|authors|authored|authoring|authorship|authority|authorities|coauthor|coauthors) ;;
*auth*) r=0; break ;;
esac
done
shopt -u nocasematch
return "$r"
}
_print_flags() {
echo "SCOPE_FRONTEND=$FRONTEND"
echo "SCOPE_BACKEND=$BACKEND"
@@ -293,9 +313,10 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
# named for sessions (recovered-sessions.jsonl, GST-911) holds rows, not
# session handling, so it is not auth; session config still is.
case "$f" in
*auth*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
*jwt*|*permission*|*role*) m_auth=true ;;
*session*) _session_data "$f" || m_auth=true ;;
esac
_auth_path "$f" && m_auth=true
# Backend: code that isn't a frontend component/view file. Includes ESM/CJS
# (.mjs/.cjs) and explicit-module TS (.mts/.cts) — #1810: these matched no
+2 -1
View File
@@ -38,6 +38,7 @@ import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { baseContainsHead } from '../lib/default-base';
import { baseArg } from '../lib/gate-diff/git';
import { resultLine } from '../lib/result-codes';
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
@@ -138,7 +139,7 @@ function main(argv: string[]): void {
if (out === top || out.startsWith(top + path.sep)) throw new Error('--out must be outside the repository');
const mode = one(args, '--mode');
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
const base = git(['rev-parse', '--verify', baseArg('gstack-docs-candidate', one(args, '--base'), process.cwd()) + '^{commit}']).trim();
const self = baseContainsHead(top, base);
if (self) throw new Error(resultLine('snapshot', 'BASE_CONTAINS_HEAD', self));
const exceptionFile = flag(args, '--exception')[0];
+1
View File
@@ -258,6 +258,7 @@ gstack_result_anchor() {
DX_CAPTURE_REDACTION_FAILED) printf '%s' 'dx-capture-redaction-failed' ;;
DX_CAPTURE_INTERNAL) printf '%s' 'dx-capture-internal' ;;
POLICY_GSTACK_TOO_OLD) printf '%s' 'policy-gstack-too-old' ;;
PREGATE_GSTACK_TOO_OLD) printf '%s' 'pregate-gstack-too-old' ;;
QUEUE_NOT_READY) printf '%s' 'queue-not-ready' ;;
QUEUE_PROOF_MISSING) printf '%s' 'queue-proof-missing' ;;
QUEUE_STAMP_NOT_STAMP_ONLY) printf '%s' 'queue-stamp-not-stamp-only' ;;
+89 -24
View File
@@ -5,7 +5,8 @@
*
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
* gstack-evidence carried [--json]
* gstack-evidence passed -- <cmd>
* gstack-evidence carried [--base <branch>] [--json]
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
* [--max-age <hours>] [--allow-paths <csv>]
* gstack-evidence bundle --out <file> [--label <L>]... [--require <csv>] [--selection L=<text>]...
@@ -65,8 +66,11 @@
* `record-ci` writes the explicit CI-sourced row for a lane CI carried: it
* finds and verifies the run itself (lib/ci-carry.ts) for a command the base's
* ship_lanes_from maps, then records source "ci", the runs and profiles, the
* run's finish time as ts, bound to HEAD's clean tree; `carried` lists those
* rows at the current tree for the ship receipt.
* run's finish time as ts, bound to HEAD's clean tree, and the policy that
* mapped it (`origin/<base>@<sha>:…`); `carried` lists those rows at the
* current tree for the ship receipt, only when that policy is origin/<the
* receipt's base> (GST-1190: a row a run against another base wrote never
* counts).
*
* MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced.
* A synced record citing an unsynced log would grade FRESH on a machine where
@@ -90,10 +94,10 @@ import { resultLine } from "../lib/result-codes";
import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy";
import { versionAtRef } from "../lib/restamp";
import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation";
import { findCarry } from "../lib/ci-carry";
import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/ci-carry";
import { runChanged } from "../lib/run-evidence";
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
import { buildRunCheck } from "../lib/pregate/run-check";
import { runInventory } from "../lib/run-evidence";
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
const LOG_MAX_BYTES = 2 * 1024 * 1024;
@@ -122,10 +126,17 @@ interface EvidenceRecord {
source?: "ci";
runs?: string[];
profiles?: string[];
/** The CI jobs a jobs-mapped lane carried from (record-ci, GST-1189); tests_ran is then "n/a". */
jobs?: string[];
/** The base policy that mapped a carried lane (record-ci), as `origin/<base>@<sha>:.gstack/pregate.json`. */
policy?: string;
/** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */
tests_ran?: number | "unknown";
tests_ran?: number | "unknown" | "n/a";
/** "files" when a carried narrow lane's tests_ran counts its inventory files the run covered (record-ci), not tests. */
tests_unit?: "files";
/** Why a carried lane was not owed at all: every job it names was path-skipped by an unchanged gate (record-ci, GST-1190). */
not_owed?: string;
skipped_here?: { label: string; files: string[]; tests_ran: number | "unknown" };
}
/** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */
@@ -598,6 +609,36 @@ function identityDigest(): string {
return sha256(JSON.stringify({ bun: version("bun"), node: version("node"), os: process.platform, arch: process.arch, image: process.env.GSTACK_RUNNER_IMAGE || null }));
}
/** A local row that passed with a counted run within the max age, under this machine's identity. */
function countedPass(r: EvidenceRecord, maxAgeHours: number): boolean {
if (r.exit !== 0 || typeof r.tests_ran !== "number" || r.tests_ran <= 0) return false;
const age = Date.now() - Date.parse(r.ts);
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
return r.identity === identityDigest();
}
/**
* `passed -- <cmd>` (GST-1186, read-only): did this exact command pass here at the current working tree under any label? The
* newest local row for the command hash and tree decides, so a newer failure vetoes; it must be a counted pass (countedPass).
* CI rows never count: a CI pass counts only through record-ci's live check against the base. Prints PASSED: label=<L>
* source=local ts=<ts> and exits 0, else NOT_PASSED: <reason> and exits 1. gstack-pregate's guards read it.
*/
function cmdPassed(argv: string[]): number {
const sep = argv.indexOf("--");
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
if (!command || sep !== 0) { console.error("usage: gstack-evidence passed -- <command>"); return 2; }
const wtree = currentWtree();
if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; }
let records: EvidenceRecord[] = [];
try { records = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { records = []; }
const cmdSha = sha256(command);
const newest = records.findLast((r) => r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
if (!newest) { console.log(`NOT_PASSED: no local ledger row for this command at tree ${wtree.slice(0, 12)}`); return 1; }
if (!countedPass(newest, 24)) { console.log(`NOT_PASSED: the newest row (label=${newest.label} ts=${newest.ts}) is not a counted pass within 24 h under this runtime`); return 1; }
console.log(`PASSED: label=${newest.label} source=local ts=${newest.reused_from ?? newest.ts}`);
return 0;
}
/**
* `run --reuse`: the newest local row for this label, command and working tree decides. A counted pass under the same
* identity within the max age is copied (original ts kept) and printed as a run would be; anything else runs the command.
@@ -612,10 +653,7 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number
return false;
}
const newest = records.findLast((r) => r.label === label && r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
if (!newest || newest.exit !== 0 || typeof newest.tests_ran !== "number" || newest.tests_ran <= 0) return false;
const age = Date.now() - Date.parse(newest.ts);
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
if (newest.identity !== identityDigest()) return false;
if (!newest || !countedPass(newest, maxAgeHours)) return false;
try {
appendJsonl(file, { ...newest, reused_from: newest.reused_from ?? newest.ts }, { mode: 0o600 });
} catch (e: any) {
@@ -629,9 +667,11 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number
/**
* `record-ci`: carry a lane from CI. Its command must be a key of the base branch's ship_lanes_from; the run is found and
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane.
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane;
* inventory files every run skipped entirely run here through the entry's per_file runner (label <L>.skipped) first, and the CI
* row is written only when that run passes at the same tree.
*/
function cmdRecordCi(argv: string[]): number {
async function cmdRecordCi(argv: string[]): Promise<number> {
const sep = argv.indexOf("--");
const opts = sep < 0 ? argv : argv.slice(0, sep);
const get = (name: string) => { const i = opts.indexOf(name); return i >= 0 ? opts[i + 1] : undefined; };
@@ -651,17 +691,33 @@ function cmdRecordCi(argv: string[]): number {
const loaded = loadConfigFromBase(cwd, base);
if (isConfigUnavailable(loaded)) return none("config_unavailable", loaded.detail);
if (loaded.errors.length) return none("config_invalid", loaded.errors.map((x) => `${x.path}: ${x.message}`).join("; "));
const from = loaded.config.ship_lanes_from;
if (!Object.prototype.hasOwnProperty.call(from, command)) return none("not_mapped", `the command is not a key of origin/${base}'s ship_lanes_from`);
const m = from[command]!;
const m = laneMapping(loaded.config.ship_lanes_from, command);
if (!m) return none("not_mapped", `the command is neither a key nor a declared alias in origin/${base}'s ship_lanes_from`);
const check = buildRunCheck(cwd, null, true, base);
if ("error" in check) return none("profiles_unavailable", check.error);
const head = git(["rev-parse", "HEAD"]);
const tree = git(["rev-parse", "HEAD^{tree}"]);
if (!head || !tree) return none("no_head", "HEAD is unreadable");
const wtree = () => { const fp = wtreeFingerprint({ cwd, timeoutMs: 30000 }); return "tree" in fp ? fp.tree : null; };
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, inventory: () => runInventory(m.inventory, cwd) });
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd) });
if (!v.ok) return none(v.reason, v.detail);
let here: EvidenceRecord["skipped_here"];
if (v.remainder?.length) {
const hereLabel = `${label}.skipped`;
console.error(`gstack-evidence record-ci: ${v.remainder.length} inventory file(s) skipped every test in CI; running them here (label=${hereLabel})`);
const hereCommand = perFileCommand(m.per_file!, v.remainder);
const exit = await cmdRun(["--label", hereLabel, "--", hereCommand]);
if (exit !== 0) return none("skipped_failed", `the per_file run of ${v.remainder.length} skipped file(s) exited ${exit} (label=${hereLabel})`);
let rows: EvidenceRecord[] = [];
try { rows = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { rows = []; }
here = { label: hereLabel, files: v.remainder, tests_ran: rows.findLast((r) => r.label === hereLabel && r.source === undefined && r.cmd_sha256 === sha256(hereCommand) && r.wtree === tree)?.tests_ran ?? "unknown" };
if (typeof here.tests_ran !== "number" || here.tests_ran <= 0) return none("skipped_zero_run", `the per_file run of ${v.remainder.length} skipped file(s) ran ${here.tests_ran} tests here (label=${hereLabel}); a lane passes on its runner count, never exit 0 alone`);
for (const e of v.runs) {
const changed = runChanged(check.deps, e.run_id, e.run_attempt);
if (changed) return none("run_changed", changed);
}
if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`);
}
let paths: ReturnType<typeof ledgerPath>;
try {
paths = ledgerPath();
@@ -673,15 +729,22 @@ function cmdRecordCi(argv: string[]): number {
const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`);
const rec: EvidenceRecord = {
ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0,
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label,
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}),
};
appendJsonl(paths.file, rec, { mode: 0o600 });
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}`);
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}` : ""}`);
if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`);
return 0;
}
/** `carried`: each label's newest row when it is a CI-sourced row at the current working tree, as the receipt's ci_carried entries. */
/**
* `carried`: each label's newest row when it is a CI-sourced row at the current working tree whose mapping policy is
* origin/<base> (the receipt's base; default the resolved base branch), as the receipt's ci_carried entries.
*/
function cmdCarried(argv: string[]): number {
const at = argv.indexOf("--base");
if (at >= 0 && !argv[at + 1]) { console.error("usage: gstack-evidence carried [--base <branch>] [--json]"); return 2; }
const policyRef = `origin/${(at >= 0 ? argv[at + 1]! : resolveBaseBranch(git(["rev-parse", "--show-toplevel"]) ?? process.cwd())).replace(/^origin\//, "")}@`;
let records: EvidenceRecord[] = [];
try {
records = readJsonl<EvidenceRecord>(ledgerPath().file);
@@ -691,7 +754,7 @@ function cmdCarried(argv: string[]): number {
const wtree = currentWtree();
const latest = new Map<string, EvidenceRecord>();
for (const r of records) latest.set(r.label, r);
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs))
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef))
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; }));
if (argv.includes("--json")) console.log(JSON.stringify(carried));
else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`);
@@ -756,10 +819,10 @@ function cmdCheck(argv: string[]): number {
allFresh = false;
continue;
}
const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown";
const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown";
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : "";
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan}${latest.tests_unit === "files" ? " unit=files" : ""} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
let reason = "";
if (latest.exit !== 0) {
@@ -911,7 +974,9 @@ try {
if (sub === "run") {
process.exit(await cmdRun(rest));
} else if (sub === "record-ci") {
process.exit(cmdRecordCi(rest));
process.exit(await cmdRecordCi(rest));
} else if (sub === "passed") {
process.exit(cmdPassed(rest));
} else if (sub === "carried") {
process.exit(cmdCarried(rest));
} else if (sub === "check") {
@@ -923,7 +988,7 @@ try {
} else if (sub === "verify") {
process.exit(cmdVerify(rest));
} else {
console.error("usage: gstack-evidence run|record-ci|carried|check|bundle|ancestor|verify ...");
console.error("usage: gstack-evidence run|record-ci|passed|carried|check|bundle|ancestor|verify ...");
process.exit(2);
}
} catch (e: any) {
+3 -3
View File
@@ -32,7 +32,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { scanCandidate, parseGateIntegrityBlock, specDirectoryHoldsSpecs, type FileDiff, type CommitInfo } from '../lib/gate-diff/index';
import {
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
baseNote, commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
} from '../lib/gate-diff/git';
import { baseContainsHead, noDefaultBaseLine } from '../lib/default-base';
@@ -162,8 +162,8 @@ function main(argv: string[]): number {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
if (base.local === 'fallback') console.error(`gstack-gate-diff: ${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${baseRef}`}; base is the local ${baseRef}, which may lag its remote`);
else if (base.local) console.error(`gstack-gate-diff: ${{ unrelated: `local ${baseRef} shares no history with HEAD`, missing: `no local branch ${baseRef}`, behind: `local ${baseRef} is behind ${base.ref}`, ahead: `local ${baseRef} is ahead of ${base.ref}`, diverged: `local ${baseRef} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`);
const note = baseNote(baseRef, base);
if (note) console.error(`gstack-gate-diff: ${note}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
+6 -2
View File
@@ -74,9 +74,10 @@ _gstack_harness_cut_block() {
# plugins.entries.gstack-start; Hermes removes the plugin and its config entries when its
# folder holds exactly the files gstack wrote, unedited, and is reached without a symlink
# below the profile; otherwise it only disables it so an edit or a kept copy survives. The harness runs with PATH, HOME and the profile only. Without the harness on
# PATH it prints the command to run instead.
# PATH it prints the command to run instead. A profile with no harness config (openclaw.json, config.yaml) never had
# the plugin turned on, and the harness CLI would create a config holding only that change, so it runs no harness command.
gstack_harness_hook_release() {
local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd n=0 safe=1 total
local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd cfg n=0 safe=1 total
[ -f "$ledger" ] || return 0
while IFS=$'\t' read -r kind a b; do
case "$kind" in
@@ -91,6 +92,8 @@ gstack_harness_hook_release() {
[ "${total:-0}" -gt "$n" ] && edited=1
fi
command -v timeout >/dev/null 2>&1 && to="timeout 60"
case "$target" in openclaw) cfg=openclaw.json ;; hermes) cfg=config.yaml ;; *) cfg="" ;; esac
if [ -z "$cfg" ] || [ -f "$profile/$cfg" ]; then
case "$target" in
openclaw) cmd="config unset plugins.entries.gstack-start"
if command -v openclaw >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" OPENCLAW_STATE_DIR="$profile" $to openclaw $cmd </dev/null >/dev/null 2>&1; then :
@@ -99,6 +102,7 @@ gstack_harness_hook_release() {
if command -v hermes >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" HERMES_HOME="$profile" $to hermes plugins "$sub" gstack-start </dev/null >/dev/null 2>&1; then :
else echo " start hook: turn it off in Hermes yourself: HERMES_HOME=\"$profile\" hermes plugins disable gstack-start"; fi ;;
esac
fi
if [ "$safe" = 1 ] && [ -d "$root/__pycache__" ] && _gstack_harness_owned "$root/__pycache__" "$root"; then
rm -f "$root"/__pycache__/*.pyc
rmdir "$root/__pycache__" 2>/dev/null
+55 -8
View File
@@ -8,8 +8,11 @@
# shares them.
#
# Usage:
# gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>]
# write <git dir>/gstack-leases/<pid> holding the pid's start identity;
# gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>] [--run <id>]
# write <git dir>/gstack-leases/<pid> holding the pid's start identity
# and the open run <id> (gstack-skill-start's SESSION_ID); without --run
# it records a hold no release closes; a run counts 120 min from its
# start and a hold 120 min from its last renewal;
# pid defaults to the harness pid behind this call
# (bin/gstack-harness-pid.sh, the same derivation the session marker
# uses), or behind --from (a caller whose own parent is short-lived
@@ -24,8 +27,11 @@
# --count-shared-harness (bin/gstack-upgrade only) still prints an
# ancestor that is a shared harness (capy-server: every session on the
# machine runs under it) while its lease is under 120 minutes old
# gstack-lease release --pid <p> --run <id> [--root <checkout>]
# close run <id> on <pid>'s lease (gstack-skill-end); once every run it
# recorded is closed the lease is idle and live skips it (GST-1205)
# gstack-lease list [--root <checkout>]
# print `<pid> <age>m alive|dead|stale` for every lease
# print `<pid> <age>m alive|idle|dead|stale` for every lease
# A pid alone is not a holder: pids are reused after a reboot or a machine
# wake. A lease records its process's start identity (Linux: boot id and
# /proc start time; elsewhere `ps -o lstart`), and a lease whose pid now
@@ -47,12 +53,13 @@ BIN="$(cd "$(dirname "$0")" && pwd -P)"
}
VERB="${1:-}"; [ $# -gt 0 ] && shift
ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0
ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0 RUN=""
while [ $# -gt 0 ]; do
case "$1" in
--root) ROOT="${2:-}"; shift ;;
--pid) PID="${2:-}"; shift ;;
--from) FROM="${2:-}"; shift ;;
--run) RUN="${2:-}"; shift ;;
--exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;;
--count-shared-harness) SHARED=1 ;;
*) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;;
@@ -60,8 +67,8 @@ while [ $# -gt 0 ]; do
shift
done
case "$VERB" in
touch|live|list) ;;
-h|--help|'') sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
touch|live|list|release) ;;
-h|--help|'') sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
esac
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
@@ -248,6 +255,15 @@ reclaim() {
unlock
return 1
}
# Run entries (GST-1205), lines after the identity: `run <id> <epoch>` per
# open run, `hold <epoch>` for a holder that opened none, `idle` once every
# run closed. An entry older than 120 minutes no longer counts.
# fresh FILE [DROP] — the entries under 120 minutes old, minus the line whose
# first two fields are DROP ("run <id>" or "hold").
fresh() { sed -n '2,$p' "$1" 2>/dev/null | awk -v now="$(date +%s)" -v drop="${2:-}" '($1 == "run" && NF == 3 && now - $3 < 7200 && $1 " " $2 != drop) || ($1 == "hold" && NF == 2 && now - $2 < 7200 && $1 != drop)'; }
# idle FILE — the lease records entries and none of them still counts. A lease
# with no entries (written without --run, or by an older gstack) is never idle.
idle() { [ -n "$(sed -n '2,$p' "$1" 2>/dev/null | grep -v '^$')" ] && [ -z "$(fresh "$1")" ]; }
age_min() { # FILE — minutes since its mtime
_m=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0)
echo $(( ( $(date +%s) - _m ) / 60 ))
@@ -264,7 +280,20 @@ case "$VERB" in
exit 3
}
_tmp="$DIR/.$PID.$$"
identity "$PID" > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; }
_id=$(identity "$PID") _keep="" _held=0 _now=$(date +%s)
if [ -f "$DIR/$PID" ] && [ "$(head -1 "$DIR/$PID" 2>/dev/null)" = "$_id" ]; then
if [ -n "$RUN" ]; then _keep=$(fresh "$DIR/$PID" "run $RUN"); else _keep=$(fresh "$DIR/$PID" hold); fi
[ -z "$(sed -n '2,$p' "$DIR/$PID" 2>/dev/null | grep -v '^$')" ] && _held=1
fi
{
printf '%s\n' "$_id"
[ -z "$_keep" ] || printf '%s\n' "$_keep"
if [ -n "$RUN" ]; then
[ "$_held" -eq 1 ] && echo "hold $(stat -c %Y "$DIR/$PID" 2>/dev/null || stat -f %m "$DIR/$PID" 2>/dev/null || echo "$_now")"
echo "run $RUN $_now"
elif [ -n "$_keep" ]; then echo "hold $_now"
fi
} > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; }
find "$DIR" -maxdepth 1 -mmin +120 -type f ! -name '.lock*' -exec rm -f {} + 2>/dev/null
for _c in "$DIR"/.lock*.claim.*; do
[ -f "$_c" ] || continue
@@ -274,12 +303,29 @@ case "$VERB" in
rm -rf "$DIR"/.lock*.gone.* 2>/dev/null
unlock
exit 0 ;;
release)
case "$PID" in ''|*[!0-9]*) echo "gstack-lease: release needs --pid <number> and --run <id>" >&2; exit 2 ;; esac
[ -n "$RUN" ] && [ "$RUN" != - ] || { echo "gstack-lease: release needs --pid <number> and --run <id>" >&2; exit 2; }
[ -f "$DIR/$PID" ] || exit 0
lock || exit 3
f="$DIR/$PID" _tmp="$DIR/.$PID.$$"
if sed -n '2,$p' "$f" | awk -v id="$RUN" '$1 == "run" && $2 == id { found = 1 } END { exit !found }'; then
_keep=$(fresh "$f" "run $RUN")
{
head -1 "$f"
if [ -n "$_keep" ]; then printf '%s\n' "$_keep"; else echo idle; fi
} > "$_tmp" && touch -r "$f" "$_tmp" && mv -f "$_tmp" "$f" || { rm -f "$_tmp"; unlock; exit 2; }
fi
unlock
exit 0 ;;
list)
for f in "$DIR"/*; do
[ -f "$f" ] || continue
p=${f##*/}
_age=$(age_min "$f")
echo "$p ${_age}m $(state "$p" "$(head -1 "$f" 2>/dev/null)" "$_age")"
_st=$(state "$p" "$(head -1 "$f" 2>/dev/null)" "$_age")
[ "$_st" = alive ] && idle "$f" && _st=idle
echo "$p ${_age}m $_st"
done
exit 0 ;;
esac
@@ -296,6 +342,7 @@ done
FOUND=1
for f in "$DIR"/*; do
[ -f "$f" ] || continue
idle "$f" && continue
p=${f##*/}
_age=$(age_min "$f")
case "$ANCESTRY" in *" $p "*) [ "$SHARED" -eq 1 ] && [ "$_age" -lt 120 ] && gstack_shared_harness "$p" || continue ;; esac
+26 -6
View File
@@ -7,6 +7,7 @@
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
* [--flaky-exception "<test file>::<case>" … --pr <n> [--grant <approval id>]] a run with failures, under the owner's grant
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
* gstack-pregate validate | init [--explain] [--force] the working-tree .gstack/pregate.json
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip] [--json]
@@ -36,6 +37,7 @@ import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clear
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
import { describeSubstitution } from '../lib/pregate/remote-receipt';
import { approvalGrants, describeExceptions, type FlakyInput } from '../lib/pregate/flaky-exception';
import { ensureTmpExcluded } from '../lib/restamp';
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
@@ -58,7 +60,10 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
verifies GitHub Actions runs against the base branch's run_profiles instead (docs/pregate.md#run-evidence):
one run of the profile named after the lane, or for pregate.json/remote_gate one run per
remote_gate_runs profile covering the gate's inventory (needs --allow-repo-commands); where a lane has a
profile, --run-url is verified the same way
profile, --run-url is verified the same way. --flaky-exception "<test file>::<case>" (at most 3) --pr <n>
--grant <approval id> takes such runs with failures when every failure is named from the run's shard
results and logs, outside the PR's diff and import closure, and an owner's gstack-approvals row binds
them; without --grant it prints the row to record (pregate only; docs/pregate.md#flaky-exception)
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
validate | init the working-tree ${PREGATE_CONFIG_PATH}
sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip]
@@ -117,7 +122,7 @@ function flag(args: string[], name: string): string | undefined { const i = args
const VERB_FLAGS: Record<string, [string[], string[]]> = {
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run', '--from-run'], ['--allow-repo-commands', '--explain', '--json', '--remote', '--no-ci-carry']],
verify: [['--out'], ['--json']],
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out'], ['--allow-repo-commands']],
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out', '--flaky-exception', '--pr', '--grant'], ['--allow-repo-commands']],
lanes: [[], ['--explain', '--json']],
validate: [[], ['--json']],
init: [[], ['--explain', '--force']],
@@ -223,7 +228,12 @@ function cmdVerify(root: string, rest: string[], json: boolean): number {
if (!read.file) { for (const e of read.errors) process.stderr.write(resultLine('gstack-pregate verify', e.code, `${e.message} at ${e.path}`) + '\n'); return EXIT.fail; }
const v = verifyPregate(root, read.file);
if (json) { process.stdout.write(JSON.stringify({ ...v, verdict: read.file.verdict, tree: read.file.tree, head: read.file.head, out }) + '\n'); return v.ok ? EXIT.ok : EXIT.fail; }
if (v.ok) { process.stdout.write(`PREGATE_VERIFY: ok ${v.message}\n`); return EXIT.ok; }
if (v.ok) {
const excepted = describeExceptions(read.file);
if (excepted) process.stdout.write(`PREGATE: ${excepted}\n`);
process.stdout.write(`PREGATE_VERIFY: ok ${v.message}\n`);
return EXIT.ok;
}
process.stderr.write(resultLine('gstack-pregate verify', v.code!, v.message) + '\n');
for (const r of v.uncleared) process.stdout.write(`requires-remote: ${r.lane} (${r.platform}) ${r.tests.length} test(s) uncleared\n`);
process.stdout.write(`PREGATE_VERIFY: refused ${v.code}\n`);
@@ -233,6 +243,14 @@ function cmdVerify(root: string, rest: string[], json: boolean): number {
function cmdClear(root: string, rest: string[]): number {
const lane = flag(rest, '--remote');
if (!lane) { process.stderr.write(`gstack-pregate clear: --remote <workflow.yml/job> with --run-url <url>, --bundle-lane <label> or (for ${REMOTE_GATE_LANE}) --receipt <file> is required\n`); return EXIT.usage; }
const cases = rest.flatMap((a, i) => a === '--flaky-exception' ? [rest[i + 1]!] : []);
let flaky: FlakyInput | undefined;
if (cases.length) {
if (!rest.includes('--run') && !rest.includes('--run-url')) { process.stderr.write('gstack-pregate clear: --flaky-exception needs --run <id|url>: an exception reads GitHub Actions runs\n'); return EXIT.usage; }
const pr = Number(flag(rest, '--pr'));
if (!Number.isInteger(pr) || pr <= 0) { process.stderr.write('gstack-pregate clear: --flaky-exception needs --pr <n>, the pull request the grant binds\n'); return EXIT.usage; }
flaky = { cases, pr, grant: flag(rest, '--grant'), grantRow: approvalGrants(root) };
} else if (flag(rest, '--pr') !== undefined || flag(rest, '--grant') !== undefined) { process.stderr.write('gstack-pregate clear: --pr and --grant apply only with --flaky-exception\n'); return EXIT.usage; }
const out = outPath(root, rest);
const read = fs.existsSync(out) ? readPregateFile(out) : { file: null, errors: [] };
if (!read.file) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} is missing or invalid; run gstack-pregate first`) + '\n'); return EXIT.fail; }
@@ -242,7 +260,7 @@ function cmdClear(root: string, rest: string[]): number {
const checked = runs.length || runUrl || bundleLane ? buildRunCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
if (checked && 'error' in checked && (runs.length || checked.profiled)) { process.stderr.write(`gstack-pregate clear: ${checked.error}${checked.profiled ? `; ${lane} may have a run profile on the base branch, so it clears only with a verified --run` : ''}\n`); return EXIT.fail; }
const evidence = checked && !('error' in checked) ? checked : undefined;
const decision = clearDecision(read.file, lane, { runUrl, runs, bundleLane, receiptPath: flag(rest, '--receipt'), evidence }, root);
const decision = clearDecision(read.file, lane, { runUrl, runs, bundleLane, receiptPath: flag(rest, '--receipt'), evidence, flaky }, root);
if ('refused' in decision) {
if (decision.refused === 'usage') { process.stderr.write(`gstack-pregate clear: ${decision.message}\n`); return EXIT.usage; }
process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${decision.fix ?? RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`);
@@ -250,9 +268,11 @@ function cmdClear(root: string, rest: string[]): number {
}
const current = readPregateFile(out).file;
if (!current || current.tree !== read.file.tree || current.generated_at !== read.file.generated_at) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} changed while ${lane} was being verified; rerun the clear against the new file`) + '\n'); return EXIT.fail; }
const file = clearObligation(current, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`, decision.substitution);
const file = clearObligation(current, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`, decision.substitution, decision.exception);
writePregateFile(root, out, file);
if (/ runs=\S+ profiles=/.test(decision.receipt)) process.stdout.write(`REUSE: accepted ${lane} ${decision.receipt}\n`);
const e = decision.exception;
if (e) process.stdout.write(`FLAKY_EXCEPTION: accepted ${lane} cases=${e.cases.join(' | ')} run=${e.runs.join(',')} grant=${e.grant} by=${e.by} (pregate only; every required CI check still needs its own green run at this head)\n`);
else if (/ runs=\S+ profiles=/.test(decision.receipt)) process.stdout.write(`REUSE: accepted ${lane} ${decision.receipt}\n`);
if (decision.substitution) process.stdout.write(`PREGATE_RECEIPT_SUBSTITUTED: ${describeSubstitution(decision.substitution)}\n`);
process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${decision.receipt} verdict=${file.verdict}${decision.attested ? ` (attested, not validated: ${lane} has no machine-checkable receipt)` : ''}\n`);
if (checked && 'error' in checked) process.stderr.write(`gstack-pregate clear: run profiles unavailable (${checked.error}); ${lane} was recorded without run verification\n`);
+2 -1
View File
@@ -18,6 +18,7 @@
*/
import * as fs from 'node:fs';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { baseArg } from '../lib/gate-diff/git';
import { lintFiles, renderProseLint } from '../lib/prose-lint';
import { resultLine } from '../lib/result-codes';
@@ -54,7 +55,7 @@ function main(argv: string[]): number {
if ([base, sections, flags.has('--unreleased') || undefined].filter(Boolean).length > 1) { process.stderr.write('gstack-prose-lint: --unreleased, --base and --sections are different selections; pass one\n'); return EXIT.usage; }
for (const f of files) if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) { process.stderr.write(`gstack-prose-lint: cannot read ${f}\n`); return EXIT.usage; }
let result;
try { result = lintFiles(files, { unreleased: flags.has('--unreleased'), base, sections }); }
try { result = lintFiles(files, { unreleased: flags.has('--unreleased'), base: base && baseArg('gstack-prose-lint', base, process.cwd()), sections }); }
catch (e: any) { process.stderr.write(`gstack-prose-lint: ${e.message}\n`); return EXIT.usage; }
if (sections && flags.has('--gate') && result.empty.length) {
process.stderr.write(`gstack-prose-lint: no "## <section>" heading from --sections in ${result.empty.join(', ')}; nothing would be gated\n`);
+7 -4
View File
@@ -28,6 +28,7 @@ import { locate, readRecord } from '../lib/approval-record';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { runScriptSync } from '../lib/script-invocation';
import { baseNote } from '../lib/gate-diff/git';
import { collectCommit, collectLive } from '../lib/review-collect';
import { excludeLines, reviewExclude, type Exclusion } from '../lib/review-exclude';
import { isUnavailable } from '../lib/ship-policy';
@@ -126,8 +127,11 @@ function main(): number {
for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`);
return EXIT.fail;
}
const note = collected.base && baseNote(base, collected.base);
if (note) process.stderr.write(`gstack-review-plan: ${note}\n`);
const ref = collected.base?.ref ?? base;
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
const self = collected.mergeBase === head ? baseContainsHead(root, git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) ? `origin/${base}` : base) : null;
const self = collected.mergeBase === head ? baseContainsHead(root, ref) : null;
if (self) { process.stderr.write(`${resultLine('gstack-review-plan', 'BASE_CONTAINS_HEAD', self)}; no plan is safer than a plan for a diff compared with itself\n`); return EXIT.fail; }
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
const scopeOf = (args: string[]): Record<string, string> => {
@@ -145,7 +149,6 @@ function main(): number {
const gateTagsOf = (args: string[]): number | null => { const g = runGateDiff(args); return tagCount(g.stdout, g.status); };
const diffBase = collected.mergeBase ?? base;
const scope = scopeOf([diffBase]);
const ref = git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) !== null ? `origin/${base}` : base;
const baseSha = collected.registry.sha ?? git(['rev-parse', '--verify', '-q', `${ref}^{commit}`])?.trim() ?? '';
const policy = baseSha ? loadPolicyAt(root, baseSha, ref) : null;
const declares = !!policy && !isUnavailable(policy) && policy.policy.review_exclude_paths.length > 0;
@@ -193,11 +196,11 @@ function main(): number {
const gateScan = { tagged: gateTagged, head };
if (has('--json')) {
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, base: collected.base, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\n');
return EXIT.ok;
}
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`,
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, `REVIEW_BASE: ${ref} ${baseSha.slice(0, 12)}`,
`TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`];
out.push(...excludeLines(exclusion, base));
if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`);
+68 -30
View File
@@ -40,11 +40,12 @@ import { versionAtRef } from '../lib/restamp';
import { branchSides, stampOnlyLine, type BranchSide } from '../lib/stamp-only';
import { runScriptSync } from '../lib/script-invocation';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { exceptionConsumerProblem } from '../lib/pregate/flaky-exception';
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
import { approvalForReceipt } from '../lib/approval-citations';
import {
CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS,
bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, planTreeCarry,
bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, landedStackParent, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, planTreeCarry,
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr,
} from '../lib/ship-receipt';
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
@@ -98,7 +99,9 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
unreadable input records full).
--review-exclude <digest|none> (the digest= on gstack-review-dispatch verify's REVIEW_EXCLUDE_RECORD: line, which the reviewers' briefs
name; none without that line): must equal the digest HEAD has under the base policy's review_exclude_paths, else
RECEIPT_INVALID field=review_exclude and nothing is written (rerun the review; write alone cannot clear it); recorded as review_exclude. Carry and the queue compare that digest.
RECEIPT_INVALID field=review_exclude and nothing is written; recorded as review_exclude. Carry and the queue compare that digest.
The check binds the digest to the base policy, not to a review: write does not read the review ledger, so the expected=
digest a refusal prints passes too. After a refusal, rerun the review and pass the digest its verify prints.
--review-targeted <json|@file> ({classes, rules, brief_sha, status, findings, raised_by, head}: gstack-review-dispatch verify
--targeted-out); recorded as review_targeted bound to the receipt's product_patch_id, and refused (RECEIPT_INVALID) when the
commit the reader read (head) is not an ancestor or a change since it carries a targeted class (gate scan included). write and carry
@@ -172,9 +175,9 @@ function reviewedProduct(repoRoot: string, base: string, head: string, pregateFi
return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree };
}
/** The lanes CI carried at the checked-out tree, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */
function ciCarried(): ShipReceipt['ci_carried'] {
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--json'], { encoding: 'utf8', timeout: 60_000 });
/** The lanes CI carried at the checked-out tree under origin/<base>'s mapping, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */
function ciCarried(base: string): ShipReceipt['ci_carried'] {
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--base', base, '--json'], { encoding: 'utf8', timeout: 60_000 });
try { const list = JSON.parse(r.stdout); return Array.isArray(list) && list.length ? list : undefined; } catch { return undefined; }
}
@@ -235,7 +238,7 @@ function cmdWrite(rest: string[], json: boolean): number {
const excludeNow = review.exclude.state === 'active' ? review.exclude.record : null;
const excludeGiven = flag(rest, '--review-exclude') ?? 'none';
if ((excludeNow?.digest ?? 'none') !== excludeGiven) {
process.stderr.write(`gstack-ship-receipt write: field=review_exclude expected=${excludeNow?.digest ?? 'none'} recorded=${excludeGiven} source=${excludeNow?.source ?? `origin/${base} (no review_exclude_paths)`}: the review --review-exclude names read another selection than ${base}'s review_exclude_paths gives ${head.slice(0, 12)}; fix: rerun /ship's review on ${head.slice(0, 12)} (gstack-review-dispatch pass --base ${base}), run gstack-review-dispatch verify --ledger <ledger> and write with the --review-exclude <d> its REVIEW_EXCLUDE_RECORD: line prints (none when it prints no such line); nothing written (RECEIPT_INVALID)\n`);
process.stderr.write(`gstack-ship-receipt write: field=review_exclude expected=${excludeNow?.digest ?? 'none'} recorded=${excludeGiven} source=${excludeNow?.source ?? `origin/${base} (no review_exclude_paths)`}: the --review-exclude argument names another selection than ${base}'s review_exclude_paths gives ${head.slice(0, 12)} (write compares the argument, not the review ledger); fix: rerun /ship's review on ${head.slice(0, 12)} (gstack-review-dispatch pass --base ${base}), run gstack-review-dispatch verify --ledger <ledger> and write with the --review-exclude <d> its REVIEW_EXCLUDE_RECORD: line prints (none when it prints no such line); nothing written (RECEIPT_INVALID)\n`);
return EXIT.fail;
}
const product = reviewedProduct(repoRoot, base, head, pregateFile);
@@ -268,7 +271,7 @@ function cmdWrite(rest: string[], json: boolean): number {
version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base,
gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined,
tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined,
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(),
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(base),
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
@@ -472,15 +475,18 @@ function gitVersionBelow(repoRoot: string, minimum: string): string | null {
return major > minMajor || (major === minMajor && minor >= minMinor) ? null : m[0];
}
type PregateField = { pregate: string; requires_remote?: string[] };
type PregateField = { pregate: string; requires_remote?: string[]; flaky_exception?: ShipReceipt['flaky_exception'] };
/** A pregate result file → the receipt's pregate field, verified current for this checkout's tree (shared by write and carry). */
function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: true; field: PregateField } | { ok: false; errors: Array<{ code: string; message: string }> } {
const read = readPregateFile(file);
if (!read.file) return { ok: false, errors: read.errors.map(e => ({ code: e.code, message: `${e.message} at ${e.path}` })) };
const v = verifyPregate(repoRoot, read.file);
if (!v.ok) return { ok: false, errors: [{ code: v.code!, message: v.message ?? 'pregate is not current' }] };
const foreign = exceptionConsumerProblem(read.file, head);
if (foreign) return { ok: false, errors: [{ code: 'PREGATE_REMOTE_UNCLEARED', message: foreign }] };
const remote = read.file.requires_remote ?? [];
return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined } };
const exceptions = read.file.exceptions ?? [];
return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined, flaky_exception: exceptions.length ? exceptions.map(e => ({ lane: e.lane, runs: e.runs, cases: e.cases, grant: e.grant, by: e.by })) : undefined } };
}
/**
@@ -501,13 +507,13 @@ function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | u
return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p));
}
interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[] }
interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[]; placeholders: readonly string[] }
function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules {
const load = loadPolicyFromBase(repoRoot, base);
const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy;
const fragments = policy?.changelog_fragments?.dir.replace(/^(\.\/)+/, '').replace(/\/+$/, '');
const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs, fragments };
return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [] };
return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [], placeholders: policy?.stamp_placeholders ?? [] };
}
/**
@@ -534,7 +540,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
const versions = new Map<string, string | null>();
const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; };
const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p);
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, side: a === from && b === to ? branch?.own(p) : undefined } : undefined);
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, placeholders: rules.placeholders, side: a === from && b === to ? branch?.own(p) : undefined } : undefined);
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) }));
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path)
@@ -550,7 +556,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
const was = before.split('\n');
const now = after.split('\n');
const lines = was.map((_, i) => i).filter(i => was[i] !== now[i]);
return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1));
return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1, rules.placeholders));
});
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted, ownRows };
}
@@ -656,18 +662,37 @@ function cmdCarry(rest: string[], json: boolean): number {
} else {
const fromBase = git(['merge-base', baseRef, from], repoRoot);
if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`);
const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)];
if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
const touched = new Set([...listed[0], ...listed[1]]);
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') });
if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
// The reviewed range runs from the fork point, or from a stack parent inside it that has since landed on the base: a
// receipt written against the base while stacked reviewed the parent's hunks too, and the squash moved them into the
// base (GST-1199). A parent counts only when merging it into the current base changes no product path.
const measure = (rangeBase: string) => {
const listed = [touchedPaths(repoRoot, rangeBase, from), touchedPaths(repoRoot, headBase, head), rangeBase === fromBase ? new Set<string>() : touchedPaths(repoRoot, fromBase, from)];
if (!listed[0] || !listed[1] || !listed[2]) return `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`;
// From a stack parent, a path whose reviewed content is the base's (an earlier base merge brought it in) is base
// context, not reviewed product, unless HEAD changed it against the base (GST-1214).
// A pathspec also names everything beneath a path, so a path with any compared path under it (a file the base turned
// into a directory) stays in.
const compared = [...listed[0], ...listed[1]];
const baseMerged = rangeBase === fromBase ? [] : [...listed[0]].filter(p => !listed[2]!.has(p) && !listed[1]!.has(p) && !compared.some(q => q.startsWith(`${p}/`)));
const touched = new Set([...listed[0], ...listed[1]].filter(p => !baseMerged.includes(p)));
const found = productSpec(repoRoot, rules, from, head, { touched, sides: [[rangeBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') });
if (!found) return `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`;
const skip = baseMerged.map(p => `:(exclude,literal)${p}`);
const spec = { ...found, product: [...found.product, ...skip], tests: [...found.tests, ...skip] };
const ids = [patchId(repoRoot, rangeBase, from, spec.product), patchId(repoRoot, headBase, head, spec.product)];
for (const r of ids) if (typeof r !== 'string') return `git could not compute the product patch-id at one of the heads: ${r.error}`;
return { rangeBase, touched, spec, productFrom: ids[0] as string, productHead: ids[1] as string };
};
let measured = measure(fromBase);
if (typeof measured === 'string') return refused('RECEIPT_CARRY_REFUSED', measured);
const stackParent = !retarget && measured.productFrom !== measured.productHead ? landedStackParent(repoRoot, { rangeBase: fromBase, reviewed: from, currentBase: headBase }, measured.spec.product) : null;
if (stackParent) measured = measure(stackParent);
if (typeof measured === 'string') return refused('RECEIPT_CARRY_REFUSED', measured);
const { rangeBase, touched, spec, productFrom, productHead } = measured;
const { changed } = spec;
const productFrom = patchId(repoRoot, fromBase, from, spec.product);
const productHead = patchId(repoRoot, headBase, head, spec.product);
const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : '');
const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests));
const testFrom = asId(patchId(repoRoot, rangeBase, from, spec.tests));
const testHead = asId(patchId(repoRoot, headBase, head, spec.tests));
for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`);
// A stacked child whose parent landed on the base (#3570): the receipt reviewed the child's own range against the
// parent branch, and the PR now targets the base. That reviewed range (the recorded product patch-id, else the one
// against origin/<receipt base> when it still resolves) must be HEAD's whole product diff against the base, computed
@@ -691,8 +716,8 @@ function cmdCarry(rest: string[], json: boolean): number {
// which can differ between the two base diffs. A touched path with the same blob at both heads and at both bases has
// the same patch, so only paths that moved on one side are compared (GST-448: 6000 touched paths). A path whose only difference is base context next to unchanged hunks
// (lib/ship-patch.ts) needs a scoped review instead of the loop.
const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head };
const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, fromBase, headBase)];
const sides = { reviewedBase: rangeBase, reviewed: from, currentBase: headBase, current: head };
const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, rangeBase, headBase)];
if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
const [movedHead, movedBase] = moved as Set<string>[];
// A stamp file the branch changed as product (its own row) whose reviewed-to-current change is a stamp on its own lines
@@ -701,6 +726,16 @@ function cmdCarry(rest: string[], json: boolean): number {
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p);
if (cls === 'metadata' && spec.demoted.includes(p) && movedBase.has(p) && /(^|\/)package\.json$/.test(p)) {
const masked = Object.values(sides).map(ref => {
const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
try { const { version: _, ...rest } = JSON.parse(r.status === 0 ? r.stdout : 'null') ?? {}; return JSON.stringify(rest); } catch { return null; }
});
const [rb, rv, cb, cv] = masked;
if (masked.some(m => m === null) || rv !== cv || rb !== cb) changedProduct.push(p);
else stampOnly.push(p);
continue;
}
if (cls !== 'product') continue;
const verdict = comparePath(repoRoot, sides, p);
if (verdict === 'changed') changedProduct.push(p);
@@ -710,14 +745,14 @@ function cmdCarry(rest: string[], json: boolean): number {
if (!changedProduct.length && !left.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
else if (!changedProduct.length) {
const rest = [...spec.product, ...left.map(p => `:(exclude,literal)${p}`)];
const restFrom = patchId(repoRoot, fromBase, from, rest);
const restFrom = patchId(repoRoot, rangeBase, from, rest);
const restHead = patchId(repoRoot, headBase, head, rest);
if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${left.join(', ')}; compare the two base diffs without them)`);
}
}
const stampedRows = changedProduct.length ? [] : stampOnly;
let scopedReview: string | undefined;
const packetBody = !changedProduct.length && contextPaths.length ? contextPacket(repoRoot, { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : null;
const packetBody = !changedProduct.length && contextPaths.length ? contextPacket(repoRoot, { reviewedBase: rangeBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : null;
if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`));
if (packetBody && !changedProduct.length) {
const record = flag(rest, '--scoped-review');
@@ -743,9 +778,9 @@ function cmdCarry(rest: string[], json: boolean): number {
}
const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${rangeBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}), ...(stampedRows.length ? { stampedRows } : {}) });
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(stackParent ? { stackParent } : {}), ...(scopedReview ? { scopedReview } : {}), ...(stampedRows.length ? { stampedRows } : {}) });
}
if (!plan.ok) return refused(plan.code, plan.message);
// Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD,
@@ -773,12 +808,15 @@ function cmdCarry(rest: string[], json: boolean): number {
const now = review.depth;
if (review.exclude.state === 'refused') return refused('RECEIPT_CARRY_REFUSED', `${review.exclude.lines.join('; ')}; run /ship on ${head.slice(0, 12)} once the base policy reads`);
const digest = review.exclude.state === 'active' ? review.exclude.record.digest : 'none';
if (digest !== (receipt.review_exclude?.digest ?? 'none')) return refused('RECEIPT_CARRY_REFUSED', `review_exclude changed: recorded=${receipt.review_exclude?.digest ?? 'none'} now=${digest} source=${review.exclude.state === 'active' ? review.exclude.record.source : `${base} (no review_exclude_paths)`}; the reviewers read another selection than HEAD ${head.slice(0, 12)} has under the base policy, so carry does not apply; run /ship on ${head.slice(0, 12)}`);
if (digest !== (receipt.review_exclude?.digest ?? 'none')) return refused('RECEIPT_CARRY_REFUSED', `review_exclude changed: recorded=${receipt.review_exclude?.digest ?? 'none'} now=${digest} source=${review.exclude.state === 'active' ? review.exclude.record.source : `${base} (no review_exclude_paths)`}; the receipt's digest is the --review-exclude argument write was given, and it names another selection than HEAD ${head.slice(0, 12)} has under the base policy (carry compares that digest, not the review ledger), so carry does not apply; run /ship on ${head.slice(0, 12)}`);
if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`);
depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) }, review_exclude: receiptExclude(review.exclude) };
}
// The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on.
const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
// A receipt that records a version names HEAD's, read as write reads it: a restamp after a base merge moves it (GST-1030).
// A head with no version source keeps the recorded one (write --version named it).
const version = receipt.version === undefined ? undefined : (receiptVersion(repoRoot, head, base) ?? receipt.version);
const rebound = { ...receipt, ...(version !== undefined ? { version } : {}), ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
const errors = validateReceipt(carried);
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
+3
View File
@@ -57,6 +57,9 @@ _TEL_END=$(date +%s)
case "$TEL_START" in ''|*[!0-9]*) TEL_START="$_TEL_END" ;; esac
_TEL_DUR=$(( _TEL_END - TEL_START ))
[ -n "$SESSION_ID" ] && rm -f "$_GH/analytics/.pending-$SESSION_ID" 2>/dev/null || true
# Release the workflow lease run gstack-skill-start opened (SESSION_ID starts
# with its harness pid), so a finished run stops blocking gstack-upgrade (GST-1205).
[ -n "$SESSION_ID" ] && "$_BIN/gstack-lease" release --pid "${SESSION_ID%%-*}" --run "$SESSION_ID" >/dev/null 2>&1 || true
# Record completion before the final drain, including its durable enqueue.
# The normal sync gates still decide publication: full may sync timelines,
+11 -9
View File
@@ -127,13 +127,6 @@ fi
mkdir -p "$_GH/sessions" 2>/dev/null || true
touch "$_GH/sessions/$PARENT_PID" 2>/dev/null || true
find "$_GH/sessions" -mmin +120 -type f -exec rm {} + 2>/dev/null || true
# The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads
# before moving a checkout other sessions share. Fail-open, but a lease the
# held lock kept from being written is reported.
"$_BIN/gstack-lease" touch --pid "$PARENT_PID" >/dev/null 2>&1 || {
[ $? -eq 3 ] && echo "gstack-lease: the lease directory stayed locked by a live process; this session holds no workflow lease, so bin/gstack-upgrade cannot see it" >&2
true
}
_SESSIONS=$(find "$_GH/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
_PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "core")
@@ -241,9 +234,18 @@ _TEL_PROMPTED=$([ -f "$_GH/.telemetry-prompted" ] && echo "yes" || echo "no")
_TEL_START=$(date +%s)
# PID+epoch alone is guessable; a random suffix makes the block-binding token
# unforgeable by content reflected into the same tool result. Falls back to
# the plain form when urandom is unavailable.
# this process's pid when urandom is unavailable, so two same-second starts
# never share a SESSION_ID (it also keys their lease runs, GST-1205).
_SID_RAND=$(od -An -N4 -tx4 /dev/urandom 2>/dev/null | tr -d ' \n' || true)
_SESSION_ID="$PARENT_PID-$_TEL_START${_SID_RAND:+-$_SID_RAND}"
_SESSION_ID="$PARENT_PID-$_TEL_START-${_SID_RAND:-$$}"
# The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads
# before moving a checkout other sessions share. This run holds it until
# gstack-skill-end releases its SESSION_ID (GST-1205). Fail-open, but a lease
# the held lock kept from being written is reported.
"$_BIN/gstack-lease" touch --pid "$PARENT_PID" --run "$_SESSION_ID" >/dev/null 2>&1 || {
[ $? -eq 3 ] && echo "gstack-lease: the lease directory stayed locked by a live process; this session holds no workflow lease, so bin/gstack-upgrade cannot see it" >&2
true
}
echo "TELEMETRY: ${_TEL:-off}"
echo "TEL_PROMPTED: $_TEL_PROMPTED"
echo "SESSION_ID: $_SESSION_ID"
+17 -5
View File
@@ -1001,8 +1001,10 @@ paid test file, its fixtures and its skills' prompt files, including the
generated SKILL.md) plus `HARNESS_VERSION`, keyed with model, Claude CLI version
and policy version (`scripts/eval-trial-series.ts`). Each trial also records the
full consumed-input fingerprint as provenance. `scripts/harness-version.json`
pins every shared-harness file (`GLOBAL_TOUCHFILES` plus the session runners, the
PTY harness, the e2e gate and the LLM judge) by blob hash. When
holds the version and the harness patterns, and `scripts/harness-pins/<path>.json`
pins each shared-harness file (`GLOBAL_TOUCHFILES` plus the session runners, the
PTY harness, the e2e gate and the LLM judge) by blob hash, one file per pin, so
two PRs that record decisions for different harness files never conflict. When
`test/harness-version.test.ts` fails, run one of:
```bash
@@ -1010,8 +1012,8 @@ bun run scripts/bump-harness-version.ts --non-behavioral "<why no verdict can ch
bun run scripts/bump-harness-version.ts --bump "<what behavior changed>" # new series for every case
```
On a merge conflict in the manifest, take the higher version, then rerun the
script.
On a merge conflict in the manifest or a pin file, take the higher version, then
rerun the script.
## Ubicloud VMs (`bun run test:ubicloud`)
@@ -1696,7 +1698,17 @@ the core skills, drives `/browse` on gstack's own browser, and runs the short
test list in its last step, where each file's reason sits beside it.
`test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast.
The curated suite below runs nightly on main and on dispatch, never on a PR.
Windows checks run by path. `windows-scoped-tests.yml` (Windows Scoped Tests)
runs on every PR, and when the diff touches path, file-name or process code (the
filter is described at the top of the workflow), one `windows-latest` job runs
the PR's changed test files that are in the curated subset below. Its
`windows-scoped-tests` check passes when nothing is in scope, and a red run
blocks its PR. `windows-setup-e2e.yml` skips its Windows jobs when the only
install change is `package.json`'s version line. Both filters are pinned by
`test/windows-path-scope-workflow.test.ts`. To rerun chosen Windows-safe files on
a branch, dispatch `windows-scoped-tests.yml` with `-f files='<file> <file>'`.
The full curated suite below runs nightly on main and on dispatch, never on a PR.
`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6
--shard-jobs $WINDOWS_FREE_JOBS`), runs one strict serial shard per
`windows-latest` job
+2 -2
View File
@@ -187,7 +187,7 @@ run from inside a workflow (/ship's docs step running `install.sh --for
document-release`, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this
session holds a workflow lease on this installation: ... (UPGRADE_BUSY))`,
leaves the checkout where the run started and stays ok with `behind=`. Each
`gstack-skill-start` renews the lease. On Capy the leased pid is the machine's
`gstack-skill-start` renews the lease and records its `SESSION_ID` as an open run; `gstack-skill-end` closes that run, and a lease whose runs are all closed is idle and blocks nothing, so a finished /ship no longer holds the upgrade back (GST-1205). Each run counts for 120 minutes from its own start, whatever other sessions renew. A lease with no recorded run (written without `--run`, or by an older gstack) counts as before. On Capy the leased pid is the machine's
long-lived `capy-server`, so the deferral ends when the lease turns 120
minutes old, not when the process exits. Every session on the machine runs
under that same `capy-server`, so `bin/gstack-upgrade` run by hand also
@@ -231,7 +231,7 @@ identities, a taker stalled over 2 minutes between its `mkdir` and its owner
rename can be broken and then publish into its successor's lock. While a live process
holds the lock, or it cannot be taken at all, `touch` exits 3 without writing
(`gstack-skill-start` reports that the session holds no lease) and a reclaim
keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and
keeps the lease. `bin/gstack-lease list` shows `alive|idle|dead|stale`, and
`GSTACK_UPGRADE_FORCE=1` overrides.
## What the installer does
+6 -4
View File
@@ -80,14 +80,16 @@ It is off unless you turn it on, because turning it on changes the harness's own
|---|---|---|
| Plugin folder | `${OPENCLAW_STATE_DIR:-~/.openclaw}/extensions/gstack-start` (index.js, openclaw.plugin.json, package.json, note.txt) | `${HERMES_HOME:-~/.hermes}/plugins/gstack-start` (plugin.yaml, __init__.py, note.txt) |
| Hook | `before_prompt_build`, returning the note as `prependContext` | `pre_llm_call`, returning the note as `context` |
| Settings it changes, through the harness's own CLI | `openclaw config set plugins.entries.gstack-start.hooks.allowConversationAccess true` and `...enabled true` in `openclaw.json`. OpenClaw runs `before_prompt_build` from a plugin it did not bundle only with that grant, which also lets the plugin's hooks see conversation content (the plugin reads only its own note.txt) | `hermes plugins enable gstack-start` adds it to `plugins.enabled` in `config.yaml` (and creates `config.yaml` when there is none) |
| Settings it changes, through the harness's own CLI | `openclaw config set plugins.entries.gstack-start.hooks.allowConversationAccess true` and `...enabled true` in `openclaw.json`. OpenClaw runs `before_prompt_build` from a plugin it did not bundle only with that grant, which also lets the plugin's hooks see conversation content (the plugin reads only its own note.txt) | `hermes plugins enable gstack-start` adds it to `plugins.enabled` in `config.yaml` |
| Check | `openclaw plugins inspect gstack-start --runtime --json` lists `before_prompt_build` under `typedHooks` | `hermes plugins list` shows `gstack-start` enabled |
**Turn it on.** `gstack-harness register openclaw --start-hook` (or `hermes`), or `gstack-config set harness_start_hook on` followed by `./setup`, or `GSTACK_HARNESS_START_HOOK=1 ./setup --host openclaw`. The first of these that is set decides: the `--start-hook`/`--no-start-hook` flag, then `GSTACK_HARNESS_START_HOOK` (`1`/`on` or `0`/`off`), then `harness_start_hook`, then the choice saved in the registration's ledger. Whatever decides, the choice is saved, so refresh and upgrades keep it. The hook goes only into the harness profile's own plugin folder; a registration with a custom `--skills-dir`, or OpenClaw run with `OPENCLAW_CONFIG_PATH` pointing at another config, gets no hook (`HARNESS_HOOK_UNSUPPORTED` when you ask for one). A hook you already turned on stays on when a later run happens to have `OPENCLAW_CONFIG_PATH` set.
**Turn it on.** `gstack-harness register openclaw --start-hook` (or `hermes`), or `gstack-config set harness_start_hook on` followed by `./setup`, or `GSTACK_HARNESS_START_HOOK=1 ./setup --host openclaw`. The first of these that is set decides: the `--start-hook`/`--no-start-hook` flag, then `GSTACK_HARNESS_START_HOOK` (`1`/`on` or `0`/`off`), then `harness_start_hook`, then the choice saved in the registration's ledger. Whatever decides, the choice is saved, so refresh and upgrades keep it. The hook goes only into the harness profile's own plugin folder; a registration with a custom `--skills-dir`, or OpenClaw run with `OPENCLAW_CONFIG_PATH` pointing at another config, gets no hook (`HARNESS_HOOK_UNSUPPORTED` with `--start-hook`; with `GSTACK_HARNESS_START_HOOK` or `harness_start_hook`, `register` and `./setup --host` say why and exit 4). `OPENCLAW_CONFIG_PATH` naming the profile's own `openclaw.json`, by any path that resolves to it, is fine. A hook you already turned on stays on when a later run happens to have `OPENCLAW_CONFIG_PATH` set.
**Check it.** The receipt and `gstack-harness status` show `start hook: active` (configured for new sessions; a running OpenClaw gateway or Hermes gateway picks it up when it reloads or restarts), `written` (files in place but not active, with the reason and the command that fixes it), `disabled` (you turned it off in the harness), `unchecked` (a run that left a hook already on alone, or `status --no-check`; `gstack-harness status` checks it) or `off`. When the run that turns the hook on (or `--start-hook`) leaves it inactive, `gstack-harness register` and `./setup --host` exit 4 after registering the skills (`HARNESS_HOOK_INACTIVE`). Later refreshes and upgrades try once more each time, without failing, until the hook is active or you disable the plugin in the harness.
**Run the harness's own setup first.** gstack turns the hook on only in a harness that already has its config (`openclaw.json` from `openclaw onboard`, `config.yaml` from `hermes setup`). Without one, the harness CLI would create a config holding nothing but gstack's plugin setting, with no model, provider or terminal settings, so gstack runs no harness command: the plugin's files are written, the hook reports `written` with the setup to run, and `register` and `./setup --host` exit 4. Once the harness is set up, the next `./setup` or `gstack-harness refresh` turns the hook on, changing only the `gstack-start` plugin entry.
**Turn it off.** `gstack-harness register openclaw --no-start-hook` (or `hermes`), or `gstack-config set harness_start_hook off` followed by `./setup`. Either turns the plugin off through the harness's CLI and removes its folder. You can also switch it off in the harness (`openclaw config set plugins.entries.gstack-start.enabled false`, `hermes plugins disable gstack-start`); gstack then leaves it off and never turns it back on unless you pass `--start-hook` again. `gstack-harness unregister` and `gstack-uninstall` remove it with the rest (Hermes only disables it when its folder holds an edited file or a kept `.edited-*` copy, so your edit survives), and print the harness command to run when the harness CLI is not on `PATH`.
**Check it.** The receipt and `gstack-harness status` show `start hook: active` (configured for new sessions; a running OpenClaw gateway or Hermes gateway picks it up when it reloads or restarts), `written` (files in place but not active, with the reason and the command that fixes it), `disabled` (you turned it off in the harness), `unknown` (the harness CLI is not on `PATH`, as inside OpenClaw's or Hermes's own terminal tool, and the config file cannot settle it; gstack reads `plugins.enabled` and `plugins.disabled` from Hermes's `config.yaml` and the plugin entry from `openclaw.json` instead), `unchecked` (a run that left a hook already on alone, or `status --no-check`; `gstack-harness status` checks it) or `off`. When the run that turns the hook on (or `--start-hook`) leaves it inactive, `gstack-harness register` and `./setup --host` exit 4 after registering the skills (`HARNESS_HOOK_INACTIVE`). Later refreshes and upgrades try once more each time, without failing, until the hook is active or you disable the plugin in the harness.
**Turn it off.** `gstack-harness register openclaw --no-start-hook` (or `hermes`), or `gstack-config set harness_start_hook off` followed by `./setup`. Either turns the plugin off through the harness's CLI and removes its folder. You can also switch it off in the harness (`openclaw config set plugins.entries.gstack-start.enabled false`, `hermes plugins disable gstack-start`); gstack then leaves it off and never turns it back on unless you pass `--start-hook` again. `gstack-harness unregister` and `gstack-uninstall` remove it with the rest (Hermes only disables it when its folder holds an edited file or a kept `.edited-*` copy, so your edit survives), and print the harness command to run when the harness CLI is not on `PATH`. In a profile with no harness config yet (no `openclaw.json` or `config.yaml`), the hook was never turned on, so turning it off or unregistering runs no harness command and leaves the config alone.
**What it costs.** The note is about 50 tokens and lands on every user turn, and each copy stays in the session's history, so a long session pays more (mostly cache reads). HARN-18's panel measures the added cost per trial. OpenClaw also logs that it cannot verify where the plugin came from, because gstack writes it instead of installing it from npm or ClawHub.
+270 -13
View File
@@ -88,9 +88,9 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
| `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)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os?, skippable? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
| `remote_gate_runs` | `{ profiles, inventory, skipped? }`: the run profiles that together clear `pregate.json/remote_gate`, the command that prints the remote gate's test files, one per line, which their runs must cover, and an optional command that prints which selected files the gate itself never runs (see [run evidence](#run-evidence)) |
| `ship_lanes_from` | maps each exact /ship lane command to `{ profiles, inventory }`: the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and the command listing its test files, which their runs must cover (see [CI carry](#ci-carry)); read from the base branch only |
| `ship_lanes_from` | maps each exact /ship lane command to `{ profiles, inventory }` or `{ profiles, jobs }`, with optional `aliases` (and, on an inventory entry, an optional `per_file` runner for files CI skipped entirely, see [skipped files](#skipped-files)): the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and either the command listing its test files, which their runs must cover, or the CI jobs that must have succeeded (see [CI carry](#ci-carry)); read from the base branch only |
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
Only the `origin/<base>` copy or the user-owned file below is honoured, so a
@@ -201,9 +201,12 @@ Two stages with separate clocks, both printed:
file is read whole from disk. A file over the size limit, or whose full diff
does not parse, is scanned on its added lines alone, without that context.
- `strays`: a new file at the repository root, a new top-level directory, or a
scratch name (`zz-*`, `tmp*`, `scratch*`) anywhere.
scratch name (`zz-*`, `tmp*`, `scratch*`) or editor leftover (`*.orig`,
`*.rej`, `*.swp`, `*.swo`, `*.bak`, `*.tmp`, `*~`, `.DS_Store`, `Thumbs.db`)
anywhere.
- `literals`: for every changed exported constant or named timeout/retry
literal, the whole test tree (nightly- and Heavy-only files included) is
literal (a named literal the branch only adds, such as `limit: 300` in a new
object, is not a change), the whole test tree (nightly- and Heavy-only files included) is
searched for the old value in files that name the constant or, when the old
value is longer than one character, its module (the module's file name not
followed by another extension, or a path ending
@@ -259,7 +262,8 @@ Two stages with separate clocks, both printed:
`--allow-repo-commands` the selected guards are listed and the check is
`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).
downgraded to warn. A guard whose exact command already passed at this tree
is not run again ([guard carry](#guard-carry)). See [guards](#guards).
**Stage 2, test execution:**
@@ -710,7 +714,22 @@ other gate change. Each profile names the workflow file, the events it
accepts, every job that must have passed (each exactly once), and the artifact
holding the run's `tree.json`. `max_age_hours` (1–168, default 24) bounds how
old the run may be, and `os` (`Linux`, `macOS`, `Windows`) requires
`tree.json` to record that runner OS. Each workflow uploads `tree.json` from
`tree.json` to record that runner OS. <a id="skippable"></a>`skippable`
(`{ jobs, gate, scripts }`, GST-1190) lets named jobs be skipped by a path
filter: a skipped job counts only in a `pull_request` run (whose gate diffs
against the pull request's base; a push or dispatch gate diffs against the last
push, which the branch's push history controls), when the `gate` job (the one
whose output decides the skip) succeeded in the same attempt, and the workflow
file plus the gate's inputs are byte-identical to `origin/<base>`. Those inputs
are the declared `scripts`, which must list every repo file the gate reads,
plus every path the gate job's steps name in the base copy of the workflow
(also read under the job's `working-directory` and after a `${{ … }}`
prefix), plus `package.json` when the gate runs a package script. gstack
cannot see what a gate script imports or what a package script runs, so list
those files in `scripts`. Otherwise the pull request controlled the skip
decision, so the job refuses as `job_skipped` and the lane runs here. A lane
whose every named job was skipped this way prints `LANE: <label> not owed: CI
skipped at this tree (gate job <name>, workflow unchanged) run=…`. Each workflow uploads `tree.json` from
its own job: `run_id`, `run_attempt`, `event`, `sha` (or `head_sha`),
`tree` (`git rev-parse HEAD^{tree}` as tested), `files` (every test file the
run executed), `tests` (the count) and, with `os`, the runner OS or a list of
@@ -737,6 +756,28 @@ once:
record (a reuse is never evidence). Its tree equals the tree git computes for
the run's head commit. `files` is a list of paths and `tests` a
non-negative integer when present; without `tests` the file count stands in.
<a id="skipped-files"></a>`skipped_files`, when present, lists the files whose every test skipped on
the runner (a Docker image, a drive path or an installed gstack it lacks;
GST-1190). A carry treats them as uncovered: the pregate tests stage runs
them here. A /ship lane or guard whose inventory names one carries the
covered files and runs only the skipped ones here, through the runner its
`ship_lanes_from` entry declares as `per_file` (a command with `{files}`,
such as `"per_file": "bun test --timeout=30000 {files}"`, the same form as the
tests stage's `runner`). That run is recorded under `<label>.skipped`, the
`LANE:` line adds `skipped_here=<n> file(s) tests_ran_here=<n>`, and the CI row
is written only after it passes with a test count above zero and every carried
run still stands (`run_changed` otherwise). A failure there is
`skipped_failed` and a run of zero tests is `skipped_zero_run`; either way the
whole lane runs here. An entry with no `per_file` refuses as
`inventory_skipped`, naming the files, and the lane runs here whole. A partly
skipped file ran tests and stays covered. gstack's own Free Tests writer does
not emit the list yet: it starts in the same follow-up pull request that
declares `per_file` for gstack's `bun run test` lane, so gstack's carry does not
change until both switch on together. A `tree.json`
without the key (older writers, other repos) is read as before, with every
listed file counted as run; add the list to your writer to close that gap.
The remote gate's clearance does not use it: that gate runs on a Linux runner
like CI, where the same files skip.
- No run of the same workflow on that head, or on pregate.json's own head,
whose newest attempt started at or after the second this run was created
failed, was cancelled or is still running (`run_started_at`, so a red re-run
@@ -785,7 +826,7 @@ Then, for the lane:
Any API or git error refuses. Each refusal prints `REUSE: refused
reason=<code> — …` as `PREGATE_RECEIPT_INVALID`, and a run that fails
verification is named with its own reason (`fork_run`, `event_mismatch`,
`run_not_success`, `job_missing`, `artifact_mismatch`, `artifact_reused`,
`run_not_success`, `job_missing`, `job_skipped`, `artifact_mismatch`, `artifact_reused`,
`tree_mismatch`, `newer_run_vetoes`, `run_changed`, `run_stale`,
`os_mismatch`, `api_error`, …); the clear-level codes are `base_mismatch`,
`no_verified_run`, `tree_mismatch` (runs on different trees),
@@ -895,7 +936,8 @@ whose workflow holds a lane this machine runs (gstack's `ci-reuse/free-tests`
on Linux), with pregate.json's own head as a further veto head. Its tree must be this tree exactly (no
stamp-only substitution). Then:
- The selected local test files the run's `tree.json` lists count as passed.
- The selected local test files the run's `tree.json` lists count as passed,
except those in its `skipped_files` ([above](#skipped-files)), which run here.
The lanes check records them under `from_run` with the run receipt
(`run tree=… tests=… runs=<id>/<attempt> profiles=… heads=… verified=…`) and
prints `REUSE: accepted <n> of <m> selected local test file(s) from <receipt>;
@@ -933,11 +975,14 @@ it accepts a run only when all of these hold:
tree. A `pull_request` run counts only when the pull request already
contained its base.
- Every run executed tests, and with an inventory command, the runs'
`tree.json` files cover every file it prints. Each run is read once more
afterward (`run_changed`).
`tree.json` files cover every file it prints, none of them a file whose every
test skipped there (`skipped_files`). A lane mapped by `jobs`
instead needs no test count: each named job succeeded in the run's newest
attempt. Each run is read once more afterward (`run_changed`).
Security and advisory scans, the sealed guard, guards and every preflight-stage
check always run; only test lanes carry.
Security and advisory scans, the sealed guard and every other preflight-stage
check always run; only test lanes carry, plus guards whose exact command
already passed at this tree ([guard carry](#guard-carry)).
**The tests stage.** Without `--from-run`, when the base branch declares
`run_profiles`, `gstack-pregate` looks up the newest verified run at HEAD under
@@ -986,8 +1031,101 @@ it and the command that lists its test files:
}
```
<a id="ship-lanes-jobs"></a>A lane with no test files to list, such as a typecheck or a verify script,
names the CI jobs that run it instead (GST-1189). Declare exactly one of
`inventory` or `jobs`:
```json
"ship_lanes_from": {
"bun run verify": { "profiles": ["ci/test"], "jobs": ["verify", "typecheck"] }
}
```
It carries only when every named job concluded `success` in the newest attempt
of the verified run at HEAD's tree, read by the same `verifyRun` job check the
profile's own `jobs` use. A job that is missing refuses as `job_missing`, a
skipped one as `job_skipped`, any other conclusion as `job_not_success`, and a
run of another tree as `tree_mismatch`. The receipt reads `ci-carry tree=…
jobs=verify,typecheck runs=…`, and the ledger row records the jobs. gbrain run
41564913688 (tree `13570abc`) is the case: its verify and typecheck jobs passed
at the tree, but no inventory could name their files, so they ran again. The
same form replaces fake `check:<command>` entries some repos wrote into
`tree.json` to give a typecheck, a doc-freshness check or a lint an inventory
(gstack-evals #180): name the job instead. A gstack older than the version that
adds `jobs` rejects the key as invalid config, so a repo declaring it should
first raise its `min_gstack_version` to that version.
<a id="ship-lanes-aliases"></a>**Aliases.** A lane is keyed on its exact command, so
`env -u ANTHROPIC_API_KEY bun test tests/` never matches a `bun test tests/` key.
Declare the other spellings on the entry, read from the base like the rest of
the map (GST-1190):
```json
"bun test tests/": { "profiles": ["ci/test"], "inventory": "…", "aliases": ["env -u ANTHROPIC_API_KEY bun test tests/"] }
```
An alias is a second exact command, never a pattern: gstack does not strip env
prefixes on its own, because `env -i HOME=… PATH=… bun test tests/` runs in a
different environment than the key. An alias that repeats a key or another
entry's alias is invalid config. The ledger row binds the command the lane
actually ran.
**What `tests_ran` means on a carried lane.** No carried lane reports the whole
run's count. A lane mapped by `inventory` reports how many of its inventory
files the runs' `tree.json` covers, labeled as a file count (`tests_ran=14
unit=files` on the ledger line, `tests_ran=14 (files)` on the `LANE:` line);
alphaclaw's 14-file, 46-test guards lane used to record the run's 9677.
`tree.json` has no per-file test counts yet, so the lane's own test count is
not available. A lane mapped by `jobs` reports `tests_ran=n/a`. Only the
pregate tests stage and `gstack-ci-carry find` without `--command`, which carry
the whole run, report its test count.
**`tree.json` counts from Bun's JUnit.** A writer that builds `tree.json` from
Bun's JUnit report must take the root `testsuite` count. Nested `describe`
suites repeat the file attribute, so summing every `testsuite` counts tests
twice. harness-evals #240's `scripts/ci-tree-json.ts` does it right.
<a id="prove-a-mapping"></a>**Proving a new mapping.** `ship_lanes_from` is read from `origin/<base>`
only, so a branch that adds a mapping cannot carry from it, and running
against `--base <PR branch>` fails (`BASE_CONTAINS_HEAD`). Prove it with the dry
run instead, once per lane command:
```bash
gstack-ci-carry find --base main --policy-from head --simulate --command 'bun run verify' --allow-repo-commands
```
It reads the branch's own `ship_lanes_from` and `run_profiles`, checks the lane
against the verified CI run at this tree exactly as a carry would, and prints
`SIMULATION:` at the start of every line (`SIMULATION: CI_CARRY: accepted …` or
`SIMULATION: CI_CARRY: none (<reason>)`). It writes no evidence or ledger row
and clears no obligation. To check every /ship lane at once, the same dry run
takes lane flags: `gstack-ship-preflight --tests-only --base main --policy-from
head --simulate --allow-repo-commands --lane 'verify=bun run verify' …` prints `SIMULATION: LANE:
<label> would carry run=…`, `SIMULATION: CI_CARRY: none label=<label> (<reason>)`
or `not mapped` per lane, runs no lane and no pregate, and exits 0 when every
mapped lane would carry. Both dry runs run the branch's inventory commands to
check coverage, which is running the branch's code, so both need
`--allow-repo-commands`; without it `gstack-ci-carry find --command` stops
with a usage error (exit 2), and the preflight dry run prints
`inventory_not_allowed` for an inventory-mapped lane. truther's adoption proved its map this way by hand
before the dry run existed: run 41585332512 accepted 7 of 7 lanes. A branch's own map never carries,
even when the base has no policy at all, because the pull request controls both
the map and the CI that would satisfy it. So the first pull request that adopts
a mapping pays its full lanes; the carry starts on the pull requests after it
merges. `ship_lanes_from` itself needs `min_gstack_version` 1.94.0.0 or later in
`.gstack/ship-policy.json`, and `jobs` and `aliases` need the release that ships
them. With that floor set, a gstack that has this check but not the key prints
`needs gstack >= <version>; upgrade` (`PREGATE_GSTACK_TOO_OLD`) instead of a
generic invalid-config error. A gstack released before the check (1.94.0.0
included) still reports the unknown key as `PREGATE_CONFIG_INVALID`.
As a second guard, every `record-ci` row names the policy that mapped it
(`origin/<base>@<sha>:.gstack/pregate.json`), and `gstack-evidence carried`, which
the ship receipt reads, lists only rows whose policy is `origin/<the receipt's
base>`. A row written by a run against another base never lands in a receipt.
A carried lane prints `LANE: <label> carried run=<id>/<attempt> tests_ran=<n>`
and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci
(see above for what `<n>` counts) and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci
--label <label> --base <base> -- <command>`, which finds and verifies the run
itself, so no caller hands it run ids or a test count: `source: "ci"`, its runs
and profiles, the run's finish time as `ts`, bound to the clean tree), never a
@@ -1021,6 +1159,8 @@ them (`ci_carried=<lane>:<run>/<attempt>`).
--allow-repo-commands]` prints the verdict for HEAD; `wait [--timeout <s>]`
polls the newest run at HEAD until it completes (run it in the background) and
prints `CI_WAIT: green|red|running|none`, then `find`'s verdict on green.
`find --policy-from head --simulate` is the dry run in
[proving a new mapping](#prove-a-mapping).
<a id="ci-reuse"></a>
### Main pushes reuse the merged pull request's run
@@ -1059,6 +1199,91 @@ and without it every push falls back to the full run. Windows Free Tests now
runs nightly and by hand only (docs/support-tiers.md), never on a push, so its
`reuse` job is always skipped and every nightly run takes the strict path.
<a id="flaky-exception"></a>
## Owner-granted flaky exception
A timing flake in a test file the pull request never touched can keep a remote
gate obligation red: the rerun hits the same 5 s timeout on a loaded runner,
and nothing in the diff can fix it. `clear --flaky-exception` lets the owner
accept named cases for one head, on the run's own records:
```bash
gstack-pregate clear --remote pregate.json/remote_gate --run <id|url> \
--flaky-exception "test/docsync-fault-interface-legacy.test.ts::rejects legacy-third" \
--pr <n> --allow-repo-commands
```
The first call checks everything below and then refuses with
`reason=grant_needed`, printing the exact `gstack-approvals add` command to run
once the owner says yes: its `--scope` is `flaky-exception:<sha256>`, a digest of
the repository, PR, head, tree, lane, every run with its attempt, and the sorted
cases. Record the owner's exact words in that row, then rerun the same clear with
`--grant <the row id>`. The obligation clears only when all of this holds:
- **Every failure is named from the run's own records.** A red run uploads no
`tree.json`, so the clear reads the strict shard runner's artifacts instead
(`free-plan`, `free-result-<n>` and `free-test-shard-logs-<n>` in
`free-tests.yml`; the `windows-` names in `windows-free-tests.yml`; any other
workflow refuses with `no_shard_results`). Every job of the attempt is
reconciled: only the shard jobs (`free-suite (<n>)`) and the aggregate that
needs them may fail, every job the profile requires must pass, and any other
failed or cancelled job refuses (`unnamed_failure`). Every planned shard needs
its result; a missing result, a timed-out shard or a missing summary is
`shard_incomplete`. A failure the runner could not attribute to a test file,
an unhandled error between tests, or a `(fail)` line in a failed shard's log
that its result does not record refuses. A retry that passed is a recovered
flake; a retry that failed again keeps its own failures.
- **The cases are exactly the failures.** At most three, written as
`<test file>::<test name>` the way the run printed them. A failure no
`--flaky-exception` names refuses (`unnamed_failure`), and so does a named
case that did not fail (`case_not_failed`). The refusal lists the run's
failures in that form.
- **The files are not the PR's business.** A named file in the PR's diff
(`case_in_diff`), or one the import graph reaches from any changed path
(`case_in_closure`, lib/pregate/import-graph.ts with the base branch's
`dependencies`), refuses. The closure follows imports only: a test that reads a
changed doc, template or workflow through the file system is not reached, so
required CI stays the check on it. A paid test file (`case_paid`) and a
security test (`case_security`: a file name with cso, secrets, gitleaks, sealed,
redact or redaction, osv, security, sanitize or inject) are never excepted. Security scans themselves are pregate checks and CI jobs,
not test cases, so no exception can reach them.
- **One head, one grant.** The runs must have tested pregate.json's own head and
tree, with no stamp-only substitution (`head_mismatch`); the PR must be open with
that head (`pr_mismatch`); no newer run of the workflow may exist on the head
(`newer_run`), and the run may not move to another attempt during the check
(`run_changed`). The grant row must carry this exception's scope
(`grant_mismatch` otherwise), record `approved` (`grant_not_approved`) and be
the owner's words, not an agent's (`grant_not_owner`). That last check reads
the row's `by` field, which `gstack-approvals add --by` takes as written, so it
only catches a row honestly labeled `agent:`. The record is as trustworthy as
whoever writes it, the same as every gstack approval. A new head needs a new
run and a new grant; a grant is never rebound: `ready` and the ship receipt
refuse an exception granted for another PR or head, even when the tree matches.
pregate.json records the grant under `exceptions` (lane, repository, PR, head,
tree, runs, cases, grant id, who said it, their words and the binding), and the
obligation's receipt adds `exception=<grant> cases=<n>` to the usual run fields.
The verdict becomes `pass-with-exception`. `verify` re-checks offline that each
exception receipt has its entry, bound to this head and tree, with a binding that
still matches its fields, and prints
`PREGATE: pass-with-exception (<cases>; grant <id>)`. `gstack-ship-queue ready`
accepts it and prints one `FLAKY_EXCEPTION:` line per exception in the READY
block, and `gstack-ship-receipt write` records `flaky_exception` (lane, runs,
cases, grant, who).
**Pregate only.** The exception clears the pregate obligation and nothing else.
It never waives a red required CI check: `ready` still needs every required check
green at the exact head, so a red Free Tests check on the pull request needs its
own rerun to pass. Changing that contract is a separate decision for the owner.
**Why not `base_red`.** `base_red` (docs/ship-policy.md#base-red) proves a
failure belongs to the base by reproducing it there, at the base head and at the
PR head. A timing flake usually passes on the base (the 2026-11-02 docsync case
passed in 3.5 s on main and failed at 5 s on the PR's tree), so `base_red`
returns `IN_BRANCH` and refuses. The exception path asks a different question:
not "is the base broken?" but "is every failure named, outside this PR, and
accepted by the owner?"
<a id="guards"></a>
## Guards and the lane-done rule
@@ -1080,6 +1305,38 @@ guard is a *command* with its own fix line. Older gstack refuses unknown keys
that reads them ships. The worked gbrain configuration is in
[gbrain worked configs](#gbrain-worked-configs).
<a id="guard-carry"></a>
### A guard that already passed at this tree does not run again
A guard is skipped as passed when its exact command already passed at this tree
(GST-1186). Two sources count, checked in this order:
1. **CI.** When the base branch's `ship_lanes_from` maps the guard's exact
command, the guard is carried from the verified CI run at HEAD's clean tree,
through the same `gstack-evidence record-ci` and `findCarry` path a /ship
lane uses ([ci-carry](#ci-carry)). It prints `GUARD: <id> carried
runs=<id>/<attempt>`, or `GUARD: <id> not owed: CI skipped at this tree …`
when every job it maps was skipped by an unchanged gate
([skippable](#skippable)). When no run carries, it prints `CI_CARRY: none
guard=<id> (<reason>)` and goes on to the ledger.
2. **The evidence ledger.** When the newest local ledger row for the exact
command at this working tree, under any label, is a counted pass (`tests_ran`
above zero under the runtime the guard would launch with, within 24 hours),
the guard prints `GUARD: <id> reused label=<L> source=local ts=<ts>`. CI rows
never count here: a CI pass counts only through `record-ci`'s live check
against the base, so an old CI row does not outlive a refusal.
`gstack-evidence passed -- <command>` is the same lookup by hand. A newer
failure of the command at that tree vetoes an older pass.
Anything else runs the guard as before, with the same timeout, stall rule and
fix line. This matters for repos whose suites are guards rather than lanes
(founderstar declares `check-quick`, `play-through` and `assets` as guards),
which otherwise reran every suite /ship had just carried from CI.
Known limit: `ship_lanes_from` is read from `origin/<base>` only, so the pull
request that adds a mapping cannot carry from it. Prove it with the dry run in
[proving a new mapping](#prove-a-mapping) until it lands.
<a id="allowlist"></a>
## Allowlist: benign findings the secrets check accepts
+22 -4
View File
@@ -174,9 +174,12 @@ applies no edits is its exit too.
`gstack-review-plan --base <base>` classifies the diff, picks a **depth** and prints
the reviewers the next step dispatches: `REVIEW_CLASS:`, `REVIEW_DEPTH: <d>
runtime_lines=<n> rules=<hash> reasons=<…> codes=<…>`, `GATE_SCAN: tagged=<n>|unavailable
head=<sha>`, one `DEPTH_ERROR:` per input that failed closed, one `DISPATCH:` per reviewer
head=<sha>`, `REVIEW_BASE: <ref> <sha12>` (the base the diff was taken against: a bare
`--base main` reads `origin/main` when it shares history with HEAD, and a local `main` that lags
it is named on stderr), one `DEPTH_ERROR:` per input that failed closed, one `DISPATCH:` per reviewer
with its effort and a shell-quoted `DIFF_CMD:` (plus `DIFF_EXCLUDE: <name> none` when it
reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields.
reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields (the
base as `base`).
A plan that fails closed to full exits 0; a diff git cannot list exits 1.
**Classes** are unchanged. A mixed diff takes its most restrictive class: **docs**
@@ -204,7 +207,7 @@ elements and RDF metadata, CSS made of declarations only (no `@` rule and no
function but a same-file `url(#id)`, which closes `@import`, `image-set()`,
`image()`, `element()` and every other CSS load), no backslash escape, every
`href` a same-file `#` reference or an inline raster image, no event handler and no
entity declaration, and no DOCTYPE but a bare `<!DOCTYPE svg>` or W3C's own SVG DTD, read after character references are decoded; anything else, a
entity declaration, and no DOCTYPE but a bare `<!DOCTYPE svg>` or W3C's own SVG DTD, read after character references are decoded. A `<style>` element's text is read as written. Any `&` in it keeps the SVG code, its comments are read as CSS, an opening tag it cannot read as quoted `name="value"` pairs keeps the SVG code, and only ASCII whitespace may come before the `>` of either tag, so no character-reference `</style>`, comment that only an HTML parser ends early, quoted `>` or Unicode space can hide CSS. A non-ASCII character in an element or attribute name, such as a `gé:` prefix, keeps the SVG code too (GST-940); anything else, a
script or `foreignObject` included, keeps it code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
and fails the depth closed. The scan first lists the files that contain a candidate's
@@ -384,7 +387,7 @@ and matching is token-bounded: `costume` never reads as cost.
|---|---|
| money | a path segment naming cost, spend, budget, ledger, billing, price, fee or invoice; or a changed line with an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid provider `lib/pricing.ts` prices |
| gate | a hunk the gate scan tagged: a relaxed threshold, a skipped test, a loosened check |
| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name; a data file named for sessions (`recovered-sessions.jsonl`) outside a config location does not set `SCOPE_AUTH` |
| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name; a data file named for sessions (`recovered-sessions.jsonl`) outside a config location does not set `SCOPE_AUTH`, and `SCOPE_AUTH` fails safe: any name token containing auth sets it (`auth.ts`, `/auth/`, `oauth`, `useAuth.ts`, `nextauthmiddleware.ts`) except the authorship words `author`, `authors`, `authored`, `authoring`, `authorship`, `authority`, `authorities`, `coauthor` and `coauthors` |
| secret | a secrets, credentials, keychain or token path, or a changed line that reads a key-named environment variable (`OPENAI_API_KEY`, `GH_TOKEN`) or a credential store |
An input the classifier cannot read (the diff, `gstack-diff-scope`, the gate
@@ -493,6 +496,21 @@ through the docs stage. A path outside that scope and a gate-edited test still
route as behavior, with `--reviewed-base` (as /ship passes it) so does a file
too large for the scoped review, and without the record nothing changes.
A completed fix-check is a review, not an owner acceptance (GST-1130). Finish
reads the newest `review-fix-check` row from `gstack-review-read`. When it is a
completed clean read on committed content (freshness `CURRENT` against its own
tree) whose commit is on the branch, whose `range_base` is a branch commit
holding the reviewed tree, and for which an approved `review_loop_grant` in
`gstack-approvals` names a stop head inside `range_base..commit`, each code or test path inside `range_base..commit`
that is unchanged since routes as `fix_checked=` in `ROUTE:` and is listed on a
`FIX_CHECK: reviewed` line, a gate-edited test included. Authored docs keep the
stage 2.2 recheck, and a path edited after the fix-check still routes by the
rules above. Any other row prints
`FIX_CHECK: not counted (<reason>)` and changes nothing. `gstack-review-log`
refuses a `review-fix-check` row without boolean `completed` and `converged`,
before it consumes the start token, because without them the row reads
`UNVERIFIED` and finish cannot count it.
The receipt's summary line prints `passes=<n> blocking_found=<k> deferred=<m>`,
which is what a scorecard tracks (target: a median of 1 pass per PR). A
receipt carried across a base merge or a test-only fix
+31 -7
View File
@@ -53,12 +53,13 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
| `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. |
| `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. |
| `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, `<!-- x-stamp: {version} -->`); matched literally at a version boundary, never as a regex. The restamp writer, its mention scanner and the tree receipt use them beside the built-in forms. |
| `stamp_placeholders` | literal placeholders | `[]` | At most 16 one-line literals of at most 32 characters, never `{version}` and never a version (`["TBD"]`). On a line the branch added itself, a placeholder inside a `stamp_patterns` template (`since: 'TBD'` under `since: '{version}'`) may become that template at the new version, and finish's route, the receipt carry and the tree receipt read the change as a stamp. The built-in forms never take a placeholder, and neither does a base line, even one whose text duplicates a branch line. The writer does not fill placeholders: the repo's `restamp_command` does. A newer key: set `min_gstack_version` to the release `gstack-ship-policy validate` names. Empty accepts no placeholder. |
| `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). |
| `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. |
| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. |
| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log <file>` from its run on the gated head. Needs `min_gstack_version`. |
| `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. |
| `review_exclude_paths` | git glob pathspecs | `[]` | Content files (data, docs, binary, `.log`) `/ship`'s reviewers leave out; read from the base only, with code, gate, config, executable and named-by-code files kept. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. |
| `review_exclude_paths` | git glob pathspecs | `[]` | Content files `/ship`'s reviewers leave out: data, docs, `.log`, and binaries under a content extension (images, fonts, archives and the like) that do not start with executable magic. Read from the base only. Code (native libraries and every other binary outside the content extensions included), gate paths, agent prose, config, executables, links, `.gstack/` and `.github/` files, gate-tagged, secret-line and named-by-code files stay in review. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. |
| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. |
| `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base <base>` (the caps come from `origin/<base>`'s policy, never the branch's copy; `--policy <file>` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: <path> <bytes>/<max> ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. |
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
@@ -1287,8 +1288,8 @@ A pattern that matches no changed file is listed as `unmatched=` on the plan lin
**What is excluded, and what is kept.** A matched file is excluded only when it
is content: a data file (JSON, JSONL, CSV, TSV, images), a docs-class file, a
`.log` file, or a file git reports as binary (`-\t-` in `--numstat`; readers would only see `Binary files differ`) whose bytes hold a NUL in their first 8,000, so a branch's `.gitattributes` cannot make a text file binary content. A binary file is content even under
`config/` or in a skill directory; only `policy-dir`, `gate-path`, `executable`, `link`, `gate-tagged`, or a source or script extension (`code`) keep it.
`.log` file, or a binary file: one git reports as binary (`-\t-` in `--numstat`; readers would only see `Binary files differ`) under the branch's attributes or with no attributes from the tree, so a branch's `.gitattributes` cannot make an executable read as text, and whose bytes hold a NUL in their first 8,000, so it cannot make a text file binary content either. A binary file is content only under a known content extension (images, fonts, media, documents, compressed archives, `.bin`, `.dat`, and data blobs such as `.parquet`, `.npy` or `.sqlite`) and only when its bytes do not start with an executable image's magic (ELF, Mach-O including universal binaries, PE, WebAssembly, `#!`). Under those rules it is content even under
`config/` or in a skill directory, and only `policy-dir`, `gate-path`, `executable`, `link` or `gate-tagged` keep it. Every other binary file is `code`: source with a NUL, a native library (`.node`, `.so`, `.dylib`, `.dll`), a compiled executable, a key store, a pickle, or a file with no extension.
Everything else stays in every reader's diff with one reason code:
| Reason | Kept because |
@@ -1300,8 +1301,8 @@ Everything else stays in every reader's diff with one reason code:
| `agent-prose` | it is a skill, section, resolver, template or rule file |
| `config` | it is a data-shaped config file (`package.json`, `tsconfig*.json`, `config/`, dotfiles) |
| `gate-tagged` | the gate scan tagged one of its hunks |
| `code` | it is code or a test, not content; a binary file with a source or script extension (`.js`, `.py`, `.sh`, `.wasm`) counts as code |
| `secret-line` | an added line reads a credential (the targeted pass's secret rule) |
| `code` | it is code or a test, not content; a binary file outside the content extensions above, or one with executable magic, counts as code |
| `secret-line` | an added line reads a credential (the targeted pass's secret rule); for a file git calls binary only because of its attributes, the added lines come from `git diff --text`, which `.gitattributes` cannot change (readers' own diffs still follow the attributes and show the kept file as binary) |
| `named-by-code` | a code or test file that stays in review (not docs, a data file, a `.log` or binary, and not itself excluded) names its full repository path, at the head or the merge base |
| `consumer-scan-unavailable` | the `git grep` for those names failed, so every candidate is kept; also a live binary file whose bytes could not be read |
| `gate-scan-unavailable` | the gate scan failed or did not run at the head, so no hunk's tag is known and every candidate is kept |
@@ -1351,11 +1352,15 @@ paths and three hashes). The `digest` hashes the declared patterns and the
excluded and kept lists; `source` is provenance only. Write, carry and the queue
each recompute the digest at the current base and compare it, so a base merge
or a moved base that leaves the patterns and the lists unchanged still carries
and binds. A different digest means the reviewers read another selection:
and binds. A different digest means the digest write was given (or the receipt recorded) names another selection than the head has now:
write refuses with `RECEIPT_INVALID field=review_exclude`, carry with
`RECEIPT_CARRY_REFUSED review_exclude changed`, and the queue with
`RECEIPT_INVALID field=review_exclude expected=<digest> recorded=<digest>`. The
fix is to rerun the review, never a receipt write alone.
fix is to rerun the review and write with the digest its `verify` prints. Write
checks the digest against the base policy, so a stale selection cannot be
recorded by accident, but it does not read the review ledger: a write given the
`expected=` digest passes without a review, and only the review run shows that
the reviewers read that selection.
**Adopting it.** (1) Open a PR on the base that adds the patterns and
`min_gstack_version` at least `REVIEW_EXCLUDE_KEY_VERSION`, and ship it like any
@@ -1503,6 +1508,7 @@ receipt when the base moves:
| a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) |
| any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) |
| a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent <branch> landed`) |
| a stacked PR whose receipt was written against the base, after its parent landed and the base was merged in, HEAD's product diff exactly the reviewed range from the parent's head | carries (reason `stack parent <sha12> landed`); base context next to the child's hunks is `RECEIPT_CARRY_SCOPED_REVIEW` |
| a stamp on the branch's own rows in a stamp file the branch also changed as product: every changed line at a branch-added position, the file's mode unchanged, the base not touching it, the rest of the product diff unchanged | carries (reason `a stamp on the branch's own rows (<file>)`) |
**Stacked PRs (#3570).** A child PR's receipt is written against its parent
@@ -1520,6 +1526,24 @@ since, or a parent that landed with changes the child never merged all leave a
different diff and refuse with `RECEIPT_CARRY_PRODUCT_CHANGED`. An older
receipt whose parent branch is gone refuses with `RECEIPT_CARRY_REFUSED`.
**Stacked PRs reviewed against the base (GST-1199).** A child PR that targets
the base while stacked has a receipt whose `base` is the base and whose
reviewed range holds the parent's hunks too, and the receipt records no stack
parent. Once the parent squash-merges and the base is merged into the child,
the two heads' product diffs differ by exactly the parent's hunks, which now
sit in the base. When they differ, the carry looks for the newest commit inside
the reviewed range (the reviewed head excluded, at most 100 commits) whose
product change the current base already holds: `git merge-tree` of that commit
into the base changes no product path. A conflicted product path never counts,
so a parent that never landed is never treated as one, even when the child's
merge dropped its change. The comparison then runs from that commit, leaving out
every path whose content at the reviewed head is the base's (a base merge the
child made before its receipt brought it in) unless HEAD changed it against the
base (GST-1214): an equal
patch-id carries with reason `stack parent <sha12> landed`, base context next
to the child's unchanged hunks is `RECEIPT_CARRY_SCOPED_REVIEW`, and any other
difference refuses with `RECEIPT_CARRY_PRODUCT_CHANGED`.
Since the release that added `review_scope`. A `/ship` receipt names the head it
reviewed, so a conflict-only merge of the base or a test-only fix used to
invalidate it and cost a full re-run on identical product code.
+2 -1
View File
@@ -18,7 +18,7 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native
## What we stop doing
- No Windows-only or Gemini-only features, parity work, tuning or polish.
- No new Windows or Gemini CI jobs or eval cells. Windows CI tuning stops.
- No new Windows or Gemini CI jobs or eval cells, apart from the path-scoped Windows checks under Windows CI below (ship-90-90 plan D). Other Windows CI tuning stops.
- A Windows-only or Gemini-only report that doesn't break install, startup or a core workflow closes as won't fix ([triage rules](triage.md#closing-an-issue)), with a link to this page. A report that does break one of them gets a small fix.
## Windows CI
@@ -26,6 +26,7 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native
- `windows-smoke` is the required Windows check on pull requests.
- The six-shard Windows free suite (the `Windows Free Tests` workflow, `windows-free-tests`) runs nightly on main, or when someone starts it by hand, and never blocks a pull request.
- A red nightly run gets a fix only when it shows an install, startup or core-workflow break.
- Windows checks run by path (ship-90-90 plan D, approved 2026-11-03): `Windows Scoped Tests` runs a pull request's changed Windows-safe test files on one Windows job only when it touches path, file-name or process code (a red run blocks that pull request, like any failed check), and `Windows Setup E2E` runs only when install files change (a version-only `package.json` change doesn't count).
## What doesn't change
+4 -4
View File
@@ -50,14 +50,14 @@ per-failure-class breakdown from `test:health`.
full consumed-input fingerprint (all touchfiles, globals included) as
`series_fingerprint`, plus `harness_version`, as provenance.
(`caseSeriesIdentitiesV2` in `scripts/eval-trial-series.ts`.)
- **Harness guard.** `scripts/harness-version.json` pins the blob of every
shared execution-harness file: `GLOBAL_TOUCHFILES` plus the session runners,
the PTY harness, the e2e gate and the LLM judge (36 files). A free test
- **Harness guard.** `scripts/harness-pins/<path>.json` pins the blob of every
shared execution-harness file (`scripts/harness-version.json` keeps the version and patterns): `GLOBAL_TOUCHFILES` plus the session runners,
the PTY harness, the e2e gate and the LLM judge (42 files). A free test
(`test/harness-version.test.ts`) fails when one changes without a decision.
`bun run scripts/bump-harness-version.ts --bump "<reason>"` raises
`HARNESS_VERSION` and starts a new series for every case;
`--non-behavioral "<reason>"` records the new blobs as compatible with no
reset. Entries are one line per file so concurrent PRs that touch different
reset. Each pin is its own file so concurrent PRs that touch different
files merge cleanly; on a conflict, take the higher version, then rerun.
- **Weekly history (W4b).** Weeks for quarantine expiry and drift are
`evals-periodic.yml` runs on `main`: scheduled runs and main dispatches.
+39 -10
View File
@@ -1705,10 +1705,12 @@ session holds a workflow lease on this installation: pid <p> (<age>m
<checkout>/bin/gstack-upgrade once the lease is 120 minutes old, or
GSTACK_UPGRADE_FORCE=1 <checkout>/bin/gstack-upgrade when no other session uses
this checkout (UPGRADE_BUSY))` and leaves the
checkout where the run started. Each `gstack-skill-start` renews the lease.
checkout where the run started. Each `gstack-skill-start` renews the lease
and opens a run that its `gstack-skill-end` closes; a lease whose runs are all
closed is idle and never counts (GST-1205).
**Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows
every lease with its age and `alive|dead|stale`; a holder is named as
every lease with its age and `alive|idle|dead|stale`; a holder is named as
`pid <p> (<age>m <command>)`), then re-run `bin/gstack-upgrade`. For the
session's own run, or a shared-harness lease, re-run once the lease is 120
minutes old. When you know no other session uses the checkout, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
@@ -3340,10 +3342,13 @@ targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verif
use this code when the receipt's review_exclude digest (or the
`--review-exclude <digest>` write was given) is not the digest HEAD has under the
base policy's [`review_exclude_paths`](ship-policy.md#review-exclude-paths). The line
names `expected=` (now), `recorded=` (what the review read, `none` when it read
without exclusion) and `source=` (the base commit). The reviewers read another
selection than the one the head now has, so a receipt write alone cannot clear
it: rerun `/ship`'s review on the head (`gstack-review-dispatch pass --base <base>`),
names `expected=` (now), `recorded=` (the `--review-exclude` argument, or the digest
the receipt recorded from it; `none` without one) and `source=` (the base commit).
A mismatch proves only that this argument differs from the selection the head has
now. Write compares the argument and does not read the review ledger, so the line
says nothing about what the readers read, and a write that passes the `expected=` digest clears
the field without a review. Do not do that. Rerun
`/ship`'s review on the head (`gstack-review-dispatch pass --base <base>`),
run `gstack-review-dispatch verify --ledger <ledger>`, which prints
`REVIEW_EXCLUDE_RECORD: digest=<d>` from the briefs the readers got, then
`gstack-ship-receipt write --pr <n> --review-exclude <d> --body <body>`.
@@ -3730,6 +3735,23 @@ the entry is reported as unverified, not `REGEN_STALE`.
**Fix.** Run the printed install command (`bun install`, or the one matching the
repository's lockfile), then rerun `gstack-regen check` or the pre-gate.
<a id="pregate-gstack-too-old"></a>
### `PREGATE_GSTACK_TOO_OLD`
**Meaning.** `.gstack/pregate.json` uses a key this gstack does not know, and
the same tree's `.gstack/ship-policy.json` sets `min_gstack_version` above this
gstack. The key belongs to a newer gstack (one a later release adds), so the
line reads `needs gstack >= <version>; upgrade`. The policy still refuses,
exactly as `PREGATE_CONFIG_INVALID` would. Only a gstack that has this check
prints it; one released before it (1.94.0.0 included) reports the same key as
`PREGATE_CONFIG_INVALID`.
**Fix.** Upgrade gstack on this machine to at least the version named
(`bin/gstack-upgrade`, or the session install with `--for <skill>`), then
rerun. A repo adopting a new key raises `min_gstack_version` first, so an older
gstack that has this check prints this instead of a generic invalid-config
error.
<a id="pregate-config-invalid"></a>
### `PREGATE_CONFIG_INVALID`
@@ -3767,7 +3789,9 @@ anchored pattern and a reason ([docs/pregate.md](pregate.md#allowlist));
### `PREGATE_STRAYS`
**Meaning.** A file was added at the repository root, in a new top-level
directory, or under a scratch name (`zz-*`, `tmp*`, `scratch*`).
directory, or under a scratch name (`zz-*`, `tmp*`, `scratch*`) or an editor
leftover name (`*.orig`, `*.rej`, `*.swp`, `*.swo`, `*.bak`, `*.tmp`, `*~`,
`.DS_Store`, `Thumbs.db`).
**Fix.** Delete or move it. An intentional root file goes in `strays.allow`.
@@ -5981,7 +6005,10 @@ the reviewed tree but the base moved under it, so the diff a reviewer would see
is not the reviewed one. For a stacked PR retargeted to the base after its
parent landed, it means HEAD's product diff against the base is not the range
reviewed against the parent branch: the parent has not fully landed, or the
branch changed since. The reviewed verdict does not cover HEAD, so the
branch changed since. A stacked PR whose receipt was written against the base
carries from the newest reviewed commit the base already holds
([ship-policy.md](ship-policy.md#receipt-carry)); it refuses here only when no
such commit exists or the branch changed since. The reviewed verdict does not cover HEAD, so the
receipt is not carried. The refusal names the changed product paths, or for a
retargeted stacked PR the parent branch and the base.
@@ -6445,7 +6472,7 @@ There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `g
<a id="harness-hook-unsupported"></a>
### `gstack-harness: <harness> has no start hook` / `the start hook goes into the <harness> profile <dir>, and this registration uses another skills folder` (HARNESS_HOOK_UNSUPPORTED)
The [start hook](hosts.md#start-hook) exists for OpenClaw and Hermes only, and it goes into the harness profile's own plugin folder, the one the harness scans. A registration with `--skills-dir` somewhere else has no profile gstack can trust, and with `OPENCLAW_CONFIG_PATH` set OpenClaw reads a config outside the profile, where gstack's settings change would not land, so `--start-hook` is refused and nothing is written. Register without `--skills-dir` (set `OPENCLAW_STATE_DIR` or `HERMES_HOME` to pick the profile), or drop `--start-hook`. When the choice comes from `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` instead of the flag, such a registration just gets no hook, and its receipt says so.
The [start hook](hosts.md#start-hook) exists for OpenClaw and Hermes only, and it goes into the harness profile's own plugin folder, the one the harness scans. A registration with `--skills-dir` somewhere else has no profile gstack can trust, and with `OPENCLAW_CONFIG_PATH` naming another config OpenClaw reads settings outside the profile, where gstack's settings change would not land, so `--start-hook` is refused and nothing is written. `OPENCLAW_CONFIG_PATH` naming the profile's own `openclaw.json` (compared after resolving symlinks) is not refused. Register without `--skills-dir` (set `OPENCLAW_STATE_DIR` or `HERMES_HOME` to pick the profile), unset `OPENCLAW_CONFIG_PATH` or point it at the profile's own `openclaw.json`, or drop `--start-hook`. When the choice comes from `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` instead of the flag, the registration gets no hook, and `gstack-harness register` and `./setup --host` exit 4 with the reason (`HARNESS_HOOK_INACTIVE`, `start hook is off`); refreshes only print it.
<a id="harness-hook-choice-invalid"></a>
### `gstack-harness: GSTACK_HARNESS_START_HOOK="<value>" is not on or off` (HARNESS_HOOK_CHOICE_INVALID)
@@ -6460,7 +6487,9 @@ Each harness profile has one start hook, owned by the gstack registration that w
<a id="harness-hook-inactive"></a>
### `gstack-harness: <harness> start hook is written: <why>. Turn it on: <command>` (HARNESS_HOOK_INACTIVE, exit 4)
The skills are registered and the start hook's files are in place, but the hook is not active in the harness. gstack turns the hook on through the harness's own CLI and then reads it back, so this means the CLI was not on `PATH`, one of its commands failed or timed out (the message quotes its last output), or the harness's settings block the plugin: OpenClaw's `plugins.allow` set without `gstack-start`, `gstack-start` in `plugins.deny`, or `hooks.allowPromptInjection: false`. gstack never edits your allow or deny lists. Run the printed command, then `gstack-harness status`; `--start-hook` again retries the whole step. `./setup --host` exits 4 for the same reason. Refreshes and upgrades retry on their own without failing, and stop once you disable the plugin in the harness.
The skills are registered and the start hook's files are in place, but the hook is not active in the harness. gstack turns the hook on through the harness's own CLI and then reads it back, so this means the harness has no config yet (`no OpenClaw config at <path> yet` or `no Hermes config at <path> yet`: run `openclaw onboard` or `hermes setup` first, then `gstack-harness refresh`; gstack runs no harness command until then, because the CLI would create a config holding only gstack's plugin setting), the CLI was not on `PATH`, one of its commands failed or timed out (the message quotes its last output), or the harness's settings block the plugin: OpenClaw's `plugins.allow` set without `gstack-start`, `gstack-start` in `plugins.deny`, or `hooks.allowPromptInjection: false`. gstack never edits your allow or deny lists. Run the printed command, then `gstack-harness status`; `--start-hook` again retries the whole step. `./setup --host` exits 4 for the same reason. Refreshes and upgrades retry on their own without failing, and stop once you disable the plugin in the harness.
The same code with `start hook is off: <why>` means you asked for the hook through `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` but this registration cannot take it (a custom `--skills-dir`, `OPENCLAW_CONFIG_PATH` naming another config, or an unsafe plugin folder); the message names the fix.
<a id="harness-qm-credentials"></a>
### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS)
+4 -4
View File
@@ -37,7 +37,7 @@ Conventions:
- [/gstack-upgrade](gstack-upgrade/SKILL.md): Upgrade gstack to the latest version.
- [/guard](guard/SKILL.md): Full safety mode: destructive command warnings + directory-scoped edits.
- [/health](health/SKILL.md): Code quality dashboard.
- [/investigate](investigate/SKILL.md): Systematic debugging with root cause investigation.
- [/investigate](investigate/SKILL.md): Use when a test fails, code errors or output is wrong: find the root cause, then fix it.
- [/ios-clean](ios-clean/SKILL.md): Remove the DebugBridge SPM package and all #if DEBUG wiring from an iOS app.
- [/ios-design-review](ios-design-review/SKILL.md): Visual design audit for iOS apps on real hardware.
- [/ios-fix](ios-fix/SKILL.md): Autonomous iOS bug fixer.
@@ -56,10 +56,10 @@ Conventions:
- [/plan-devex-review](plan-devex-review/SKILL.md): Interactive developer experience plan review.
- [/plan-eng-review](plan-eng-review/SKILL.md): Eng manager-mode plan review.
- [/plan-tune](plan-tune/SKILL.md): Self-tuning question sensitivity + developer psychographic for gstack (v1: observational).
- [/qa](qa/SKILL.md): Test the site, API or CLI and fix the bugs it finds.
- [/qa](qa/SKILL.md): Use when you test the site, API or CLI and fix bugs.
- [/qa-only](qa-only/SKILL.md): Report browser/API/CLI/job/worker/webhook bugs.
- [/retro](retro/SKILL.md): Weekly engineering retrospective.
- [/review](review/SKILL.md): Review this PR's diff.
- [/review](review/SKILL.md): Use when you review this PR's diff.
- [/scrape](scrape/SKILL.md): Pull data from a web page through the Aside browser — your real, already signed-in sessions.
- [/setup-browser-cookies](setup-browser-cookies/SKILL.md): Import cookies from your real Chromium browser into the headless browse session.
- [/setup-deploy](setup-deploy/SKILL.md): Configure deployment settings for /land-and-deploy.
@@ -71,7 +71,7 @@ Conventions:
- [/test-audit](test-audit/SKILL.md): Find low-value or duplicate tests and the test-only code they keep alive.
- [/ubicloud](ubicloud/SKILL.md): Run a heavy suite, build or eval on an ephemeral 16-vCPU Ubicloud VM: TTL-named VMs, a reaper, quota preflight and a hang watchdog.
- [/unfreeze](unfreeze/SKILL.md): Clear the freeze boundary set by /freeze, allowing edits to all directories again.
- [/verify](verify/SKILL.md): Build loop while coding: run the repo's checks and the tests /ship would pick, fix, repeat until green.
- [/verify](verify/SKILL.md): Use when coding: run the checks and tests /ship picks until green.
- [/write-skill](write-skill/SKILL.md): Write a new agent skill or improve one without bloat: the edit ladder, levels, drift and size checks, full diets.
## Browse Commands
+31 -2
View File
@@ -2,7 +2,7 @@
name: investigate
preamble-tier: 2
version: 1.0.0
description: Systematic debugging with root cause investigation. (gstack)
description: "Use when a test fails, code errors or output is wrong: find the root cause, then fix it. (gstack)"
allowed-tools:
- Bash
- Read
@@ -15,9 +15,29 @@ allowed-tools:
triggers:
- debug this
- fix this bug
- test fails
- failing test
- why is this broken
- root cause analysis
- this doesn't work
- build is broken
- stack trace
- investigate this error
- root cause analysis
contract:
version: 1
items:
- id: debug-report
kind: report_labels
heading: DEBUG REPORT
labels:
- Symptom
- Root cause
- Fix
- Evidence
- Regression test
- Status
- id: check-after-last-edit
kind: check_after_last_edit
hooks:
PreToolUse:
- matcher: "Edit"
@@ -61,6 +81,15 @@ gbrain:
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
## Done means
Finish only when each of these exists (contract v1):
- `debug-report`: a `DEBUG REPORT` with Symptom, Root cause, Fix, Evidence, Regression test and Status filled in (`none: <why>` where the skill's own stop rule leaves one empty).
- `check-after-last-edit`: a test or repro command run after your last edit.
`gstack-skill-contract check` reads them from the transcript (docs/skill-routing-and-contracts.md). For an item that truly does not apply, run `gstack-skill-contract waive <item> --reason "<why>"` (this session only; it counts as a miss).
## Shared preamble (load first)
This skill follows gstack shared preamble `4e82fa4c839f` (AskUserQuestion format, voice, writing, completeness and completion rules). If your Read of that file, headed by this id, is still in context, do not read it again; otherwise Read `~/.claude/skills/gstack/investigate/sections/preamble.md` in full before the command below. A section or subsection here with a shared heading replaces the shared one.
+27 -5
View File
@@ -3,10 +3,10 @@ name: investigate
preamble-tier: 2
version: 1.0.0
description: |
Systematic debugging with root cause investigation. Iron Law: no fixes
without root cause.
Use when asked to "debug this", "fix this bug", "why is this broken",
"investigate this error", or "root cause analysis". (gstack)
Use when a test fails, code errors or output is wrong: find the root cause, then fix it.
Systematic debugging; Iron Law: no fixes without root cause. Fits a failing
build, test, script, service or config, an error message or stack trace,
wrong or missing output, or broken data, before any edit that fixes it. (gstack)
allowed-tools:
- Bash
- Read
@@ -19,9 +19,29 @@ allowed-tools:
triggers:
- debug this
- fix this bug
- test fails
- failing test
- why is this broken
- root cause analysis
- this doesn't work
- build is broken
- stack trace
- investigate this error
- root cause analysis
contract:
version: 1
items:
- id: debug-report
kind: report_labels
heading: DEBUG REPORT
labels:
- Symptom
- Root cause
- Fix
- Evidence
- Regression test
- Status
- id: check-after-last-edit
kind: check_after_last_edit
hooks:
PreToolUse:
- matcher: "Edit"
@@ -63,6 +83,8 @@ gbrain:
render_as: "## Recent eureka moments (cross-project)"
---
{{CONTRACT_DONE_MEANS}}
{{PREAMBLE}}
# Systematic Debugging
+93 -18
View File
@@ -14,7 +14,9 @@
* max age, the run's own tree.json naming HEAD's tree);
* every run executed tests;
* with an inventory command, the runs' tree.json files cover every file it
* prints (a lane carries only what CI provably ran);
* prints (a lane carries only what CI provably ran); with named jobs instead
* (a lane with no test files, GST-1189), each of those jobs succeeded in the
* run's newest attempt, read by verifyRun like the profile's own jobs;
* every run still stands after the inventory ran (runChanged).
*
* Anything else returns a refusal with a reason code, and the caller runs the
@@ -25,12 +27,18 @@
* bounded by a timeout, and returns green, red with the failing jobs, still
* running, or none.
*/
import { runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RunDeps, type RunEvidence, type RunProfile, type RunSource } from './run-evidence';
import { normalPath, runChanged, runInventory, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RunDeps, type RunEvidence, type RunProfile, type RunSource } from './run-evidence';
export type CarryRefusal = 'dirty_tree' | 'no_profile' | 'no_run' | 'no_verified_run' | 'tree_mismatch' | 'no_tests'
| 'inventory_failed' | 'inventory_empty' | 'inventory_uncovered' | 'run_changed' | 'tree_changed' | 'api_error';
| 'inventory_failed' | 'inventory_empty' | 'inventory_uncovered' | 'inventory_skipped' | 'run_changed' | 'tree_changed' | 'api_error';
export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string };
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number; receipt: string } | CarryRefused;
/**
* `tests` never claims a whole run's count for a narrow lane: with an inventory it is the number of the lane's inventory files
* the runs' tree.json covers (`unit: 'files'`; tree.json has no per-file test counts yet), with named jobs `n/a`, and only
* without either (the whole run carried) the runs' test count. `remainder`: inventory files every run skipped entirely
* (tree.json `skipped_files`), which the caller must run here with the lane's per_file runner before the lane counts.
*/
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; remainder?: string[]; receipt: string } | CarryRefused;
/** How many of HEAD's runs per workflow are tried, newest first. */
const CANDIDATES = 10;
@@ -38,12 +46,13 @@ const no = (reason: CarryRefusal, detail: string): CarryRefused => ({ ok: false,
const newestFirst = (a: ApiRun, b: ApiRun) => (b.created_at ?? '').localeCompare(a.created_at ?? '') || b.id - a.id;
/** The newest run of `profile` on `head` that verifies; a refusal lists each candidate's reason, newest first. */
function newestVerified(src: RunSource, name: string, profile: RunProfile, head: string): { ok: true; evidence: RunEvidence } | CarryRefused {
function newestVerified(src: RunSource, name: string, profile: RunProfile, head: string, jobs: string[]): { ok: true; evidence: RunEvidence } | CarryRefused {
if (jobs.length) profile = { ...profile, jobs: [...new Set([...profile.jobs, ...jobs])] };
const listed = src.deps.runsForHead(profile.workflow, head).filter(r => profile.events.includes(r.event)).sort(newestFirst).slice(0, CANDIDATES);
if (!listed.length) return no('no_run', `no ${profile.workflow} run (${profile.events.join(', ')}) on ${head.slice(0, 12)} yet`);
const refusals: string[] = [];
for (const run of listed) {
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now });
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now, unchanged: src.unchanged });
if (v.ok) return v;
refusals.push(`run ${run.id}: ${v.reason}`);
}
@@ -52,9 +61,13 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
/**
* The CI evidence for HEAD's tree under `profiles` (one verified run each), or why there is none. `wtree` reads the
* working-tree fingerprint (before the lookup and after it); `inventory` lists the files the runs must cover.
* working-tree fingerprint (before the lookup and after it); `inventory` lists the files the runs must cover, or `jobs`
* names the jobs that must have succeeded in each run's newest attempt (a lane with no test files; no test count needed).
*/
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string } }): CarryVerdict {
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean }): CarryVerdict {
const jobs = o.jobs ?? [];
let covered: number | undefined;
let remainder: string[] = [];
const before = o.wtree();
if (before !== o.tree) return no('dirty_tree', `the working tree (${before ? before.slice(0, 12) : 'fingerprint unavailable'}) is not HEAD's committed tree ${o.tree.slice(0, 12)}; only a clean, committed tree carries`);
if (!o.profiles.length) return no('no_profile', 'no run profile is named');
@@ -63,20 +76,24 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
for (const name of o.profiles) {
const profile = src.profiles[name];
if (!profile) return no('no_profile', `${name} is not a run_profiles name on the base branch`);
const v = newestVerified(src, name, profile, o.head);
const v = newestVerified(src, name, profile, o.head, jobs);
if (!v.ok) return v;
runs.push(v.evidence);
}
const other = runs.find(e => e.tree !== o.tree);
if (other) return no('tree_mismatch', `run ${other.run_id} tested tree ${other.tree.slice(0, 12)}, not ${o.tree.slice(0, 12)}`);
const empty = runs.find(e => !e.files.length || e.tests === 0);
const empty = jobs.length ? undefined : runs.find(e => !e.files.length || e.tests === 0);
if (empty) return no('no_tests', `run ${empty.run_id}'s tree.json ${empty.files.length ? 'records 0 tests' : 'lists no test files'}`);
if (o.inventory) {
const listed = o.inventory();
if ('error' in listed) return no('inventory_failed', listed.error);
if (!listed.length) return no('inventory_empty', 'the inventory command printed no files');
const missing = uncoveredFiles(listed, runs);
if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`);
const absent = uncoveredFiles(listed, runs, 'covered');
const some = (fs: string[]) => `${fs.slice(0, 5).join(', ')}${fs.length > 5 ? ', …' : ''}`;
if (absent.length) return no('inventory_uncovered', `${absent.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${some(absent)}`);
remainder = uncoveredFiles(listed, runs, 'uncovered');
if (remainder.length && !o.perFile) return no('inventory_skipped', `${remainder.length} of ${listed.length} inventory file(s) skipped every test in CI and the lane declares no per_file runner: ${some(remainder)}`);
covered = new Set(listed.map(normalPath)).size - remainder.length;
}
for (const e of runs) {
const changed = runChanged(src.deps, e.run_id, e.run_attempt);
@@ -87,6 +104,13 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
}
const after = o.wtree();
if (after !== o.tree) return no('tree_changed', `the working tree changed while the runs were verified (now ${after ? after.slice(0, 12) : 'unreadable'})`);
if (jobs.length) {
const gates = [...new Set(o.profiles.map(n => src.profiles[n]?.skippable?.gate).filter(Boolean))];
const allSkipped = runs.every(e => jobs.every(j => e.skipped_jobs?.includes(j)));
const notOwed = allSkipped ? `CI skipped at this tree (gate job ${gates.join(', ')}, workflow unchanged)` : undefined;
return { ok: true, runs, tests: 'n/a', ...(notOwed ? { notOwed } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} jobs=${jobs.join(',')}${notOwed ? ` not_owed=${gates.join(',')}` : ''} ${runReceiptFields(runs, src.now)}` };
}
if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', ...(remainder.length ? { remainder } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered}${remainder.length ? ` skipped_here=${remainder.length}` : ''} ${runReceiptFields(runs, src.now)}` };
const tests = runs.reduce((n, e) => n + (e.tests ?? e.files.length), 0);
return { ok: true, runs, tests, receipt: `ci-carry tree=${o.tree.slice(0, 12)} tests=${tests} ${runReceiptFields(runs, src.now)}` };
}
@@ -109,24 +133,75 @@ export async function waitForRun(deps: RunDeps, o: { workflow: string; events: s
}
}
/** `ship_lanes_from` (base branch only): exact lane command → the profiles whose runs carry it and the command listing its test files. */
export interface ShipLaneMapping { profiles: string[]; inventory: string }
/**
* `ship_lanes_from` (base branch only): exact lane command → the profiles whose runs carry it and either the command listing
* its test files (`inventory`) or the CI jobs that run it (`jobs`, for a lane with no test files such as a typecheck; GST-1189).
*/
export interface ShipLaneMapping { profiles: string[]; inventory?: string; jobs?: string[]; aliases?: string[]; per_file?: string }
/**
* The `ship_lanes_from` entry for an exact lane command: the entry it keys, else the entry that declares it in `aliases`
* (GST-1190: the same lane run under another exact spelling, such as an env prefix). Never normalized: an alias is a
* second exact command the base's owner declared, not a pattern.
*/
export function laneMapping(from: Record<string, ShipLaneMapping>, command: string): ShipLaneMapping | undefined {
if (Object.prototype.hasOwnProperty.call(from, command)) return from[command];
return Object.values(from).find(m => m.aliases?.includes(command));
}
/** findCarry's coverage rule for one mapping: its inventory (a pin-file command run in `cwd`, with a per_file runner or not) or its named jobs. */
export const mappingCoverage = (m: ShipLaneMapping, cwd: string): { inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean } =>
m.jobs ? { jobs: m.jobs } : { inventory: () => runInventory(m.inventory!, cwd), perFile: !!m.per_file };
/**
* What `gstack-evidence record-ci` reported, read from its own last record line onward (the per_file remainder's output comes
* first, so a test printing look-alike lines cannot change the reading): the carried runs, count, unit and remainder, any
* not-owed reason, or the last CARRY_NONE reason.
*/
export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; none?: string } {
const lines = stderr.split('\n');
const at = lines.findLastIndex(x => x.startsWith('gstack-evidence: recorded label=') && x.includes(' log=- source=ci runs='));
const none = lines.findLast(x => x.startsWith('CARRY_NONE: '))?.slice('CARRY_NONE: '.length);
if (at < 0) return none ? { none } : {};
const got = / tests_ran=(\d+|n\/a)( unit=files)? log=- source=ci runs=(\S+)/.exec(lines[at]!);
const here = / skipped_here=(\d+) tests_ran_here=(\S+)$/.exec(lines[at]!);
const notOwed = lines.slice(at + 1).find(x => x.startsWith('gstack-evidence: not owed: '))?.slice('gstack-evidence: not owed: '.length);
return { runs: got?.[3], tests: got?.[1], files: !!got?.[2], ...(notOwed ? { notOwed } : {}), ...(here ? { here: `skipped_here=${here[1]} file(s) tests_ran_here=${here[2]}` } : {}) };
}
/** The per_file runner command for `files`: `{files}` replaced by the paths, each single-quoted for bash. */
export const perFileCommand = (template: string, files: string[]): string =>
template.replaceAll('{files}', files.map(f => `'${f.replace(/'/g, `'\\''`)}'`).join(' '));
/** `<path>: <message>` errors for `ship_lanes_from`; empty when valid. Shared with lib/pregate/config.ts. */
export function shipLanesErrors(raw: unknown, profileNames: readonly string[]): Array<{ path: string; message: string }> {
if (raw === undefined) return [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'ship_lanes_from', message: 'must be an object of exact lane command → { profiles: [run_profiles names], inventory: command }' }];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'ship_lanes_from', message: 'must be an object of exact lane command → { profiles: [run_profiles names], inventory: command } or { profiles, jobs: [job names] }' }];
const errors: Array<{ path: string; message: string }> = [];
const oneLine = (s: unknown) => typeof s === 'string' && !!s.trim() && s.length <= 512 && !/[\0\r\n]/.test(s);
const declared = new Map<string, string>(Object.keys(raw as object).map(k => [k, 'a key']));
for (const [command, m] of Object.entries(raw as Record<string, unknown>)) {
const at = `ship_lanes_from[${JSON.stringify(command)}]`;
if (!oneLine(command)) errors.push({ path: at, message: 'the key must be the exact lane command: one line under 512 characters' });
if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push({ path: at, message: 'must be { profiles, inventory }' }); continue; }
if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push({ path: at, message: 'must be { profiles, inventory } or { profiles, jobs }' }); continue; }
const o = m as Record<string, unknown>;
for (const k of Object.keys(o)) if (k !== 'profiles' && k !== 'inventory') errors.push({ path: `${at}.${k}`, message: 'unknown key; valid keys: profiles, inventory' });
for (const k of Object.keys(o)) if (!['profiles', 'inventory', 'jobs', 'aliases', 'per_file'].includes(k)) errors.push({ path: `${at}.${k}`, message: 'unknown key; valid keys: profiles, inventory, jobs, aliases, per_file' });
if ('per_file' in o && (!oneLine(o.per_file) || !(o.per_file as string).includes('{files}') || !('inventory' in o))) errors.push({ path: `${at}.per_file`, message: 'must be one command line under 512 characters containing {files}, on an entry with an inventory (it runs the inventory files CI skipped entirely)' });
if ('aliases' in o) {
const aliases = o.aliases;
if (!Array.isArray(aliases) || !aliases.length || aliases.some(a => !oneLine(a)) || new Set(aliases).size !== aliases.length) errors.push({ path: `${at}.aliases`, message: 'must be a non-empty array of distinct exact lane commands, each one line under 512 characters' });
else for (const a of aliases as string[]) {
if (declared.has(a)) errors.push({ path: `${at}.aliases`, message: `${JSON.stringify(a)} is already ${declared.get(a)}; each exact command maps to one entry` });
else declared.set(a, `an alias of ${JSON.stringify(command)}`);
}
}
if (!Array.isArray(o.profiles) || !o.profiles.length || o.profiles.some(p => typeof p !== 'string')) errors.push({ path: `${at}.profiles`, message: 'must be a non-empty array of run_profiles names' });
else for (const p of o.profiles as string[]) if (!profileNames.includes(p)) errors.push({ path: `${at}.profiles`, message: `${JSON.stringify(p)} is not a run_profiles name` });
if (!oneLine(o.inventory)) errors.push({ path: `${at}.inventory`, message: 'must be one command line under 512 characters that prints the lane\'s test files, one per line' });
if (('inventory' in o) === ('jobs' in o)) errors.push({ path: at, message: 'declare exactly one of inventory (a command listing the lane\'s test files) or jobs (the CI jobs that run it)' });
else if ('jobs' in o) {
const jobs = o.jobs;
if (!Array.isArray(jobs) || !jobs.length || jobs.some(j => typeof j !== 'string' || !oneLine(j) || j.length > 128) || new Set(jobs).size !== jobs.length) errors.push({ path: `${at}.jobs`, message: 'must be a non-empty array of distinct CI job names, each one line under 128 characters' });
} else if (!oneLine(o.inventory)) errors.push({ path: `${at}.inventory`, message: 'must be one command line under 512 characters that prints the lane\'s test files, one per line' });
}
return errors;
}
+21
View File
@@ -137,6 +137,27 @@ export function resolveBase(ref: string, cwd: string, head?: string): { ref: str
return sha ? { ref, sha } : null;
}
export type ResolvedBase = NonNullable<ReturnType<typeof resolveBase>>;
/** Why resolveBase read another ref than the name given (`local main is behind origin/main; base is origin/main`); null when the name stood as given. */
export function baseNote(name: string, base: ResolvedBase): string | null {
if (base.local === 'fallback') return `${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${name}`}; base is the local ${name}, which may lag its remote`;
if (!base.local) return null;
return `${{ unrelated: `local ${name} shares no history with HEAD`, missing: `no local branch ${name}`, behind: `local ${name} is behind ${base.ref}`, ahead: `local ${name} is ahead of ${base.ref}`, diverged: `local ${name} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`;
}
/**
* A --base argument as the commit a review or ship bin reads (GST-1036): resolveBase against HEAD, so a bare `main`
* reads origin/main over a stale local main, with the note on stderr as `<bin>: <note>` when it read another ref than
* the name. An unresolvable name comes back as given, so the caller's own git error still names it.
*/
export function baseArg(bin: string, name: string, cwd: string): string {
const base = resolveBase(name, cwd, 'HEAD');
const note = base && baseNote(name, base);
if (note) process.stderr.write(`${bin}: ${note}\n`);
return base?.sha ?? name;
}
/** A word for a printed shell line: as is when it is plain, single-quoted otherwise (ref names may hold `;`, `$`, `(`, `{1,2}`). */
function shellWord(s: string): string {
return /^[A-Za-z0-9._\/@^~+:-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
+11 -9
View File
@@ -22,7 +22,7 @@ import { spawnSync } from 'node:child_process';
import { bunIsolationArgs } from './bun-isolation';
import { runBashSync } from './script-invocation';
import { frontmatterList, routerLine } from './skill-triggers';
import { activateHook, assertHookRoot, hookChoice, hookFiles, hookProfile, hookRoot, inspectHook, isHookTarget, profileSkills, type HookResult } from './harness-start-hook';
import { activateHook, assertHookRoot, foreignOpenclawConfig, hookChoice, hookConfig, hookFiles, hookProfile, hookRoot, inspectHook, isHookTarget, profileSkills, type HookResult } from './harness-start-hook';
export type SkillsMode = 'router' | 'hybrid' | 'all';
export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok';
@@ -135,7 +135,7 @@ export const HARNESS_CODES = {
HARNESS_RENDER_FAILED: { anchor: 'harness-render-failed', fix: 'run bun install in the gstack checkout, then register again; the render output above names the failing skill' },
HARNESS_BUDGET_EXCEEDED: { anchor: 'harness-budget-exceeded', fix: 'this is a gstack bug: report it with the byte count printed above' },
HARNESS_NOT_REGISTERED: { anchor: 'harness-not-registered', fix: 'run gstack-harness status to see the registrations on this machine' },
HARNESS_HOOK_UNSUPPORTED: { anchor: 'harness-hook-unsupported', fix: 'the start hook is for OpenClaw and Hermes registered in their own profile: drop --skills-dir (set OPENCLAW_STATE_DIR or HERMES_HOME to pick the profile), or drop --start-hook' },
HARNESS_HOOK_UNSUPPORTED: { anchor: 'harness-hook-unsupported', fix: 'the start hook is for OpenClaw and Hermes registered in their own profile: drop --skills-dir (set OPENCLAW_STATE_DIR or HERMES_HOME to pick the profile), unset OPENCLAW_CONFIG_PATH or point it at the profile\'s own openclaw.json, or drop --start-hook' },
HARNESS_HOOK_CHOICE_INVALID: { anchor: 'harness-hook-choice-invalid', fix: 'set it to on or off (GSTACK_HARNESS_START_HOOK also takes 1 or 0), or unset it' },
HARNESS_HOOK_OWNED_ELSEWHERE: { anchor: 'harness-hook-owned-elsewhere', fix: 'if another gstack install wrote it, run that install\'s gstack-harness register <harness> --no-start-hook; otherwise move the folder aside, then register again' },
HARNESS_HOOK_INACTIVE: { anchor: 'harness-hook-inactive', fix: 'run the command printed above, then gstack-harness status; the skills themselves are registered' },
@@ -525,7 +525,7 @@ function shellRegistry(opts: RegisterOptions, fn: string, args: string[]): boole
return sourcedBash(opts.checkout, 'gstack-install-registry.sh', fn, args, opts.stateRoot, opts.env).status === 0;
}
interface HookPlan { on: boolean; prevOn: boolean; explicit: boolean; retry: boolean; profile: string; root: string; files: Map<string, Buffer>; note?: string }
interface HookPlan { on: boolean; prevOn: boolean; explicit: boolean; retry: boolean; profile: string; root: string; files: Map<string, Buffer>; note?: string; fix?: string; requested?: boolean }
/** The start hook for this run: whether it is on, where it goes, and its files (null for targets without a hook). */
function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): HookPlan | null {
@@ -544,17 +544,19 @@ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): Hoo
const name = t.name;
const off = (p: HookPlan) => { if (saved) assertHookRoot(name, saved.profile, saved.root, saved.root, (code, msg) => { throw new HarnessError(code, msg); }); return p; };
if (!choice.on) return off(plan);
const why = dest !== profileSkills(t.name, profile) ? 'this registration uses another skills folder (' + dest + ')'
: t.name === 'openclaw' && opts.env.OPENCLAW_CONFIG_PATH ? 'OPENCLAW_CONFIG_PATH points OpenClaw at a config outside that profile' : '';
const requested = !opts.inherit;
const foreign = t.name === 'openclaw' ? foreignOpenclawConfig(opts.env, profile) : '';
const [why, fix] = dest !== profileSkills(t.name, profile) ? ['this registration uses another skills folder (' + dest + ')', 'register without --skills-dir to get the hook, or pass --no-start-hook to keep this registration without it']
: foreign ? ['OPENCLAW_CONFIG_PATH points OpenClaw at ' + foreign + ', outside that profile', 'unset OPENCLAW_CONFIG_PATH or set it to ' + hookConfig('openclaw', profile) + ', then register again'] : ['', ''];
if (why && (plan.explicit || !prevOn)) {
if (plan.explicit) throw new HarnessError('HARNESS_HOOK_UNSUPPORTED', 'the start hook goes into the ' + t.displayName + ' profile ' + profile + ', and ' + why);
return off({ ...plan, on: false, note: 'off: ' + why + ' (' + choice.source + ' asked for the hook)' });
return off({ ...plan, on: false, requested, fix, note: 'off: ' + why + ' (' + choice.source + ' asked for the hook)' });
}
try {
assertHookRoot(t.name, profile, root, prevOn ? prev!.meta.hook_root : undefined, (code, msg) => { throw new HarnessError(code, msg); });
} catch (e) {
if (plan.explicit || prevOn || !(e instanceof HarnessError)) throw e;
return off({ ...plan, on: false, note: 'off: ' + e.message + ' (' + choice.source + ' asked for the hook; ' + HARNESS_CODES[e.code].fix + ')' });
return off({ ...plan, on: false, requested, fix: HARNESS_CODES[e.code].fix, note: 'off: ' + e.message + ' (' + choice.source + ' asked for the hook)' });
}
return { ...plan, files: hookFiles(t.name, root) };
}
@@ -562,7 +564,7 @@ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): Hoo
/** Turns the hook on after its files are written (when this run turns it on, was asked to, or the last try did not end active), and reads it back. */
function finishHook(opts: RegisterOptions, hook: HookPlan | null, released: string): RegisterResult['hook'] {
if (!hook) return undefined;
if (!hook.on) return { state: 'off', ...(hook.note ? { detail: hook.note } : {}), ...(released ? { fix: released } : {}) };
if (!hook.on) return { state: 'off', ...(hook.note ? { detail: hook.note } : {}), ...(released || hook.fix ? { fix: released || hook.fix } : {}), ...(hook.requested ? { required: true } : {}) };
const t = opts.target.name as 'openclaw' | 'hermes';
const required = !hook.prevOn || hook.explicit;
if (!required && !hook.retry) return { state: 'unchecked', profile: hook.profile, root: hook.root, fix: 'gstack-harness status shows whether it is active' };
@@ -678,7 +680,7 @@ export function register(opts: RegisterOptions): RegisterResult {
if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']);
if (same && hook?.on && hook.explicit && prev.meta.hook_active !== 'no') writeLedger(lfile, { ...prev, meta: { ...prev.meta, hook_active: 'no' } });
const hookResult = finishHook(opts, hook, released);
const active = hookResult?.required || hookResult?.retried ? ({ active: 'yes', disabled: 'disabled' } as Record<string, string>)[hookResult.state] ?? 'no' : '';
const active = hook?.on && (hookResult?.required || hookResult?.retried) ? ({ active: 'yes', disabled: 'disabled' } as Record<string, string>)[hookResult!.state] ?? 'no' : '';
const saved = active ? readLedger(lfile) : null;
if (saved && saved.meta.hook_active !== active) writeLedger(lfile, { ...saved, meta: { ...saved.meta, hook_active: active } });
return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept, ...(hookResult ? { hook: hookResult } : {}) };
+54 -5
View File
@@ -22,14 +22,14 @@ export const START_NOTE_MAX = 300;
export const START_NOTE = 'gstack: if you use a gstack skill this turn, first run the gstack-skill-start command in its Preamble section exactly as written, with no pipe, head, || true or ; echo. Read its output, then follow the skill.';
export type HookTarget = 'openclaw' | 'hermes';
export type HookState = 'active' | 'written' | 'disabled' | 'unchecked' | 'off';
export type HookState = 'active' | 'written' | 'disabled' | 'unknown' | 'unchecked' | 'off';
export interface HookResult { state: HookState; fix?: string; detail?: string; profile?: string; root?: string }
type Env = Record<string, string | undefined>;
const MANAGED = "Managed by gstack-harness: it rewrites this file. Turn the hook off: gstack-harness register %s --no-start-hook";
const SPECS = {
openclaw: {
profileVar: 'OPENCLAW_STATE_DIR', home: '.openclaw', sub: 'extensions', skills: ['skills'],
profileVar: 'OPENCLAW_STATE_DIR', home: '.openclaw', sub: 'extensions', skills: ['skills'], name: 'OpenClaw', config: 'openclaw.json', setup: 'openclaw onboard',
activate: [['config', 'set', 'plugins.entries.' + HOOK_ID + '.hooks.allowConversationAccess', 'true'], ['config', 'set', 'plugins.entries.' + HOOK_ID + '.enabled', 'true']],
files: (): Record<string, string> => ({
'index.js': [
@@ -55,7 +55,7 @@ const SPECS = {
}),
},
hermes: {
profileVar: 'HERMES_HOME', home: '.hermes', sub: 'plugins', skills: ['skills', 'gstack'],
profileVar: 'HERMES_HOME', home: '.hermes', sub: 'plugins', skills: ['skills', 'gstack'], name: 'Hermes', config: 'config.yaml', setup: 'hermes setup',
activate: [['plugins', 'enable', HOOK_ID]],
files: (): Record<string, string> => ({
'plugin.yaml': ['# ' + MANAGED.replace('%s', 'hermes'), 'name: ' + HOOK_ID, 'version: 1.0.0', "description: Puts gstack's start-command note on each user turn (managed by gstack-harness).", ''].join('\n'),
@@ -91,6 +91,51 @@ export const hookRoot = (t: HookTarget, profile: string) => path.join(profile, S
/** The skills folder a registration must use for the hook to go into that profile. */
export const profileSkills = (t: HookTarget, profile: string) => path.join(profile, ...SPECS[t].skills);
const activateCommands = (t: HookTarget, profile: string) => SPECS[t].activate.map(a => SPECS[t].profileVar + '=' + JSON.stringify(profile) + ' ' + t + ' ' + a.join(' ')).join(' && ');
/** The harness's own config file in a profile: the file activation edits through the harness CLI. */
export const hookConfig = (t: HookTarget, profile: string) => path.join(profile, SPECS[t].config);
function canonical(p: string): string {
try { return fs.realpathSync(p); } catch {}
const parent = path.dirname(p);
return parent === p ? p : path.join(canonical(parent), path.basename(p));
}
/** The config OPENCLAW_CONFIG_PATH names when it is not the profile's own openclaw.json (compared after realpath), else ''. */
export function foreignOpenclawConfig(env: Env, profile: string): string {
const v = env.OPENCLAW_CONFIG_PATH;
if (!v) return '';
const named = path.resolve(v);
return canonical(named) === canonical(hookConfig('openclaw', profile)) ? '' : named;
}
/** A profile with no harness config yet: turning the plugin on through the harness CLI would create a config holding only that setting, so gstack waits for the harness's own setup. */
function noConfig(t: HookTarget, profile: string): HookResult | null {
const cfg = hookConfig(t, profile);
if (fs.existsSync(cfg)) return null;
const s = SPECS[t];
return { state: 'written', detail: 'no ' + s.name + ' config at ' + cfg + ' yet, and turning the plugin on would create one holding only that setting', fix: 'run ' + s.name + "'s own setup first (" + s.profileVar + '=' + JSON.stringify(profile) + ' ' + s.setup + '), then gstack-harness refresh' };
}
/** Reads the hook from the harness's config file when its CLI is not on PATH (a harness's own terminal tool often lacks it). */
function inspectConfig(t: HookTarget, profile: string, fix: string): HookResult {
const why = t + ' is not on PATH';
const written: HookResult = { state: 'written', detail: why + ', so gstack could not turn the plugin on or check it', fix };
const cfg = hookConfig(t, profile);
const missing = noConfig(t, profile);
if (missing) return { ...missing, detail: why + ' and ' + missing.detail };
let plugins: any;
try { plugins = ((t === 'hermes' ? Bun.YAML.parse(fs.readFileSync(cfg, 'utf8')) : JSON.parse(fs.readFileSync(cfg, 'utf8'))) as any)?.plugins ?? {}; }
catch { return { state: 'unknown', detail: why + ' and ' + cfg + ' could not be parsed, so gstack could not check the plugin', fix: 'gstack-harness status from a shell where ' + t + ' is on PATH' }; }
if (t === 'hermes') {
const listed = (k: string) => Array.isArray(plugins[k]) && plugins[k].includes(HOOK_ID);
if (listed('disabled')) return { state: 'disabled', detail: 'turned off in Hermes (plugins.disabled in ' + cfg + '); gstack leaves it off', fix };
if (listed('enabled') && fs.existsSync(path.join(hookRoot(t, profile), 'plugin.yaml'))) return { state: 'active', detail: 'read from plugins.enabled in ' + cfg + ' (' + why + ')' };
return written;
}
const entry = plugins.entries?.[HOOK_ID] ?? {};
if (entry.enabled === false) return { state: 'disabled', detail: 'turned off in openclaw.json (plugins.entries.' + HOOK_ID + '.enabled false); gstack leaves it off', fix };
if (entry.enabled === true && entry.hooks?.allowConversationAccess === true) return { state: 'unknown', detail: cfg + ' turns it on, but ' + why + ', so gstack could not check that OpenClaw loads it', fix: 'gstack-harness status from a shell where openclaw is on PATH' };
return written;
}
/** The plugin's files (absolute path -> bytes): fixed code plus note.txt, which the code reads at each turn. */
export function hookFiles(t: HookTarget, root: string): Map<string, Buffer> {
@@ -124,6 +169,8 @@ const onPath = (bin: string, env: Env) => (env.PATH ?? '').split(path.delimiter)
/** Turns the plugin on in the harness. Returns the first failure, or ''. */
export function activateHook(t: HookTarget, profile: string, env: Env, home: string): string {
if (!onPath(t, env)) return t + ' is not on PATH';
const missing = noConfig(t, profile);
if (missing) return missing.detail!;
for (const a of SPECS[t].activate) {
const r = runHarness(t, profile, a, env, home, 60_000);
if (!r.ok) return t + ' ' + a.join(' ') + ' failed (' + r.why + ')';
@@ -136,12 +183,14 @@ function parseJson(s: string): any {
return null;
}
/** Reads the hook back from the harness: active (configured for new sessions), disabled by the user, or written (with the fix). */
/** Reads the hook back from the harness: active (configured for new sessions), disabled by the user, written (with the fix), or unknown (no CLI and a config that cannot settle it). Runs no harness command while the profile has no harness config. */
const READ_TIMEOUT = 15_000;
export function inspectHook(t: HookTarget, profile: string, env: Env, home: string): HookResult {
const fix = activateCommands(t, profile);
if (!onPath(t, env)) return { state: 'written', detail: t + ' is not on PATH, so gstack could not turn the plugin on or check it', fix };
if (!onPath(t, env)) return inspectConfig(t, profile, fix);
const missing = noConfig(t, profile);
if (missing) return missing;
if (t === 'hermes') {
const r = runHarness(t, profile, ['plugins', 'list', '--json'], env, home, READ_TIMEOUT);
const row = (parseJson(r.stdout) ?? []).find?.((p: any) => p?.name === HOOK_ID);
+8
View File
@@ -83,6 +83,8 @@ export interface PregateCheck {
/** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */
export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> }
export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string; command?: string; substitution?: PregateReceiptSubstitution }
/** An owner-granted flaky exception (lib/pregate/flaky-exception.ts): the runs, cases and grant that cleared one obligation, bound to one head and tree. */
export interface PregateException { lane: string; repo: string; pr: number; head: string; tree: string; runs: string[]; cases: string[]; grant: string; by: string; words: string; binding: string }
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.
@@ -93,6 +95,8 @@ export interface PregateFile {
policy_user_path?: string;
/** This run's scratch directory under the state root (logs, scan files). */
scratch?: string;
/** Obligations cleared from runs with failures under an owner's grant (`clear --flaky-exception`). */
exceptions?: PregateException[];
}
export interface ShipReceipt {
schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string;
@@ -123,6 +127,8 @@ export interface ShipReceipt {
review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: string };
/** Step 5 failures gstack-base-red proved are the base's own (lib/base-red.ts): a conditional receipt the queue refuses until /ship --resume rewrites it without this block. */
base_red?: { cases: string[]; files: string[]; base: string; lane: string; expires_at: string };
/** The pregate's owner-granted flaky exceptions (gstack-pregate clear --flaky-exception): pregate only, never a required CI check. */
flaky_exception?: Array<{ lane: string; runs: string[]; cases: string[]; grant: string; by: string }>;
/** The base policy's review_exclude_paths at the reviewed head (lib/review-exclude.ts); absent when the base declares none. Carry and the queue compare `digest`. */
review_exclude?: { source: string; patterns: string[]; applied: string[]; excluded: number; excluded_bytes: number; kept: number; gate_tagged: string[]; patterns_sha256: string; list_sha256: string; digest: string };
}
@@ -234,6 +240,7 @@ export const ARTIFACT_SCHEMAS = {
head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: 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,
substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } },
exceptions: { type: 'array', items: { type: 'object', required: ['lane', 'repo', 'pr', 'head', 'tree', 'runs', 'cases', 'grant', 'by', 'words', 'binding'], properties: { lane: str, repo: str, pr: { type: 'integer' }, head: str, tree: str, runs: strList, cases: strList, grant: str, by: str, words: str, binding: str } } },
},
},
'ship-receipt': {
@@ -250,6 +257,7 @@ export const ARTIFACT_SCHEMAS = {
carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } },
head_tree: str, product_patch_id: str, gstack_version: str,
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } },
flaky_exception: { type: 'array', items: { type: 'object', required: ['lane', 'runs', 'cases', 'grant', 'by'], properties: { lane: str, runs: strList, cases: strList, grant: str, by: str } } },
review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data'] } } } },
review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } },
review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } },
+4 -1
View File
@@ -17,6 +17,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveBase } from './gate-diff/git';
import { findSection } from './markdown-sections';
import { scan } from './redact-engine';
import type { ResultCodeName } from './result-codes';
@@ -55,9 +56,11 @@ export function readCapture(file: string, roots: string[], what: string): string
return cleanText(fs.readFileSync(real), `${what} file`);
}
/** Resolve `ref` to a commit sha without letting it be read as an option. */
/** Resolve `ref` to a commit sha without letting it be read as an option; a bare branch name reads origin's tracking branch first (resolveBase, GST-1036). */
export function resolveCommit(cwd: string, ref: string): string | ShotError {
if (!ref || ref.startsWith('-') || /\s/.test(ref)) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `ref ${JSON.stringify(ref)} is not a plain ref` };
const base = resolveBase(ref, cwd, 'HEAD');
if (base) return base.sha;
const r = git(cwd, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]);
return r.status === 0 ? r.stdout.trim() : { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: `base ref ${ref}` };
}
+38 -3
View File
@@ -11,9 +11,17 @@
* incident: a gbrain serial test landed without its manifest row and
* passed both the isolation check and pregate; `test/cli-goldens.test.ts`
* spawns the CLI as a subprocess, so no import graph selects it.
*
* GST-1186: a guard whose exact command already passed at this tree is not run
* again. #3901's carry (gstack-evidence record-ci) carries it from a verified
* CI run when the base's ship_lanes_from maps the command, else a counted
* ledger pass of the same command at the same working tree stands
* (gstack-evidence passed). ship_lanes_from is read from origin/<base> only, so
* the PR that adds a mapping cannot carry from it yet.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { laneMapping, recordCiResult } from '../../ci-carry';
import { matchesAny } from '../../ship-policy';
import type { Guard } from '../config';
import { baseInputs, sha256 } from '../context';
@@ -23,8 +31,29 @@ import { checkScratch } from '../run-scratch';
import { readRange, STALL_IDLE, supervise } from '../supervise';
import type { EnvironmentCause } from '../types';
import type { CheckDef, CheckResult, PregateContext } from '../types';
import { runScriptSync } from '../../script-invocation';
interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null }
interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null; prior?: string; carryNone?: string }
const EVIDENCE = path.resolve(import.meta.dir, '..', '..', '..', 'bin', 'gstack-evidence');
/**
* The pass that already covers this guard's exact command at this tree (GST-1186): a CI carry (or a CI skip the unchanged gate
* decided, printed as not owed), else a same-tree local ledger pass under the runtime the guard would launch with.
*/
function priorPass(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv): { prior?: string; carryNone?: string } {
const evidence = (args: string[]) => runScriptSync(EVIDENCE, args, { cwd: ctx.repoRoot, env, encoding: 'utf8', timeout: 600_000 });
let carryNone: string | undefined;
if (laneMapping(ctx.config.ship_lanes_from, guard.command)) {
const ci = evidence(['record-ci', '--label', `guard-${guard.id}`, '--base', ctx.base, '--', guard.command]);
const got = recordCiResult(String(ci.stderr ?? ''));
if (ci.status === 0 && got.runs) return { prior: got.notOwed ? `not owed: ${got.notOwed} runs=${got.runs}` : `carried runs=${got.runs}` };
carryNone = got.none ?? `record-ci exit ${ci.status}`;
}
const local = evidence(['passed', '--', guard.command]);
const hit = /^PASSED: (.+)$/m.exec(String(local.stdout ?? ''))?.[1];
return local.status === 0 && hit ? { prior: `reused ${hit}`, carryNone } : { carryNone };
}
async function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv, dir: string): Promise<GuardRun> {
const timeoutMs = guard.timeout_s ? guard.timeout_s * 1000 : ctx.timeoutMs;
@@ -53,9 +82,14 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
if (runtime.refusal) return { ...base, ...runtime.refusal, cause: 'environment', environment: launchCause('runtime', runtime.refusal.detail), lines: [`runtime: ${runtime.label}`, environmentLine(launchCause('runtime', runtime.refusal.detail))] };
const dir = checkScratch(ctx.scratch, 'guards');
const runs: GuardRun[] = [];
for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir));
for (const g of selected) {
const p = priorPass(ctx, g, runtime.env);
runs.push(p.prior ? { guard: g, exit: 0, timedOut: false, stalled: false, tail: [], log: '-', environment: null, ...p } : { ...(await runGuard(ctx, g, runtime.env, dir)), carryNone: p.carryNone });
}
const lines = [`runtime: ${runtime.label}`];
for (const r of runs) {
if (r.carryNone) lines.push(`CI_CARRY: none guard=${r.guard.id} (${r.carryNone})`);
if (r.prior) { lines.push(`GUARD: ${r.guard.id} ${r.prior}`); continue; }
lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`);
if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`));
if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`);
@@ -68,7 +102,8 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
if (failed.length) return { ...withRuns, status: 'fail', cause: 'diff', code: 'PREGATE_GUARD_FAILED', detail: `${failed.length} guard(s) failed: ${failed.map(r => r.guard.id).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}${blocked.length ? `; blocked by the environment: ${blocked.map(r => r.guard.id).join(', ')}` : ''}`, fix: fixOf(failed) };
if (blocked.length) return environmentResult(withRuns, blocked[0]!.environment!, `${blocked.length} guard(s) could not run on this machine: ${blocked.map(r => `${r.guard.id} (${r.environment!.kind})`).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}`);
if (timedOut.length) return { ...withRuns, status: 'incomplete', code: timedOut.some(r => r.stalled) ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${timedOut.length} guard(s) did not finish: ${timedOut.map(r => `${r.guard.id} (${r.stalled ? 'stalled' : 'timed out'})`).join(', ')}`, fix: 'fix the hang, or raise the guard\'s timeout_s, --timeout or --stall-timeout; an unfinished guard never passes' };
return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}` };
const prior = runs.filter(r => r.prior).length;
return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}${prior ? ` (${prior} from an earlier pass at this tree)` : ''}` };
}
export const guardsCheck: CheckDef = { id: 'guards', stage: 'preflight', tier: 2, downgradable: false, needsRepoCommands: true, run };
+4 -3
View File
@@ -40,7 +40,7 @@ import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
import { cargoModel, type CargoModel } from '../rust';
import { adapterFor, readRange, STALL_IDLE, supervise, type Counter, type Supervised } from '../supervise';
import { verifyFromRun, type RunProfile } from '../../run-evidence';
import { ranFiles, verifyFromRun, type RunProfile } from '../../run-evidence';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
@@ -643,9 +643,10 @@ function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: s
const names = 'error' in check ? [] : fromRunProfiles(ctx, check.profiles);
const v = 'error' in check ? { ok: false as const, reason: 'profiles_unavailable', detail: check.error } : verifyFromRun(check, ref, names, ctx.wtree, ctx.head);
if (!v.ok) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every selected test runs here`, passed: [] };
const ran = new Set(v.evidence.files);
const ran = new Set(ranFiles(v.evidence));
const passed = sel.localTests.filter(t => ran.has(t));
return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run`, passed, receipt: v.receipt };
const skipped = sel.localTests.filter(t => v.evidence.skipped?.includes(t)).length;
return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run${skipped ? ` (${skipped} skipped every test in the run)` : ''}`, passed, receipt: v.receipt };
}
/**
+12 -5
View File
@@ -57,10 +57,10 @@ export function constantsIn(text: string): Map<string, string> {
return out;
}
/** Named numeric call sites (`timeout: 5000`) keyed by name; the last value per name wins, which is enough to spot a change. */
export function callLiteralsIn(text: string): Map<string, string> {
const out = new Map<string, string>();
for (const m of text.matchAll(CALL_PATTERN)) out.set(m[1]!, m[2]!);
/** Named numeric call sites (`timeout: 5000`) keyed by name, every value in source order. */
export function callLiteralsIn(text: string): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const m of text.matchAll(CALL_PATTERN)) out.set(m[1]!, [...(out.get(m[1]!) ?? []), m[2]!]);
return out;
}
@@ -69,7 +69,14 @@ export function changedLiterals(file: string, oldText: string, newText: string):
const oldC = constantsIn(oldText); const newC = constantsIn(newText);
for (const [name, old] of oldC) { const now = newC.get(name); if (now !== undefined && now !== old) out.push({ name, old, new: now, file }); }
const oldCall = callLiteralsIn(oldText); const newCall = callLiteralsIn(newText);
for (const [name, old] of oldCall) { const now = newCall.get(name); if (now !== undefined && now !== old && !oldC.has(name)) out.push({ name, old, new: now, file }); }
for (const [name, olds] of oldCall) {
if (oldC.has(name)) continue;
const added = [...(newCall.get(name) ?? [])];
const removed = olds.filter(v => { const i = added.indexOf(v); if (i >= 0) added.splice(i, 1); return i < 0; });
if (!added.length) continue;
const seen = new Set<string>();
removed.forEach((old, i) => { if (!seen.has(old)) { seen.add(old); out.push({ name, old, new: added[Math.min(i, added.length - 1)]!, file }); } });
}
return out;
}
+1 -1
View File
@@ -12,7 +12,7 @@ import type { CheckDef, CheckResult, PregateContext } from '../types';
/** Conventional root files a repo adds without a declaration. */
export const ROOT_OK = /^(?:README|LICENSE|LICENCE|NOTICE|CHANGELOG|CONTRIBUTING|CODE_OF_CONDUCT|SECURITY|AUTHORS|VERSION|Makefile|Dockerfile|Procfile|Gemfile|Rakefile|Cargo\.(?:toml|lock)|go\.(?:mod|sum)|package(?:-lock)?\.json|bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|tsconfig[\w.-]*\.json|bunfig\.toml|pyproject\.toml|pubspec\.(?:yaml|lock)|analysis_options\.yaml|dart_test\.yaml|requirements[\w.-]*\.txt|setup\.(?:py|cfg)|\.[\w.-]+|[\w.-]+\.config\.[cm]?[jt]s|[\w.-]+\.md)$/i;
export const STRAY_NAME = /^(?:zz-|tmp|temp|scratch|debug-|probe)|\.(?:orig|rej|swp|swo|bak|tmp)$|~$|^\.DS_Store$|^Thumbs\.db$/i;
export const STRAY_NAME = /^(?:zz-|tmp|scratch)|\.(?:orig|rej|swp|swo|bak|tmp)$|~$|^\.DS_Store$|^Thumbs\.db$/i;
function run(ctx: PregateContext): CheckResult {
const inputs = baseInputs(ctx);
+30 -7
View File
@@ -46,9 +46,13 @@
* tree, the command listing the gate's test files they must
* cover, and the command naming the selected files the gate
* itself never runs (gstack: paid files)
* ship_lanes_from exact /ship lane command → `{ profiles, inventory }`: the
* run profiles whose verified runs at HEAD's tree carry that
* lane, and the command listing its test files (lib/ci-carry.ts)
* ship_lanes_from exact /ship lane command → `{ profiles, inventory }` or
* `{ profiles, jobs }`: the run profiles whose verified runs
* at HEAD's tree carry that lane, and either the command
* listing its test files or the CI jobs that run it (a lane
* with no test files, GST-1189), plus optional `aliases`:
* other exact commands for the same lane (GST-1190)
* (lib/ci-carry.ts)
* allowlist `{ finding, paths?, pattern?, reason }` entries the `secrets`
* check accepts as benign: a gstack-redact finding id or a
* non-secret category, scoped by path globs and/or a regex
@@ -74,7 +78,8 @@ import { shipLanesErrors, type ShipLaneMapping } from '../ci-carry';
import type { ResultCodeName } from '../result-codes';
import { runProfileErrors, type RemoteGateRuns, type RunProfile } from '../run-evidence';
import { sealedErrors } from '../sealed-guard';
import { containedRelative, globToRegExp } from '../ship-policy';
import { containedRelative, globToRegExp, runningGstackVersion, SHIP_POLICY_PATH } from '../ship-policy';
import { cmpVersion, parseVersion } from '../version-source';
export const PREGATE_CONFIG_PATH = '.gstack/pregate.json';
export const PREGATE_CONFIG_SCHEMA = 'https://github.com/garrytan/gstack/docs/pregate.md#pregate-v1';
@@ -291,6 +296,22 @@ function git(cwd: string, args: string[]): { ok: boolean; out: string } {
return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() };
}
/**
* GST-1190: an unknown key in a policy whose project floor (min_gstack_version in the same tree's ship-policy.json) is
* above this gstack belongs to a newer gstack. It still refuses, but as an upgrade, not as a typo.
*/
function newerGstackErrors(parsed: { config: PregateConfig; errors: ConfigError[]; declaresLanes: boolean }, policyText: string | null): typeof parsed {
const unknown = (e: ConfigError) => e.message.startsWith('unknown key');
if (!policyText || !parsed.errors.some(unknown)) return parsed;
let min: unknown;
try { min = (JSON.parse(policyText) as Record<string, unknown> | null)?.min_gstack_version; } catch { return parsed; }
const running = runningGstackVersion();
const want = typeof min === 'string' ? parseVersion(min) : null;
const have = parseVersion(running);
if (!want || !have || cmpVersion(want, have) <= 0) return parsed;
return { ...parsed, errors: parsed.errors.map(e => unknown(e) ? { ...e, code: 'PREGATE_GSTACK_TOO_OLD' as const, message: `needs gstack >= ${min as string}; upgrade (this gstack is ${running} and does not know this key)` } : e) };
}
export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad {
const ref = `origin/${base.replace(/^origin\//, '')}`;
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
@@ -302,13 +323,15 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks:
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' };
const policy = git(repoRoot, ['show', `${sha.out}:${SHIP_POLICY_PATH}`]);
return { ...newerGstackErrors(parseConfig(show.out, knownChecks), policy.ok ? policy.out : null), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' };
}
export function loadConfigFromHead(repoRoot: string, knownChecks: readonly string[] = []): ConfigLoad {
const file = path.join(repoRoot, PREGATE_CONFIG_PATH);
if (!fs.existsSync(file)) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `head (no ${PREGATE_CONFIG_PATH})`, kind: 'none' };
return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' };
const policy = path.join(repoRoot, SHIP_POLICY_PATH);
return { ...newerGstackErrors(parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), fs.existsSync(policy) ? fs.readFileSync(policy, 'utf8') : null), 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. */
@@ -365,7 +388,7 @@ export function explainConfig(): string {
'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)',
'run_profiles: name → { workflow, events, jobs, artifact, max_age_hours?, os? }; the GitHub Actions runs gstack-pregate clear --run verifies as evidence on the identical tree (docs/pregate.md#run-evidence)',
'remote_gate_runs: { profiles, inventory, skipped? }; the run profiles that together clear the remote gate obligation, the command listing the gate\'s test files they must cover, and an optional command printing which selected files (PREGATE_FILES_FILE) the gate itself does not run, which then need no run (docs/pregate.md#run-evidence)',
'ship_lanes_from: exact /ship lane command → { profiles, inventory }; a verified run of each profile at HEAD\'s clean tree whose tree.json covers the inventory carries the lane instead of running it again (docs/pregate.md#ci-carry)',
'ship_lanes_from: exact /ship lane command → { profiles, inventory } or { profiles, jobs }, with optional aliases (other exact commands for the same lane); a verified run of each profile at HEAD\'s clean tree whose tree.json covers the inventory, or whose newest attempt ran every named job to success, carries the lane instead of running it again (docs/pregate.md#ci-carry)',
`allowlist: [{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted. finding is a gstack-redact id (env.kv, google.api_key, pii.phone.e164, …) or a category (${ALLOWLISTABLE_CATEGORIES.join(', ')}); paths are globs; pattern is a regex matched at the finding's position in the added line; one of the two is required, reason always. Never ${NON_ALLOWLISTABLE.join(' or ')} or engine.* (docs/pregate.md#allowlist)`,
'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';
+403
View File
@@ -0,0 +1,403 @@
/**
* pregate flaky exception (GST-1096, docs/pregate.md#flaky-exception): an owner-granted exception that lets GitHub
* Actions runs with failures clear one requires-remote obligation. `gstack-pregate clear --remote <lane> --run <run>
* --flaky-exception "<test file>::<case>" … --pr <n> --grant <approval id>` (lib/pregate/index.ts clearByRuns) takes
* the runs only when:
*
* - every failure is named from the run's own records: each job of the attempt reconciles (only test-shard jobs, and
* the aggregate that needs them, may fail), the plan and every shard's strict result are present and complete (a
* timed-out or unattributed shard is incomplete evidence), and each failed shard's log names no failure its result
* omits;
* - the named cases are exactly the failures, at most three, none in a paid test file or a test of a security scan,
* and each file is outside the PR's diff and the diff's import closure (lib/pregate/import-graph.ts);
* - the runs tested pregate.json's own head and tree (no stamp-only substitution), the PR's head is that head, and an
* approved gstack-approvals row in the owner's words carries the scope `exceptionBinding` computes over repository,
* PR, head, tree, lane, runs with their attempts and cases, so it cannot be rebound to another head or case list.
*
* It is pregate only: nothing here touches a required CI check, which still needs its own green run at the head.
* base_red is not used: it proves a failure by reproducing it on the base, and a timing flake passes there.
*/
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import * as path from 'node:path';
import { locate, readRecord } from '../approval-record';
import type { PregateException, PregateFile } from '../headless-artifacts';
import { parseRunRef, verifyRun, type ApiJob, type ApiRun, type RunArtifactDeps, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
import { wtreeFingerprint } from '../script-invocation';
import { resolveBaseBranch } from '../ship-policy';
import type { FreeCiPlan, FreeCiResult, FreeShardOutcome } from '../../scripts/test-free-shards';
import { isConfigUnavailable, loadConfigFromBase } from './config';
import { buildGraph, testsFor } from './import-graph';
export type FailureDeps = RunArtifactDeps;
export interface GrantRow { id: string; outcome: string; scope: string; words: string; by: string }
export interface FlakyInput { cases: string[]; pr?: number; grant?: string; grantRow?: (id: string) => GrantRow | null }
export type FlakyRefusal = { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
/** What a flaky exception is granted for; `exceptionBinding` hashes exactly these fields. */
export type ExceptionSubject = Pick<PregateException, 'lane' | 'repo' | 'pr' | 'head' | 'tree' | 'runs' | 'cases'>;
export interface FlakyRuns { evidence: RunEvidence[]; red: Array<{ evidence: RunEvidence; failures: string[] }> }
export const MAX_FLAKY_CASES = 3;
export const FLAKY_SCOPE_PREFIX = 'flaky-exception:';
/** The receipt field an exception clear adds; verify and verdictOf read it back. */
export const EXCEPTION_RECEIPT = / exception=(\S+)/;
/** The strict shard runner's artifacts and job names (scripts/test-free-shards.ts --ci-run) in the workflows that upload them. */
interface ShardLayout { plan: string; result: string; logs: string; shardJob: string; aggregate: string }
const SHARD_LAYOUTS: Record<string, ShardLayout> = {
'free-tests.yml': { plan: 'free-plan', result: 'free-result-', logs: 'free-test-shard-logs-', shardJob: 'free-suite', aggregate: 'free-tests' },
'windows-free-tests.yml': { plan: 'windows-plan', result: 'windows-result-', logs: 'windows-free-test-shard-logs-', shardJob: 'windows-free-shard', aggregate: 'windows-free-tests' },
};
/** A test of a security scanner (/cso, secrets, gitleaks, the sealed guard, redaction, OSV): never excepted. */
const SECURITY_TEST = /(?:^|[-_.])(?:cso|secrets?|gitleaks|sealed|redact\w*|osv|security|sanitize\w*|injection|inject)(?=[-_.])/;
const SHA = /^[0-9a-f]{40}$/;
const LOG_FILE_HEADER = /^(\S.*\.test\.(?:[cm]?[jt]s|tsx|jsx)):$/;
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
const short = (s: string | undefined) => (s ?? '').slice(0, 12);
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
/** The gstack-approvals scope that grants one exception: a digest of every field it binds, cases in sorted order. */
export function exceptionBinding(e: ExceptionSubject): string {
const lines = [`lane=${e.lane}`, `repo=${e.repo}`, `pr=${e.pr}`, `head=${e.head}`, `tree=${e.tree}`, ...e.runs.map(r => `run=${r}`), ...[...e.cases].sort().map(c => `case=${c}`)];
return FLAKY_SCOPE_PREFIX + sha256(lines.join('\n'));
}
/** The approvals rows of this checkout's branch record (lib/approval-record.ts), looked up by id. */
export function approvalGrants(repoRoot: string, env: NodeJS.ProcessEnv = process.env): (id: string) => GrantRow | null {
return id => {
const loc = locate({ cwd: repoRoot, env });
return 'code' in loc ? null : readRecord(loc).rows.find(r => r.id === id) ?? null;
};
}
/** The `--flaky-exception` values as `<file>::<case>` strings, or the usage refusal. */
export function parseCases(cases: string[]): string[] | FlakyRefusal {
if (cases.length > MAX_FLAKY_CASES) return { refused: 'usage', message: `--flaky-exception names at most ${MAX_FLAKY_CASES} cases; got ${cases.length}` };
const bad = cases.find(c => { const i = c.indexOf('::'); return i <= 0 || i + 2 >= c.length; });
if (bad !== undefined) return { refused: 'usage', message: `--flaky-exception takes "<test file>::<case>", the file and the test name as the run printed them; got ${JSON.stringify(bad)}` };
if (new Set(cases).size !== cases.length) return { refused: 'usage', message: '--flaky-exception names each case once' };
return cases;
}
/** One-line summary for verify's PREGATE line: `pass-with-exception (<cases>; grant <id>)`. */
export function describeExceptions(file: PregateFile): string | null {
const list = file.exceptions ?? [];
if (!list.length) return null;
return `pass-with-exception (${list.flatMap(e => e.cases).join(', ')}; grant ${list.map(e => e.grant).join(', ')})`;
}
/** READY's line for each exception: it waives the pregate obligation, never a required CI check. */
export function flakyExceptionLines(file: PregateFile): string[] {
return (file.exceptions ?? []).map(e => `FLAKY_EXCEPTION: ${e.lane} cases=${e.cases.join(' | ')} run=${e.runs.join(',')} grant=${e.grant} by=${e.by} (pregate only; every required CI check still needs its own green run at this head)`);
}
/** verify's offline check: every exception receipt has its entry, bound to this head and tree, with an intact binding; null when all hold. */
export function exceptionProblem(file: PregateFile): string | null {
const list = file.exceptions ?? [];
const remote = file.requires_remote ?? [];
for (const r of remote) {
const grant = EXCEPTION_RECEIPT.exec(r.receipt ?? '')?.[1];
if (!grant) continue;
const e = list.find(x => x.lane === r.lane && x.grant === grant);
if (!e) return `${r.lane}'s receipt names flaky exception grant ${grant}, but pregate.json has no exceptions entry for it; clear again`;
if (e.head !== file.head || e.tree !== file.tree) return `${r.lane}'s flaky exception (grant ${grant}) is bound to head ${short(e.head)} tree ${short(e.tree)}, not pregate.json's head ${short(file.head)} tree ${short(file.tree)}; a new head needs a new run and a new grant`;
if (exceptionBinding(e) !== e.binding) return `${r.lane}'s flaky exception (grant ${grant}): the binding does not match its recorded fields (granted scope ${e.binding}); clear again`;
const runs = / runs=(\S+)/.exec(r.receipt ?? '')?.[1];
if (runs !== e.runs.join(',')) return `${r.lane}'s receipt names runs ${runs ?? 'none'}, but its flaky exception (grant ${grant}) was granted for ${e.runs.join(',')}; clear again`;
}
const orphan = list.find(e => !remote.some(r => r.lane === e.lane && EXCEPTION_RECEIPT.exec(r.receipt ?? '')?.[1] === e.grant));
return orphan ? `pregate.json records a flaky exception for ${orphan.lane} (grant ${orphan.grant}) that no receipt uses; clear again` : null;
}
/** Why `file`'s exceptions cannot stand for this PR head: each binds the PR and head it was granted for, and a same-tree commit or another PR reusing the record never inherits it. */
export function exceptionConsumerProblem(file: PregateFile, head: string, pr?: string): string | null {
const e = (file.exceptions ?? []).find(x => x.head !== head || (pr !== undefined && String(x.pr) !== pr));
return e ? `flaky exception grant ${e.grant} is bound to PR #${e.pr} at ${short(e.head)}, not ${pr ? `PR #${pr} at ` : ''}${short(head)}; a new head needs a new run and a new grant` : null;
}
/** Why red run `id` no longer stands as completed, failed attempt `attempt`; read again once the whole clear is assembled. */
export function redRunChanged(deps: RunDeps, id: number, attempt: number): string | null {
const again = deps.run(id);
return again.run_attempt === attempt && again.status === 'completed' && again.conclusion === 'failure' ? null : `run ${id} moved to attempt ${again.run_attempt} (${again.status === 'completed' ? again.conclusion : again.status}) while it was being verified`;
}
/** verify's ok-line suffix naming each exception. */
export function exceptionSummary(file: PregateFile): string {
return (file.exceptions ?? []).map(e => `flaky exception ${e.cases.join(', ')} (grant ${e.grant}, by ${e.by}, run ${e.runs.join(',')})`).join('; ');
}
const invalid = (lane: string, reason: string, detail: string, fix?: string): FlakyRefusal => ({ refused: 'PREGATE_RECEIPT_INVALID', message: `FLAKY_EXCEPTION: refused reason=${reason} — ${detail}; ${lane} stays uncleared`, ...(fix ? { fix } : {}) });
const errText = (e: unknown) => String((e as Error)?.message ?? e).slice(0, 300);
/**
* The runs for `names` (one per profile, matched by workflow): a green run verifies as `clear --run` verifies it, a red
* run through `readRunFailures`. At least one must be red, or no exception is needed.
*/
export function flakyRuns(lane: string, check: { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date; failures?: FailureDeps }, names: string[], refs: string[], vetoHead: string | undefined): FlakyRuns | FlakyRefusal {
if (!check.failures) return { refused: 'usage', message: 'the run artifacts cannot be read here (no GitHub origin), so failures cannot be named' };
const runs: Array<{ ref: string; id: number; run: ApiRun }> = [];
for (const ref of refs) {
const id = parseRunRef(ref, check.repo);
if (id === null) return invalid(lane, 'run_ref_invalid', `${JSON.stringify(ref)} is not a run id or a run URL on ${check.repo}`);
try { runs.push({ ref, id, run: check.deps.run(id) }); }
catch (e) { return invalid(lane, 'api_error', `reading run ${id} failed: ${errText(e)}`, `check gh auth status and that gh can read ${check.repo}'s Actions runs, then rerun the same clear`); }
}
if (runs.every(r => r.run.status === 'completed' && r.run.conclusion === 'success')) return invalid(lane, 'run_green', `${runs.map(r => `run ${r.id} attempt ${r.run.run_attempt}`).join(', ')} passed; clear with --run alone, no exception is needed`);
const out: FlakyRuns = { evidence: [], red: [] };
const used = new Set<number>();
for (const name of names) {
const profile = check.profiles[name]!;
const pick = runs.find(r => !used.has(r.id) && r.run.path === `.github/workflows/${profile.workflow}`) ?? runs.find(r => !used.has(r.id));
if (!pick) return { refused: 'usage', message: `${lane} needs one run per profile (${names.join(', ')})` };
used.add(pick.id);
if (pick.run.conclusion === 'success') {
const v = verifyRun(check.deps, { repo: check.repo, runRef: pick.ref, profileName: name, profile, vetoHeads: vetoHead ? [vetoHead] : [], now: check.now });
if (!v.ok) return invalid(lane, v.reason, `${name}: ${v.detail}`);
out.evidence.push(v.evidence);
continue;
}
const r = readRunFailures(check.deps, check.failures, { repo: check.repo, id: pick.id, run: pick.run, profileName: name, profile, now: check.now });
if (!r.ok) return invalid(lane, r.reason, r.detail);
out.evidence.push(r.evidence);
out.red.push({ evidence: r.evidence, failures: r.failures });
}
return out;
}
type Read<T> = { ok: true } & T | { ok: false; reason: string; detail: string };
const fail = (reason: string, detail: string) => ({ ok: false as const, reason, detail });
/** The one JSON file of artifact `name`, parsed; a message when it is missing, ambiguous or unreadable. */
function jsonArtifact<T>(art: FailureDeps, id: number, name: string): T | { error: string } {
let files: Record<string, string>;
try { files = art.artifactFiles(id, name); } catch (e) { return { error: errText(e) }; }
const json = Object.entries(files).filter(([f]) => f.endsWith('.json'));
if (json.length !== 1) return { error: `${name} holds ${json.length} JSON files, not one` };
try { return JSON.parse(json[0]![1]) as T; } catch (e) { return { error: `${json[0]![0]} is not JSON: ${errText(e)}` }; }
}
const isError = (v: unknown): v is { error: string } => !!v && typeof v === 'object' && 'error' in v && typeof (v as { error: unknown }).error === 'string';
const escapeRe = (s: string) => s.replace(/[.*+?^${{}()|[\]\\]/g, '\\$&');
/**
* A red run under `profile` read from its own records (B8: a red run uploads no tree.json): every job of the attempt, the
* plan, every shard's strict result and each failed shard's logs. Returns the evidence (the plan's files, the tests the
* shards ran) and the failed cases as `<file>::<test name>`, or the refusal naming what could not be reconciled.
*/
function readRunFailures(deps: RunDeps, art: FailureDeps, o: { repo: string; id: number; run: ApiRun; profileName: string; profile: RunProfile; now: Date }): Read<{ evidence: RunEvidence; failures: string[] }> {
const { run, profile: p, id } = o;
const layout = SHARD_LAYOUTS[p.workflow];
if (!layout) return fail('no_shard_results', `${p.workflow} uploads no strict shard results gstack can read (only ${Object.keys(SHARD_LAYOUTS).join(' and ')} do), so its failures cannot be named`);
const at = `run ${id} attempt ${run.run_attempt}`;
try {
if ((run.repository?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return fail('other_repository', `${at} belongs to ${run.repository?.full_name ?? 'an unknown repository'}, not ${o.repo}`);
if ((run.head_repository?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return fail('fork_run', `${at} ran for ${run.head_repository?.full_name ?? 'an unknown repository'}, not ${o.repo}`);
if (run.path !== `.github/workflows/${p.workflow}`) return fail('workflow_mismatch', `${at} ran ${run.path ?? 'an unknown workflow'}, not .github/workflows/${p.workflow}`);
if (!p.events.includes(run.event)) return fail('event_mismatch', `${at} is a ${run.event} run; ${o.profileName} accepts ${p.events.join(', ')}`);
if (run.status !== 'completed') return fail('run_in_progress', `${at} is ${run.status}`);
if (run.conclusion !== 'failure') return fail('run_not_failure', `${at} concluded ${run.conclusion}; only a run that finished with test failures can carry an exception (a cancelled or timed-out run is incomplete evidence)`);
if (!SHA.test(run.head_sha)) return fail('api_error', `${at} has no head commit`);
const jobs = deps.jobs(id, run.run_attempt);
const finishedAt = jobs.map(j => j.completed_at).filter((t): t is string => !!t).sort().pop() ?? run.updated_at;
const finished = Date.parse(finishedAt ?? '');
const maxAge = p.max_age_hours ?? 24;
if (!Number.isFinite(finished)) return fail('api_error', `${at} has no completion time`);
if (o.now.getTime() - finished > maxAge * 3600_000) return fail('run_stale', `${at} finished ${finishedAt}, more than ${maxAge} h before now`);
const newer = deps.runsForHead(p.workflow, run.head_sha).find(r => r.id !== id && (r.run_started_at ?? r.created_at ?? '') >= (run.created_at ?? ''));
if (newer) return fail('newer_run', `run ${newer.id} of ${p.workflow} on ${short(run.head_sha)} started after ${at}; clear with the newest run`);
const shardJobs = reconcileJobs(jobs, layout, p, at);
if ('reason' in shardJobs) return fail(shardJobs.reason, shardJobs.detail);
const plan = jsonArtifact<FreeCiPlan>(art, id, layout.plan);
if (isError(plan)) return fail('shard_incomplete', `${at} has no readable ${layout.plan} (${plan.error})`);
if (plan.version !== 1 || typeof plan.id !== 'string' || typeof plan.revision !== 'string' || !Array.isArray(plan.shards) || !plan.shards.length
|| plan.shards.some((s, i) => s.shard !== i + 1 || !Array.isArray(s.files) || s.files.some(f => typeof f !== 'string'))) return fail('result_mismatch', `${at}'s ${layout.plan} is not a strict shard plan`);
const headTree = deps.commitTree(run.head_sha);
if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`);
if (plan.revision !== run.head_sha && (!SHA.test(plan.revision) || deps.commitTree(plan.revision) !== headTree)) return fail('tree_mismatch', `${at} tested ${short(plan.revision)}, whose tree is not its head ${short(run.head_sha)}'s tree ${short(headTree)}`);
if (shardJobs.size !== plan.shards.length || plan.shards.some(s => !shardJobs.has(s.shard))) return fail('shard_mismatch', `${at} has ${layout.shardJob} job(s) for shard(s) ${[...shardJobs.keys()].sort((a, b) => a - b).join(', ') || 'none'}, but its plan has ${plan.shards.length} shard(s)`);
const failures: string[] = [];
let tests = 0;
for (const s of plan.shards) {
const res = jsonArtifact<FreeCiResult>(art, id, `${layout.result}${s.shard}`);
if (isError(res)) return fail('shard_incomplete', `shard ${s.shard} has no readable ${layout.result}${s.shard} (${res.error})`);
if (res.planId !== plan.id || res.revision !== plan.revision || res.outcome?.shard !== s.shard || JSON.stringify(res.outcome.files) !== JSON.stringify(s.files)) return fail('result_mismatch', `shard ${s.shard}'s result is not shard ${s.shard} of plan ${plan.id}`);
const named = shardFailures(res, s.shard);
if ('reason' in named) return fail(named.reason, named.detail);
const job = shardJobs.get(s.shard)!;
if ((job.conclusion === 'failure') !== (named.final.length > 0)) return fail('shard_mismatch', `shard ${s.shard}'s job concluded ${job.conclusion}, but its result names ${named.final.length} final failure(s)`);
if (job.conclusion === 'failure') {
const problem = logProblem(art, id, `${layout.logs}${s.shard}`, s.shard, s.files, named.all);
if (problem) return fail(problem.reason, problem.detail);
}
failures.push(...named.final);
tests += res.outcome.summary?.testsRan ?? 0;
}
const again = deps.run(id);
if (again.run_attempt !== run.run_attempt || again.status !== 'completed' || again.conclusion !== run.conclusion) return fail('run_changed', `run ${id} moved to attempt ${again.run_attempt} (${again.status === 'completed' ? again.conclusion : again.status}) while it was being verified`);
return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files: plan.shards.flatMap(s => s.files), tests }, failures: [...new Set(failures)] };
} catch (e) {
return fail('api_error', `reading run ${id} failed: ${errText(e)}`);
}
}
/** Shard number → job, once every job of the attempt is accounted for: only shard jobs and the aggregate may fail. */
function reconcileJobs(jobs: ApiJob[], layout: ShardLayout, p: RunProfile, at: string): Map<number, ApiJob> | { reason: string; detail: string } {
const shardRe = new RegExp(`^${escapeRe(layout.shardJob)} \\((\\d+)\\)$`);
const shards = new Map<number, ApiJob>();
for (const j of jobs) {
if (j.conclusion === null) return { reason: 'shard_incomplete', detail: `${at}'s ${j.name} job has not finished` };
const m = shardRe.exec(j.name);
if (m) {
if (shards.has(Number(m[1]))) return { reason: 'shard_mismatch', detail: `${at} has two jobs named ${j.name}` };
if (j.conclusion !== 'success' && j.conclusion !== 'failure') return { reason: 'shard_incomplete', detail: `${at}'s ${j.name} job concluded ${j.conclusion}` };
shards.set(Number(m[1]), j);
continue;
}
if (j.name === layout.aggregate) continue;
const allowed = p.jobs.includes(j.name) ? ['success'] : ['success', 'skipped', 'neutral'];
if (!allowed.includes(j.conclusion)) return { reason: 'unnamed_failure', detail: `${at}'s ${j.name} job concluded ${j.conclusion}; only test-shard failures can be named` };
}
const missing = p.jobs.find(n => !jobs.some(j => j.name === n));
if (missing) return { reason: 'job_missing', detail: `${at} has no job named ${missing}` };
const aggregate = jobs.filter(j => j.name === layout.aggregate);
if (aggregate.length !== 1) return { reason: 'job_missing', detail: `${at} has ${aggregate.length} jobs named ${layout.aggregate}` };
if (![...shards.values()].some(j => j.conclusion === 'failure')) return { reason: 'unnamed_failure', detail: `${at} failed, but no ${layout.shardJob} job did, so the failure is not a test case` };
if (aggregate[0]!.conclusion !== 'failure') return { reason: 'shard_mismatch', detail: `${at}'s ${layout.aggregate} job concluded ${aggregate[0]!.conclusion} although a shard failed` };
return shards;
}
function completeSummary(o: FreeShardOutcome): boolean {
const s = o.summary;
return !!s && Number.isInteger(s.testsRan) && s.testsRan! >= 0 && s.filesRan === o.files.length && (o.files.length === 0 || s.sawTerminalSummary === true);
}
/**
* A shard's final failures (`<file>::<test>`) and every failure it recorded. A retry that passed recovered its files; a
* retry that failed replaces the first run's failures for the files it ran. Anything unattributed or incomplete refuses.
*/
function shardFailures(r: FreeCiResult, n: number): { final: string[]; all: Set<string> } | { reason: string; detail: string } {
const o = r.outcome;
const key = (f: { file: string | null; testName: string }) => `${f.file}::${f.testName}`;
if (o.status === 'timed-out') return { reason: 'shard_incomplete', detail: `shard ${n} timed out, so the tests after the hang never ran` };
if (!completeSummary(o)) return { reason: 'shard_incomplete', detail: `shard ${n}'s result has no complete run summary` };
if (o.status === 'passed') {
if (o.exitCode !== 0 || o.failingFiles.length || o.unattributedFailures || r.retry) return { reason: 'result_mismatch', detail: `shard ${n} passed with failures recorded` };
return { final: [], all: new Set() };
}
if (o.status !== 'failed') return { reason: 'result_mismatch', detail: `shard ${n} has status ${String(o.status)}` };
if (o.unattributedFailures) return { reason: 'unnamed_failure', detail: `shard ${n}: ${o.unattributedFailures} failure(s) the runner could not attribute to a test file` };
const first = o.failures ?? [];
const unnamed = first.filter(f => !f.file || !o.files.includes(f.file) || !f.testName);
if (!first.length || unnamed.length) return { reason: 'unnamed_failure', detail: `shard ${n} failed ${first.length ? `with ${unnamed.length} failure(s) not attributed to one of its files` : 'without naming a failure'}` };
if (JSON.stringify([...new Set(first.map(f => f.file))].sort()) !== JSON.stringify([...o.failingFiles].sort())) return { reason: 'result_mismatch', detail: `shard ${n}'s named failures and failing files disagree` };
const all = new Set(first.map(key));
const t = r.retry;
if (!t) return { final: [...all], all };
if (t.status === 'timed-out' || !completeSummary(t)) return { reason: 'shard_incomplete', detail: `shard ${n}'s retry did not complete` };
if (t.files.some(f => !o.failingFiles.includes(f))) return { reason: 'result_mismatch', detail: `shard ${n}'s retry ran files that had not failed` };
const again = t.failures ?? [];
if (t.unattributedFailures || (t.status === 'failed' && (!again.length || again.some(f => !f.file || !t.files.includes(f.file))))) return { reason: 'unnamed_failure', detail: `shard ${n}'s retry failed without naming each failure` };
if (t.status === 'passed' && (again.length || t.exitCode !== 0)) return { reason: 'result_mismatch', detail: `shard ${n}'s retry passed with failures recorded` };
for (const f of again) all.add(key(f));
const final = [...first.filter(f => !t.files.includes(f.file!)), ...again].map(key);
return { final: [...new Set(final)], all };
}
/** Why a failed shard's logs do not reconcile with its result: missing, an unhandled error between tests, or a failure the result omits. */
function logProblem(art: FailureDeps, id: number, name: string, n: number, planned: string[], recorded: Set<string>): { reason: string; detail: string } | null {
let files: Record<string, string>;
try { files = art.artifactFiles(id, name); } catch (e) { return { reason: 'logs_missing', detail: `shard ${n} failed and has no readable ${name} (${errText(e)})` }; }
const logs = Object.entries(files).filter(([f]) => f.endsWith('.log'));
if (!logs.length) return { reason: 'logs_missing', detail: `shard ${n} failed and its ${name} holds no .log file` };
const canonical = (printed: string) => {
const stripped = printed.replace(/\\/g, '/').replace(/^(?:\.{1,2}\/)+/, '');
return planned.includes(stripped) ? stripped : planned.find(p => stripped.endsWith(`/${p}`) || p.endsWith(`/${stripped}`)) ?? stripped;
};
const { classifyBunTestOutputLine, parseBunFailureResult, stripAnsiLine } = require('../../scripts/lib/shard-engine') as typeof import('../../scripts/lib/shard-engine');
for (const [, text] of logs) {
let file: string | null = null;
let recap = false;
let unhandled = 0;
for (const raw of text.split('\n')) {
const line = stripAnsiLine(raw).replace(/^::group::/, '').replace(/\r$/, '');
if (/^\d+ tests? failed:$/.test(line)) { recap = true; file = null; continue; }
if (classifyBunTestOutputLine(line) === 'unhandled-between-tests') { unhandled++; continue; }
const header = LOG_FILE_HEADER.exec(line);
if (header) { if (!recap) file = canonical(header[1]!); continue; }
const failed = recap ? null : parseBunFailureResult(line);
if (failed !== null && !recorded.has(`${file}::${failed}`)) return { reason: 'unnamed_failure', detail: `shard ${n}'s log names ${file}::${failed}, which its result does not` };
}
if (unhandled) return { reason: 'unnamed_failure', detail: `shard ${n}'s log shows ${unhandled} unhandled error(s) between tests` };
}
return null;
}
/** The PR's changed paths against the merge base with origin/<base>, and each test the diff reaches through the import graph. */
function prScope(repoRoot: string, file: PregateFile): { diff: Set<string>; closure: Map<string, string[]> } | { error: string } {
const git = (args: string[]) => { const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
const base = resolveBaseBranch(repoRoot);
const mb = git(['merge-base', `refs/remotes/origin/${base}`, '--end-of-options', file.head!])?.trim();
if (!mb) return { error: `git cannot find the merge base of origin/${base} and ${short(file.head)}` };
const listed = git(['diff', '--name-status', '-z', '--no-renames', '--end-of-options', mb, file.head!]);
if (listed === null) return { error: `git diff ${short(mb)} ${short(file.head)} failed` };
const parts = listed.split('\0').filter(Boolean);
const diff = new Set<string>();
const deleted: string[] = [];
for (let i = 0; i + 1 < parts.length; i += 2) { diff.add(parts[i + 1]!); if (parts[i] === 'D') deleted.push(parts[i + 1]!); }
const loaded = loadConfigFromBase(repoRoot, base);
if (isConfigUnavailable(loaded) || loaded.errors.length) return { error: `the base branch's .gstack/pregate.json cannot be read, so its dependency declarations are unknown` };
const at = () => { const fp = wtreeFingerprint({ cwd: repoRoot }); return 'tree' in fp ? fp.tree : null; };
if (at() !== file.tree) return { error: `the working tree is not pregate.json's tree ${short(file.tree)}; the import closure must be read from that tree` };
const declarations = loaded.config.dependencies;
const graph = buildGraph(repoRoot, { declarations, extraFiles: deleted });
const closure = new Map<string, string[]>();
for (const changed of diff) for (const t of testsFor(graph, changed, declarations).tests) closure.set(t, [...(closure.get(t) ?? []), changed]);
if (at() !== file.tree) return { error: 'the working tree changed while the import closure was read' };
return { diff, closure };
}
/**
* After the runs verified and covered the obligation: the named cases must be exactly the failures, in no paid or
* security test, outside the PR's diff and import closure, the PR's head must be pregate.json's, and the grant row must
* carry this exception's binding in the owner's words. Returns the exception to record.
*/
export function flakyDecision(file: PregateFile, lane: string, check: { repo: string; failures?: FailureDeps }, runs: FlakyRuns, input: FlakyInput & { cases: string[] }, repoRoot: string): { exception: PregateException } | FlakyRefusal {
const label = runs.red.map(r => `run ${r.evidence.run_id} attempt ${r.evidence.run_attempt}`).join(', ');
const failures = [...new Set(runs.red.flatMap(r => r.failures))];
const extra = failures.filter(f => !input.cases.includes(f));
if (extra.length) return invalid(lane, 'unnamed_failure', `${label} failed ${extra.slice(0, 5).join(', ')}${extra.length > 5 ? ` and ${extra.length - 5} more` : ''}, which no --flaky-exception names`);
const notFailed = input.cases.find(c => !failures.includes(c));
if (notFailed) return invalid(lane, 'case_not_failed', `${notFailed} did not fail in ${label}; its failures are ${failures.join(', ')}`);
const files = [...new Set(input.cases.map(c => c.slice(0, c.indexOf('::'))))];
const { isPaidTestFile } = require('../../test/helpers/paid-test-set') as typeof import('../../test/helpers/paid-test-set');
const paid = files.find(f => isPaidTestFile(f));
if (paid) return invalid(lane, 'case_paid', `${paid} is a paid test file; the paid gate covers it, never an exception`);
const security = files.find(f => SECURITY_TEST.test(path.posix.basename(f)));
if (security) return invalid(lane, 'case_security', `${security} tests a security scan, which an exception never covers`);
let pr: { head_sha: string; state: string; head_repo: string | null };
try { pr = check.failures!.pull(input.pr!); }
catch (e) { return invalid(lane, 'api_error', `reading PR #${input.pr} failed: ${errText(e)}`); }
if (pr.state !== 'open') return invalid(lane, 'pr_mismatch', `PR #${input.pr} is ${pr.state}`);
if ((pr.head_repo ?? '').toLowerCase() !== check.repo.toLowerCase()) return invalid(lane, 'pr_mismatch', `PR #${input.pr}'s head is on ${pr.head_repo ?? 'an unknown repository'}, not ${check.repo}`);
if (pr.head_sha !== file.head) return invalid(lane, 'pr_mismatch', `PR #${input.pr}'s head is ${short(pr.head_sha)}, not pregate.json's head ${short(file.head)}`);
const scope = prScope(repoRoot, file);
if ('error' in scope) return invalid(lane, 'closure_failed', scope.error);
const touched = files.find(f => scope.diff.has(f));
if (touched) return invalid(lane, 'case_in_diff', `${touched} is in the PR's diff`);
const reached = files.find(f => scope.closure.has(f));
if (reached) return invalid(lane, 'case_in_closure', `${reached} depends on a file the PR changes (${scope.closure.get(reached)!.slice(0, 5).join(', ')})`);
const subject: ExceptionSubject = { lane, repo: check.repo, pr: input.pr!, head: file.head!, tree: file.tree, runs: runs.evidence.map(e => `${e.run_id}/${e.run_attempt}`), cases: [...input.cases].sort() };
const binding = exceptionBinding(subject);
if (!input.grant) {
const ask = `Accept flaky ${subject.cases.join(', ')} in run ${subject.runs.join(',')} for PR #${subject.pr} at ${short(subject.head)}? Pregate only; required CI still needs a green run.`.slice(0, 300);
return invalid(lane, 'grant_needed', `every failure in ${label} is named (${subject.cases.join(', ')}) and outside the PR's diff and its import closure; the owner must grant the exception`,
`ask the owner, record their words with gstack-approvals add --ask ${shellQuote(ask)} --words "<the owner's exact words>" --by "<owner>" --scope "${binding}" --source pregate-flaky-exception, then rerun this clear with --grant <the row id it prints>`);
}
const row = input.grantRow?.(input.grant) ?? null;
if (!row) return invalid(lane, 'grant_missing', `no gstack-approvals row ${input.grant} in this branch's record`);
if (row.scope !== binding) return invalid(lane, 'grant_mismatch', `row ${row.id}'s scope ${JSON.stringify(row.scope.slice(0, 80))} is not this exception's binding ${binding}; a grant covers one repository, PR, head, tree, lane, run attempt and case list, so a new head needs a new grant`);
if (row.outcome !== 'approved') return invalid(lane, 'grant_not_approved', `row ${row.id} records outcome ${row.outcome}, not approved`);
if (/^agent:/i.test(row.by)) return invalid(lane, 'grant_not_owner', `row ${row.id} records an agent's words (${row.by}); only the owner grants an exception`);
return { exception: { ...subject, grant: row.id, by: row.by, words: row.words, binding } };
}
+35 -11
View File
@@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts';
import { ARTIFACT_SCHEMAS, checkSchema, type PregateException, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts';
import { wtreeFingerprint, type WtreeResult } from '../script-invocation';
import { resolveStateRoot } from '../state-root';
import { guardsCheck } from './checks/guards';
@@ -28,6 +28,7 @@ import { timingCheck } from './checks/timing';
import { NON_DOWNGRADABLE, PREGATE_CONFIG_PATH, userPolicyAbsent } from './config';
import { sha256, withFingerprint } from './context';
import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt';
import { EXCEPTION_RECEIPT, exceptionProblem, exceptionSummary, flakyDecision, flakyRuns, parseCases, redRunChanged, type FailureDeps, type FlakyInput } from './flaky-exception';
import { buildRunCheck } from './run-check';
import { resolveBaseBranch } from '../ship-policy';
import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiJob, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
@@ -134,6 +135,7 @@ export function verdictOf(file: PregateFile): string {
if (statuses.has('incomplete') || statuses.has('not_installed')) return 'incomplete';
const uncleared = (file.requires_remote ?? []).filter(r => !r.receipt);
if (uncleared.length) return `requires-remote (${uncleared.length} uncleared)`;
if ((file.requires_remote ?? []).some(r => EXCEPTION_RECEIPT.test(r.receipt ?? ''))) return 'pass-with-exception';
if (statuses.has('requires-remote')) return 'requires-remote cleared';
if (statuses.has('warn')) return 'warn';
return 'pass';
@@ -234,8 +236,12 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre
// A run receipt was verified online at clear for its tree; offline it must still name this tree (or carry its substitution).
const foreignRuns = (file.requires_remote ?? []).filter(r => { const run = parseRunReceipt(r.receipt ?? ''); return run && !r.substitution && run.tree !== file.tree.slice(0, 12); });
if (foreignRuns.length) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: `${foreignRuns.length} run receipt(s) verified for another tree: ${foreignRuns.map(r => `${r.lane} (${r.receipt})`).join(', ')}; clear again with runs on tree ${file.tree.slice(0, 12)}`, current, uncleared: foreignRuns };
// A flaky exception was granted for one head, tree, run attempt and case list; recorded for another, or edited, it clears nothing.
const exception = exceptionProblem(file);
if (exception) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: exception, current, uncleared: [] };
const substituted = (file.requires_remote ?? []).filter(r => r.substitution).map(r => `${r.lane}: ${describeSubstitution(r.substitution!)}`);
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}`, current, uncleared };
const excepted = exceptionSummary(file);
return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}${excepted ? `; ${excepted}` : ''}`, current, uncleared };
}
/**
@@ -259,10 +265,10 @@ function userPolicyChange(file: PregateFile): string | null {
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; runs?: string[]; evidence?: RunCheck }
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck; flaky?: FlakyInput }
/** What a run clear needs: the base branch's run profiles, the verifier's dependencies, the clock, and the remote gate's inventory. */
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string } }
export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; unchanged?: (p: RunProfile) => string | null; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string }; failures?: FailureDeps }
export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution; exception?: PregateException } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
/**
* What `clear` may record for one obligation. A `pregate.json/remote_gate`
@@ -284,12 +290,21 @@ export type ClearDecision = { receipt: string; attested: boolean; substitution?:
* `--run` (and `--run-url` on a lane the base branch profiles) goes to
* clearByRuns, which accepts only verified Actions runs; a profiled lane
* refuses `--bundle-lane`, and the remote gate clears by runs only when the
* base declares remote_gate_runs.
* base declares remote_gate_runs. `flaky` (`--flaky-exception`) takes runs with failures the same way under an owner's
* grant (lib/pregate/flaky-exception.ts).
*/
export function clearDecision(file: PregateFile, lane: string, input: ClearInput, repoRoot: string): ClearDecision {
const list = file.requires_remote ?? [];
const target = list.find(r => r.lane === lane);
if (!target) return { refused: 'usage', message: `no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}` };
if (input.flaky) {
const cases = parseCases(input.flaky.cases);
if (!Array.isArray(cases)) return cases;
const refs = [...(input.runs ?? []), ...(input.runUrl ? [input.runUrl] : [])];
if (!refs.length) return { refused: 'usage', message: '--flaky-exception needs --run <id|url>: an exception reads GitHub Actions runs' };
if (!input.flaky.pr) return { refused: 'usage', message: '--flaky-exception needs --pr <n>, the pull request the grant binds' };
return clearByRuns(file, lane, refs, input.evidence, repoRoot, { ...input.flaky, cases });
}
const profiled = input.evidence && (lane === REMOTE_GATE_LANE ? input.evidence.gateRuns : input.evidence.profiles[lane]);
if (input.runs?.length || (input.runUrl && profiled)) return clearByRuns(file, lane, [...(input.runs ?? []), ...(input.runUrl ? [input.runUrl] : [])], input.evidence, repoRoot);
if (input.bundleLane && profiled && lane !== REMOTE_GATE_LANE) return { refused: 'usage', message: `${lane} has a run profile on the base branch; clear it with --run <id|url>, which verifies the run` };
@@ -372,7 +387,7 @@ function treeSubstitution(file: PregateFile, gatedTree: string, lane: string, re
* does not run (never an inventory file); any other lane needs one verified run of the profile named after it. The run
* tree must be pregate.json's tree or a stamp-only predecessor of it, exactly as a gate report's tree must.
*/
function clearByRuns(file: PregateFile, lane: string, refs: string[], check: RunCheck | undefined, repoRoot: string): ClearDecision {
function clearByRuns(file: PregateFile, lane: string, refs: string[], check: RunCheck | undefined, repoRoot: string, flaky?: FlakyInput & { cases: string[] }): ClearDecision {
if (!check) return { refused: 'usage', message: `--run needs the base branch's run_profiles; none could be read` };
const isGate = lane === REMOTE_GATE_LANE;
const names = isGate ? check.gateRuns?.profiles ?? [] : check.profiles[lane] ? [lane] : [];
@@ -404,13 +419,17 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run
}
return null;
};
const evidence = assign(0, new Set());
const red = flaky ? flakyRuns(lane, check, names, refs, file.head) : null;
if (red && 'refused' in red) return red;
const evidence = red ? red.evidence : assign(0, new Set());
if (!evidence) {
const failed = names.map(name => `${name}: ${refs.map(ref => { const v = verdict(name, ref); return v.ok ? `${ref} ok` : `${ref} reason=${v.reason} ${v.detail}`; }).join('; ')}`);
return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=no_verified_run — no run per profile verifies (${failed.join(' | ')}); ${lane} stays uncleared` };
}
const trees = [...new Set(evidence.map(e => e.tree))];
if (trees.length > 1) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=tree_mismatch — the runs tested different trees (${trees.map(t => t.slice(0, 12)).join(', ')}); ${lane} stays uncleared` };
const off = red && evidence.find(e => e.head_sha !== file.head || e.tree !== file.tree);
if (off) return { refused: 'PREGATE_RECEIPT_INVALID', message: `FLAKY_EXCEPTION: refused reason=head_mismatch — run ${off.run_id} attempt ${off.run_attempt} tested head ${off.head_sha.slice(0, 12)} tree ${off.tree.slice(0, 12)}, not pregate.json's head ${(file.head ?? '').slice(0, 12)} tree ${file.tree.slice(0, 12)}; an exception binds the exact head and tree, never a stamp-only predecessor; ${lane} stays uncleared` };
const sub = treeSubstitution(file, trees[0]!, lane, repoRoot);
if ('refused' in sub) return { ...sub, message: `REUSE: refused reason=tree_not_stamp_only — ${sub.message}` };
const idle = evidence.filter(e => (e.tests ?? e.files.length) === 0);
@@ -433,24 +452,29 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run
}
const missing = uncoveredFiles([...required], evidence);
if (missing.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${isGate ? 'inventory_uncovered' : 'tests_uncovered'} — ${missing.length} of the ${required.size} test file(s) ${lane} must cover appear in no run's tree.json (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}); ${lane} stays uncleared` };
const granted = red ? flakyDecision(file, lane, check, red, flaky!, repoRoot) : null;
if (granted && 'refused' in granted) return granted;
for (const e of evidence) {
let changed: string | null;
try { changed = runChanged(check.deps, e.run_id, e.run_attempt); }
try { changed = red?.red.some(r => r.evidence === e) ? redRunChanged(check.deps, e.run_id, e.run_attempt) : runChanged(check.deps, e.run_id, e.run_attempt); }
catch (err: any) { changed = `reading run ${e.run_id} again failed: ${String(err?.message ?? err).slice(0, 200)}`; }
if (changed) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=run_changed — ${changed}; ${lane} stays uncleared` };
}
const tests = evidence.reduce((n, e) => n + (e.tests ?? e.files.length), 0);
const accepted = sub.substitution ? ` accepted-for=${file.tree.slice(0, 12)} stamp-only=${sub.substitution.files.length}` : '';
return { receipt: `${isGate ? 'remote-gate' : 'run'} tree=${trees[0]!.slice(0, 12)} tests=${tests}${skippedCount ? ` skipped=${skippedCount}` : ''}${accepted} ${runReceiptFields(evidence, check.now)}`, attested: false, substitution: sub.substitution };
const exception = granted?.exception;
return { receipt: `${isGate ? 'remote-gate' : 'run'} tree=${trees[0]!.slice(0, 12)} tests=${tests}${skippedCount ? ` skipped=${skippedCount}` : ''}${exception ? ` exception=${exception.grant} cases=${exception.cases.length}` : ''}${accepted} ${runReceiptFields(evidence, check.now)}`, attested: false, substitution: sub.substitution, ...(exception ? { exception } : {}) };
}
/** Record the receipt that clears one obligation (a run URL, an evidence-bundle lane label, or a remote gate report, with the stamp-only substitution when one was accepted). */
export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: PregateReceiptSubstitution): PregateFile {
export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: PregateReceiptSubstitution, exception?: PregateException): PregateFile {
const list = file.requires_remote ?? [];
const target = list.find(r => r.lane === lane);
if (!target) throw new Error(`no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}`);
target.receipt = receipt; target.cleared_by = by;
if (substitution) target.substitution = substitution; else delete target.substitution;
const exceptions = [...(file.exceptions ?? []).filter(e => e.lane !== lane), ...(exception ? [exception] : [])];
if (exceptions.length) file.exceptions = exceptions; else delete file.exceptions;
file.verdict = verdictOf(file);
return file;
}
+46 -7
View File
@@ -11,27 +11,66 @@ 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 { ghRunDeps, runInventory } from '../run-evidence';
import { ghArtifactDeps, ghRunDeps, runInventory, type RunProfile } from '../run-evidence';
import { wtreeFingerprint } from '../script-invocation';
import { resolveBaseBranch } from '../ship-policy';
import { normalizeRemoteUrl } from '../ship-queue-train-record';
import { REMOTE_GATE_LANE } from './checks/lanes';
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase } from './config';
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from './config';
import type { RunCheck } from './index';
/**
* `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is
* recorded unverified only once the base shows it has none.
* Null when a skippable profile's workflow, its declared `scripts` and every repo path its gate job names are byte-identical
* at HEAD and origin/<base> (GST-1190); else the first that differs. Named paths are read from the gate job's block in the base
* workflow (after `${{ … }}` prefixes, and also under each working-directory), plus package.json when the job runs a package
* script. Files the gate reaches indirectly (imports, a package script's own files) count only when `scripts` declares them.
*/
export function buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root)): RunCheck | { error: string; profiled?: boolean } {
const loaded = loadConfigFromBase(root, base);
export function gateInputsChanged(root: string, base: string, p: RunProfile): string | null {
const ref = `origin/${base.replace(/^origin\//, '')}`;
const wf = `.github/workflows/${p.workflow}`;
const gate = p.skippable?.gate;
if (!gate) return 'the profile names no gate job';
const blob = (rev: string, f: string) => { const r = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${rev}:${f}`], { cwd: root, encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : null; };
const show = spawnSync('git', ['show', `${ref}:${wf}`], { cwd: root, encoding: 'utf8', timeout: 30_000, maxBuffer: 8 * 1024 * 1024 });
if (show.status !== 0) return `${wf} is not on ${ref}`;
const lines = show.stdout.split('\n');
const jobsAt = lines.findIndex(l => /^jobs:\s*$/.test(l));
const blocks: string[][] = [];
for (let i = jobsAt + 1; jobsAt >= 0 && i < lines.length && !/^\S/.test(lines[i]!); i++) {
if (/^ [\w-]+:\s*$/.test(lines[i]!)) blocks.push([lines[i]!]);
else blocks.at(-1)?.push(lines[i]!);
}
const block = blocks.find(b => b[0]!.trim() === `${gate}:` || b.some(l => /^ name:/.test(l) && l.trim() === `name: ${gate}`));
if (!block) return `${wf} on ${ref} has no job ${gate}`;
const text = block.join('\n');
const dirs = ['', ...[...text.matchAll(/working-directory:\s*['"]?([\w.\/-]+)/g)].map(m => `${m[1]!.replace(/^\.?\/+|\/+$/g, '')}/`)];
const tokens = [...text.matchAll(/[\w.\/-]+/g)].map(m => m[0]).filter(t => t.includes('/') || /\.(sh|ts|js|mjs|cjs|py|rb)$/.test(t));
const named = [...new Set(tokens.flatMap(t => dirs.map(d => path.posix.normalize(d + t.replace(/^\.?\/+/, ''))).filter(n => !n.startsWith('..'))))];
const pkg = /\b(bun|npm|pnpm|yarn)\s+(run\s+)?[\w:-]+/.test(text) ? dirs.map(d => `${d}package.json`) : [];
for (const f of [wf, ...(p.skippable!.scripts ?? []), ...named, ...pkg]) {
const a = blob(ref, f);
const b = blob('HEAD', f);
if (a === null && b === null && f !== wf && !p.skippable!.scripts?.includes(f)) continue;
if (a === null || a !== b) return `${f} differs from ${ref}`;
}
return null;
}
/**
* `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is
* recorded unverified only once the base shows it has none. `policy: 'head'` reads the branch's profiles instead, only
* for gstack-ship-preflight --simulate, a dry run that records and clears nothing.
*/
export function buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root), policy: 'base' | 'head' = 'base'): RunCheck | { error: string; profiled?: boolean } {
const loaded = policy === 'head' ? loadConfigFromHead(root) : loadConfigFromBase(root, base);
if (isConfigUnavailable(loaded)) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is unreadable (${loaded.detail}); run profiles come only from the base`, profiled: true };
if (loaded.errors.length) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is invalid: ${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, profiled: true };
const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', timeout: 30_000 });
const repo = /^https:\/\/github\.com\/(.+)$/.exec(normalizeRemoteUrl(origin.status === 0 ? origin.stdout.trim() : ''))?.[1];
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === null ? false : lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] };
return {
repo, deps: ghRunDeps(repo, root), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
repo, deps: ghRunDeps(repo, root), failures: ghArtifactDeps(repo), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
unchanged: p => gateInputsChanged(root, base, p),
inventory: (command, tree, selected) => {
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
+1 -1
View File
@@ -313,7 +313,7 @@ export function branchAddedLines(cwd: string, since: string, files: string[], to
return out;
}
const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const VERSION_RE = '(?<v>\\d+(?:\\.\\d+){1,3})(?![0-9.])';
/**
* The built-in stamp forms as scanners, the version in the `v` group: `(was X)`, `since: X` and `vX`.
+5
View File
@@ -16,6 +16,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'the ship policy sets min_gstack_version above this gstack, so this gstack refuses to read it',
fix: 'upgrade gstack (bin/gstack-upgrade, or the session install with --for <skill>) to the version named, then rerun',
},
PREGATE_GSTACK_TOO_OLD: {
anchor: 'pregate-gstack-too-old',
summary: 'the pregate policy uses a key this gstack does not know, and the project floor (min_gstack_version in .gstack/ship-policy.json) is above this gstack, so the key belongs to a newer gstack; the policy stays refused',
fix: 'upgrade gstack (bin/gstack-upgrade, or the session install with --for <skill>) to at least the version named, then rerun',
},
QUEUE_NOT_READY: {
anchor: 'queue-not-ready',
summary: 'the lane is not ready to report: the working tree, HEAD or CI state does not match the pushed PR head',
+12 -5
View File
@@ -10,6 +10,7 @@
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveBase, type ResolvedBase } from './gate-diff/git';
import { GENERATED_PATH, parseRegistry } from './regen';
import { RESULT_CODES } from './result-codes';
import { classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth';
@@ -31,6 +32,8 @@ export interface Collected extends ClassInput {
untracked: string[];
consumed: string[] | 'all';
drawings: string[];
/** collectLive only: the ref resolveBase read for the base name (GST-1036), null when nothing resolved. */
base?: ResolvedBase | null;
}
function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } {
@@ -255,16 +258,20 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings });
}
/** The working tree against the merge base with origin/<base> (or <base>; a remote-qualified base reads refs/remotes/<base> only), untracked files counted per path; the registry is the base ref's commit. */
/**
* The working tree against the merge base with the base resolveBase reads (lib/gate-diff/git.ts: a bare name is
* origin/<base> when it shares history with HEAD, else the local branch; a remote-qualified base reads
* refs/remotes/<base> only), untracked files counted per path; the registry is the base ref's commit.
*/
export function collectLive(root: string, base: string): Collected {
const errors: DepthError[] = [];
const qualified = base.startsWith('origin/') || git(root, ['remote']).out.split('\n').some(r => r && base.startsWith(`${r}/`));
const ref = (qualified ? [`refs/remotes/${base}`] : [`refs/remotes/origin/${base}`, `refs/heads/${base}`]).find(r => git(root, ['rev-parse', '--verify', '-q', `${r}^{commit}`]).ok) ?? base;
const resolved = resolveBase(base, root, 'HEAD');
const ref = resolved?.sha ?? base;
const registry = loadRegistryAt(root, ref);
const mb = git(root, ['merge-base', ref, 'HEAD']);
if (!mb.ok) {
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` });
return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
return { ...result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }), base: resolved };
}
const mergeBase = mb.out.trim();
const diff = readDiff(root, [mergeBase], errors);
@@ -284,5 +291,5 @@ export function collectLive(root: string, base: string): Collected {
const drawings = drawingsAmong(diff.perPath, root, p => {
try { return fs.statSync(path.join(root, p)).size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; }
});
return result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings });
return { ...result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings }), base: resolved };
}
+32 -21
View File
@@ -93,7 +93,16 @@ const SVG_MARKUP = /^<(?:!--|!\[CDATA\[|\?xml\s|!DOCTYPE\s+svg(?:\s+PUBLIC\s+(["
export const SVG_REFUSED = [/&(?!(?:amp|lt|gt|quot|apos);)/, /\\/, /@import/i, /url\((?!\s*#|['"]\s*#)/i, /expression\s*\(/i, /-moz-binding|\bbehavior\s*:/i];
/** A drawing's CSS, a style element's text or a style attribute's value, holds declarations only: no @ rule and no function but url(#id). */
const SVG_STYLE_OPEN = /<(?:[A-Za-z_][\w.-]*:)?style\b/gi;
const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style\s*>/i;
const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style[ \t\n\r]*>/i;
/**
* A style element's opening tag, read as quoted name="value" pairs separated by the ASCII whitespace XML and HTML share: any
* other shape (a value holding < or >, an unquoted or valueless attribute, />, another space) refuses, so it cannot hide a closing tag.
*/
const SVG_STYLE_TAG = /^<(?:[A-Za-z_][\w.-]*:)?style(?:[ \t\n\r]+[\w.:-]+[ \t\n\r]*=[ \t\n\r]*(?:"[^"<>]*"|'[^'<>]*'))*[ \t\n\r]*>/i;
/** An element name, ended by ASCII whitespace, / or >: a non-ASCII character in the name (a prefix such as gé:) refuses rather than reading as its ASCII start. */
const SVG_TAG_NAME = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)(?=[ \t\n\r/>]|$)/;
/** Every name= in the text, non-ASCII letters included so a prefix such as xé:href is read whole and refused. */
const SVG_ATTR = /(?<![\w.:\-\u0080-\uffff])([A-Za-z_\u0080-\uffff][\w.:\-\u0080-\uffff]*)\s*=\s*(?=(?:"([^"]*)"|'([^']*)'|([^\s"'<>=]+)))/g;
const SVG_CSS_REF = /url\(\s*(['"]?)#[\w.:-]*\1\s*\)/gi;
const SVG_CSS_REFUSED = /[@(]/;
/** An href or src value a drawing may hold: a same-document reference, or a raster image inline. */
@@ -102,10 +111,12 @@ const SVG_HREF = /^\s*(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+
/**
* Whether an SVG's whole text is a drawing (GST-911): every element in SVG_ELEMENTS, no event handler, every href or src a
* same-document reference or an inline raster image, CSS of declarations only, and nothing SVG_REFUSED names, read after character references are
* decoded. Every name= in the text is read, so a stray quote cannot hide a later attribute inside its value; an unquoted value
* stops at the next =, which keeps the scan linear. A style element's CSS is walked with indexOf to its closing tag, dropping
* comments and reading CDATA as CSS, so neither can hide a closing tag; any other markup inside it, or a style element that never
* closes, refuses. Anything else, an SVG that can run code included, stays code.
* decoded. Every name= in the text is read (SVG_ATTR), so a stray quote cannot hide a later attribute inside its value, and a name
* holding a non-ASCII character refuses, like an element name (SVG_TAG_NAME); an unquoted value
* stops at the next =, which keeps the scan linear. A style element's opening tag is read quote by quote (SVG_STYLE_TAG), and its CSS is walked with indexOf to its closing tag over the raw
* text, so a character reference cannot fake a closing tag or a comment, and any & in it refuses (GST-940). Comments and CDATA are
* read as CSS, so neither can hide a closing tag and an HTML-only comment end (<!--> or --!>) exposes nothing unread; any other
* markup inside it, or a style element that never closes, refuses. Anything else, an SVG that can run code included, stays code.
*/
export function svgDrawing(text: string): boolean {
const t = decodeCharRefs(text);
@@ -113,36 +124,36 @@ export function svgDrawing(text: string): boolean {
const cssDrawing = (css: string) => !SVG_CSS_REFUSED.test(css.replace(SVG_CSS_REF, ''));
for (let at = 0; ;) {
SVG_STYLE_OPEN.lastIndex = at;
const open = SVG_STYLE_OPEN.exec(t);
const open = SVG_STYLE_OPEN.exec(text);
if (!open) break;
let i = t.indexOf('>', open.index) + 1;
if (!i) return false;
const tag = SVG_STYLE_TAG.exec(text.slice(open.index, open.index + 4096));
if (!tag) return false;
let i = open.index + tag[0].length;
let css = '';
for (;;) {
const lt = t.indexOf('<', i);
const lt = text.indexOf('<', i);
if (lt === -1) return false;
css += t.slice(i, lt);
if (t.startsWith('<!--', lt)) {
i = t.indexOf('-->', lt + 4) + 3;
if (i === 2) return false;
} else if (t.startsWith('<![CDATA[', lt)) {
const end = t.indexOf(']]>', lt + 9);
css += text.slice(i, lt);
const [start, close] = text.startsWith('<!--', lt) ? [lt + 4, '-->'] : text.startsWith('<![CDATA[', lt) ? [lt + 9, ']]>'] : [-1, ''];
if (start !== -1) {
const end = text.indexOf(close, start);
if (end === -1) return false;
css += t.slice(lt + 9, end);
css += ' ' + text.slice(start, end) + ' ';
i = end + 3;
} else if (SVG_STYLE_CLOSE.test(t.slice(lt, lt + 256))) {
} else if (SVG_STYLE_CLOSE.test(text.slice(lt, lt + 256))) {
at = lt + 1;
break;
} else return false;
}
if (!cssDrawing(css)) return false;
if (css.includes('&') || !cssDrawing(css)) return false;
}
for (let at = t.indexOf('<'); at !== -1; at = t.indexOf('<', at + 1)) {
if (SVG_MARKUP.test(t.slice(at, at + 512))) continue;
const tag = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/.exec(t.slice(at, at + 256));
const tag = SVG_TAG_NAME.exec(t.slice(at, at + 256));
if (!tag || !SVG_ELEMENTS.has(tag[1]!.toLowerCase())) return false;
}
for (const m of t.matchAll(/(?<![\w.:-])([A-Za-z_][\w.:-]*)\s*=\s*(?=(?:"([^"]*)"|'([^']*)'|([^\s"'<>=]+)))/g)) {
for (const m of t.matchAll(SVG_ATTR)) {
if (/[^\x00-\x7f]/.test(m[1]!)) return false;
const local = m[1]!.slice(m[1]!.lastIndexOf(':') + 1).toLowerCase();
const value = m[2] ?? m[3] ?? m[4] ?? '';
if (local.startsWith('on') || ((local === 'href' || local === 'src') && !SVG_HREF.test(value)) || (local === 'style' && !cssDrawing(value))) return false;
@@ -153,7 +164,7 @@ export function svgDrawing(text: string): boolean {
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, SVG_ELEMENTS: [...SVG_ELEMENTS],
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_STYLE_TAG: [SVG_STYLE_TAG], SVG_TAG_NAME: [SVG_TAG_NAME], SVG_ATTR: [SVG_ATTR], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
})).digest('hex').slice(0, 16);
/**
+3
View File
@@ -272,6 +272,9 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE;
if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true';
stampGateFindings(rec.findings, env.GSTACK_STAMP_SESSION_KIND);
if (rec.skill === 'review-fix-check' && (typeof rec.completed !== 'boolean' || typeof rec.converged !== 'boolean')) {
throw new Error('review-log: a review-fix-check row must carry completed and converged as true or false (review-army item 6 fields; without them gstack-review-read marks the row UNVERIFIED and finish cannot count it)');
}
// A /ship pass whose findings were all fixed, or deferred or noted below the floor, left no open defect (review-army
// item 6), so a claimed issues_found is recorded clean with zero counts and Step 11.5 can bind it (GST-430). Only the
// logger-stamped action counts (never a caller's status), and the findings must account for every claimed issue.
+67 -14
View File
@@ -122,20 +122,38 @@ function select(i: ExcludeInput, patterns: readonly string[]): { byPattern: Map<
return { byPattern, files };
}
/** Executable image magic: ELF, Mach-O (thin, universal and 64-bit universal; 0xcafebabe is also a Java class), PE, WebAssembly and a script's #!. */
const NATIVE_MAGIC = [[0x7f, 0x45, 0x4c, 0x46], [0xfe, 0xed, 0xfa, 0xce], [0xfe, 0xed, 0xfa, 0xcf], [0xce, 0xfa, 0xed, 0xfe], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xca, 0xfe, 0xba, 0xbf], [0x4d, 0x5a], [0x00, 0x61, 0x73, 0x6d], [0x23, 0x21]];
/**
* The matched paths git calls binary whose bytes hold a NUL in their first 8,000 (git's own test), read from the head blob,
* the file on a live tree, or the old blob for a deletion. A branch's .gitattributes can make git call a text file binary;
* only real bytes make it binary content here.
* The matched paths git calls binary by their bytes alone: `git diff --numstat` with attributes read from the empty tree, so
* a branch's .gitattributes (`diff`, `-binary`, `text`) cannot make an executable image read as text and skip the
* magic check. null when git cannot run it (a git without --attr-source); the caller then reads every matched file's bytes.
*/
function binaryContent(i: ExcludeInput, files: ReadonlyMap<string, Matched>, claimed: ReadonlySet<string>, unread: Set<string>): Set<string> {
function bytesBinary(i: ExcludeInput, patterns: readonly string[]): string[] | null {
const empty = git(i.root, ['hash-object', '-t', 'tree', '--stdin'], '');
if (!empty.ok) return null;
const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase];
const r = git(i.root, [`--attr-source=${empty.out.trim()}`, 'diff', '--numstat', '-z', '--no-renames', ...range, '--', ...patterns.map(p => `:(top,glob)${p}`)]);
return r.ok ? r.out.split('\0').filter(l => l.startsWith('-\t-\t')).map(l => l.slice(4)) : null;
}
const nativeImage = (b: Buffer) => NATIVE_MAGIC.some(m => m.every((x, k) => b[k] === x));
/**
* The claimed paths (git calls them binary under the branch's attributes or under none: bytesBinary) whose bytes hold a NUL
* in their first 8,000 (git's own test), read from the head blob, the file on a live tree, or the old blob for a deletion.
* A branch's .gitattributes can make git call a text file binary; only real bytes make it binary content here. A binary file that starts with executable image magic also goes in `native`.
*/
function binaryContent(i: ExcludeInput, files: ReadonlyMap<string, Matched>, claimed: ReadonlySet<string>, unread: Set<string>, native: Set<string>): Set<string> {
const out = new Set<string>();
const sniff = (p: string, b: Buffer) => { if (!b.includes(0)) return; out.add(p); if (nativeImage(b)) native.add(p); };
const real = (sha: string) => /^[0-9a-f]{40,64}$/.test(sha) && !/^0+$/.test(sha);
const bySha = new Map<string, string[]>();
for (const p of claimed) {
const m = files.get(p);
if (!m) continue;
if (!i.head && m.newMode !== '000000') {
try { const fd = fs.openSync(path.join(i.root, p), 'r'); const buf = Buffer.alloc(8000); const n = fs.readSync(fd, buf, 0, 8000, 0); fs.closeSync(fd); if (buf.subarray(0, n).includes(0)) out.add(p); } catch { unread.add(p); }
try { const fd = fs.openSync(path.join(i.root, p), 'r'); const buf = Buffer.alloc(8000); const n = fs.readSync(fd, buf, 0, 8000, 0); fs.closeSync(fd); sniff(p, buf.subarray(0, n)); } catch { unread.add(p); }
continue;
}
const sha = real(m.newSha) ? m.newSha : real(m.oldSha) ? m.oldSha : '';
@@ -144,8 +162,36 @@ function binaryContent(i: ExcludeInput, files: ReadonlyMap<string, Matched>, cla
for (const [sha, paths] of bySha) {
const r = spawnSync('git', ['cat-file', 'blob', sha], { cwd: i.root, timeout: 30_000, maxBuffer: 8000 });
const read = r.status === 0 || (r.error as NodeJS.ErrnoException | undefined)?.code === 'ENOBUFS';
const binary = read && (r.stdout ?? Buffer.alloc(0)).subarray(0, 8000).includes(0);
for (const p of paths) if (!read) unread.add(p); else if (binary) out.add(p);
const head = (r.stdout ?? Buffer.alloc(0)).subarray(0, 8000);
for (const p of paths) if (!read) unread.add(p); else sniff(p, head);
}
return out;
}
/**
* The added lines of matched files git calls binary only because of their attributes (the bytes hold no NUL): the
* collector's added lines are empty for them, so the secret-line rule reads `git diff --text` instead, which no
* .gitattributes, textconv or external diff driver changes. One git diff per KEEP_ARG_MAX_BYTES of literal pathspecs;
* git prints one section per path in byte order, so a chunk whose sections do not line up with its sorted paths, or a
* git failure, puts every path of that chunk in `unread`.
*/
function attributeBinaryLines(i: ExcludeInput, paths: readonly string[], unread: Set<string>): Record<string, string[]> {
const out: Record<string, string[]> = {};
const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase];
const chunks: string[][] = [];
let bytes = Infinity;
for (const p of [...paths].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) {
const n = argBytes([p]);
if (bytes + n > KEEP_ARG_MAX_BYTES) { chunks.push([]); bytes = 0; }
chunks[chunks.length - 1]!.push(p);
bytes += n;
}
for (const chunk of chunks) {
const r = git(i.root, ['diff', '--text', '--no-textconv', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '-U0', ...range, '--', ...chunk.map(literal)]);
const sections = r.ok ? r.out.split(/^(?=diff --git )/m).filter(s => s.startsWith('diff --git ')) : [];
const aligned = sections.length === chunk.length && chunk.every((p, k) => /["\\\x00-\x1f\x7f]/.test(p) || sections[k]!.startsWith(`diff --git a/${p} b/${p}\n`));
if (!aligned) { for (const p of chunk) unread.add(p); continue; }
chunk.forEach((p, k) => { let hunk = false; out[p] = sections[k]!.split('\n').filter(l => (hunk ||= l.startsWith('@@')) && l.startsWith('+')).map(l => l.slice(1)); });
}
return out;
}
@@ -159,17 +205,21 @@ function contentKind(i: ExcludeInput, p: string, binary: ReadonlySet<string>): C
return classifyPath(p, i.classRoot) === 'docs' ? 'docs' : null;
}
/** Source and script extensions: a NUL in one does not make it excludable binary content. */
const CODE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|go|rs|java|kts?|scala|swift|exs?|php|cs|c|h|cc|cpp|hpp|mm?|dart|pl|pm|lua|sh|bash|zsh|fish|ps1|bat|cmd|vue|svelte|html?|wasm)$/i;
/**
* The only extensions a binary file may be excludable content under: images, fonts, media, documents, compressed archives
* and data blobs. Any other binary (source with a NUL, a native library or executable, a key store, a pickle, no
* extension) is code; so is one that starts with executable image magic whatever its extension.
*/
const BINARY_CONTENT_EXT = /\.(?:png|jpe?g|gif|webp|bmp|ico|avif|tiff?|heic|heif|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|ogv|webm|mov|avi|mkv|flac|aac|opus|pdf|docx|xlsx|pptx|odt|ods|odp|gz|tgz|zip|tar|bz2|xz|zst|7z|bin|dat|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|sqlite3?|db|safetensors)$/i;
/** Why a matched file is kept by its path, mode and lines alone; null for content the policy may exclude. A binary file readers would only see as "Binary files differ" is content unless its directory, mode or gate tags keep it. */
function pathReason(i: ExcludeInput, p: string, m: Matched, binary: ReadonlySet<string>): KeptReason | null {
/** Why a matched file is kept by its path, mode and lines alone; null for content the policy may exclude. A binary file readers would only see as "Binary files differ" is content unless its directory, mode, gate tags, extension or magic keep it. */
function pathReason(i: ExcludeInput, p: string, m: Matched, binary: ReadonlySet<string>, native: ReadonlySet<string>): KeptReason | null {
if (/^\.(gstack|github)\//.test(p)) return 'policy-dir';
if (isGatePath(p)) return 'gate-path';
if (m.oldMode === '100755' || m.newMode === '100755') return 'executable';
if ([m.oldMode, m.newMode].some(x => x === '120000' || x === '160000')) return 'link';
if (i.gateTagged?.has(p)) return 'gate-tagged';
if (binary.has(p)) return CODE_EXT.test(p) ? 'code' : null;
if (binary.has(p)) return BINARY_CONTENT_EXT.test(p) && !native.has(p) ? null : 'code';
if (isAgentProse(p)) return 'agent-prose';
if (isDataConfig(p)) return 'config';
if (contentKind(i, p, binary) === null) return 'code';
@@ -227,9 +277,12 @@ export function reviewExclude(i: ExcludeInput): Exclusion {
const sel = select(i, patterns);
if ('error' in sel) return { state: 'refused', code: 'REVIEW_DIFF_UNREADABLE', lines: [`${i.context}: git could not list the files review_exclude_paths matches (${sel.error}); fix: git fetch origin ${base}, then rerun once; nothing was planned (REVIEW_DIFF_UNREADABLE)`] };
const unread = new Set<string>();
const binary = binaryContent(i, sel.files, new Set(i.perPath.filter(e => e.binary && sel.files.has(e.path)).map(e => e.path)), unread);
const native = new Set<string>();
const claimed = i.perPath.filter(e => e.binary && sel.files.has(e.path)).map(e => e.path);
const binary = binaryContent(i, sel.files, new Set([...claimed, ...(bytesBinary(i, patterns) ?? sel.files.keys())]), unread, native);
const text = { ...i, added: { ...i.added, ...attributeBinaryLines(i, claimed.filter(p => !binary.has(p) && !unread.has(p)), unread) } };
const reasons = new Map<string, KeptReason | null>();
for (const [p, m] of sel.files) reasons.set(p, unread.has(p) ? 'consumer-scan-unavailable' : pathReason(i, p, m, binary));
for (const [p, m] of sel.files) reasons.set(p, unread.has(p) ? 'consumer-scan-unavailable' : pathReason(text, p, m, binary, native));
const candidates = new Set([...reasons].filter(([, r]) => r === null).map(([p]) => p));
const named = grepFixed(i, [...candidates].filter(p => !p.includes('\n')), candidates, [i.mergeBase]);
for (const p of candidates) {
+89 -11
View File
@@ -42,7 +42,11 @@ import * as path from 'node:path';
import { sha256Hex, writeReceipt } from './egress-receipt';
import { runBashSync } from './script-invocation';
export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string }
/**
* `skippable` (GST-1190): named jobs the workflow may skip by path; a skip counts only in a pull_request run whose `gate` job
* succeeded, with the workflow, the declared `scripts` and any path the gate's steps name matching origin/<base> byte for byte.
*/
export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string; skippable?: { jobs: string[]; gate: string; scripts: string[] } }
export interface RemoteGateRuns { profiles: string[]; inventory: string; skipped?: string }
export interface ApiRun {
id: number; run_attempt: number; status: string; conclusion: string | null; event: string; head_sha: string;
@@ -50,7 +54,14 @@ export interface ApiRun {
repository?: { full_name: string } | null; head_repository?: { full_name: string } | null;
}
export interface ApiJob { name: string; conclusion: string | null; completed_at?: string | null }
export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown }
export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown; skipped_files?: unknown }
/** What a red run's reader needs beyond RunDeps (lib/pregate/flaky-exception.ts): every file of one artifact, and a pull request's head. */
export interface RunArtifactDeps {
/** Every file of the run's one unexpired artifact `name`, path → text; throws when it is missing, ambiguous or too large. */
artifactFiles(id: number, name: string): Record<string, string>;
pull(n: number): { head_sha: string; state: string; head_repo: string | null };
}
/** Everything verifyRun reads, injectable so every refusal has a fixture. */
export interface RunDeps {
@@ -62,18 +73,21 @@ export interface RunDeps {
}
export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | 'workflow_mismatch' | 'event_mismatch' | 'run_in_progress'
| 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch'
| 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_skipped' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch'
| 'artifact_reused' | 'tree_mismatch' | 'newer_run_vetoes' | 'run_changed' | 'run_stale' | 'os_mismatch' | 'api_error';
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null }
/** `skipped`: the files whose every test skipped on the runner (tree.json `skipped_files`, GST-1190); undefined when an older tree.json does not say. */
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[]; skipped_jobs?: string[] }
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string };
const SHA = /^[0-9a-f]{40}$/;
const RUN_EVENTS = ['push', 'workflow_dispatch', 'pull_request'];
const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os'];
const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os', 'skippable'];
const WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/;
const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/;
export const DEFAULT_MAX_AGE_HOURS = 24;
const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024;
/** Shard logs are the whole test stream, so a red run's reader allows more than a tree.json. */
const MAX_LOG_ARTIFACT_BYTES = 64 * 1024 * 1024;
/** A numeric run id, or a run URL on this repository (`…/actions/runs/<id>[/attempts/<n>][/job/<j>]`). */
export function parseRunRef(ref: string, repo: string): number | null {
@@ -104,6 +118,14 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
if (typeof p.artifact !== 'string' || !NAME.test(p.artifact)) errors.push({ path: `${at}.artifact`, message: 'must be the artifact name holding tree.json' });
if ('max_age_hours' in p && (!Number.isInteger(p.max_age_hours) || (p.max_age_hours as number) < 1 || (p.max_age_hours as number) > 168)) errors.push({ path: `${at}.max_age_hours`, message: 'must be an integer from 1 to 168' });
if ('os' in p && (typeof p.os !== 'string' || !/^(Linux|macOS|Windows)$/.test(p.os))) errors.push({ path: `${at}.os`, message: 'must be Linux, macOS or Windows' });
if ('skippable' in p) {
const s = p.skippable as Record<string, unknown> | null;
const names = (v: unknown) => Array.isArray(v) && v.length > 0 && v.length <= 32 && v.every(x => typeof x === 'string' && !!x.trim() && x.length <= 128);
const repoPath = (x: unknown) => typeof x === 'string' && !!x && !x.startsWith('/') && !x.split('/').includes('..') && !/[\0\r\n]/.test(x);
if (!s || typeof s !== 'object' || Array.isArray(s) || Object.keys(s).some(k => !['jobs', 'gate', 'scripts'].includes(k)) || !names(s.jobs) || typeof s.gate !== 'string' || !s.gate.trim()
|| (s.jobs as string[]).includes(s.gate as string) || !Array.isArray(s.scripts) || !s.scripts.length || !s.scripts.every(repoPath))
errors.push({ path: `${at}.skippable`, message: 'must be { jobs: [job names], gate: <the job that decides the skip>, scripts: [every repo file the gate reads] }, the gate not among the jobs' });
}
}
}
if (gateRuns !== undefined && gateRuns !== null) {
@@ -126,13 +148,13 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
const fail = (reason: RunRefusal, detail: string): RunVerdict => ({ ok: false, reason, detail });
/** When a run's newest attempt started. A run vetoes the candidate when it started at or after the second the candidate was created: a red re-run of an older run counts, and re-running the candidate does not hide a failure in between. */
const started = (r: ApiRun): string => r.run_started_at ?? r.created_at ?? '';
const normalPath = (f: string): string => f.replace(/^(\.\/)+/, '');
export const normalPath = (f: string): string => f.replace(/^(\.\/)+/, '');
/**
* The verdict for run `runRef` under `profile`. `vetoHeads` are further heads
* whose newer runs of the same workflow can veto (the consumer's own head).
*/
export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; now: Date }): RunVerdict {
export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; now: Date; unchanged?: (p: RunProfile) => string | null }): RunVerdict {
const id = parseRunRef(o.runRef, o.repo);
if (id === null) return fail('run_ref_invalid', `${JSON.stringify(o.runRef)} is not a run id or a run URL on ${o.repo}`);
const p = o.profile;
@@ -153,10 +175,22 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
const maxAge = p.max_age_hours ?? DEFAULT_MAX_AGE_HOURS;
if (!Number.isFinite(finished)) return fail('api_error', `${at} has no completion time`);
if (o.now.getTime() - finished > maxAge * 3600_000) return fail('run_stale', `${at} finished ${finishedAt}, more than ${maxAge} h before now`);
const skippedJobs: string[] = [];
for (const name of p.jobs) {
const hits = jobs.filter(j => j.name === name);
if (hits.length === 0) return fail('job_missing', `${at} has no job named ${name}`);
if (hits.length > 1) return fail('job_ambiguous', `${at} has ${hits.length} jobs named ${name}`);
if (hits[0]!.conclusion === 'skipped') {
const skip = p.skippable;
if (!skip?.jobs.includes(name)) return fail('job_skipped', `${at}'s ${name} job was skipped`);
if (run.event !== 'pull_request') return fail('job_skipped', `${at}'s ${name} job was skipped in a ${run.event} run; only a pull_request run's gate diffs against the PR base, so only its skip counts`);
const gate = jobs.filter(j => j.name === skip.gate);
if (gate.length !== 1 || gate[0]!.conclusion !== 'success') return fail('job_skipped', `${at}'s ${name} job was skipped and its gate job ${skip.gate} ${gate.length === 1 ? `concluded ${gate[0]!.conclusion}` : gate.length ? 'is ambiguous' : 'is missing'}`);
const changed = o.unchanged ? o.unchanged(p) : 'no base copy of the workflow was compared';
if (changed) return fail('job_skipped', `${at}'s ${name} job was skipped, but ${changed}, so this branch decided the skip`);
skippedJobs.push(name);
continue;
}
if (hits[0]!.conclusion !== 'success') return fail('job_not_success', `${at}'s ${name} job concluded ${hits[0]!.conclusion}`);
}
const heads = [...new Set([run.head_sha, ...(o.vetoHeads ?? []).filter(h => SHA.test(h))])];
@@ -186,11 +220,13 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`);
if (rec.files !== undefined && (!Array.isArray(rec.files) || rec.files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} files is not a list of paths`);
if (rec.tests !== undefined && (!Number.isInteger(rec.tests) || (rec.tests as number) < 0)) return fail('artifact_mismatch', `${at}'s ${p.artifact} tests is not a non-negative integer`);
if (rec.skipped_files !== undefined && (!Array.isArray(rec.skipped_files) || rec.skipped_files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} skipped_files is not a list of paths`);
const files = ((rec.files ?? []) as string[]).map(normalPath);
const tests = rec.tests === undefined ? null : (rec.tests as number);
const changed = runChanged(deps, id, run.run_attempt);
if (changed) return fail('run_changed', changed);
return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files, tests } };
const skipped = rec.skipped_files === undefined ? {} : { skipped: (rec.skipped_files as string[]).map(normalPath) };
return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files, tests, ...skipped, ...(skippedJobs.length ? { skipped_jobs: skippedJobs } : {}) } };
} catch (e: any) {
return fail('api_error', `reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`);
}
@@ -210,13 +246,21 @@ export function runInventory(command: string, cwd: string, env?: Record<string,
}
/** The files in `inventory` that no verified run's tree.json lists. */
export function uncoveredFiles(inventory: string[], evidence: RunEvidence[]): string[] {
const ran = new Set(evidence.flatMap(e => e.files.map(normalPath)));
/** A run's files minus the ones whose every test skipped there (GST-1190): only those ran anything. Older tree.json: all files. */
export const ranFiles = (e: RunEvidence): string[] => { const skipped = new Set(e.skipped ?? []); return e.files.map(normalPath).filter(f => !skipped.has(f)); };
/**
* The inventory files no run covers. `skipped: 'uncovered'` (a carry, which drops tests on a machine where they might run)
* also counts a file every test of which skipped in the run; `'covered'` (the remote gate, a Linux runner like CI) does not.
*/
export function uncoveredFiles(inventory: string[], evidence: RunEvidence[], skipped: 'uncovered' | 'covered' = 'covered'): string[] {
const ran = new Set(evidence.flatMap(e => (skipped === 'uncovered' ? ranFiles(e) : e.files.map(normalPath))));
return inventory.filter(f => !ran.has(normalPath(f)));
}
/** What a run check reads: the repository, the verifier's dependencies, the base branch's profiles and the clock. */
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date }
/** `unchanged` (findCarry only): null when a profile's workflow and gate inputs match the base, else which file differs. */
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date; unchanged?: (p: RunProfile) => string | null }
export type FromRunVerdict = { ok: true; evidence: RunEvidence; receipt: string } | { ok: false; reason: string; detail: string };
/**
@@ -298,3 +342,37 @@ export function ghRunDeps(repo: string, repoRoot: string): RunDeps {
},
};
}
/** The gh-backed artifact and pull request reads for `repo` (lib/pregate/flaky-exception.ts): one unexpired artifact by name, every regular file in it under a byte cap. */
export function ghArtifactDeps(repo: string): RunArtifactDeps {
const all = <T>(p: string, key: string): T[] => gh(['api', '--paginate', p, '--jq', `.${key}[]`]).split('\n').filter(Boolean).map(l => JSON.parse(l) as T);
return {
artifactFiles: (id, name) => {
const listed = all<{ name: string; size_in_bytes: number; expired: boolean }>(`repos/${repo}/actions/runs/${id}/artifacts?name=${encodeURIComponent(name)}&per_page=100`, 'artifacts').filter(a => a.name === name && !a.expired);
if (listed.length !== 1) throw new Error(`run ${id} has ${listed.length} unexpired artifacts named ${name}`);
if (!(listed[0]!.size_in_bytes <= MAX_LOG_ARTIFACT_BYTES)) throw new Error(`artifact ${name} is ${listed[0]!.size_in_bytes} bytes, over ${MAX_LOG_ARTIFACT_BYTES}`);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-run-evidence-'));
try {
gh(['run', 'download', String(id), '--repo', repo, '--name', name, '--dir', dir], 300_000);
const out: Record<string, string> = {};
let bytes = 0;
const walk = (rel: string) => {
for (const entry of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
const child = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) { walk(child); continue; }
if (!entry.isFile()) continue;
bytes += fs.statSync(path.join(dir, child)).size;
if (bytes > MAX_LOG_ARTIFACT_BYTES) throw new Error(`artifact ${name} unpacks to more than ${MAX_LOG_ARTIFACT_BYTES} bytes`);
out[child] = fs.readFileSync(path.join(dir, child), 'utf8');
}
};
walk('');
return out;
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
},
pull: n => {
const p = JSON.parse(gh(['api', `repos/${repo}/pulls/${n}`])) as { state: string; head: { sha: string; repo: { full_name: string } | null } };
return { head_sha: p.head.sha, state: p.state, head_repo: p.head.repo?.full_name ?? null };
},
};
}
+18 -13
View File
@@ -10,6 +10,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fragmentSlug } from './changelog-fragments';
import { laneMapping, recordCiResult, type ShipLaneMapping } from './ci-carry';
import { isConfigUnavailable, loadConfigFromBase } from './pregate/config';
import { parseUnifiedDiff, versionAtRef } from './restamp';
import { branchSides, stampOnlyLine } from './stamp-only';
@@ -113,7 +114,8 @@ export function releasePaths(policy: Record<string, any> | null, cwd: string, re
* added since the merge base with origin/<base>, and not added by the base since): after a lost slot and a merge of the
* base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead,
* the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). A branch line is
* named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980).
* named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980); on it a declared
* `stamp_placeholders` value may also become the new version inside a `stamp_patterns` template (GST-1026).
*/
export function stampedFiles(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : [];
@@ -123,11 +125,13 @@ export function stampedFiles(policy: Record<string, any> | null, cwd: string, re
let after = '';
try { after = extractVersion(fs.readFileSync(path.join(cwd, before.rel), 'utf8'), before.rel); } catch { return []; }
if (!after) return [];
const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : [];
const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []);
const templates = strings(policy?.stamp_patterns);
const placeholders = strings(policy?.stamp_placeholders);
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]);
if (diff.status !== 0) return [];
const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref);
const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at);
const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at, placeholders);
return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 &&
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!, h.removedAt[i]!)))).map(f => f.path);
}
@@ -171,15 +175,15 @@ export function laneConcurrency(mode: string | undefined, cpus = os.availablePar
return cpus <= 4 ? 1 : Infinity;
}
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string }
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; notOwed?: string; here?: string }
/** The base branch's `ship_lanes_from` entry for each lane whose command is a key, byte for byte (the ledger binds its sha256). */
export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map<string, { profiles: string[]; inventory: string }> {
/** The base branch's `ship_lanes_from` entry for each lane whose command is a key or a declared alias, byte for byte (the ledger binds its sha256). */
export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map<string, ShipLaneMapping> {
const loaded = loadConfigFromBase(cwd, base);
const map = new Map<string, { profiles: string[]; inventory: string }>();
const map = new Map<string, ShipLaneMapping>();
if (isConfigUnavailable(loaded) || loaded.errors.length) return map;
const from = loaded.config.ship_lanes_from;
for (const l of list) if (Object.prototype.hasOwnProperty.call(from, l.command)) map.set(l.label, from[l.command]!);
for (const l of list) { const m = laneMapping(from, l.command); if (m) map.set(l.label, m); }
return map;
}
@@ -189,9 +193,9 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co
*/
export function carryLane(cwd: string, base: string, l: { label: string; command: string }): LaneResult {
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], 600_000);
const got = / tests_ran=(\d+) log=- source=ci runs=(\S+)/.exec(rec.stderr);
if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[1]!, log: '-', carried: got[2]! };
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: /^CARRY_NONE: (.+)$/m.exec(rec.stderr)?.[1] ?? `record_failed: ${firstLine(rec.stderr)}` };
const got = recordCiResult(rec.stderr);
if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}) };
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}` };
}
/**
@@ -201,7 +205,7 @@ export function carryLane(cwd: string, base: string, l: { label: string; command
*/
export async function runLanes(cwd: string, list: { label: string; command: string }[], concurrency = Infinity, opts: { base?: string; reuse?: boolean } = {}): Promise<LaneResult[]> {
const out: LaneResult[] = [];
const mapped = opts.base ? mappedLanes(cwd, opts.base, list) : new Map<string, { profiles: string[]; inventory: string }>();
const mapped = opts.base ? mappedLanes(cwd, opts.base, list) : new Map<string, ShipLaneMapping>();
let next = 0;
const worker = async () => {
while (next < list.length) {
@@ -222,7 +226,8 @@ export async function runLanes(cwd: string, list: { label: string; command: stri
/** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone; a carried lane names its CI runs. */
export function laneLine(l: LaneResult): { line: string; ok: boolean } {
if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}`, ok: true };
if (l.notOwed) return { line: `LANE: ${l.label} not owed: ${l.notOwed} run=${l.carried}`, ok: true };
if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}${l.here ? ` ${l.here}` : ''}`, ok: true };
const zero = l.exit === 0 && l.testsRan === '0';
const state = l.exit !== 0 ? 'FAIL' : zero ? 'ZERO-RUN' : l.testsRan === 'unknown' ? 'pass (count unavailable)' : 'pass';
return { line: `LANE: ${l.label} ${state} exit=${l.exit} tests_ran=${l.testsRan} log=${l.log}${l.reused ? ` reused=${l.reused}` : ''}`, ok: l.exit === 0 && !zero };
+35
View File
@@ -0,0 +1,35 @@
/**
* Validation of the stamp policy keys (stamp_patterns templates and stamp_placeholders), split out of ship-policy.ts
* to keep that module under the module-size ratchet.
*/
import type { PolicyError } from './ship-policy';
import { parseVersion } from './version-source';
const MAX_TEMPLATES = 16;
const MAX_TEMPLATE = 128;
const MAX_PLACEHOLDERS = 16;
const MAX_PLACEHOLDER = 32;
export const VERSION_SLOT = '{version}';
export function templateErrors(value: unknown, out: PolicyError[]): void {
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; }
if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` });
value.forEach((t, i) => {
const at = `stamp_patterns[${i}]`;
if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; }
if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` });
else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` });
});
}
export function placeholderErrors(value: unknown, out: PolicyError[]): void {
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_placeholders', message: 'must be an array of literal placeholders' }); return; }
if (value.length > MAX_PLACEHOLDERS) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_placeholders', message: `more than ${MAX_PLACEHOLDERS} placeholders` });
value.forEach((p, i) => {
const at = `stamp_placeholders[${i}]`;
if (typeof p !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; }
if (!p.trim() || p.length > MAX_PLACEHOLDER || /[\0\r\n]/.test(p)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one non-empty line of at most ${MAX_PLACEHOLDER} characters` });
else if (p.includes(VERSION_SLOT)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} must not contain ${VERSION_SLOT}` });
else if (parseVersion(p)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} is a version, not a placeholder` });
});
}
+9 -14
View File
@@ -18,6 +18,8 @@ import * as path from 'node:path';
import { defaultBaseBranch, noDefaultBaseLine } from './default-base';
import { RESULT_CODES, type ResultCodeName } from './result-codes';
import { cmpVersion, parseVersion } from './version-source';
import { placeholderErrors, templateErrors } from './ship-policy-stamps';
export { VERSION_SLOT } from './ship-policy-stamps';
export const SHIP_POLICY_PATH = '.gstack/ship-policy.json';
export const SHIP_POLICY_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1';
@@ -64,6 +66,8 @@ export interface ShipPolicy {
stamp_files: string[];
/** Literal stamp templates with one `{version}` slot, beside the built-in `(was X)`, `since: X`, `vX`; writer and classifier share them. */
stamp_patterns: string[];
/** Placeholders (`TBD`) a restamp may replace with the new version inside a `stamp_patterns` template, on the branch's own lines only. */
stamp_placeholders: string[];
/** The repo's own restamp, run first in the staging worktree with the target version; gstack's version writer is skipped. */
restamp_command: string | null;
/** Migration numbering: `{ dir, id }` where `id` is a regex with one capture group over the file name. */
@@ -161,6 +165,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
maintainers: [],
stamp_files: [],
stamp_patterns: [],
stamp_placeholders: [],
restamp_command: null,
migrations: null,
gate_command: null,
@@ -193,6 +198,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.',
stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.',
stamp_patterns: 'Literal templates with exactly one {version} slot (at most 16, 128 characters each), for example `"version": "{version}"` or `since: \'{version}\'`; the restamp writer and the tree receipt use them beside (was X), since: X and vX.',
stamp_placeholders: 'Literal placeholders (at most 16, one line, 32 characters each, never `{version}` or a version), for example `["TBD"]`: a line the branch added that reads a placeholder in a stamp_patterns template (`since: \'TBD\'`) may become that template at the new version, and finish, the receipt carry and the tree receipt read it as a stamp. Empty (default) accepts no placeholder.',
restamp_command: 'The repo’s own restamp, run first in gstack-restamp’s staging worktree (a clean checkout of HEAD) with the target version as its argument and in GSTACK_RESTAMP_VERSION; gstack’s version writer is skipped, output confinement still applies. Needs a clean caller tree and --allow-repo-commands.',
migrations: '{ dir, id }: the migration directory and a regex with one capture group for the number in a file name; `gstack-pregate regate` reports MIGRATION_COLLISION against the base branch and every open PR.',
gate_command: 'The repo’s full pre-merge gate command. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. null when the repo has none.',
@@ -236,6 +242,7 @@ export const BUMP_NEVER_KEY_VERSION = '1.91.353.0';
export const BASE_RED_KEY_VERSION = '1.91.374.0';
/** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */
export const TRAIN_GATES_KEY_VERSION = '1.92.0.0';
export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.16.0';
export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0';
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
@@ -245,6 +252,7 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION,
stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION,
};
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
@@ -274,9 +282,6 @@ const MAX_PATTERN = 256;
const MAX_REGEX = 512;
const MAX_COMMAND = 512;
const SHA_RE = /^[0-9a-f]{7,64}$/;
const MAX_TEMPLATES = 16;
const MAX_TEMPLATE = 128;
export const VERSION_SLOT = '{version}';
function hasDotDot(p: string): boolean {
return p.split(/[\\/]+/).some(seg => seg === '..');
@@ -343,17 +348,6 @@ function excludeErrors(value: unknown, out: PolicyError[]): void {
});
}
function templateErrors(value: unknown, out: PolicyError[]): void {
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; }
if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` });
value.forEach((t, i) => {
const at = `stamp_patterns[${i}]`;
if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; }
if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` });
else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` });
});
}
function migrationErrors(value: unknown, out: PolicyError[]): void {
const m = value as Record<string, unknown> | null;
if (!m || typeof m !== 'object' || Array.isArray(m)) { out.push({ code: 'POLICY_INVALID', path: 'migrations', message: 'must be { dir, id } or null' }); return; }
@@ -497,6 +491,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
if ('release_outputs' in obj) listErrors('release_outputs', obj.release_outputs, errors, globError);
if ('stamp_files' in obj) listErrors('stamp_files', obj.stamp_files, errors, globError);
if ('stamp_patterns' in obj) templateErrors(obj.stamp_patterns, errors);
if ('stamp_placeholders' in obj) placeholderErrors(obj.stamp_placeholders, errors);
if ('restamp_command' in obj && obj.restamp_command !== null) {
const t = obj.restamp_command;
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'restamp_command', message: 'must be a non-empty string or null' });
+44
View File
@@ -8,8 +8,12 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { findCarry, laneMapping, mappingCoverage } from './ci-carry';
import { defaultBaseBranch } from './default-base';
import { isConfigUnavailable, loadConfigFromHead } from './pregate/config';
import { buildRunCheck } from './pregate/run-check';
import { classifyDiff } from './review-depth';
import { wtreeFingerprint } from './script-invocation';
import { audit } from './ship-audit';
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
@@ -20,6 +24,11 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste
--lanes serial|parallel how Step 5 runs its lanes; unset, one at a time on 4 or fewer cores, all at once above
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
(a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS
gstack-ship-preflight --tests-only --base <base> --policy-from head --simulate [--allow-repo-commands] --lane <label>=<command>...
a dry run that proves a new ship_lanes_from mapping: which lanes the branch's own map would carry from a verified
CI run at this tree. Every line starts SIMULATION:; no lane runs, no evidence or ledger row is written and no
obligation clears (a real carry reads origin/<base>'s map only). An inventory-mapped lane runs the branch's
inventory command, so it needs --allow-repo-commands. Exit 0 when every mapped lane would carry, else 1
gstack-ship-preflight --audit --base <base> --out <dir> the single reads and Steps 7-8's deterministic audits
(CONTEXT, TODOS, COVERAGE_MAP, Coverage, PLAN_*, READER: coverage=needed|none plan=needed|none)
@@ -164,6 +173,40 @@ async function tests(cwd: string, base: string, list: { label: string; command:
r.next('Step 6: select eval suites for prompt-related changes with the project\'s selector, then Step 7');
}
/**
* --simulate (GST-1190): HEAD's ship_lanes_from and run_profiles, findCarry against this tree, nothing else. It never calls
* gstack-evidence or gstack-pregate, so it writes no row and clears no obligation: a PR controls both its own map and the
* CI that would satisfy it, so head policy never carries. The branch's inventory commands run only with `allow`
* (--allow-repo-commands), as gstack-ci-carry find --simulate requires.
*/
function simulate(cwd: string, base: string, list: { label: string; command: string }[], allow: boolean): { text: string; exit: number } {
const lines: string[] = [];
const say = (s: string) => lines.push(`SIMULATION: ${s}`);
const done = (exit: number) => ({ text: lines.join('\n') + '\n', exit });
const loaded = loadConfigFromHead(cwd);
say(`policy ${isConfigUnavailable(loaded) ? 'unavailable' : loaded.label}; a dry run: only the branch's inventory commands run, and only with --allow-repo-commands; no evidence or ledger row is written, nothing clears (a carry reads origin/${base}'s map only)`);
if (isConfigUnavailable(loaded) || loaded.errors.length) { say(`refused: ${isConfigUnavailable(loaded) ? loaded.detail : loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`); return done(1); }
const check = buildRunCheck(cwd, null, allow, base, 'head');
if ('error' in check) { say(`refused: ${check.error}`); return done(1); }
const head = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
const tree = git(cwd, ['rev-parse', 'HEAD^{tree}']).stdout.trim();
const wtree = () => { const fp = wtreeFingerprint({ cwd }); return 'tree' in fp ? fp.tree : null; };
let mapped = 0;
let carried = 0;
for (const l of list) {
const m = laneMapping(loaded.config.ship_lanes_from, l.command);
if (!m) { say(`LANE: ${l.label} not mapped by the branch's ship_lanes_from; /ship would run it`); continue; }
mapped++;
if (m.inventory && !allow) { say(`CI_CARRY: none label=${l.label} (inventory_not_allowed: the branch's inventory command is a pin-file command; pass --allow-repo-commands)`); continue; }
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd) });
if (!v.ok) { say(`CI_CARRY: none label=${l.label} (${v.reason}: ${v.detail})`); continue; }
carried++;
say(`LANE: ${l.label} would carry run=${v.runs.map(e => `${e.run_id}/${e.run_attempt}`).join(',')} tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ''}${v.remainder ? ` and would run ${v.remainder.length} skipped file(s) here with per_file` : ''}`);
}
say(`${carried} of ${mapped} mapped lane(s) would carry once origin/${base} holds this map`);
return done(mapped > 0 && carried === mapped ? 0 : 1);
}
export async function preflight(argv: readonly string[], cwd: string): Promise<{ text: string; exit: number }> {
const { flag, has } = flagReader(argv);
const base = flag('--base');
@@ -178,6 +221,7 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
if (!['base', 'head'].includes(policyFrom)) return { text: 'gstack-ship-preflight: --policy-from needs base|head\n', exit: 2 };
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
if (has('--simulate')) return has('--tests-only') && policyFrom === 'head' && list.length ? simulate(cwd, base, list, has('--allow-repo-commands')) : { text: 'gstack-ship-preflight: --simulate needs --tests-only --policy-from head and at least one --lane <label>=<command>\n', exit: 2 };
const mode = flag('--lanes');
const concurrency = has('--lanes') && mode === undefined ? '--lanes needs serial or parallel' : laneConcurrency(mode);
if (typeof concurrency === 'string') return { text: `gstack-ship-preflight: ${concurrency}\n`, exit: 2 };
+7
View File
@@ -29,6 +29,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { PregateFile } from './headless-artifacts';
import { CHECKS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
import { exceptionConsumerProblem, flakyExceptionLines } from './pregate/flaky-exception';
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
import type { ResultCodeName } from './result-codes';
import { extractReceipt } from './ship-receipt';
@@ -232,6 +233,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL);
let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`;
let pregateOk = false;
let accepted: PregateFile | null = null;
const treeNow = wtreeFingerprint({ cwd: ctx.repoRoot });
// The mirror stands in for a record that is missing, unreadable, recorded for another tree, or current but
// waiting on a remote receipt another checkout holds; a record whose own checks failed or stayed incomplete (the diff's doing
@@ -242,6 +244,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
if (read.file) {
const v = verifyPregate(ctx.repoRoot, read.file, treeNow);
pregateOk = v.ok;
if (v.ok) accepted = read.file;
useMirror = !v.ok && v.code !== 'PREGATE_LANE_FAILED' && v.code !== 'PREGATE_INCOMPLETE' && v.code !== 'PREGATE_ENVIRONMENT';
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`;
} else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`;
@@ -253,9 +256,12 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
const v = read?.file && !gap ? verifyPregate(ctx.repoRoot, read.file, treeNow) : null;
if (read?.file && v?.ok) {
pregateOk = true;
accepted = read.file;
pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from the state root's pregate/by-tree/${treeNow.tree.slice(0, 12)}.json, recorded by another checkout)`;
} else if (gap) pregateLine += `; the state root's mirror for this tree cannot stand in: ${gap}`;
}
const foreign = accepted && exceptionConsumerProblem(accepted, head, o.pr);
if (foreign) { pregateOk = false; accepted = null; pregateLine += ` — ${foreign} (PREGATE_REMOTE_UNCLEARED)`; }
const receipt = bindShippedReceipt(ctx.repoRoot, `origin/${ctx.base}`, info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? ''));
const attempt = currentAttempt(ctx.repoRoot, o.pr);
const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head);
@@ -268,6 +274,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
`ci: ${ci.line}`,
...(hint ? [hint] : []),
pregateLine,
...(accepted ? flakyExceptionLines(accepted) : []),
gateLine(policy, attempt),
receipt.line,
...(receipt.depthLine ? [receipt.depthLine] : []),
+3 -2
View File
@@ -224,7 +224,8 @@ function headInputs(root: string, baseRef: string, head: string, r?: ShipReceipt
/**
* The queue's exclusion check: the digest the receipt recorded (none when absent) against the one `head` has under the current
* base policy. Only the digest counts (the policy's patterns and the excluded list), so a base merge or a moved base that leaves
* both unchanged binds; a changed selection needs the review rerun, which receipt write alone cannot stand in for.
* both unchanged binds; a changed selection needs the review rerun (write checks the same digest and does not read the
* review ledger).
*/
export function excludeMismatch(x: Exclusion, r: ShipReceipt, head: string, base: string): string | null {
const expected = x.state === 'active' ? x.record.digest : 'none';
@@ -232,7 +233,7 @@ export function excludeMismatch(x: Exclusion, r: ShipReceipt, head: string, base
if (expected === recorded) return null;
const source = x.state === 'active' ? x.record.source : r.review_exclude?.source ?? base;
const h = head.slice(0, 12);
return `field=review_exclude expected=${expected} recorded=${recorded} source=${source}: the reviewers read another selection than ${base}'s review_exclude_paths gives ${h}; the receipt is unchanged; fix: rerun /ship's review on ${h} (gstack-review-dispatch pass --base ${base.replace(/^(refs\/remotes\/)?origin\//, '')}), then gstack-ship-receipt write --pr <n> --review-exclude <d> --body <body> with the digest gstack-review-dispatch verify --ledger <ledger> prints on REVIEW_EXCLUDE_RECORD:; a receipt write alone does not clear this`;
return `field=review_exclude expected=${expected} recorded=${recorded} source=${source}: the receipt records another selection than ${base}'s review_exclude_paths gives ${h}; the receipt is unchanged; fix: rerun /ship's review on ${h} (gstack-review-dispatch pass --base ${base.replace(/^(refs\/remotes\/)?origin\//, '')}), then gstack-ship-receipt write --pr <n> --review-exclude <d> --body <body> with the digest gstack-review-dispatch verify --ledger <ledger> prints on REVIEW_EXCLUDE_RECORD: (write checks the digest against the base policy and does not read the review ledger, so only the rerun shows the reviewers read this selection)`;
}
/** The receipt's recorded gate scan when it names this head; otherwise null (unavailable). */
const boundScan = (r: ShipReceipt, head: string): number | null => {
+32 -5
View File
@@ -6,6 +6,7 @@
* computes the P7 adoption census over merged PRs (deduplicated by run and
* thread id against an owner-maintained thread cohort).
*/
import { spawnSync } from 'node:child_process';
import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts';
import { approvalSummary } from './approval-citations';
import { FRAGMENT_README } from './changelog-fragments';
@@ -19,7 +20,7 @@ const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'ci_carried', 'spend_usd',
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop',
'review_loop_exception', 'review_targeted', 'review_exclude', 'carry', 'base_red',
'review_loop_exception', 'review_targeted', 'review_exclude', 'carry', 'base_red', 'flaky_exception',
];
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
@@ -177,6 +178,8 @@ export interface CarryPlanInput {
rangeDiff: string;
/** The base the receipt was reviewed against when it differs from the carry's (a stack parent that landed, #3570): productPatchId.from is then the recorded diff against it. */
retargetedFrom?: string;
/** A commit inside the reviewed range whose product change the base already holds (a stack parent that squash-merged under a receipt written against the base, GST-1199): productPatchId.from is then the range from it. */
stackParent?: string;
/** A clean scoped review of product paths whose only difference is base context next to unchanged hunks (lib/ship-patch.ts, GST-384): the carry reason it adds. */
scopedReview?: string;
/** Stamp files the branch changed as product whose only change since the reviewed head is a stamp on the branch's own rows, the base untouched, and without which the two product patches match (GST-980). */
@@ -189,9 +192,9 @@ const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test an
/**
* The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else
* `package.json`) and the base policy's `stamp_patterns` templates.
* `package.json`) and the base policy's `stamp_patterns` templates and `stamp_placeholders`.
*/
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide }
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[]; side?: BranchSide; placeholders?: readonly string[] }
/**
* A policy-named stamp path's change is a stamp when the new text reads as the
@@ -214,7 +217,7 @@ export function stampOnlyText(change: CarryChange): boolean {
const now = after.split('\n');
if (was.length !== now.length) return false;
const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]);
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side, i + 1));
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side, i + 1, change.placeholders ?? []));
}
/**
@@ -253,7 +256,8 @@ export function planCarry(input: CarryPlanInput): CarryPlan {
const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS);
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` };
}
const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : [];
const parent = input.retargetedFrom ?? input.stackParent?.slice(0, 12);
const reasons: string[] = parent ? [`stack parent ${parent} landed`] : [];
if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`);
if (input.scopedReview && input.productPatchId.from !== input.productPatchId.head) reasons.push(input.scopedReview);
if (input.stampedRows?.length && input.productPatchId.from !== input.productPatchId.head) reasons.push(`a stamp on the branch's own rows (${input.stampedRows.join(', ')})`);
@@ -271,6 +275,29 @@ export function planCarry(input: CarryPlanInput): CarryPlan {
};
}
/** How many commits of the reviewed range landedStackParent tries, newest first. */
export const STACK_PARENT_WALK = 100;
/**
* The newest commit inside the reviewed range (rangeBase..reviewed, the reviewed head itself excluded) whose product
* change the current base already holds: merging it into currentBase changes no product path. That is a stack parent
* that squash-merged (or merged) into the base after the receipt was written against the base (GST-1199). A commit with no
* product change of its own (a test-first commit) is no parent, since any base trivially holds it. Null when no
* commit within STACK_PARENT_WALK qualifies or git cannot tell; a conflicted product path is never held.
*/
export function landedStackParent(repoRoot: string, s: { rangeBase: string; reviewed: string; currentBase: string }, product: readonly string[]): string | null {
const run = (args: string[]) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
const commits = run(['rev-list', '--topo-order', `--max-count=${STACK_PARENT_WALK + 1}`, `${s.rangeBase}..${s.reviewed}`]);
if (commits.status !== 0) return null;
for (const commit of commits.stdout.split('\n').filter(c => c && c !== s.reviewed)) {
if (run(['diff', '--quiet', s.rangeBase, commit, '--', ...product]).status !== 1) continue;
const merged = run(['merge-tree', '--write-tree', '--no-messages', s.currentBase, commit]);
const tree = merged.status === 0 || merged.status === 1 ? merged.stdout.split('\n')[0]?.trim() : undefined;
if (tree && run(['diff', '--quiet', s.currentBase, tree, '--', ...product]).status === 0) return commit;
}
return null;
}
export interface TreeCarryInput {
from: string; head: string;
/** HEAD^{tree}, and every tree the receipt already covers (its head_tree plus earlier carries' trees). */
+48 -12
View File
@@ -15,6 +15,8 @@ import { mergeChangelogs, normalizeChangelog } from './changelog-check';
import { versionAtRef } from './restamp';
import { classifyPath } from './review-depth';
import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch';
import { locate, readRecord } from './approval-record';
import { reviewFreshness } from './review-evidence';
import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth';
import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
import { resolveStateRoot } from './state-root';
@@ -171,20 +173,30 @@ function commitRelease(cwd: string, release: string[], trailer: string | undefin
/**
* A harness pin edit that only records changed harness files as non-behavioral at the same version and patterns (GST-1031): release
* metadata, like a version stamp. A bump, a pattern change or an entry without its reason is behavior.
* metadata, like a version stamp. The pins live one file per harness file under scripts/harness-pins/ (GST-1084), beside a manifest
* holding only version and patterns. A bump, a pattern change or a pin without its reason is behavior.
*/
const HARNESS_PIN = 'scripts/harness-version.json';
const HARNESS_MANIFEST = 'scripts/harness-version.json';
const HARNESS_PINS = 'scripts/harness-pins/';
function harnessRepin(cwd: string, from: string, to: string, p: string): boolean {
if (p !== HARNESS_PIN) return false;
const read = (tree: string): any => { const s = git(cwd, ['show', `${tree}:${p}`]); try { return s.status === 0 ? JSON.parse(s.stdout) : null; } catch { return null; } };
const a = read(from), b = read(to);
if (!a?.files || !b?.files || typeof b.files !== 'object' || a.version !== b.version || JSON.stringify(a.patterns) !== JSON.stringify(b.patterns)) return false;
return Object.entries(b.files as Record<string, any>).every(([f, e]) => JSON.stringify(a.files[f]) === JSON.stringify(e)
|| (e?.since === b.version && typeof e?.nonBehavioral === 'string' && e.nonBehavioral.trim().length >= 10));
if (p !== HARNESS_MANIFEST && !(p.startsWith(HARNESS_PINS) && p.endsWith('.json'))) return false;
const read = (tree: string, file: string): any => { const s = git(cwd, ['show', `${tree}:${file}`]); if (s.status !== 0) return undefined; try { return JSON.parse(s.stdout); } catch { return null; } };
const a = read(from, HARNESS_MANIFEST), b = read(to, HARNESS_MANIFEST);
if (!a || !b || Object.keys(b).sort().join() !== 'patterns,version' || a.version !== b.version || JSON.stringify(a.patterns) !== JSON.stringify(b.patterns)) return false;
if (p === HARNESS_MANIFEST) return true;
const e = read(to, p);
return e === undefined || JSON.stringify(read(from, p)) === JSON.stringify(e)
|| (e?.since === b.version && typeof e?.nonBehavioral === 'string' && e.nonBehavioral.trim().length >= 10);
}
/** Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). */
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted', string[]>;
/**
* Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts).
* A code or test path the newest completed clean review-fix-check row read (inside its range_base..commit range, where
* range_base holds the reviewed tree, and unchanged since) counts as fix_checked when an approved review_loop_grant names a stop
* head inside range_base..commit: the owner's granted fix-check after Step 11 is a review (GST-1130). Authored docs keep the
* stage 2.2 recheck.
*/
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted' | 'fix_checked', string[]>;
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null {
const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return null; }
@@ -204,7 +216,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
sides = { reviewedBase: rb, reviewed, currentBase: cb, current: now };
touched = new Set([...a, ...b]);
}
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [] };
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [], fix_checked: [] };
for (const p of [...changedSet].sort()) {
const verdict = sides ? (touched.has(p) ? comparePath(cwd, sides, p) : 'same') : 'changed';
by[verdict === 'same' ? 'base' : release.includes(p) || harnessRepin(cwd, reviewed, now, p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
@@ -219,6 +231,30 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
by.context = by.context.filter(p => !packetBody.over.includes(p));
by.tests = by.tests.filter(p => !packetBody.over.includes(p));
}
const fixRow = bin(cwd, 'gstack-review-read', [], 60_000).stdout.split('\n---CONFIG---')[0].split('\n')
.flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review-fix-check' ? [v] : []; } catch { return []; } }).at(-1);
const commitOf = (c: unknown) => typeof c === 'string' && /^[0-9a-f]{7,64}$/.test(c) ? git(cwd, ['rev-parse', '--verify', '--quiet', `${c}^{commit}`]).stdout.trim() : '';
const onBranch = (c: string) => !!c && git(cwd, ['merge-base', '--is-ancestor', c, 'HEAD']).status === 0;
const fixCommit = commitOf(fixRow?.commit_full), fixFrom = commitOf(fixRow?.range_base);
const fresh = fixRow && reviewFreshness(fixRow, fixRow.wtree);
const ancestor = (a: string, d: string) => git(cwd, ['merge-base', '--is-ancestor', a, d]).status === 0;
const grants = fixRow ? (loc => 'file' in loc ? readRecord(loc).rows : [])(locate({ cwd, env: process.env })) : [];
const grant = grants.find(g => g.outcome === 'approved' && `${g.ask} ${g.scope}`.includes('review_loop_grant')
&& (`${g.ask} ${g.scope}`.match(/\b[0-9a-f]{7,40}\b/g) ?? []).some(s => { const c = commitOf(s); return !!c && !!fixFrom && !!fixCommit && ancestor(fixFrom, c) && ancestor(c, fixCommit); }));
const notCounted = !fixRow ? '' : fresh?.status !== 'CURRENT' ? fresh?.reason ?? 'not a review row'
: fixRow.tree !== fixRow.wtree || !onBranch(fixCommit) ? 'the row was logged on content no branch commit holds'
: !onBranch(fixFrom) ? 'the row names no range_base commit on this branch'
: git(cwd, ['rev-parse', `${fixFrom}^{tree}`]).stdout.trim() !== reviewed ? `range_base ${fixFrom.slice(0, 12)} does not hold the reviewed tree`
: !grant ? 'no approved review_loop_grant names a stop head inside its range_base..commit' : '';
if (notCounted) r.add(`FIX_CHECK: not counted (${notCounted})`);
const counted = !!fixRow && !notCounted;
if (counted) {
const range = touchedPaths(cwd, fixFrom, fixCommit), after = touchedPaths(cwd, fixCommit, now);
if (!range || !after) { r.stop(`git could not diff the fix-check range ${fixFrom.slice(0, 12)}..${fixCommit.slice(0, 12)} against the reviewed and current trees: stage 2 is blocked`); return null; }
by.fix_checked = [...by.behavior, ...by.tests, ...by.context].filter(p => range.has(p) && !after.has(p)).sort();
for (const k of ['behavior', 'tests', 'context'] as const) by[k] = by[k].filter(p => !by.fix_checked.includes(p));
r.add(`FIX_CHECK: reviewed ${by.fix_checked.length ? short(by.fix_checked) : 'none'} (fix-check at ${fixCommit.slice(0, 12)}, range from ${fixFrom.slice(0, 12)}, grant ${grant!.id})`);
}
const loop = opts.loop;
if (loop) {
const head = loop.fix_check.head;
@@ -233,7 +269,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
by.behavior = by.behavior.filter(p => !by.accepted.includes(p));
by.docs = by.docs.filter(p => !by.accepted.includes(p));
}
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${loop ? ` accepted=${by.accepted.length}` : ''}`);
r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${counted ? ` fix_checked=${by.fix_checked.length}` : ''}${loop ? ` accepted=${by.accepted.length}` : ''}`);
if (loop) r.add(`REVIEW_LOOP_EXCEPTION: accepted ${by.accepted.length ? short(by.accepted) : 'none'} (changed since fix-check ${loop.fix_check.head.slice(0, 12)}; accepted by ${loop.accepted_by} ${loop.date} via ${loop.via})`);
if (by.behavior.length) { r.stop(`behavior, tests or build inputs changed since review (${short(by.behavior.sort())}): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)`); return null; }
const scoped = [...by.context, ...by.tests].sort();
+28 -4
View File
@@ -4,7 +4,8 @@
* lines the branch wrote itself. Split from restamp.ts (module-size ratchet).
*/
import { spawnSync } from 'node:child_process';
import { branchAddedLines, forkVersion, rewriteStamps, stampMentions } from './restamp';
import { branchAddedLines, escapeRegExp, forkVersion, rewriteStamps, stampMentions } from './restamp';
import { VERSION_SLOT } from './ship-policy';
import { cmpVersion, parseVersion, versionWidth } from './version-source';
/**
@@ -26,12 +27,20 @@ import { cmpVersion, parseVersion, versionWidth } from './version-source';
* stage's bound for its own lines (GST-895, GST-963): finish's stampedFiles,
* the tree receipt and the receipt carry all pass the same side. When the
* version does not move (a slot that keeps its claimed VERSION), only such a
* branch line may change: the stage moves nothing else then.
* branch line may change: the stage moves nothing else then. On such a branch
* line a declared placeholder (`stamp_placeholders`, GST-1026: gbrain's
* `since: 'TBD'`) may also become the new version, but only inside a
* `stamp_patterns` template with literal text around its slot, never a built-in
* form: with at least one such rewrite, the line must then be the added line,
* or one whose remaining stamp-form moves pass the fork-version bound.
*/
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number): boolean {
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number, placeholders: readonly string[] = []): boolean {
if (removed === added) return false;
if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates)) return true;
return !!side && at !== undefined && side.at.has(at) && side.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates);
if (!side || at === undefined || !side.at.has(at) || !side.added.has(removed)) return false;
if (stampOnlyWithin(removed, added, side.fork, newV, templates)) return true;
const filled = fillPlaceholders(removed, newV, templates, placeholders);
return filled.count > 0 && (filled.text === added || stampOnlyWithin(filled.text, added, side.fork, newV, templates));
}
/**
@@ -64,6 +73,21 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal
};
}
/** Each declared placeholder rewritten to `newV` inside each template with literal text around its slot (a bare edge at a word boundary). */
function fillPlaceholders(removed: string, newV: string, templates: readonly string[], placeholders: readonly string[]): { text: string; count: number } {
let text = removed;
let count = 0;
for (const t of templates) {
const [pre, post] = t.split(VERSION_SLOT) as [string, string];
if (!pre && !post) continue;
for (const p of placeholders) {
const re = new RegExp(`${escapeRegExp(pre)}${pre ? '' : '(?<![A-Za-z0-9_.])'}${escapeRegExp(p)}${post ? '' : '(?![A-Za-z0-9_.])'}${escapeRegExp(post)}`, 'g');
text = text.replace(re, () => { count++; return pre + newV + post; });
}
}
return { text, count };
}
function stampOnlyWithin(removed: string, added: string, oldV: string, newV: string, templates: readonly string[]): boolean {
const floor = parseVersion(oldV);
const width = versionWidth(oldV);
+5 -4
View File
@@ -78,15 +78,16 @@ export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: st
/**
* Every removed/added pair differs only by stamp rewrites to the new version:
* the old version, or a tentative one (newer than it) the stamp_paths stage
* moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines).
* moved (#3512), each in a stamp form (`stampOnlyLine`, bounded by the fork version on the branch's own lines, where a
* declared placeholder may also move inside a stamp_patterns template, GST-1026).
*/
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide): { why: string; line: number } | null {
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[], side?: BranchSide, placeholders: readonly string[] = []): { why: string; line: number } | null {
for (const h of hunks) {
if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at };
for (let i = 0; i < h.removed.length; i++) {
const r = h.removed[i]!;
const a = h.added[i]!;
if (!stampOnlyLine(r, a, oldV, newV, templates, side, h.removedAt[i])) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
if (!stampOnlyLine(r, a, oldV, newV, templates, side, h.removedAt[i], placeholders)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
}
}
return null;
@@ -130,7 +131,7 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre
const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null;
const own = rule ? side?.(f.path) : undefined;
if (oldV && newV && (oldV !== newV || own) && rule) {
const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own);
const bad = stampOnlyHunks(f.hunks, oldV, newV, policy.stamp_patterns, own, policy.stamp_placeholders);
return bad ? changed(rule, bad.why, bad.line) : stamp(rule);
}
return changed('other', `${f.hunks.length} hunk(s) outside every stamp rule`);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.94.8",
"version": "1.94.24",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -2,7 +2,7 @@
name: qa
preamble-tier: 4
version: 2.0.0
description: Test the site, API or CLI and fix the bugs it finds. (gstack)
description: Use when you test the site, API or CLI and fix bugs. (gstack)
allowed-tools:
- Bash
- Read
+2 -2
View File
@@ -3,9 +3,9 @@ name: qa
preamble-tier: 4
version: 2.0.0
description: |
Test the site, API or CLI and fix the bugs it finds.
Use when you test the site, API or CLI and fix bugs.
Covers browser/API/CLI/job/worker/webhook bugs and commits verified fixes atomically.
Use when asked to "qa", "QA", "qa test this", "test this site", "find bugs", "find bugs on site",
Fits "qa", "QA", "qa test this", "test this site", "find bugs", "find bugs on site",
"test and fix", or "fix what's broken".
Proactively suggest when the user says a feature is ready for testing
or asks "does this work?". Three tiers: Quick (critical/high only),
+27 -1
View File
@@ -2,7 +2,7 @@
name: review
preamble-tier: 4
version: 1.0.0
description: Review this PR's diff. (gstack)
description: Use when you review this PR's diff. (gstack)
allowed-tools:
- Bash
- Read
@@ -15,13 +15,39 @@ allowed-tools:
- WebSearch
triggers:
- review this pr
- look over my diff
- code review
- check my diff
- review my changes
- pre-landing review
contract:
version: 1
items:
- id: specialists-dispatched
kind: selected_specialists_dispatched
marker: "Dispatching N specialists:"
- id: adversarial-pass
kind: agent_dispatch
match: Think like an attacker and a chaos engineer
- id: review-report
kind: report_labels
heading: Pre-Landing Review
labels:
- Gate edits
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
## Done means
Finish only when each of these exists (contract v1):
- `specialists-dispatched`: the specialist selection printed (`Dispatching N specialists:` ...), then one Agent dispatch per specialist it names (none when a small diff skips them).
- `adversarial-pass`: an Agent dispatch for the adversarial pass, its prompt including "Think like an attacker and a chaos engineer".
- `review-report`: a `Pre-Landing Review` with Gate edits filled in (`none: <why>` where the skill's own stop rule leaves one empty).
`gstack-skill-contract check` reads them from the transcript (docs/skill-routing-and-contracts.md). For an item that truly does not apply, run `gstack-skill-contract waive <item> --reason "<why>"` (this session only; it counts as a miss).
## Shared preamble (load first)
This skill follows gstack shared preamble `4e82fa4c839f` (AskUserQuestion format, voice, writing, completeness and completion rules). If your Read of that file, headed by this id, is still in context, do not read it again; otherwise Read `~/.claude/skills/gstack/review/sections/preamble.md` in full before the command below. A section or subsection here with a shared heading replaces the shared one.
+23 -4
View File
@@ -3,10 +3,11 @@ name: review
preamble-tier: 4
version: 1.0.0
description: |
Review this PR's diff. Checks it against the base branch for SQL safety, LLM trust
boundary violations, conditional side effects, and other structural issues. Use when
asked to "review this PR", "code review", "pre-landing review", or "check my diff".
Proactively suggest when the user is about to merge or land code changes. (gstack)
Use when you review this PR's diff.
Checks it for SQL safety, LLM trust boundaries, conditional side effects and
bugs that pass CI and break production. Fits "look over my diff", "review my
changes", "code review", "check my diff" or "pre-landing review".
Proactively suggest before a merge. (gstack)
codex_default_prompt: "Use $gstack-review to review this diff against its base before it lands: find the bugs that pass CI and break in production, fix the clear ones, and list what remains."
allowed-tools:
- Bash
@@ -20,11 +21,29 @@ allowed-tools:
- WebSearch
triggers:
- review this pr
- look over my diff
- code review
- check my diff
- review my changes
- pre-landing review
contract:
version: 1
items:
- id: specialists-dispatched
kind: selected_specialists_dispatched
marker: "Dispatching N specialists:"
- id: adversarial-pass
kind: agent_dispatch
match: Think like an attacker and a chaos engineer
- id: review-report
kind: report_labels
heading: Pre-Landing Review
labels:
- Gate edits
---
{{CONTRACT_DONE_MEANS}}
{{PREAMBLE}}
{{BASE_BRANCH_DETECT}}
+43 -15
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* Keep HARNESS_VERSION honest (CEO-27/ENG-3/DX-6). scripts/harness-version.json
* pins the blob of every shared-harness file (GLOBAL_TOUCHFILES plus its
* `patterns`). test/harness-version.test.ts fails when a harness file changes
* holds the version and the harness `patterns`; scripts/harness-pins/<path>.json
* pins the blob of every shared-harness file (GLOBAL_TOUCHFILES plus those
* patterns). test/harness-version.test.ts fails when a harness file changes
* without a decision, and this script records the decision:
*
* --bump "<reason>" the change alters how cases behave: version + 1,
@@ -12,9 +13,10 @@
* compatible and no series resets
* --check print drift and the fix; exit 1 on drift
*
* Entries are one line per file, so two PRs touching different harness files
* merge cleanly. When both bump, or both touch one file, git conflicts on the
* manifest: take the higher version, then rerun this script.
* Each pin is its own file (GST-1084), so two PRs touching different harness
* files never edit the same file and merge cleanly. When both bump, or both
* touch one file, git conflicts on the manifest or that pin: take the higher
* version, then rerun this script.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -23,14 +25,35 @@ import { harnessFiles, treeEntries, type TreeEntry } from './eval-trial-series';
const ROOT = path.resolve(import.meta.dir, '..');
export const MANIFEST = 'scripts/harness-version.json';
export const PINS_DIR = 'scripts/harness-pins';
export const DOC_ANCHOR = 'docs/TESTING_INTERNALS.md#harness-version';
export interface ManifestEntry { blob: string; since: number; nonBehavioral?: string }
export interface HarnessManifest { version: number; patterns: string[]; files: Record<string, ManifestEntry> }
export interface Drift { changed: string[]; added: string[]; removed: string[] }
const pinPath = (root: string, file: string) => path.join(root, PINS_DIR, `${file}.json`);
/** The pin files under root's PINS_DIR, keyed by pinned path; any file that is not <path>.json is an error. */
function readPins(root: string): Record<string, ManifestEntry> {
const dir = path.join(root, PINS_DIR);
const files: Record<string, ManifestEntry> = {};
for (const rel of (fs.readdirSync(dir, { recursive: true }) as string[]).map(f => f.split(path.sep).join('/')).sort()) {
if (fs.statSync(path.join(dir, rel)).isDirectory()) continue;
if (!rel.endsWith('.json')) throw new Error(`${PINS_DIR}/${rel}: every harness pin is <pinned path>.json`);
try {
files[rel.slice(0, -'.json'.length)] = JSON.parse(fs.readFileSync(path.join(dir, rel), 'utf8'));
} catch (e) {
throw new Error(`${PINS_DIR}/${rel}: ${(e as Error).message}`);
}
}
return files;
}
export function readManifest(root = ROOT): HarnessManifest {
return JSON.parse(fs.readFileSync(path.join(root, MANIFEST), 'utf8'));
const head = JSON.parse(fs.readFileSync(path.join(root, MANIFEST), 'utf8'));
if (Object.keys(head).sort().join() !== 'patterns,version') throw new Error(`${MANIFEST} holds only version and patterns; pins live in ${PINS_DIR}/<path>.json`);
return { version: head.version, patterns: head.patterns, files: readPins(root) };
}
/** The tracked harness files with blobs of their working-tree bytes (an unstaged edit counts; a deleted file drops out). */
@@ -43,11 +66,11 @@ export function workingHarnessFiles(root: string, patterns: readonly string[]):
return tracked.map((entry, index) => ({ file: entry.file, blob: blobs[index]! }));
}
/** Harness files whose blob differs from, is missing from, or no longer matches an entry in the manifest. */
/** Harness files whose blob differs from, is missing from, or no longer matches an entry in the manifest; a pin ahead of the manifest version (a bump that failed partway) counts as changed. */
export function harnessDrift(manifest: HarnessManifest, files: TreeEntry[]): Drift {
const current = new Map(files.map(entry => [entry.file, entry.blob]));
return {
changed: files.filter(entry => manifest.files[entry.file] && manifest.files[entry.file]!.blob !== entry.blob).map(entry => entry.file).sort(),
changed: files.filter(entry => manifest.files[entry.file] && (manifest.files[entry.file]!.blob !== entry.blob || manifest.files[entry.file]!.since > manifest.version)).map(entry => entry.file).sort(),
added: files.filter(entry => !manifest.files[entry.file]).map(entry => entry.file).sort(),
removed: Object.keys(manifest.files).filter(file => !current.has(file)).sort(),
};
@@ -64,7 +87,7 @@ export function formatDrift(drift: Drift, version: number): string {
'Fix (pick one):',
' bun run scripts/bump-harness-version.ts --non-behavioral "<why no verdict can change>" # refactor/comment/logging: no series reset',
' bun run scripts/bump-harness-version.ts --bump "<what behavior changed>" # starts a new pass-rate series for every case',
`Merge conflict in ${MANIFEST}: take the higher version, then rerun the script. See ${DOC_ANCHOR}.`,
`Merge conflict in ${MANIFEST} or ${PINS_DIR}/: take the higher version, then rerun the script. See ${DOC_ANCHOR}.`,
].join('\n');
}
@@ -82,11 +105,16 @@ export function decide(manifest: HarnessManifest, files: TreeEntry[], decision:
: manifest.files[entry.file]!])) };
}
/** Stable, one-entry-per-line JSON so concurrent edits to different files merge cleanly. */
export function serializeManifest(manifest: HarnessManifest): string {
const files = Object.keys(manifest.files).sort().map(file => ` ${JSON.stringify(file)}: ${JSON.stringify(manifest.files[file])}`);
return `{\n "version": ${manifest.version},\n "patterns": [\n${manifest.patterns.map(pattern => ` ${JSON.stringify(pattern)}`).join(',\n')}\n ],\n`
+ ` "files": {\n${files.join(',\n')}\n }\n}\n`;
/** Write one pin file per harness file (rewriting only pins whose bytes change, deleting dropped ones), then the manifest head, so a failed pin write never moves the version. */
export function writeManifest(root: string, manifest: HarnessManifest): void {
for (const file of Object.keys(readPins(root))) if (!manifest.files[file]) fs.rmSync(pinPath(root, file));
for (const [file, entry] of Object.entries(manifest.files)) {
const target = pinPath(root, file), text = `${JSON.stringify(entry)}\n`;
if (fs.existsSync(target) && fs.readFileSync(target, 'utf8') === text) continue;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, text);
}
fs.writeFileSync(path.join(root, MANIFEST), `${JSON.stringify({ version: manifest.version, patterns: manifest.patterns }, null, 2)}\n`);
}
if (import.meta.main) {
@@ -106,7 +134,7 @@ if (import.meta.main) {
}
if (mode === '--non-behavioral' && !hasDrift(drift)) { console.log('no harness drift: nothing to record'); process.exit(0); }
const next = decide(manifest, files, mode === '--bump' ? 'bump' : 'non-behavioral', argv[1] ?? '');
fs.writeFileSync(path.join(ROOT, MANIFEST), serializeManifest(next));
writeManifest(ROOT, next);
console.log(mode === '--bump'
? `HARNESS_VERSION ${manifest.version} -> ${next.version}: every case starts a new pass-rate series (reason: ${argv[1]})`
: `recorded ${drift.changed.length + drift.added.length} file(s) as non-behavioral at HARNESS_VERSION ${next.version}${drift.removed.length ? `, dropped ${drift.removed.length} removed file(s)` : ''}`);
@@ -0,0 +1 @@
{"blob":"f862cf5241580c7593b9ee62eacf5ef046248d26","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"}
@@ -0,0 +1 @@
{"blob":"e8ce3cb98474dded38c5128a631d283b0ca35b63","since":6,"nonBehavioral":"F1 rule-16 flaky rerun: report aggregation, rerun planning and the gate slice exit code; no case's execution, trial outcome, trial record or panel verdict changes, so no pass-rate series resets"}
@@ -0,0 +1 @@
{"blob":"e0d18da4ea02ac443835a68992abeea2b47f22fc","since":6,"nonBehavioral":"the headline adds only streamed session cost; a billed session's cost is already in its collector total; cache writes without a 5m key fall back to the total minus 1h; cost fields only"}
@@ -0,0 +1 @@
{"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6}
@@ -0,0 +1 @@
{"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"}
@@ -0,0 +1 @@
{"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"}
@@ -0,0 +1 @@
{"blob":"b552b8825d693493decef76b05b976858cab087e","since":6,"nonBehavioral":"test-pr-profile.ts lists .github/workflows/windows-scoped-tests.yml as free-lane only; the workflow runs no paid case, so no case selection or verdict can change"}
@@ -0,0 +1 @@
{"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6}
@@ -0,0 +1 @@
{"blob":"d66dac458b8c13250973e550edf8087d4044cd0b","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets. Also: runAgentSdkTest now validates systemPrompt before starting; every live caller already passed the claude_code preset, so the prompt the model sees is unchanged and no verdict can change"}
@@ -0,0 +1 @@
{"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6}
@@ -0,0 +1 @@
{"blob":"ce7a5eaa66e502a052ea9c53348dd2ab25db6148","since":6,"nonBehavioral":"Removes the plan-design 0D focus picker from the shared harness; floor.ts gains an optional pickSetupChoice hook that no other caller passes, so no other case's input or verdict can change. plan-design-finding-floor, the only case whose seed raises that menu, passes the picker from its owned fixture test/fixtures/design-floor-focus-picker.ts, so its owned bytes start its own new series."}
@@ -0,0 +1 @@
{"blob":"bff225017e235f0be6ece29f04971ac04b95b544","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"}
@@ -0,0 +1 @@
{"blob":"c040ebbfa815b8cce19ef80c6133bd5a5474149a","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"}
@@ -0,0 +1 @@
{"blob":"efb7e197dd5f66c3ad0e34074b14e640754f7a45","since":6}
@@ -0,0 +1 @@
{"blob":"91996ff2bc303ee29f4e6f1705d1468b2536b32e","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets"}
@@ -0,0 +1 @@
{"blob":"667d0e09b20fe5d7366f57b77b5fb9b04baa37ef","since":6}
@@ -0,0 +1 @@
{"blob":"4085ad18cd99698f675deadad2a76a796427b693","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets"}
@@ -0,0 +1 @@
{"blob":"cc072895461dc52ea317d1beff72121342420d30","since":6}
@@ -0,0 +1 @@
{"blob":"ff32d03f32e4af76c6c7314a79cd1b35263ed0dd","since":6,"nonBehavioral":"llm-judge.ts adds a pass-through byte counter (bytesReceived, lastByteMs) to the aborted-judge diagnostic; the request, response bytes, deadline and scoring are unchanged. GSTA-224 B2 (#3122): skill-fixture.ts follows review's renamed Step 2.5 heading so the review E2E cases extract the same section (now the skill's own reworded bytes); llm-judge.ts changes one bullet of the cross-skill consistency judge's intended architecture to name review-bot-history.md beside the older greptile-history.md name, so its calibration corpus (greptile-history inputs) stays valid. No other case's input or verdict can change; the cross-skill judge's judged files changed in this PR, so its owned inputs start its own series"}
@@ -0,0 +1 @@
{"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":6}
@@ -0,0 +1 @@
{"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6}
@@ -0,0 +1 @@
{"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6}
@@ -0,0 +1 @@
{"blob":"404d7515076d5c00804f89e23ea69296312c00e1","since":6}
@@ -0,0 +1 @@
{"blob":"c09b69611fb22ddf093adb2baa83434494e5f8fa","since":6,"nonBehavioral":"Widens the two plan-eng-review boundary predicates' qid regex to also accept the optional ;signal=<key> hint suffix (#3442). Only a question carrying that suffix can change verdict, and only the changed question-tuning resolver asks for it; every case that runs a skill with that resolver owns its generated SKILL.md, which this change also edits, so those cases already start their own series. No other case's input or verdict can change."}
@@ -0,0 +1 @@
{"blob":"3f2daa12ff6484f96b8d28d910a50231c4bb3ae8","since":6}
@@ -0,0 +1 @@
{"blob":"554e064ef259b8444280eddfe9b0db93c66a8202","since":6}
@@ -0,0 +1 @@
{"blob":"81c5bb4c48ab928aca2f186a1ade9df7169d7fbe","since":6}
@@ -0,0 +1 @@
{"blob":"4f0731ef39bc3a6a7c5c592ce98690a24cb1ed7f","since":6,"nonBehavioral":"PTY transcript pricing finds the session by the caller's --session-id or the one new transcript for the launch cwd; the launcher adds no argument; native AUQ capture opts out; ledger cost fields only, no prompt, argv, answer, timeout or verdict changes"}
@@ -0,0 +1 @@
{"blob":"ddba9cb68c7c0da935cff7c1cea258dee2dff740","since":6}
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff. Show more