Files
gstack/lib/ship-policy.ts
T
Garry Tanandcapy-ai[bot] 14d7414af3 v1.93.33.0 wave(review): #3705 #3744 #3745 #3764 #3765 #3795 (#3852)
* fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391)

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

* ship driver: trim two phrases for headroom with #3662

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

* ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere

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

* test: regenerate ship goldens

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

* fix: pre-landing review fixes

comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each.

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

* fix: pass-2 review fixes

A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks.

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

* fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251)

gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns.

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

* fix: no test edit skips review; guard packet writes; exclusive attempt state

A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create.

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

* fix: attempt prints gstack's install command; carry checks the product diff outside context paths

The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads.

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

* fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs

A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory.

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

* docs(unattended): carry's scoped-review refusal and what it keeps

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

* docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1)

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

* test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209)

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

* fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430)

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

* fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448)

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

* fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442)

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

* docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444)

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

* fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals

- A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the
  blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account
  for the claimed issue and critical counts (GST-430).
- The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442).
- patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448).
- Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444).

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

* test: pin the Step 16 --docs-requested route with its --docs-result

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

* fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1)

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

* fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept

- deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot
  dispose of a gate finding the unattended logger forces open (outside-voice#1).
- reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses;
  noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL.
- patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR.

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

* fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT

- isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition.
- reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so
  interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before.

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

* fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor

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

* fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team)

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

* docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit)

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

* fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin)

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

* fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check

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

* test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055)

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

* fix(review): a stale local base branch yields to its origin twin in gate-diff and diff-scope (GST-462)

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

* fix(ship): Step 11 saves the targeted record and the receipt writes carry it; Step 9 clears it (GST-464)

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

* changelog: GST-464 and GST-462

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

* fix(review): pass 1: ahead and diverged labels, diff-scope needs shared history; preflight refreshes origin/<base> and stops on a diff far beyond the PR (GST-462, HARN-15)

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

* fix: pass-2 review fixes for base twin, prSized stop and targeted record

Resolve the tracking twin by refs/remotes/<remote>/<ref> and only for a bare
branch name, keep a local base that is ahead or diverged, quote the prSized
stop's base and name the fork case, and have every Step 9 entry create,
exclude and clear .gstack/tmp/targeted.json.

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

* fix: origin's tracking base wins whenever related; prSized lower bound

The fix-check found that keeping a local base ahead of origin left unpushed
base commits out of the gate scan while review-plan listed them. The tracking
branch now wins whenever it shares history with HEAD, an explicit origin/main
reads refs/remotes/origin/main, the preflight also stops when the diff lists
fewer files than the PR, and the Step 9 exclude line survives a missing
.git/info and an exclude file without a final newline.

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

* changelog: the tracking-ref claim holds while the tracking branch exists

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

* test: repin the two ship section briefs under no-loss replay GST-464

GST-464 brief-identity check: every reviewer brief lib/review-brief.ts cuts
from ship/sections/review-army.md and adversarial.md stays byte-identical to
the bodies the L and GST-257 replays admitted; only lead instructions outside
the cut spans changed. The check is a test CI runs.

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

* ship: keep Step 9's PASS_HEAD sentence whole; repin review-army under GST-464

The targeted-record line follows the PASS_HEAD/RANGE_BASE sentence instead of
splitting it (test/review-fix-range.test.ts pins that sentence). The brief-body
check still holds: every reviewer brief is byte-identical.

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

* fix: restore bin/gstack-ship-receipt after the main merge

The merge commit b10e2db wrote a truncated copy of the file. This is the
three-way merge of both sides with the refused() union: main's codes plus
RECEIPT_CARRY_SCOPED_REVIEW.

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

* review-collect: narrow the consumer scan and give it one 300 s deadline (GST-544)

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

* review-collect: keep the consumer scan deadline under the 180 s plan timeout (review fix)

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

* review-collect: plain paths under --literal-pathspecs; whole-millisecond timeout override (review fix)

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

* review-collect: whole-tree grep for non-UTF-8 names, no argument spread, name spawn errors (review fix)

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

* review-collect: cap the consumer grep output at 256 MB across calls; per-scan deadline wording (review fix, cycle 4)

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

* review-collect: read a missing spawn status as null (Bun 1.4.2 returns undefined on ENOENT)

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

* test: review-collect whole-tree helper reads a null status as -1 (typecheck:test ratchet)

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

* review-bots: /review and /ship triage every configured review bot, not only Greptile (#3122)

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

* fix: pre-landing review fixes (gh before 2.48 pages without --slurp; driver routes Step 10; tests for guards, review-bot fence, REMOTE_SLUG owner)

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

* fix: drop the review-bot fence test that could not fail (the tool-fence WARN degrade is unreachable: placeholder spans are skipped first)

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

* fix: Step 11 review fixes (record by comment id so a filename never reaches a shell; lookup failures are errors; tojson pages; longest bot prefix wins)

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

* fix: pass-2 review fixes (record refuses * paths, colliding ids and a missing fetch dir; parent requires id and dir; botOwning doc)

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

* review: classify the PR type and always check repo conventions (#356)

The checklist now opens with a PR-type line (application, CI/infra,
scripts/tooling, docs-only or mixed). Application and mixed diffs run
every category; other types always run five categories, including the
new Repo Conventions & Platform Fit, and report the rest as not
applicable in one line instead of skipping silently.

No-loss replay: GSTA-224 H1 preregistered no-loss run (run id
gsta224-h1-20261031, Opus 5.5, 81 calls, $9.53) against main's checklist
with a no-gstack arm; every preregistered bar passed. The
review/checklist.md pin in test/review-packets-removed.test.ts moves to
a32963ef with replay 'GSTA-224 H1'.

Refs GSTA-224

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

* review-bots: allowlist the record placeholders and match Step 10's id rule in tests

The record fence uses <comment-id>, <fix|fp|already-fixed> and <category>,
each with an enum or numeric grammar; <greptile-dir> becomes <bot-dir>.
review/greptile-triage.md also lists every REVIEW_BOTS skip reason.

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

* docs: review-bot triage covers every configured bot (#3122)

README, CONTRIBUTING, the contributor reference and standalone-binaries
name gstack-review-bots and the review-bot fence; TODOS drops the #3122
item; the changelog fragment covers record-by-id and lookup errors.

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

* test: the PR-type test also pins the compose, Terraform and README/CHANGELOG phrases

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

* test: follow the review-bot rename in the remote free tier's three reds

The cross-skill consistency judge describes review-bot-history.md and its
record helper; the tracker-guard exemption for the removed raw Greptile
fetch in review/greptile-triage.md is dropped (the fetch now lives in
gstack-review-bots, whose bodies still reach context only through
gstack-issue-guard); the Step 10 PR-check test expects 'Review bots: not
run'. HARNESS_VERSION stays 6: skill-fixture.ts and llm-judge.ts are
recorded non-behavioral for every other case.

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

* test: keep the cross-skill judge's rubric to one renamed bullet

Name review-bot-history.md beside the older greptile-history.md so the
calibration corpus still matches, and keep the REMOTE_SLUG bullet. One
live 3-sample panel at this tree: consistent 3/3, scores 4/4/4.

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

* changelog: H1 fragment passes prose lint

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

* changelog: name the H1 fragment after its branch; TODOS drops #356

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

* docs: /review's PR type step in docs/skills.md (#356)

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

* ship-policy: review_exclude_paths key and loadPolicyAt (GST-508)

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

* review: apply the base policy's review_exclude_paths and the reviewer diff ceiling (GST-508)

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

* receipt: bind review_exclude in write, carry and the queue (GST-508)

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

* docs, tests and changelog for review_exclude_paths (GST-508)

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

* fix: pre-landing review fixes (binary by bytes, links kept, untracked files out of the selection)

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

* fix: keep binary files at code paths under review_exclude_paths

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

* docs: name the reasons that keep a binary file under review_exclude_paths

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

* fix: keep every review_exclude_paths candidate when the gate scan is unknown; docs example names its version

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

* test: keep the NUL fixture clear of the sync-spawn tripwire

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

* changelog: split the ceiling sentence

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

* fix: keep a live binary file whose bytes cannot be read; tests render outside voices from a clean checkout and read the ship fixture text

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

* test(review-exclude): explicit +x for the census script; skip the chmod-000 read case on Windows and root

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

* refactor: move loadPolicyAt to lib/ship-policy-at.ts so lib/ship-policy.ts stays under the 800-line module cap

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

* fix: refuse a base that contains HEAD; skip a self-pointing origin/HEAD (GST-774)

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

* chore: regenerate gstack-doctor-components.sh for BASE_CONTAINS_HEAD

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

* fix: land resumes a merged merge-only PR instead of refusing BASE_CONTAINS_HEAD (GST-774 review)

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

* docs: changelog fragment for GST-774

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

* docs: troubleshooting names diff-scope base_contains_head and the self-pointing no_base case (GST-774 docs audit)

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

* test: repin the reviewer briefs to GST-508's no-loss-tested bytes

ship/sections/review-army.md, ship/sections/adversarial.md and lib/review-depth.ts now pin
the bytes the GST-508 preregistered no-loss replay ran at ef2760f (handoffs/GSTA-202/noloss-results.md
in the GStack Minder drive): Arm V 33/41 vs R 33/41, delta 4, exact sign flip, p_NI 0.0279 (pass).
The merge of main 83ae96e leaves these three files byte-identical to ef2760f, and an Arm P unit
(3593) renders byte-identical briefs at ef2760f and at the merge.

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

* fix: GST-774 review round 1 — merged resume depth, aliased and detached PR checkouts, develop/trunk defaults

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

* ship: Step 20 says a metrics row, keeping DRIVER.md at 12 KB after the main merge

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

* test: ship-parts resend case leaves room for the first phase-3 part after the Step 9 targeted-record sentence

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

* fix: /ship delivers review/checklist.md in two parts so checklist v1 fits the 24576-byte part cap (#3670)

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

* changelog: drop the GST-407 and GST-430 fragments, already released on main (stacked history)

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

* wave review-53cfb5bfe011: lib/ship-policy.ts back to 800 lines (module-size ratchet after merging #3744 and #3795)

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

* wave review-53cfb5bfe011: /ship pass 1 fixes

- lib/ship-receipt-depth.ts: a merged resume (land after a true merge) no longer recomputes the review_exclude digest over the emptied range, so it lands instead of RECEIPT_INVALID (failing-first land test with review_exclude_paths active).
- bin/gstack-review-dispatch render: a standalone targeted render under an active review_exclude_paths refuses and points at the pass manifest voices stage, instead of writing a brief with no selection that leaves verify dirty (failing-first test).
- test/default-base.test.ts: #3795 expectations follow main R51 depth (light for an 80-line code diff; runtime_lines=80 still proves the real base).
- test/review-dispatch.test.ts: the GST-897 fingerprint test renders from the clean checkout, like its neighbor, because CI rebuilds the tracked lib/diagram-render/dist/diagram-render.html and the reviewer diff ceiling refused the ROOT render.

* wave review-53cfb5bfe011: /ship pass 2 fixes

- lib/review-exclude.ts: binaryContent reads each blob's first 8,000 bytes (git cat-file blob, 8 KB buffer) instead of one --batch read capped at 256 MB, so head mode (receipt, carry, queue) and the plan agree past 256 MB of blobs; an unreadable blob is kept, as in live mode. Failing-first test with two 270 MB blobs.
- lib/review-bots.ts: a file-level bot review comment (subject_type file, no line position) is triaged instead of read as outdated. Failing-first test.
- test/ship-queue-land.test.ts: the merged-resume exclusion test runs its gate scan under the fixture state root, not the real home (CI free-suite 6 home containment).

* fix: merge follow-ups for main 3d99c22: origin/* stays qualified in diff-scope's twin step, retro skeleton back under its cap, combined no-loss pin GST-508+R51+GST-911

* fix(review): receipt write gate-scans whenever the base declares review_exclude_paths; collectLive never re-qualifies origin/<base> (#3852 /ship red-team#1, outside-voice#1)

* test: the docs-only receipt digest test keeps its gate scan out of the real home (free-suite shard 5 FREE_SUBSHARD_REJECTED)

* fix(merge fc292e22f): tentative REVIEW_EXCLUDE_KEY_VERSION, classify targeted.json, render main's new outside-voice test from a clean checkout

- lib/ship-policy.ts: REVIEW_EXCLUDE_KEY_VERSION 1.93.0.0 -> 1.94.0.0, the next-minor tentative form stamp_paths moves (docs/ship-policy.md, #3512); 1.93.0.0 is a released heading that never names the key.
- test/gstack-clean-producers.test.ts (new from main): KEPT gains targeted.json, the wave's --review-targeted input.
- test/review-dispatch.test.ts: main's wrong-tree test rendered the outside voice from ROOT; a CI build that dirties ROOT pushes that diff past the wave's REVIEW_DIFF_MAX_BYTES ceiling, so render refused and verify saw no brief (exit 3). It now runs in cleanCheckout like the wave's other outside-voice tests.

* test(merge 68410828f): GST-967's brief-body pin carries the wave's GST-508 placeholders

The merged sectionTexts differ from main's by exactly the wave's GST-508 exclude-pathspec lines (merged minus main equals wave minus fc292e22f); the 1,440-case plan grid still hashes to main's MAIN_PLANS. Checks: GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md.

* fix(merge e85005482): targeted classes since the read accept an empty range; the wave's receipt tests pass main's targeted record

- lib/ship-receipt-depth.ts targetedClassesSince (main, GST-986) reads the range from the commit the targeted reader read to HEAD through headInputs, which since #3795 runs gstack-diff-scope's BASE_CONTAINS_HEAD check. When the reader read HEAD itself the range is empty, diff-scope reported base_contains_head, and every write with --review-targeted was refused (the product patch matched). It now passes merged=true (--merged), which skips only that check; an ancestor check already guards the range.
- test/review-exclude.test.ts and test/review-targeted.test.ts: since #3887 write refuses a money head with no targeted record and requires the record's head, so the wave's two writes that must succeed pass one, as main's own tests do (targetedAtHead in test/ship-receipt-depth.test.ts).

* test(merge e85005482): drop the wave's duplicate targeted.json KEPT entry

Main (#3887) classified .gstack/tmp/targeted.json in KEPT too; the auto-merge kept both keys, which typecheck:test rejects (TS1117). Main's entry stays.

* test(merge e85005482): ship size ledger entry for the merged tree (-232 B)

Main's ship size ledger plus the wave's entries summed to 345,334 B; the merged tree measures 345,102 B because DRIVER.md takes main's shorter Step 19 line. Recorded with scripts/size-ledger.ts record ship.

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-03 01:40:07 +00:00

801 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ship-policy — the machine-readable repo ship policy (plan C1):
* `.gstack/ship-policy.json`, read from the committed base-branch copy
* (`origin/<base>`) so a PR cannot smuggle a command into its own gate.
*
* File presence never authorizes execution: `release_tool` runs only under
* `/ship` or `--allow-repo-commands` (bin/gstack-restamp enforces that; this
* module only names the commands a policy declares). Every repo-controlled
* path (`stamp_paths`, `release_outputs`, `changelog`, `mirror`) is
* containment-checked here: no absolute paths, no `..`, globs confined to the
* repository, regex size caps, and symlink-escape checks when a glob is
* resolved against a tree. Docs: docs/ship-policy.md. Callers: bin/gstack-
* ship-policy, lib/restamp.ts, lib/tree-receipt.ts, lib/ship-receipt.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
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';
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';
export type BumpPolicy = 'patch' | 'ask' | 'never';
export type QueueMode = 'claim-at-open' | 'stamp-at-merge';
export type HistoryPolicy = 'merge-only' | 'squash-ok';
/** `frozen`: /ship fixes the diff in pass 1, defers unchanged code to issues and reviews only the range after; `all`: every finding is fixed now. */
export type ReviewScope = 'frozen' | 'diff-plus-blocking' | 'all';
export interface ShipPolicy {
$schema?: string;
/** `patch` skips the MINOR/MAJOR prompts in /ship Step 12; `ask` keeps them; `never` keeps VERSION on every unit PR. */
bump: BumpPolicy;
/** `stamp-at-merge`: no claim at PR open; stamp when the PR is next. `claim-at-open` is today's queue. */
queue_mode: QueueMode;
/** The repo's own release tool (for example `bun run release:restamp`); run only under /ship or --allow-repo-commands. */
release_tool: string | null;
/** Globs the release tool may write beyond the stamp files; any other output is a refusal. */
release_outputs: string[];
/** Globs where `(was X)`, `since: X` and `vX` mentions are rewritten on a restamp. */
stamp_paths: string[];
/** The CHANGELOG re-headed on a restamp; null for repos without one. */
changelog: string | null;
/** Unversioned CHANGELOG fragments (`<dir>/<slug>.md`) a PR adds instead of an `[Unreleased]` section; null keeps `[Unreleased]`. */
changelog_fragments: { dir: string; required_checks?: string[] } | null;
/** Path of a paired repo to restamp identically (tier 2). */
mirror: string | null;
/** `merge-only` refuses a squash or rebase plan in /ship. */
history: HistoryPolicy;
/** Each must be an ancestor of HEAD before push (`gstack-evidence ancestor`). */
preregistration_shas: string[];
/** Evidence labels that consume VERSION or generated bytes and always rerun on the stamped tree. */
stamp_sensitive_gates: string[];
/** The /ship review loop's finding scope (docs/ship-policy.md#review-scope). */
review_scope: ReviewScope;
/** Git glob pathspecs (root-anchored) whose data, docs, binary and log files /ship's reviewers leave out; read from the base only (docs/ship-policy.md#review-exclude-paths). */
review_exclude_paths: string[];
/** Consumed by the plan reviews (D1). */
pr_count: { default_pr_count: number; split_requires_owner: boolean };
/** Maintainer logins for contributor-PR mode (E3); empty means "use push permission". */
maintainers: string[];
/** The repo's stamp rule: when non-empty, only these files can be stamps (the allowlist applies before every stamp rule). */
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[];
/** 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. */
migrations: { dir: string; id: string } | null;
/** The repo's full pre-merge gate; gstack-ship-queue prints it for the caller to run (gstack never executes it). */
gate_command: string | null;
/** true: a merge needs a passing full-gate record bound to a tree equal to the merging one modulo stamps. */
full_e2e: boolean;
/** The oldest gstack that may read this policy; a newer key set to a non-default value requires it. */
min_gstack_version: string | null;
/** Merge trains in gstack-ship-queue (docs/ship-policy.md#run-a-merge-train); null keeps the per-PR queue. */
train: TrainPolicy | null;
/** Documentation caps /ship Step 14.5 checks before the verification gate (`gstack-ship-receipt docs-caps`); null declares none. */
docs: { caps: Array<{ path: string; max_bytes: number }> } | null;
/** How /ship reproduces a Step 5 failure on the base branch before calling it base-red (docs/ship-policy.md#base-red); null never calls one base-red. */
base_red: BaseRedPolicy | null;
}
/** `base_red`: `setup` installs dependencies in a scratch checkout (null when none are needed); `select` runs the failing test files, named by its `{files}` slot. */
export interface BaseRedPolicy { setup: string | null; select: string; timeout_sec: number }
/** The `train` block: the gate workflow trains dispatch and how its verdict is read. */
export interface TrainPolicy {
/** Workflow file dispatched for the full gate and the bisect (it accepts the inputs in TRAIN_DISPATCH_INPUTS). */
gate_workflow: string;
/** Workflow file dispatched on train/<nonce> before the paid gate; its run must be green on the train head. */
free_workflow: string;
/** The check run the train's own gate run publishes on the train head when its census is complete and green. */
full_gate_check: string;
/** Artifact name of the train record (and the prefix of each bisect prefix's artifact). */
record_artifact: string;
/** Most PRs in one train. */
max_size: number;
/** A train under max_size dispatches once its oldest READY PR has waited this long. */
fill_hours: number;
/** How `ready` computes the screen's selection: gstack's paid selector, or not at all. */
screen_selection: 'gstack-paid' | 'none';
/** Bisect admission price of one case trial that has no recorded cost. */
trial_reservation_usd: number;
/**
* The repo's own gates (docs/ship-policy.md#run-a-merge-train): every entry must be GREEN on every prefix T0..Tk.
* A block with gates declares only gates, land, max_size and fill_hours; without gates the block is the gstack-evals
* kind above, and the six gstack-evals fields above are absent on a block with gates (read them only when gates is absent).
*/
gates?: TrainGate[];
/** How `train land` merges a slot; absent means squash (gstack-evals trains only). */
land?: TrainLand;
}
/** One generic train gate: the repo's CI workflows dispatched by nonce on each prefix, or a command run on a Ubicloud VM. */
export type TrainGate = WorkflowsGate | CommandGate;
export interface WorkflowsGate {
kind: 'workflows';
/** Workflow files dispatched on each prefix ref; each accepts a `nonce` input and names it in run-name as `[eval-bg <nonce>]`. */
workflows: string[];
/** `<workflow file>/<job name as GitHub reports it>`: each must conclude success (skipped or missing is INCOMPLETE). */
required_jobs: string[];
/** Artifact with JUnit XML or a JSON test list; when set it must exist and report at least one executed test. */
record?: string;
/** Check runs `train land` waits on at the merged commit (default none). */
post_merge_checks?: string[];
}
export interface CommandGate {
kind: 'command';
/** Run on a Ubicloud VM (scripts/ubicloud/ubi-runner.sh) from a clean checkout of the prefix head; exit 0 is GREEN. */
command: string;
timeout_sec: number;
/** One cap over every prefix run of one train, at the VM's list price. */
spend_cap_usd: number;
/** The only local variables forwarded to the VM. */
pass_env?: string[];
}
export type MergeMethod = 'squash' | 'merge' | 'rebase';
export interface TrainLand {
merge_method: MergeMethod;
/** Checks `train land` requires green on the landing commit, for repos without changelog_fragments (whose required_checks are the one list). */
required_checks?: string[];
}
export const DEFAULT_POLICY: ShipPolicy = {
bump: 'ask',
queue_mode: 'claim-at-open',
release_tool: null,
release_outputs: [],
stamp_paths: [],
changelog: 'CHANGELOG.md',
changelog_fragments: null,
mirror: null,
history: 'squash-ok',
preregistration_shas: [],
stamp_sensitive_gates: [],
review_scope: 'frozen',
review_exclude_paths: [],
pr_count: { default_pr_count: 1, split_requires_owner: true },
maintainers: [],
stamp_files: [],
stamp_patterns: [],
restamp_command: null,
migrations: null,
gate_command: null,
full_e2e: false,
min_gstack_version: null,
train: null,
docs: null,
base_red: null,
};
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
export const POLICY_KEYS = Object.keys(DEFAULT_POLICY) as PolicyKey[];
/** What each key means, printed by `init --explain` (JSON carries no comments). */
export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
bump: 'patch | ask | never. `patch` skips the MINOR/MAJOR prompt in /ship Step 12; `ask` keeps it, and a session no person answers needs the owner\'s words (--answer) before a bump is written; `never` keeps VERSION on every unit PR (Step 12 writes nothing and asks nothing; the release process bumps). `never` needs min_gstack_version and rules out stamp-at-merge, train and changelog_fragments.',
queue_mode: 'claim-at-open | stamp-at-merge. stamp-at-merge stamps when the PR is next (`gstack-restamp --next`), from the base branch version at that moment.',
release_tool: 'The repo’s own release command, run in an isolated staging worktree with an enumerated allowed-output set. Never runs without /ship or --allow-repo-commands.',
release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.',
stamp_paths: 'Globs (repo-relative, no `..`) where exact `(was X)`, `since: X` and `vX` mentions of the old version are rewritten.',
changelog: 'CHANGELOG path re-headed on a restamp (the PR’s `[Unreleased]` or old-version section moves to the top under the new version). null to skip.',
changelog_fragments: '{ dir, required_checks? } or null: a PR adds one unversioned `<dir>/<slug>.md` fragment (optional `bump: minor|major` front matter) instead of an `[Unreleased]` section and leaves VERSION, the manifest version and released CHANGELOG sections at its merge base; the restamp folds every fragment under the allocated version and deletes the files (docs/ship-policy.md#changelog-fragments). `required_checks` lists the check runs or status contexts `train land` requires present and green on a stamp commit. null keeps `[Unreleased]`.',
mirror: 'Relative path of a paired repo to restamp identically; writes are confined to it. null to skip.',
history: 'merge-only | squash-ok. merge-only makes /ship refuse a squash or rebase plan.',
preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.',
stamp_sensitive_gates: 'Evidence labels (gstack-evidence --label) that read VERSION or generated bytes; the tree receipt never marks them reusable.',
review_scope: 'frozen | diff-plus-blocking | all. frozen (default): /ship Step 9 fixes findings in the diff on pass 1, files blocking findings in code the PR did not change as issues, and later passes read only the range since the previous pass and fix only blocking findings; diff-plus-blocking: the same, but a blocking finding in code the PR did not change is fixed in this PR instead of filed; all: every finding is fixed now (the pre-1.91.249 loop).',
review_exclude_paths: 'Git glob pathspecs, root-anchored and case-sensitive (`receipts/**`, `**/*.jsonl`; at most 64, 256 characters each, no leading `:` or `!`): /ship\'s reviewers leave out the data, docs, binary and .log files they match. Read from the base branch only, so a branch cannot exclude its own files. Code, gate paths, agent prose, config, executables, .gstack/ and .github/ files, gate-tagged or secret-bearing files and files a kept code or test file names by full path stay reviewed (docs/ship-policy.md#review-exclude-paths).',
pr_count: '{ default_pr_count, split_requires_owner }: the PR-count row in plan reviews.',
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.',
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.',
full_e2e: 'true: every merge needs a passing full-gate result (gstack-ship-queue gate-ahead --record, or the lane\'s --gate-exit 0 --gate-log on the gated head) bound to a tree equal to the merging one modulo stamps; ready, stamp and verify refuse without one (QUEUE_GATE_MISSING).',
train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? } (the gstack-evals gate) or { gates, land, max_size, fill_hours } (any repo: gates lists { kind: workflows, workflows, required_jobs, record?, post_merge_checks? } and { kind: command, command, timeout_sec, spend_cap_usd, pass_env? }, each GREEN on every prefix; land is { merge_method: squash|merge|rebase, required_checks? }) or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|land|watch|init|doctor|prune, ready --screen|--no-screen, verify --train). gates and land need min_gstack_version at the release that reads them (validate names it). null keeps today\'s per-PR queue unchanged.',
docs: '{ caps: [{ path, max_bytes }] } or null: documentation files with a byte cap. /ship Step 14.5 runs `gstack-ship-receipt docs-caps` before the verification gate and prints one DOCS_CAP: line per cap; a file over its cap (or absent) blocks the ship until the doc is trimmed or the cap raised on the base branch. null declares none.',
base_red: '{ setup, select, timeout_sec } or null: how /ship Step 5 proves a failure is the base branch\'s own (docs/ship-policy.md#base-red). gstack-base-red checks out the base head and the PR head in scratch worktrees, runs setup (dependency install, or null), then select with the failing test files in its {files} slot; only the same failures in both, in files the PR does not touch, are base-red, and /ship then pauses before READY instead of failing. Read from the base branch only. null keeps every failure in-branch.',
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
};
/**
* The gstack release that introduced each key. Keys newer than
* POLICY_COMPAT_FLOOR need `min_gstack_version` before they take a
* non-default value, so a crew on mixed gstack versions sees one named
* refusal instead of a policy each version reads differently. Unknown keys
* always fail closed: there is no extension mechanism.
*/
export const POLICY_COMPAT_FLOOR = '1.91.98.0';
export const SHIP_QUEUE_KEYS_VERSION = '1.91.108.0';
export const TRAIN_KEYS_VERSION = '1.91.146.0';
// Each constant is the release that first reads its key: a policy declaring the key must name at least
// that version as min_gstack_version, so an older gstack refuses the policy by name instead of failing on
// an unknown key. review_scope shipped in 1.91.256.0 (#3481), docs.caps in 1.91.270.0 (#3487). A new key's
// constant is written as a tentative future version clear of main's pace (the next minor, never the next patch,
// which main takes first and the released-key test below then reds at the pin), in this exact
// `_KEY_VERSION = '<version>'` form: this file is in .gstack/ship-policy.json stamp_paths with that form as a
// stamp_patterns template, so the slot stamp's gstack-restamp stamp_paths stage moves the tentative value to the
// allocated version. The stage reads lines the branch added: a tentative value newer than the fork version moves,
// an untouched released constant stays, and a branch-added line naming the base's own version is re-read as a
// stale stamp and moves too, so never rewrite a released constant's line. Until the tree receipt accepts a
// stamp-form move whose old value is not the base version (#3512), the train's stamp-only check passes such a
// move only when the tentative value equals the base version at the slot, which a next-minor value never does:
// a PR that adds a key cannot clear the train until #3512 lands. test/gstack-ship-policy.test.ts holds
// an unreleased constant newer than every CHANGELOG heading and a released one on a heading whose entry names
// its key, so a value a stamp did not move fails there.
export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0';
export const DOCS_CAPS_KEY_VERSION = '1.91.270.0';
export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.91.336.0';
/** `bump: never` is a value of an older key, so the per-key floor check misses it; compatErrors checks it by value. */
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 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',
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
pr_count: '1.91.73.0', maintainers: '1.91.73.0', stamp_files: '1.91.98.0', stamp_patterns: '1.91.98.0', restamp_command: '1.91.98.0',
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
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,
};
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
/**
* The release this checkout reads policies as: its VERSION file, or the
* newest key it knows when that is later (an unstamped tree that carries a
* new key already reads policies written for it).
*/
export function runningGstackVersion(): string {
let own = '0.0.0.0';
try { own = fs.readFileSync(path.resolve(import.meta.dir, '..', 'VERSION'), 'utf8').trim(); } catch {}
const known = [...Object.values(POLICY_KEY_INTRODUCED), BUMP_NEVER_KEY_VERSION, TRAIN_GATES_KEY_VERSION].reduce((a, b) => (newer(b, a) ? b : a));
return parseVersion(own) && !newer(known, own) ? own : known;
}
/** The newest introducing version among the keys a parsed policy sets: the gstack a crew needs to read it. */
export function minimumReader(raw: Record<string, unknown>): string {
return Object.keys(raw).filter((k): k is PolicyKey => POLICY_KEYS.includes(k as PolicyKey))
.map(k => (k === 'bump' && raw.bump === 'never' ? BUMP_NEVER_KEY_VERSION : k === 'train' && genericTrainKeys(raw.train) ? TRAIN_GATES_KEY_VERSION : POLICY_KEY_INTRODUCED[k])).reduce((a, b) => (newer(b, a) ? b : a), POLICY_KEY_INTRODUCED.bump);
}
export interface PolicyError { code: ResultCodeName; path: string; message: string }
const MAX_LIST = 64;
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 === '..');
}
/** A repo-relative path or glob: no absolute paths, drive letters, `..` segments, NULs or control bytes. */
export function containedRelative(p: string): string | null {
if (typeof p !== 'string' || p.length === 0) return 'must be a non-empty string';
if (p.length > MAX_PATTERN) return `longer than ${MAX_PATTERN} characters`;
if (/[\0\r\n]/.test(p)) return 'contains a control character';
if (path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')) return 'must be repo-relative, not absolute';
if (p.startsWith('~')) return 'must not start with ~';
if (hasDotDot(p)) return 'must not contain a `..` segment';
return null;
}
/** Glob → anchored RegExp. `**` spans directories, `*` and `?` stay inside one segment. Size-capped. */
export function globToRegExp(glob: string): RegExp {
let out = '^';
for (let i = 0; i < glob.length; i++) {
const ch = glob[i]!;
if (ch === '*') {
if (glob[i + 1] === '*') {
i += 1;
if (glob[i + 1] === '/') { i += 1; out += '(?:.*/)?'; } else out += '.*';
} else out += '[^/]*';
} else if (ch === '?') out += '[^/]';
else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
out += '$';
if (out.length > MAX_REGEX) throw new Error(`pattern expands past ${MAX_REGEX} characters`);
return new RegExp(out);
}
export function matchesAny(globs: readonly string[], rel: string): boolean {
const norm = rel.replace(/\\/g, '/').replace(/^\.\//, '');
return globs.some(g => globToRegExp(g).test(norm));
}
function listErrors(key: string, value: unknown, out: PolicyError[], check: (item: string) => string | null): void {
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: key, message: 'must be an array of strings' }); return; }
if (value.length > MAX_LIST) out.push({ code: 'POLICY_CONTAINMENT', path: key, message: `more than ${MAX_LIST} entries` });
value.forEach((item, i) => {
if (typeof item !== 'string') { out.push({ code: 'POLICY_INVALID', path: `${key}[${i}]`, message: 'must be a string' }); return; }
const why = check(item);
if (why) out.push({ code: 'POLICY_CONTAINMENT', path: `${key}[${i}]`, message: `${JSON.stringify(item)} ${why}` });
});
}
function globError(g: string): string | null {
const why = containedRelative(g);
if (why) return why;
try { globToRegExp(g); } catch (e: any) { return e.message; }
return null;
}
/** `review_exclude_paths`: git glob pathspecs, each one repo-relative line with no pathspec magic of its own; every error is POLICY_INVALID at its index. */
function excludeErrors(value: unknown, out: PolicyError[]): void {
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'review_exclude_paths', message: 'must be an array of git glob pathspecs' }); return; }
if (value.length > MAX_LIST) out.push({ code: 'POLICY_INVALID', path: 'review_exclude_paths', message: `more than ${MAX_LIST} patterns` });
value.forEach((p, i) => {
const why = typeof p !== 'string' ? 'must be a string' : containedRelative(p) ?? (/^[:!]/.test(p) ? 'must not start with : or ! (no pathspec magic or negation)' : /[\x00-\x1f\x7f]/.test(p) ? 'contains a control character' : p.replace(/\/+$/, '') === '' || p === '.' ? 'must not name the whole repository' : null);
if (why) out.push({ code: 'POLICY_INVALID', path: `review_exclude_paths[${i}]`, message: `${JSON.stringify(p)} ${why}` });
});
}
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; }
for (const k of Object.keys(m)) if (k !== 'dir' && k !== 'id') out.push({ code: 'POLICY_INVALID', path: `migrations.${k}`, message: 'unknown key' });
const dirWhy = typeof m.dir === 'string' ? containedRelative(m.dir) : 'must be a repo-relative directory';
if (dirWhy) out.push({ code: 'POLICY_CONTAINMENT', path: 'migrations.dir', message: dirWhy });
if (typeof m.id !== 'string' || !m.id || m.id.length > MAX_PATTERN) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `must be a regex of 1-${MAX_PATTERN} characters` }); return; }
try {
const groups = new RegExp(`${m.id}|`).exec('')!.length - 1;
if (groups !== 1) out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `needs exactly one capture group for the migration number (has ${groups})` });
} catch (e: any) { out.push({ code: 'POLICY_INVALID', path: 'migrations.id', message: `not a valid regex: ${e.message}` }); }
}
const TRAIN_FIELDS: Record<Exclude<keyof TrainPolicy, 'gates' | 'land'>, (v: unknown) => string | null> = {
gate_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as evals.yml'),
free_workflow: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/.test(v) ? null : 'must be a workflow file name such as free-tests.yml'),
full_gate_check: v => (typeof v === 'string' && /^[A-Za-z0-9_./-]{1,64}$/.test(v) ? null : 'must be a check-run name ([A-Za-z0-9_./-]{1,64})'),
record_artifact: v => (typeof v === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(v) ? null : 'must be an artifact name ([A-Za-z0-9_.-]{1,64})'),
max_size: v => (Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 6 ? null : 'must be an integer from 1 to 6'),
fill_hours: v => (typeof v === 'number' && v > 0 && v <= 24 ? null : 'must be a number of hours above 0 and at most 24'),
screen_selection: v => (v === 'gstack-paid' || v === 'none' ? null : 'must be gstack-paid | none'),
trial_reservation_usd: v => (typeof v === 'number' && v > 0 && v <= 100 ? null : 'must be dollars above 0 and at most 100'),
};
const WORKFLOW_FILE = /^[A-Za-z0-9_.-]{1,64}\.ya?ml$/;
const CHECK_NAME = /^[^\n]{1,100}$/;
const GENERIC_TRAIN_KEYS = ['gates', 'land', 'max_size', 'fill_hours'];
/** Whether a raw train block uses the generic keys (gates or land), which need TRAIN_GATES_KEY_VERSION. */
export function genericTrainKeys(train: unknown): boolean {
return !!train && typeof train === 'object' && !Array.isArray(train) && ('gates' in train || 'land' in train);
}
function nameList(at: string, v: unknown, max: number, item: RegExp, out: PolicyError[], what: string): v is string[] {
if (!Array.isArray(v) || !v.length || v.length > max || new Set(v).size !== v.length || v.some(x => typeof x !== 'string' || !item.test(x))) {
out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(v)} must be 1-${max} distinct ${what}` });
return false;
}
return true;
}
function workflowsGateErrors(o: Record<string, unknown>, at: string, out: PolicyError[]): void {
const wfs = nameList(`${at}.workflows`, o.workflows, 8, WORKFLOW_FILE, out, 'workflow file names such as ci.yml') ? o.workflows as string[] : [];
if (nameList(`${at}.required_jobs`, o.required_jobs, 64, /^[A-Za-z0-9_.-]{1,64}\.ya?ml\/[^\n]{1,100}$/, out, '<workflow file>/<job name> entries')) {
for (const job of o.required_jobs as string[]) {
const wf = job.slice(0, job.indexOf('/'));
if (wfs.length && !wfs.includes(wf)) out.push({ code: 'POLICY_INVALID', path: `${at}.required_jobs`, message: `${JSON.stringify(job)} names ${wf}, which is not in workflows` });
}
for (const wf of wfs) if (!(o.required_jobs as string[]).some(j => j.startsWith(`${wf}/`))) out.push({ code: 'POLICY_INVALID', path: `${at}.required_jobs`, message: `no required job of ${wf}; a dispatched workflow with no required job proves nothing` });
}
if ('record' in o && (typeof o.record !== 'string' || !/^[A-Za-z0-9_.-]{1,64}$/.test(o.record))) out.push({ code: 'POLICY_INVALID', path: `${at}.record`, message: 'must be an artifact name ([A-Za-z0-9_.-]{1,64})' });
if ('post_merge_checks' in o) nameList(`${at}.post_merge_checks`, o.post_merge_checks, 20, CHECK_NAME, out, 'check names of 1-100 characters');
}
function commandGateErrors(o: Record<string, unknown>, at: string, out: PolicyError[]): void {
if (typeof o.command !== 'string' || !o.command.trim()) out.push({ code: 'POLICY_INVALID', path: `${at}.command`, message: 'must be a non-empty string' });
else if (o.command.length > MAX_COMMAND || /[\0\r\n]/.test(o.command)) out.push({ code: 'POLICY_CONTAINMENT', path: `${at}.command`, message: `must be one line under ${MAX_COMMAND} characters` });
if (!Number.isInteger(o.timeout_sec) || (o.timeout_sec as number) < 60 || (o.timeout_sec as number) > 21600) out.push({ code: 'POLICY_INVALID', path: `${at}.timeout_sec`, message: 'must be an integer from 60 to 21600' });
if (typeof o.spend_cap_usd !== 'number' || !(o.spend_cap_usd > 0) || o.spend_cap_usd > 100) out.push({ code: 'POLICY_INVALID', path: `${at}.spend_cap_usd`, message: 'must be dollars above 0 and at most 100' });
if (!('pass_env' in o)) return;
const p = o.pass_env;
if (!Array.isArray(p) || p.length > 16 || new Set(p).size !== p.length || p.some(n => typeof n !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(n))) out.push({ code: 'POLICY_INVALID', path: `${at}.pass_env`, message: 'must be up to 16 distinct variable names' });
else if (p.some(n => /^UBICLOUD_/.test(n))) out.push({ code: 'POLICY_CONTAINMENT', path: `${at}.pass_env`, message: 'the runner never forwards its own Ubicloud credentials to a VM' });
}
function gateErrors(g: unknown, at: string, out: PolicyError[]): void {
const o = g as Record<string, unknown> | null;
if (!o || typeof o !== 'object' || Array.isArray(o)) { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be { kind: workflows, ... } or { kind: command, ... }' }); return; }
const allowed = o.kind === 'workflows' ? ['kind', 'workflows', 'required_jobs', 'record', 'post_merge_checks'] : o.kind === 'command' ? ['kind', 'command', 'timeout_sec', 'spend_cap_usd', 'pass_env'] : null;
if (!allowed) { out.push({ code: 'POLICY_INVALID', path: `${at}.kind`, message: `${JSON.stringify(o.kind)} must be workflows | command` }); return; }
for (const k of Object.keys(o)) if (!allowed.includes(k)) out.push({ code: 'POLICY_INVALID', path: `${at}.${k}`, message: `unknown key for a ${o.kind} gate` });
if (o.kind === 'workflows') workflowsGateErrors(o, at, out); else commandGateErrors(o, at, out);
}
function landErrors(v: unknown, obj: Record<string, unknown>, out: PolicyError[]): void {
const l = v as Record<string, unknown> | null;
if (!l || typeof l !== 'object' || Array.isArray(l)) { out.push({ code: 'POLICY_INVALID', path: 'train.land', message: 'must be { merge_method, required_checks? }' }); return; }
for (const k of Object.keys(l)) if (k !== 'merge_method' && k !== 'required_checks') out.push({ code: 'POLICY_INVALID', path: `train.land.${k}`, message: 'unknown key' });
if (!['squash', 'merge', 'rebase'].includes(l.merge_method as string)) out.push({ code: 'POLICY_INVALID', path: 'train.land.merge_method', message: `${JSON.stringify(l.merge_method)} must be squash | merge | rebase` });
if (!('required_checks' in l)) return;
if (obj.changelog_fragments != null) out.push({ code: 'POLICY_INVALID', path: 'train.land.required_checks', message: 'this policy declares changelog_fragments; its required_checks are the one list train land reads' });
else nameList('train.land.required_checks', l.required_checks, 50, CHECK_NAME, out, 'check names of 1-100 characters');
}
function trainErrors(value: unknown, obj: Record<string, unknown>, out: PolicyError[]): void {
const t = value as Record<string, unknown> | null;
if (!t || typeof t !== 'object' || Array.isArray(t)) { out.push({ code: 'POLICY_INVALID', path: 'train', message: `must be { ${Object.keys(TRAIN_FIELDS).join(', ')} }, { ${GENERIC_TRAIN_KEYS.join(', ')} } or null` }); return; }
const generic = 'gates' in t;
const fields = generic ? GENERIC_TRAIN_KEYS : [...Object.keys(TRAIN_FIELDS), 'land'];
for (const k of Object.keys(t)) {
if (!fields.includes(k)) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: generic && k in TRAIN_FIELDS ? 'belongs to the gstack-evals gate; a train with gates declares only gates, land, max_size and fill_hours' : 'unknown key' });
}
for (const k of generic ? GENERIC_TRAIN_KEYS : Object.keys(TRAIN_FIELDS)) if (!(k in t)) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: `missing (every ${generic ? 'field of a train with gates' : 'train field'} is required)` });
for (const [k, check] of Object.entries(TRAIN_FIELDS)) {
if (!(k in t) || (generic && k !== 'max_size' && k !== 'fill_hours')) continue;
const size = t[k] as number;
const why = generic && k === 'max_size' ? (Number.isInteger(size) && size >= 1 && size <= 5 ? null : 'must be an integer from 1 to 5 (every prefix is gated: at most six runs per gate)') : check(t[k]);
if (why) out.push({ code: 'POLICY_INVALID', path: `train.${k}`, message: `${JSON.stringify(t[k])} ${why}` });
}
if (generic) {
if (!Array.isArray(t.gates) || !t.gates.length || t.gates.length > 4) out.push({ code: 'POLICY_INVALID', path: 'train.gates', message: 'must be a list of 1-4 gates' });
else t.gates.forEach((g, i) => gateErrors(g, `train.gates[${i}]`, out));
}
if ('land' in t) landErrors(t.land, obj, out);
const method = (t.land as { merge_method?: unknown } | undefined)?.merge_method ?? 'squash';
if ('land' in t && obj.history === 'merge-only' && method !== 'merge') out.push({ code: 'POLICY_INVALID', path: 'train.land.merge_method', message: `history merge-only allows only the merge method; train land would use ${String(method)}` });
}
/**
* Schema and containment check of a parsed policy object. Unknown keys are
* errors (a typo must not silently mean "default", and a key from a newer
* gstack must not be ignored by an older one).
*/
export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = {}): PolicyError[] {
const errors: PolicyError[] = [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ code: 'POLICY_INVALID', path: '', message: 'policy must be a JSON object' }];
const obj = raw as Record<string, unknown>;
const newest = Object.values(POLICY_KEY_INTRODUCED).reduce((a, b) => (newer(b, a) ? b : a));
for (const key of Object.keys(obj)) {
if (key !== '$schema' && !POLICY_KEYS.includes(key as PolicyKey)) errors.push({ code: 'POLICY_INVALID', path: key, message: `unknown key (this gstack reads keys introduced up to ${newest}; a key from a newer gstack needs an upgrade, a misspelled one a fix); valid keys: ${POLICY_KEYS.join(', ')}` });
}
compatErrors(obj, opts.gstackVersion ?? runningGstackVersion(), errors);
const oneOf = (key: string, values: readonly string[]) => {
if (key in obj && !values.includes(obj[key] as string)) errors.push({ code: 'POLICY_INVALID', path: key, message: `must be one of ${values.join(' | ')}, got ${JSON.stringify(obj[key])}` });
};
oneOf('bump', ['patch', 'ask', 'never']);
if (obj.bump === 'never') {
const stamps = [obj.queue_mode === 'stamp-at-merge' && 'queue_mode stamp-at-merge', obj.train != null && 'train', obj.changelog_fragments != null && 'changelog_fragments'].filter(Boolean);
if (stamps.length) errors.push({ code: 'POLICY_INVALID', path: 'bump', message: `never keeps VERSION on every PR, but ${stamps.join(' and ')} stamps a version at every merge; drop one` });
}
oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']);
oneOf('history', ['merge-only', 'squash-ok']);
oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']);
if ('review_exclude_paths' in obj) excludeErrors(obj.review_exclude_paths, errors);
if ('release_tool' in obj && obj.release_tool !== null) {
const t = obj.release_tool;
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'release_tool', message: 'must be a non-empty string or null' });
else if (t.length > MAX_COMMAND) errors.push({ code: 'POLICY_CONTAINMENT', path: 'release_tool', message: `longer than ${MAX_COMMAND} characters` });
else if (/[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'release_tool', message: 'contains a newline or NUL' });
}
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 ('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' });
else if (t.length > MAX_COMMAND || /[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'restamp_command', message: `must be one line under ${MAX_COMMAND} characters` });
}
if ('migrations' in obj && obj.migrations !== null) migrationErrors(obj.migrations, errors);
if ('gate_command' in obj && obj.gate_command !== null) {
const t = obj.gate_command;
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'gate_command', message: 'must be a non-empty string or null' });
else if (t.length > MAX_COMMAND || /[\0\r\n]/.test(t)) errors.push({ code: 'POLICY_CONTAINMENT', path: 'gate_command', message: `must be one line under ${MAX_COMMAND} characters` });
}
if ('full_e2e' in obj && typeof obj.full_e2e !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'full_e2e', message: 'must be true or false' });
if ('train' in obj && obj.train !== null) trainErrors(obj.train, obj, errors);
if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, errors);
if ('changelog_fragments' in obj && obj.changelog_fragments !== null) fragmentsErrors(obj.changelog_fragments, errors);
if ('base_red' in obj && obj.base_red !== null) baseRedErrors(obj.base_red, errors);
if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
for (const key of ['changelog', 'mirror'] as const) {
if (!(key in obj) || obj[key] === null) continue;
if (typeof obj[key] !== 'string') { errors.push({ code: 'POLICY_INVALID', path: key, message: 'must be a string or null' }); continue; }
const p = obj[key] as string;
const why = key === 'mirror' ? mirrorPathError(p) : containedRelative(p);
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: key, message: `${JSON.stringify(p)} ${why}` });
}
if ('preregistration_shas' in obj) listErrors('preregistration_shas', obj.preregistration_shas, errors, s => (SHA_RE.test(s) ? null : 'is not a 7-64 character hex commit id'));
if ('stamp_sensitive_gates' in obj) listErrors('stamp_sensitive_gates', obj.stamp_sensitive_gates, errors, s => (/^[A-Za-z0-9_.:-]{1,64}$/.test(s) ? null : 'is not an evidence label ([A-Za-z0-9_.:-]{1,64})'));
if ('maintainers' in obj) listErrors('maintainers', obj.maintainers, errors, s => (/^[A-Za-z0-9-]{1,39}$/.test(s) ? null : 'is not a GitHub login'));
if ('pr_count' in obj) {
const pc = obj.pr_count as Record<string, unknown> | null;
if (!pc || typeof pc !== 'object' || Array.isArray(pc)) errors.push({ code: 'POLICY_INVALID', path: 'pr_count', message: 'must be { default_pr_count, split_requires_owner }' });
else {
if (!Number.isInteger(pc.default_pr_count) || (pc.default_pr_count as number) < 1) errors.push({ code: 'POLICY_INVALID', path: 'pr_count.default_pr_count', message: 'must be an integer >= 1' });
if (typeof pc.split_requires_owner !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'pr_count.split_requires_owner', message: 'must be a boolean' });
for (const k of Object.keys(pc)) if (!['default_pr_count', 'split_requires_owner'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `pr_count.${k}`, message: 'unknown key' });
}
}
return errors;
}
/**
* min_gstack_version: a well-formed version, not newer than this gstack
* (POLICY_GSTACK_TOO_OLD), and at least the introducing version of every
* post-floor key the file sets to a non-default value.
*/
function compatErrors(obj: Record<string, unknown>, running: string, errors: PolicyError[]): void {
const min = obj.min_gstack_version;
if (min !== undefined && min !== null && (typeof min !== 'string' || !parseVersion(min))) {
errors.push({ code: 'POLICY_INVALID', path: 'min_gstack_version', message: 'must be a version (MAJOR.MINOR.PATCH[.MICRO]) or null' });
return;
}
if (typeof min === 'string' && parseVersion(running) && newer(min, running)) {
errors.push({ code: 'POLICY_GSTACK_TOO_OLD', path: 'min_gstack_version', message: `this policy needs gstack ${min} or later; this gstack is ${running}` });
return;
}
for (const key of POLICY_KEYS) {
const since = POLICY_KEY_INTRODUCED[key];
if (!(key in obj) || !newer(since, POLICY_COMPAT_FLOOR)) continue;
if (JSON.stringify(obj[key]) === JSON.stringify((DEFAULT_POLICY as any)[key])) continue;
if (typeof min === 'string' && !newer(since, min)) continue;
errors.push({ code: 'POLICY_INVALID', path: key, message: `introduced in gstack ${since}; set min_gstack_version to ${since} or later so an older gstack refuses this policy by name` });
}
if (genericTrainKeys(obj.train) && !(typeof min === 'string' && !newer(TRAIN_GATES_KEY_VERSION, min))) {
errors.push({ code: 'POLICY_INVALID', path: 'train', message: `gates and land were introduced in gstack ${TRAIN_GATES_KEY_VERSION}; set min_gstack_version to ${TRAIN_GATES_KEY_VERSION} or later so an older gstack refuses this policy by name` });
}
if (obj.bump === 'never' && !(typeof min === 'string' && !newer(BUMP_NEVER_KEY_VERSION, min))) {
errors.push({ code: 'POLICY_INVALID', path: 'bump', message: `never was introduced in gstack ${BUMP_NEVER_KEY_VERSION}; set min_gstack_version to ${BUMP_NEVER_KEY_VERSION} or later so an older gstack refuses this policy by name` });
}
}
/**
* A mirror is a sibling checkout, so leading `..` segments are allowed; an
* interior `..`, an absolute path or the repo itself (`.`) are not.
*/
function mirrorPathError(p: string): string | null {
if (!p || p.length > MAX_PATTERN) return 'must be a non-empty relative path';
if (/[\0\r\n]/.test(p)) return 'contains a control character';
if (path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p)) return 'must be relative to the repository root';
const segs = p.split(/[\\/]+/).filter(s => s && s !== '.');
if (segs.length === 0) return 'must not be the repository itself';
let i = 0;
while (i < segs.length && segs[i] === '..') i += 1;
if (segs.slice(i).includes('..')) return 'must not contain an interior `..` segment';
if (i === segs.length) return 'must name a directory, not only parents';
return null;
}
/** `docs`: `{ caps: [{ path, max_bytes }] }`, every path contained, every cap a positive integer, at most 64 caps. */
function docsErrors(raw: unknown, errors: PolicyError[]): void {
const d = raw as Record<string, unknown> | null;
if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'docs', message: 'must be { caps: [{ path, max_bytes }] } or null' }); return; }
for (const k of Object.keys(d)) if (k !== 'caps') errors.push({ code: 'POLICY_INVALID', path: `docs.${k}`, message: 'unknown key' });
if (!Array.isArray(d.caps)) { errors.push({ code: 'POLICY_INVALID', path: 'docs.caps', message: 'must be an array of { path, max_bytes }' }); return; }
if (d.caps.length > MAX_LIST) errors.push({ code: 'POLICY_CONTAINMENT', path: 'docs.caps', message: `more than ${MAX_LIST} entries` });
d.caps.forEach((cap, i) => {
const c = cap as Record<string, unknown> | null;
if (!c || typeof c !== 'object' || Array.isArray(c)) { errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}]`, message: 'must be { path, max_bytes }' }); return; }
for (const k of Object.keys(c)) if (!['path', 'max_bytes'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}].${k}`, message: 'unknown key' });
const why = typeof c.path === 'string' ? containedRelative(c.path) : 'must be a string';
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: `docs.caps[${i}].path`, message: `${JSON.stringify(c.path)} ${why}` });
if (!Number.isInteger(c.max_bytes) || (c.max_bytes as number) < 1) errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}].max_bytes`, message: 'must be an integer >= 1' });
});
}
function baseRedErrors(raw: unknown, errors: PolicyError[]): void {
const b = raw as Record<string, unknown> | null;
if (!b || typeof b !== 'object' || Array.isArray(b)) { errors.push({ code: 'POLICY_INVALID', path: 'base_red', message: 'must be { setup, select, timeout_sec } or null' }); return; }
for (const k of Object.keys(b)) if (!['setup', 'select', 'timeout_sec'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `base_red.${k}`, message: 'unknown key' });
const command = (key: string, value: unknown, nullable: boolean) => {
if (nullable && value === null) return;
if (typeof value !== 'string' || !value.trim()) errors.push({ code: 'POLICY_INVALID', path: `base_red.${key}`, message: `must be a non-empty string${nullable ? ' or null' : ''}` });
else if (value.length > MAX_COMMAND || /[\0\r\n]/.test(value)) errors.push({ code: 'POLICY_CONTAINMENT', path: `base_red.${key}`, message: `must be one line under ${MAX_COMMAND} characters` });
};
command('setup', b.setup, true);
command('select', b.select, false);
if (typeof b.select === 'string' && !b.select.includes('{files}')) errors.push({ code: 'POLICY_INVALID', path: 'base_red.select', message: 'needs a {files} slot for the failing test files' });
if (!Number.isInteger(b.timeout_sec) || (b.timeout_sec as number) < 10 || (b.timeout_sec as number) > 7200) errors.push({ code: 'POLICY_INVALID', path: 'base_red.timeout_sec', message: 'must be an integer from 10 to 7200' });
}
function fragmentsErrors(raw: unknown, errors: PolicyError[]): void {
const d = raw as Record<string, unknown> | null;
if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir, required_checks? } or null' }); return; }
const rc = d.required_checks;
if (rc !== undefined && (!Array.isArray(rc) || rc.length > 50 || new Set(rc).size !== rc.length || rc.some(n => typeof n !== 'string' || !/^[^\n]{1,100}$/.test(n)))) errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments.required_checks', message: `${JSON.stringify(rc)} must be up to 50 distinct check names of 1-100 characters` });
for (const k of Object.keys(d)) if (k !== 'dir' && k !== 'required_checks') errors.push({ code: 'POLICY_INVALID', path: `changelog_fragments.${k}`, message: 'unknown key' });
const why = typeof d.dir === 'string' ? containedRelative(d.dir) ?? (/[*?[\]{}]/.test(d.dir) || d.dir.replace(/\/+$/, '') === '.' ? 'must be one directory, not a glob or the repository root' : null) : 'must be a string';
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: 'changelog_fragments.dir', message: `${JSON.stringify(d.dir)} ${why}` });
}
/** Merge a parsed file over the defaults, recording per-key provenance. */
export function mergePolicy(raw: Record<string, unknown> | null, sourceLabel: string): { policy: ShipPolicy; values: Record<string, string> } {
const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
const values: Record<string, string> = {};
for (const key of POLICY_KEYS) {
if (raw && key in raw) { (policy as any)[key] = raw[key]; values[key] = sourceLabel; }
else values[key] = 'default';
}
if (raw && typeof raw.$schema === 'string') policy.$schema = raw.$schema;
return { policy, values };
}
export interface PolicySource { kind: 'base' | 'head' | 'none'; ref: string | null; sha: string | null; label: string }
export interface LoadedPolicy {
policy: ShipPolicy;
present: boolean;
source: PolicySource;
errors: PolicyError[];
values: Record<string, string>;
}
export type PolicyLoad = LoadedPolicy | { unavailable: true; base: string; detail: string };
function git(repoRoot: string, args: string[]): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
}
/**
* `--base` when given, else the default base (lib/default-base.ts): origin/HEAD's branch unless it is the checked-out
* one, the PR's base, origin/main, origin/master. When none resolves it stays `main`, which origin then lacks, so every
* reader of origin/<base> refuses; when origin/HEAD named the checked-out branch, stderr says why.
*/
export function resolveBaseBranch(repoRoot: string, explicit?: string): string {
if (explicit && explicit.trim()) return explicit.trim().replace(/^origin\//, '');
const found = defaultBaseBranch(repoRoot);
if (!found.branch && found.skipped) process.stderr.write(`gstack: ${noDefaultBaseLine(found.skipped)}\n`);
return found.branch ?? 'main';
}
export function finish(rawText: string | null, source: PolicySource): LoadedPolicy {
if (rawText === null) return { policy: mergePolicy(null, source.label).policy, present: false, source, errors: [], values: mergePolicy(null, source.label).values };
let raw: unknown;
try { raw = JSON.parse(rawText); }
catch (e: any) { return { policy: JSON.parse(JSON.stringify(DEFAULT_POLICY)), present: true, source, errors: [{ code: 'POLICY_INVALID', path: SHIP_POLICY_PATH, message: `not valid JSON: ${e.message}` }], values: {} }; }
const errors = validatePolicy(raw);
const merged = mergePolicy(errors.length ? null : (raw as Record<string, unknown>), source.label);
return { policy: merged.policy, present: true, source, errors, values: merged.values };
}
/**
* The reviewed policy: `.gstack/ship-policy.json` as committed on
* `origin/<base>`. An unresolvable base is `unavailable` (POLICY_SOURCE_
* UNAVAILABLE); the caller must never fall back to the working tree silently.
*/
export function loadPolicyFromBase(repoRoot: string, base: string): PolicyLoad {
const ref = `origin/${base.replace(/^origin\//, '')}`;
const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
if (!sha.ok || !sha.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref} is not a resolvable commit` };
const source: PolicySource = { kind: 'base', ref, sha: sha.out, label: `${ref}@${sha.out.slice(0, 12)}:${SHIP_POLICY_PATH}` };
const exists = git(repoRoot, ['cat-file', '-e', `${sha.out}:${SHIP_POLICY_PATH}`]);
if (!exists.ok) return finish(null, { ...source, kind: 'none', label: `${ref}@${sha.out.slice(0, 12)} (no ${SHIP_POLICY_PATH})` });
const show = git(repoRoot, ['show', `${sha.out}:${SHIP_POLICY_PATH}`]);
return finish(show.ok ? show.out : null, source);
}
/** The working-tree policy: unreviewed, labeled so in every receipt. */
export function loadPolicyFromHead(repoRoot: string): LoadedPolicy {
const file = path.join(repoRoot, SHIP_POLICY_PATH);
const source: PolicySource = { kind: 'head', ref: null, sha: null, label: `head (unreviewed) ${SHIP_POLICY_PATH}` };
if (!fs.existsSync(file)) return finish(null, { ...source, kind: 'none', label: `head (no ${SHIP_POLICY_PATH})` });
return finish(fs.readFileSync(file, 'utf8'), source);
}
/** The one POLICY_SOURCE_UNAVAILABLE line, with the base branch substituted into the fix clause. */
export function policyUnavailableLine(context: string, base: string, detail: string): string {
const row = RESULT_CODES.POLICY_SOURCE_UNAVAILABLE;
return `${context}: ${row.summary} (${detail}); fix: ${row.fix.replace('<base>', base)} (POLICY_SOURCE_UNAVAILABLE)`;
}
export function isUnavailable(load: PolicyLoad): load is { unavailable: true; base: string; detail: string } {
return (load as any).unavailable === true;
}
/** Commands a policy declares; their presence never authorizes execution. */
export function declaredCommands(policy: ShipPolicy): Array<{ key: string; command: string; cwd: string }> {
const out: Array<{ key: string; command: string; cwd: string }> = [];
if (policy.release_tool) out.push({ key: 'release_tool', command: policy.release_tool, cwd: '.' });
if (policy.release_tool && policy.mirror) out.push({ key: 'mirror.release_tool', command: policy.release_tool, cwd: policy.mirror });
if (policy.restamp_command) out.push({ key: 'restamp_command', command: policy.restamp_command, cwd: '.' });
return out;
}
/** The example `init` writes: every key at its default plus `$schema`. */
export function examplePolicyJson(overrides: Partial<ShipPolicy> = {}): string {
const body: Record<string, unknown> = { $schema: SHIP_POLICY_SCHEMA };
for (const key of POLICY_KEYS) body[key] = key in overrides ? (overrides as any)[key] : (DEFAULT_POLICY as any)[key];
return JSON.stringify(body, null, 2) + '\n';
}
export function explainPolicy(): string {
return POLICY_KEYS.map(key => `${key}: ${POLICY_EXPLANATIONS[key]} default: ${JSON.stringify((DEFAULT_POLICY as any)[key])} introduced: ${POLICY_KEY_INTRODUCED[key]}`).join('\n') + '\n';
}
/** `key=value (source)` lines plus the `policy:` provenance line, as show-effective prints them. */
export function renderEffective(load: LoadedPolicy, opts: { base?: string } = {}): string {
const lines = [`policy: ${load.source.kind === 'base' ? 'base' : load.source.kind === 'head' ? 'head (unreviewed)' : 'none (defaults)'} ${load.source.label}`];
for (const key of POLICY_KEYS) lines.push(`${key}=${JSON.stringify((load.policy as any)[key])} (${load.values[key] ?? 'default'})`);
return lines.join('\n') + '\n' + shipConsequences(load.policy, opts.base ?? '<base>').map(l => `ship: ${l}`).join('\n') + '\n';
}
/** What `/ship` does differently under this policy: the workflow prose the skill prints instead of carrying (docs/ship-policy.md#ship). */
export function shipConsequences(policy: ShipPolicy, base: string): string[] {
const out: string[] = [];
if (policy.history === 'merge-only') out.push('history merge-only: never rebase, squash or force-push this branch; `gstack-ship-policy check-history --plan <merge|squash|rebase>` refuses a rewrite plan (HISTORY_POLICY_VIOLATION)');
if (policy.queue_mode === 'stamp-at-merge') {
out.push(`queue_mode stamp-at-merge: Step 12 items 1-4 are replaced by \`gstack-restamp --next --base ${base} --pr <pr-number> --expect-base <base-sha>\` when this PR is next in the queue (\`--after <predecessor-pr>\` gates ahead in a throwaway worktree when it is not); follow its next: line; claims are ignored`);
out.push(`shared merge queue: when a coordinator runs the queue, \`gstack-ship-queue ready --pr <n>\`, \`gate-ahead --after <pr> --pr <n>\` and \`stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha>\` replace the calls above; stamp only when your slot is called, send the READY block and the QUEUE_STAMP: line; the coordinator runs \`gstack-ship-queue verify\` (docs/ship-policy.md#run-a-shared-merge-queue)${policy.gate_command ? `; gate command: \`${policy.gate_command}\`` : ''}`);
out.push('gate-ahead: with .gstack/tmp/gate-ahead.json present, Step 16 reuses only the lanes `gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr <pr> --bundle <bundle> --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` is the coordinator\'s call');
} else out.push('queue_mode claim-at-open: Step 12 runs as written (claims through gstack-next-version)');
const frag = policy.changelog_fragments?.dir.replace(/\/+$/, '');
if (frag) out.push(`changelog_fragments ${frag}: Step 12 writes no version and Step 13 writes the entry as ${frag}/<branch-slug>.md (the entry text only, no heading; \`bump: minor\` or \`bump: major\` front matter only when the change warrants more than the policy bump), never an [Unreleased] section; lint it with \`gstack-prose-lint ${frag}/<branch-slug>.md --gate\`; \`gstack-ship-queue land\` assigns the version at merge (docs/ship-policy.md#changelog-fragments)`);
out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size'
: policy.bump === 'never' ? 'bump never: Step 12 keeps VERSION (no write, no question) and refuses --bump; the release process bumps outside /ship'
: 'bump ask: Step 12 asks for MINOR and MAJOR as written; in a session no person answers, a bump waits for the owner\'s words (--answer)');
out.push(policy.review_scope === 'all'
? 'review_scope all: Step 9.4 fixes every finding on every pass (gstack-review-scope classify --policy all)'
: policy.review_scope === 'diff-plus-blocking'
? 'review_scope diff-plus-blocking: Step 9.4 runs `gstack-review-scope classify --policy diff-plus-blocking` each pass; pass 1 fixes the diff and every blocking finding, in unchanged code too, later passes read only the range since the previous pass and fix only blocking findings; notes go to the PR body'
: 'review_scope frozen: Step 9.4 runs `gstack-review-scope classify` each pass; pass 1 fixes the diff and defers blocking findings in unchanged code to issues, later passes read only the range since the previous pass and fix only blocking findings; notes go to the PR body');
if (policy.review_exclude_paths.length) out.push(`review_exclude_paths ${policy.review_exclude_paths.join(' ')}: Step 9's reviewers leave out the data, docs, binary and .log files these git globs match on this branch; gstack-review-plan prints REVIEW_EXCLUDE: with the counts and the kept files, and every brief names what was left out (docs/ship-policy.md#review-exclude-paths)`);
if (policy.preregistration_shas.length) out.push(`preregistration: \`gstack-evidence ancestor --from-policy --base ${base}\` must print only ANCESTOR: ok before push; the ship receipt records the result`);
if (policy.release_tool) out.push(`release_tool: \`${policy.release_tool}\` runs only inside gstack-restamp (staging worktree, outputs confined to release_outputs)`);
return out;
}
/**
* Files under `globs` inside a committed tree, as `git ls-tree` lists them.
* Symlinks (mode 120000) never match: a stamp write through a link would
* escape the tree the glob confines it to.
*/
export function listTreeFiles(repoRoot: string, treeish: string): Array<{ path: string; mode: string }> {
const r = git(repoRoot, ['ls-tree', '-r', '-z', treeish]);
if (!r.ok) return [];
return r.out.split('\0').filter(Boolean).map(line => {
const [meta, p] = line.split('\t');
return { path: p ?? '', mode: meta?.split(' ')[0] ?? '' };
}).filter(e => e.path);
}
export function stampFilesInTree(repoRoot: string, treeish: string, globs: readonly string[]): string[] {
if (globs.length === 0) return [];
return listTreeFiles(repoRoot, treeish).filter(e => e.mode !== '120000' && matchesAny(globs, e.path)).map(e => e.path);
}
/**
* A working-tree path a stamp may write: inside `root` after realpath (a
* symlink out of the repository is refused even when the glob matched).
*/
export function assertInsideRoot(root: string, rel: string): string | null {
const realRoot = fs.realpathSync(root);
const abs = path.resolve(realRoot, rel);
if (abs !== realRoot && !abs.startsWith(realRoot + path.sep)) return `${rel} resolves outside ${root}`;
let probe = abs;
while (!fs.existsSync(probe)) {
const parent = path.dirname(probe);
if (parent === probe) break;
probe = parent;
}
let real: string;
try { real = fs.realpathSync(probe); } catch { return null; }
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return `${rel} resolves through a symlink to outside ${root}`;
return null;
}