mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
* 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 commitb10e2dbwrote 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 atef2760f(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 main83ae96eleaves these three files byte-identical toef2760f, and an Arm P unit (3593) renders byte-identical briefs atef2760fand 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 main3d99c22: 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(mergefc292e22f): 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(merge68410828f): 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 minusfc292e22f); 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(mergee85005482): 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(mergee85005482): 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(mergee85005482): 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>
552 lines
34 KiB
TypeScript
552 lines
34 KiB
TypeScript
/**
|
|
* bin/gstack-gate-diff — the CLI contract over real git repos built from the
|
|
* patch fixtures (test/helpers/gate-diff-repo.ts): the GATE_SUMMARY first line,
|
|
* the candidate model (merge-base → working tree + untracked), rename detection,
|
|
* unsupported path kinds, the exit contract, and the 0600 state-root artifact.
|
|
*
|
|
* Value: protects=stdout/exit contract /review and /ship branch on, artifact placement outside the tree;
|
|
* fails_when=the first line changes shape, exit 2 fires for anything but no_base/internal, the artifact lands in the repo or world-readable;
|
|
* why_new=the CLI is new; seam=none.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { runBin } from './helpers/run-bin';
|
|
import { buildGateDiffRepo, gitIn } from './helpers/gate-diff-repo';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-gate-diff');
|
|
const SUMMARY_RE = /^GATE_SUMMARY: listed=(\d+) eligible=(\d+) inspected=(\d+) unread=(\d+) tagged=\{([^}]*)\} unmatched=(\d+) coverage=RH-1,RH-3,RH-4,RH-12,RH-13,RH-14,RH-15,RH-16 languages_unlisted=(\S+) candidate=(\S+) artifact=(\S+)$/;
|
|
|
|
let home: string;
|
|
const repos: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-home-'));
|
|
});
|
|
afterEach(() => {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
for (const r of repos.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
|
});
|
|
|
|
function repo(opts: Parameters<typeof buildGateDiffRepo>[0] = {}): string {
|
|
const dir = buildGateDiffRepo(opts);
|
|
repos.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function run(cwd: string, args: string[]) {
|
|
return runBin(BIN, args, { cwd, gstackHome: home, env: { GSTACK_STATE_ROOT: undefined } });
|
|
}
|
|
|
|
function summary(stdout: string) {
|
|
const m = SUMMARY_RE.exec(stdout.split('\n')[0]);
|
|
if (!m) throw new Error(`no GATE_SUMMARY first line in:\n${stdout}`);
|
|
return { listed: +m[1], eligible: +m[2], inspected: +m[3], unread: +m[4], tagged: m[5], unmatched: +m[6], languages: m[7], candidate: m[8], artifact: m[9] };
|
|
}
|
|
|
|
describe('gstack-gate-diff arguments and exit contract', () => {
|
|
test('--help exits 0 with usage on stdout', () => {
|
|
const r = run(repo(), ['--help']);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('usage: gstack-gate-diff [base-ref] [--format table|jsonl] [--commit <sha>] [--help]');
|
|
});
|
|
|
|
test('an unknown option or bad --format is a tool failure: GATE_ERROR=internal, exit 2', () => {
|
|
const bad = run(repo(), ['--bogus']);
|
|
expect(bad.status).toBe(2);
|
|
expect(bad.stdout.trim().split('\n')).toEqual([expect.stringMatching(/^GATE_ERROR=internal reason=usage /)]);
|
|
const fmt = run(repo(), ['--format', 'xml']);
|
|
expect(fmt.status).toBe(2);
|
|
expect(fmt.stdout).toStartWith('GATE_ERROR=internal reason=usage');
|
|
});
|
|
|
|
test('an unresolvable base is exit 2 with GATE_ERROR=no_base and a concrete fetch as the ONLY stdout line', () => {
|
|
const r = run(repo({ fixture: 'rh1-skip' }), ['nosuchbranch']);
|
|
expect(r.status).toBe(2);
|
|
expect(r.stdout.trim().split('\n')).toEqual(['GATE_ERROR=no_base ref=nosuchbranch fix=git fetch origin +nosuchbranch:refs/remotes/origin/nosuchbranch']);
|
|
const remote = run(repo({ fixture: 'rh1-skip' }), ['upstream/release']);
|
|
expect(remote.stdout.trim()).toBe('GATE_ERROR=no_base ref=upstream/release fix=git fetch origin +upstream/release:refs/remotes/origin/upstream/release');
|
|
// A relative, symbolic or full ref is not a refspec: it gets a plain fetch, never `+HEAD~9:refs/remotes/origin/HEAD~9`.
|
|
for (const ref of ['HEAD~9', 'FETCH_HEAD', 'refs/heads/nosuch']) {
|
|
expect([ref, run(repo({ fixture: 'rh1-skip' }), [ref]).stdout.trim()]).toEqual([ref, `GATE_ERROR=no_base ref=${ref} fix=git fetch origin`]);
|
|
}
|
|
// Nor does a relative ref get a remote-tracking twin: `feature~1` with no local `feature` stays no_base
|
|
// even though `origin/feature~1` would resolve (to a commit other than the one named).
|
|
const rooted = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-root-'));
|
|
repos.push(rooted);
|
|
gitIn(rooted, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${repo({ fixture: 'rh1-skip', commit: true })}`, '.']);
|
|
gitIn(rooted, ['checkout', '-q', '--orphan', 'solo']);
|
|
gitIn(rooted, ['commit', '-q', '--allow-empty', '-m', 'root']);
|
|
gitIn(rooted, ['branch', '-q', '-D', 'feature']);
|
|
expect(gitIn(rooted, ['rev-parse', '--verify', '-q', 'origin/feature~1^{commit}']).trim()).not.toBe('');
|
|
const rel = run(rooted, ['feature~1']);
|
|
expect(rel.status).toBe(2);
|
|
expect(rel.stdout.trim()).toBe('GATE_ERROR=no_base ref=feature~1 fix=git fetch origin');
|
|
});
|
|
|
|
test('a clone with no local main resolves the base to origin/main; the fix line it prints when that is missing too actually works', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-clone-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
|
expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
|
|
expect(gitIn(dir, ['branch', '-r']).trim()).not.toContain('origin/main');
|
|
|
|
const missing = run(dir, ['main']);
|
|
expect(missing.status).toBe(2);
|
|
expect(missing.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch origin +main:refs/remotes/origin/main');
|
|
// Value: protects=the fix line for a `<remote>/<branch>` base names the branch, not the tracking ref, in its refspec;
|
|
// fails_when=noBaseFix builds the refspec from the raw ref (`origin/main:refs/remotes/origin/origin/main`) or resolveBase
|
|
// reports a direct hit as a fallback; why_new=the only remote-form control uses a remote that does not exist, so it never
|
|
// reaches the known-remote branch; seam=none; no_claim=nothing about remotes other than origin.
|
|
expect(run(dir, ['origin/main']).stdout.trim()).toBe('GATE_ERROR=no_base ref=origin/main fix=git fetch origin +main:refs/remotes/origin/main');
|
|
|
|
gitIn(dir, missing.stdout.trim().replace(/^.* fix=git /, '').split(' '));
|
|
expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
|
|
const r = run(dir, ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
|
expect(r.stderr).toContain('gstack-gate-diff: no local branch main; base is origin/main');
|
|
const direct = run(dir, ['origin/main']);
|
|
expect(direct.status).toBe(0);
|
|
expect(summary(direct.stdout).tagged).toBe('RH-1:1');
|
|
expect(direct.stderr).not.toContain('no local branch');
|
|
});
|
|
|
|
// Value: protects=a local base branch that shares no history with HEAD yields to its remote-tracking twin, and when
|
|
// the twin is missing too the printed fix repairs the next run instead of looping on the stale local branch;
|
|
// fails_when=resolveBase returns the first ref that resolves without checking it is related to HEAD, so the fix line's
|
|
// fetch into origin/main never changes what the next run picks; why_new=the other clone tests have no local main at
|
|
// all; seam=none; no_claim=nothing about a local branch that is related but behind.
|
|
test('a stale local main unrelated to HEAD yields to origin/main; without the twin the fix line repairs the next run', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-stale-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
|
gitIn(dir, ['checkout', '-q', '--orphan', 'main']);
|
|
gitIn(dir, ['rm', '-rfq', '.']);
|
|
fs.writeFileSync(path.join(dir, 'README'), 'unrelated history\n');
|
|
gitIn(dir, ['add', 'README']);
|
|
gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'unrelated main']);
|
|
gitIn(dir, ['checkout', '-q', 'feature']);
|
|
expect(spawnSync('git', ['merge-base', 'main', 'HEAD'], { cwd: dir, timeout: 30_000 }).status).not.toBe(0);
|
|
|
|
const stale = run(dir, ['main']);
|
|
expect(stale.status).toBe(2);
|
|
expect(stale.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch origin +main:refs/remotes/origin/main');
|
|
gitIn(dir, stale.stdout.trim().replace(/^.* fix=git /, '').split(' '));
|
|
const r = run(dir, ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
|
expect(r.stderr).toContain('gstack-gate-diff: local main shares no history with HEAD; base is origin/main');
|
|
expect(r.stderr).not.toContain('no local branch');
|
|
// A ref name with shell metacharacters is quoted in the printed fix line (it is meant to be run verbatim).
|
|
const odd = run(dir, ['rel;echo pwned']);
|
|
expect(odd.status).toBe(2);
|
|
expect(odd.stdout.trim()).toBe("GATE_ERROR=no_base ref=rel;echo pwned fix=git fetch origin '+rel;echo pwned:refs/remotes/origin/rel;echo pwned'");
|
|
});
|
|
|
|
// Value: protects=a local base branch that is related to HEAD but behind its remote-tracking twin yields to the twin, so
|
|
// commits the base already has (merged into the branch from origin) do not count as the branch's change (GST-462,
|
|
// GBRA-218); fails_when=resolveBase returns the local ref whenever it resolves and shares history with HEAD; seam=a
|
|
// tracking ref moved with update-ref, as a fetch would; no_claim=nothing about remotes other than origin.
|
|
test('a local main behind, ahead of or diverged from origin/main yields to it and says so; beside an unrelated origin/main it stays the base', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-behind-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', `file://${src}`, '.']);
|
|
gitIn(dir, ['checkout', '-q', 'main']);
|
|
gitIn(dir, ['update-ref', 'refs/remotes/origin/main', 'refs/remotes/origin/feature']);
|
|
gitIn(dir, ['checkout', '-q', '-b', 'topic', 'origin/main']);
|
|
fs.writeFileSync(path.join(dir, 'NOTES.md'), 'topic note\n');
|
|
gitIn(dir, ['add', 'NOTES.md']);
|
|
gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'topic']);
|
|
expect(spawnSync('git', ['merge-base', '--is-ancestor', 'main', 'origin/main'], { cwd: dir, timeout: 30_000 }).status).toBe(0);
|
|
|
|
const r = run(dir, ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('');
|
|
expect(r.stderr).toContain('gstack-gate-diff: local main is behind origin/main; base is origin/main');
|
|
gitIn(dir, ['update-ref', 'refs/heads/main', 'refs/remotes/origin/main']);
|
|
const current = run(dir, ['main']);
|
|
expect(summary(current.stdout).tagged).toBe('');
|
|
expect(current.stderr).not.toContain('base is origin/main');
|
|
const commitOn = (ref: string, name: string) => {
|
|
const tree = gitIn(dir, ['rev-parse', `${ref}^{tree}`]).trim();
|
|
const sha = gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit-tree', tree, '-p', ref, '-m', name]).trim();
|
|
gitIn(dir, ['update-ref', ref, sha]);
|
|
};
|
|
commitOn('refs/heads/main', 'unpushed');
|
|
const ahead = run(dir, ['main']);
|
|
expect(ahead.status).toBe(0);
|
|
expect(ahead.stderr).toContain('gstack-gate-diff: local main is ahead of origin/main; base is origin/main');
|
|
commitOn('refs/remotes/origin/main', 'upstream moved');
|
|
const diverged = run(dir, ['main']);
|
|
expect(diverged.status).toBe(0);
|
|
expect(diverged.stderr).toContain('gstack-gate-diff: local main has diverged from origin/main; base is origin/main');
|
|
const orphan = gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit-tree', gitIn(dir, ['rev-parse', 'HEAD^{tree}']).trim(), '-m', 'unrelated']).trim();
|
|
gitIn(dir, ['update-ref', 'refs/remotes/origin/main', orphan]);
|
|
const unrelated = run(dir, ['main']);
|
|
expect(unrelated.status).toBe(0);
|
|
expect(summary(unrelated.stdout).tagged).toBe('');
|
|
expect(unrelated.stderr).not.toContain('base is origin/main');
|
|
});
|
|
|
|
// Value: protects=the gate scan reads the same base as gstack-review-plan's path list (origin/<base>), so commits a local
|
|
// main carries but origin lacks are scanned when the branch includes them; fails_when=a related local main that is ahead
|
|
// of origin/main stays the base (fix-check, pass 2: an unpushed test.skip listed in paths, gate_tagged=0); seam=a local
|
|
// branch moved with update-ref; no_claim=a fork whose origin is not the PR's base repository (ship preflight's prSized)
|
|
test('a local main ahead of origin/main yields to it, so its unpushed test.skip is scanned', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-ahead-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', `file://${src}`, '.']);
|
|
gitIn(dir, ['branch', '-q', '--no-track', 'main', 'origin/feature']);
|
|
gitIn(dir, ['checkout', '-q', '-b', 'topic', 'main']);
|
|
fs.writeFileSync(path.join(dir, 'NOTES.md'), 'topic note\n');
|
|
gitIn(dir, ['add', 'NOTES.md']);
|
|
gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'topic']);
|
|
const r = run(dir, ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
|
expect(r.stderr).toContain('gstack-gate-diff: local main is ahead of origin/main; base is origin/main');
|
|
});
|
|
|
|
// Value: protects=the base cannot be redirected to the candidate by a ref the contributor controls: a tag named
|
|
// origin/main, or a fetched branch named origin/main (refs/remotes/origin/origin/main), never replaces origin's tracking
|
|
// branch, and an explicit origin/main is never re-qualified; fails_when=resolveBase resolves the twin by its short name or
|
|
// tries a twin for a remote-qualified ref (outside voice, pass 2: both emptied the gate scan of a test.skip); seam=refs a
|
|
// fetch would create; no_claim=a tag shadowing a local branch name, which predates this change
|
|
test('a tag or a fetched branch named origin/main cannot empty the gate scan, named bare, qualified or defaulted', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-shadow-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', `file://${src}`, '.']);
|
|
gitIn(dir, ['checkout', '-q', '-b', 'topic', 'origin/feature']);
|
|
expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
|
|
gitIn(dir, ['tag', 'origin/main', 'HEAD']);
|
|
expect(summary(run(dir, ['main']).stdout).tagged).toBe('RH-1:1');
|
|
expect(summary(run(dir, ['origin/main']).stdout).tagged).toBe('RH-1:1');
|
|
gitIn(dir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']);
|
|
expect(summary(run(dir, []).stdout).tagged).toBe('RH-1:1');
|
|
gitIn(dir, ['tag', '-d', 'origin/main']);
|
|
gitIn(dir, ['update-ref', 'refs/remotes/origin/origin/main', 'HEAD']);
|
|
expect(summary(run(dir, ['origin/main']).stdout).tagged).toBe('RH-1:1');
|
|
});
|
|
|
|
// Value: protects=a clone whose only remote is not called origin resolves the base through that remote and the fix line
|
|
// fetches from it; fails_when=remoteNames or resolveBase hard-code origin, or noBaseFix falls back to the literal 'origin'
|
|
// while a real remote exists; why_new=every existing fixture has either an origin remote or none; seam=none;
|
|
// no_claim=nothing about the order when origin and another remote both carry the branch.
|
|
// Value: protects=a bare base name scans from the same merge base /ship merges against (origin/<base>) when the local
|
|
// branch is related but behind (#3650); fails_when=resolveBase prefers any related local branch, so a stale local main
|
|
// widens the scan to commits already upstream; why_new=the stale-main test above covers only unrelated history;
|
|
// seam=none; no_claim=nothing about a local base that is ahead of its remote-tracking twin.
|
|
test('a related local main that is behind origin/main yields to origin/main (#3650)', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const seed = gitIn(src, ['rev-parse', 'main']).trim();
|
|
gitIn(src, ['checkout', '-q', 'main']);
|
|
fs.mkdirSync(path.join(src, '.github', 'workflows'), { recursive: true });
|
|
fs.writeFileSync(path.join(src, '.github', 'workflows', 'ci.yml'), 'on: push\njobs: {}\n');
|
|
gitIn(src, ['add', '-A']);
|
|
gitIn(src, ['commit', '-q', '-m', 'upstream ci change']);
|
|
gitIn(src, ['checkout', '-q', 'feature']);
|
|
gitIn(src, ['rebase', '-q', 'main']);
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-behind-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', '--branch', 'feature', `file://${src}`, '.']);
|
|
gitIn(dir, ['branch', '-f', 'main', seed]);
|
|
const bare = run(dir, ['main']);
|
|
const remote = run(dir, ['origin/main']);
|
|
expect([bare.status, remote.status]).toEqual([0, 0]);
|
|
expect(summary(bare.stdout).candidate).toBe(summary(remote.stdout).candidate);
|
|
expect(bare.stdout).toBe(remote.stdout);
|
|
expect(bare.stderr).toContain('gstack-gate-diff: local main is behind origin/main; base is origin/main');
|
|
});
|
|
|
|
// Value: protects=a ref a contributor controls (a fork's origin/upstream/main branch, a tag named origin/main, another
|
|
// remote's main) never replaces the base the caller named or a related local base, so it cannot hide a gate edit;
|
|
// fails_when=resolveBase rewrites remote-qualified names, resolves the twin by its short name, or lets any remote beat a
|
|
// related local base; why_new=the cases above have one remote and no colliding refs; seam=none; no_claim=nothing about
|
|
// a remote's HEAD symref.
|
|
test('a fork branch, a colliding tag or another remote never replaces the base (#3650 review)', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const seed = gitIn(src, ['rev-parse', 'main']).trim();
|
|
const clone = (remote: string) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-refs-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', '-o', remote, '--branch', 'feature', `file://${src}`, '.']);
|
|
return dir;
|
|
};
|
|
const named = clone('upstream');
|
|
gitIn(named, ['remote', 'add', 'origin', 'file:///nonexistent-fork']);
|
|
gitIn(named, ['update-ref', 'refs/remotes/origin/upstream/main', 'HEAD']);
|
|
expect(summary(run(named, ['upstream/main']).stdout).tagged).toBe('RH-1:1');
|
|
const tagged = clone('origin');
|
|
gitIn(tagged, ['tag', 'origin/main', 'HEAD']);
|
|
expect(summary(run(tagged, ['main']).stdout).tagged).toBe('RH-1:1');
|
|
const fork = clone('zfork');
|
|
gitIn(fork, ['branch', '-f', 'main', seed]);
|
|
gitIn(fork, ['update-ref', 'refs/remotes/zfork/main', 'HEAD']);
|
|
expect(summary(run(fork, ['main']).stdout).tagged).toBe('RH-1:1');
|
|
});
|
|
|
|
test('a clone whose only remote is upstream resolves the base to upstream/main, and the fix line fetches from upstream', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-upstream-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', '-o', 'upstream', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
|
expect(gitIn(dir, ['remote']).trim()).toBe('upstream');
|
|
|
|
const missing = run(dir, ['main']);
|
|
expect(missing.status).toBe(2);
|
|
expect(missing.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch upstream +main:refs/remotes/upstream/main');
|
|
|
|
gitIn(dir, missing.stdout.trim().replace(/^.* fix=git /, '').split(' '));
|
|
const r = run(dir, ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
|
expect(r.stderr).toContain('gstack-gate-diff: no local branch main; base is upstream/main');
|
|
});
|
|
|
|
// Value: protects=resolveBase walks past origin to the other remotes (origin exists but lacks the branch, upstream has it);
|
|
// fails_when=the loop stops after origin or only consults origin when origin exists; why_new=the upstream-only test has one
|
|
// remote and the origin tests have only origin, so the second iteration was never reached; seam=none; no_claim=nothing about
|
|
// which remote wins when both carry the branch.
|
|
test('origin without main and upstream with it: the base is upstream/main', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-two-remotes-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
|
gitIn(dir, ['remote', 'add', 'upstream', `file://${src}`]);
|
|
gitIn(dir, ['fetch', '-q', 'upstream', 'main:refs/remotes/upstream/main']);
|
|
expect(gitIn(dir, ['branch', '-r']).trim()).not.toContain('origin/main');
|
|
const r = run(dir, ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
|
expect(r.stderr).toContain('gstack-gate-diff: no local branch main; base is upstream/main');
|
|
});
|
|
|
|
// Value: protects=the default base (no argument) is found through the same remote list as resolveBase and the fix line, so an
|
|
// upstream-only clone of a master-default repository defaults to upstream/master instead of a `main` that exists nowhere;
|
|
// fails_when=defaultBase probes only `origin/<name>`; why_new=no test ran without a base in a clone whose remote is not
|
|
// origin; seam=the remote HEAD symref is removed first because single-branch clones set it on some git versions and not
|
|
// others; no_claim=nothing about a remote HEAD that points at the feature branch.
|
|
test('no explicit base in an upstream-only clone of a master-default repository: the base is upstream/master', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
gitIn(src, ['branch', '-m', 'main', 'master']);
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-default-upstream-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', '-o', 'upstream', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
|
gitIn(dir, ['fetch', '-q', 'upstream', 'master:refs/remotes/upstream/master']);
|
|
gitIn(dir, ['remote', 'set-head', 'upstream', '-d']);
|
|
const r = run(dir, []);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
|
expect(r.stderr).toContain('gstack-gate-diff: no local branch master; base is upstream/master');
|
|
});
|
|
|
|
// Value: protects=a shallow clone gets a fix line that unshallows into the tracking ref, both when the base is absent and
|
|
// when it is present but shares no reachable history (merge-base fails), and that line repairs the clone;
|
|
// fails_when=the shallow branch of noBaseFix keeps the bare `--unshallow origin main` (FETCH_HEAD only), or a resolved base
|
|
// with no merge-base stops being no_base; why_new=the existing shallow test covers only the `<sha>^` --deepen form, and no
|
|
// test reaches the merge-base-null branch; seam=none; no_claim=nothing about --deepen.
|
|
test('a shallow single-branch clone: the fix line unshallows into origin/main, including when origin/main exists but has no merge-base', () => {
|
|
const src = repo({ fixture: 'rh1-skip', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-shallow-base-'));
|
|
repos.push(dir);
|
|
gitIn(dir, ['clone', '-q', '--depth', '1', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
|
|
|
const absent = run(dir, ['main']);
|
|
expect(absent.status).toBe(2);
|
|
expect(absent.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch --unshallow origin +main:refs/remotes/origin/main');
|
|
|
|
gitIn(dir, ['fetch', '-q', '--depth', '1', 'origin', 'main:refs/remotes/origin/main']);
|
|
expect(gitIn(dir, ['rev-parse', '--verify', '-q', 'origin/main^{commit}']).trim()).not.toBe('');
|
|
const disjoint = run(dir, ['main']);
|
|
expect(disjoint.status).toBe(2);
|
|
expect(disjoint.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch --unshallow origin +main:refs/remotes/origin/main');
|
|
|
|
gitIn(dir, disjoint.stdout.trim().replace(/^.* fix=git /, '').split(' '));
|
|
expect(gitIn(dir, ['rev-parse', '--is-shallow-repository']).trim()).toBe('false');
|
|
const r = run(dir, ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
|
expect(r.stderr).toContain('gstack-gate-diff: no local branch main; base is origin/main');
|
|
});
|
|
|
|
test('an empty candidate is exit 0 with a zero summary, never an error', () => {
|
|
const r = run(repo(), ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout)).toMatchObject({ listed: 0, eligible: 0, inspected: 0, unread: 0, tagged: '', unmatched: 0 });
|
|
expect(r.stdout.trim().split('\n')).toHaveLength(1);
|
|
});
|
|
|
|
test('a candidate of only unmatched product paths is exit 0 (unmatched is counted, never exit 2)', () => {
|
|
const r = run(repo({ fixture: 'rh4-placeholder' }), ['main']);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-4:1');
|
|
const plain = repo();
|
|
fs.writeFileSync(path.join(plain, 'src.ts'), 'export const a = 1;\n');
|
|
const u = run(plain, ['main']);
|
|
expect(u.status).toBe(0);
|
|
expect(summary(u.stdout)).toMatchObject({ listed: 0, unmatched: 1 });
|
|
expect(u.stderr).toContain('unmatched: src.ts');
|
|
});
|
|
|
|
test('the template errexit block branches on the exit without swallowing it', () => {
|
|
const block = `set -euo pipefail
|
|
if GATE_OUT=$("${BIN}" "$1" 2>&1); then
|
|
printf '%s\\n' "$GATE_OUT" | head -1
|
|
else
|
|
GATE_EXIT=$?
|
|
case "$GATE_EXIT" in
|
|
2) echo "Gate integrity: UNAVAILABLE — $(printf '%s\\n' "$GATE_OUT" | grep -m1 '^GATE_ERROR=')" ;;
|
|
*) echo "Gate integrity: UNAVAILABLE — helper exit $GATE_EXIT" ;;
|
|
esac
|
|
fi
|
|
echo AFTER`;
|
|
const dir = repo({ fixture: 'rh1-skip' });
|
|
const env = { ...process.env, GSTACK_HOME: home, GSTACK_STATE_DIR: home };
|
|
delete (env as Record<string, string | undefined>).GSTACK_STATE_ROOT;
|
|
const ok = spawnSync('bash', ['-c', block, 'x', 'main'], { cwd: dir, env, encoding: 'utf-8', timeout: 60_000 });
|
|
expect(ok.stdout.split('\n')[0]).toStartWith('GATE_SUMMARY: ');
|
|
expect(ok.stdout).toContain('AFTER');
|
|
const gone = spawnSync('bash', ['-c', block, 'x', 'nosuchbranch'], { cwd: dir, env, encoding: 'utf-8', timeout: 60_000 });
|
|
expect(gone.stdout).toContain('Gate integrity: UNAVAILABLE — GATE_ERROR=no_base ref=nosuchbranch');
|
|
expect(gone.stdout).toContain('AFTER');
|
|
});
|
|
});
|
|
|
|
describe('gstack-gate-diff candidate model and output', () => {
|
|
test('one comparison: merge-base → working tree, staged + unstaged + untracked, with the candidate fingerprint and a 0600 artifact', () => {
|
|
const dir = repo({ fixture: 'rh1-skip', commit: true, untracked: { 'vitest.config.ts': 'export default { test: { testTimeout: 60000 } };\n' } });
|
|
fs.appendFileSync(path.join(dir, 'src/add.ts'), '// @ts-ignore\nexport const extra = 1;\n');
|
|
const r = run(dir, ['main', '--format', 'jsonl']);
|
|
expect(r.status).toBe(0);
|
|
const s = summary(r.stdout);
|
|
expect(s).toMatchObject({ listed: 3, eligible: 3, inspected: 3, unread: 0, tagged: 'RH-1:1,RH-13:1,RH-15?:1', unmatched: 0, languages: 'none' });
|
|
expect(s.candidate).toMatch(/^[0-9a-f]{12}\+[0-9a-f]{12}$/);
|
|
const mergeBase = gitIn(dir, ['merge-base', 'main', 'HEAD']).trim();
|
|
expect(s.candidate.startsWith(mergeBase.slice(0, 12))).toBe(true);
|
|
|
|
const rows = r.stdout.trim().split('\n').slice(1).map((l) => JSON.parse(l));
|
|
expect(rows.map((x) => [x.path, x.tags, x.level])).toEqual([
|
|
['src/add.ts', ['RH-13'], 'read'],
|
|
['test/add.test.ts', ['RH-1'], 'read'],
|
|
['vitest.config.ts', ['RH-15?'], 'read'],
|
|
]);
|
|
|
|
expect(s.artifact).toBe(path.join(home, 'projects', path.basename(dir), 'gate-diff', `${s.candidate}.jsonl`));
|
|
expect(path.isAbsolute(s.artifact)).toBe(true);
|
|
expect(s.artifact.startsWith(dir + path.sep)).toBe(false);
|
|
expect(fs.statSync(s.artifact).mode & 0o777).toBe(0o600);
|
|
expect(fs.readFileSync(s.artifact, 'utf-8').trim().split('\n').map((l) => JSON.parse(l).id)).toEqual(rows.map((x) => x.id));
|
|
expect(gitIn(dir, ['status', '--porcelain']).split('\n').filter(Boolean).some((l) => l.includes('gate-diff'))).toBe(false);
|
|
});
|
|
|
|
test('table format: GATE_SUMMARY, then one block per read hunk and nothing else on stdout', () => {
|
|
const r = run(repo({ fixture: 'adv-forged-measured' }), ['main']);
|
|
expect(r.status).toBe(0);
|
|
const lines = r.stdout.trimEnd().split('\n');
|
|
expect(lines[0]).toStartWith('GATE_SUMMARY: ');
|
|
expect(lines[1]).toMatch(/^\[[0-9a-f]{12}\] RH-15 test\/flaky\.test\.ts @@ -\d+,\d+ \+\d+,\d+ @@$/);
|
|
expect(lines.slice(2).every((l) => l.startsWith(' '))).toBe(true);
|
|
expect(lines.at(-1)).toBe(' citation: // measured: ship-measure report 2026-10-09 shows p99 48s');
|
|
expect(lines.some((l) => l.startsWith(' note: RH-15: timeout 5000 -> 60000'))).toBe(true);
|
|
});
|
|
|
|
test('the working-tree fingerprint changes the candidate when an untracked file appears; the merge-base half does not', () => {
|
|
const dir = repo({ fixture: 'rh1-skip', commit: true });
|
|
const a = summary(run(dir, ['main']).stdout).candidate;
|
|
fs.writeFileSync(path.join(dir, 'new.ts'), 'export const n = 1;\n');
|
|
const b = summary(run(dir, ['main']).stdout).candidate;
|
|
expect(a).not.toBe(b);
|
|
expect(a.split('+')[0]).toBe(b.split('+')[0]);
|
|
});
|
|
|
|
test('rename detection: a moved test is listed with old_path and no tags', () => {
|
|
const r = run(repo({ fixture: 'control-rename', commit: true }), ['main', '--format=jsonl']);
|
|
const rows = r.stdout.trim().split('\n').slice(1).map((l) => JSON.parse(l));
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ path: 'test/math/add.test.ts', old_path: 'test/add.test.ts', tags: [], level: 'listed', old: [], new: [] });
|
|
});
|
|
|
|
test('binary, symlink and submodule paths are named unsupported on stderr, never zero hunks', () => {
|
|
const dir = repo({ fixture: 'rh1-skip' });
|
|
fs.writeFileSync(path.join(dir, 'test/fixtures.bin'), Buffer.from([0, 1, 2, 255, 0, 7]));
|
|
fs.symlinkSync('add.test.ts', path.join(dir, 'test/link.test.ts'));
|
|
const dep = path.join(dir, 'vendor', 'dep');
|
|
fs.mkdirSync(dep, { recursive: true });
|
|
gitIn(dep, ['init', '-q']);
|
|
fs.writeFileSync(path.join(dep, 'x'), 'x\n');
|
|
gitIn(dep, ['add', 'x']);
|
|
gitIn(dep, ['commit', '-q', '-m', 'dep']);
|
|
gitIn(dir, ['-c', 'advice.addEmbeddedRepo=false', 'add', 'vendor/dep']);
|
|
const r = run(dir, ['main', '--format', 'jsonl']);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stderr).toContain('unsupported (binary): test/fixtures.bin');
|
|
expect(r.stderr).toContain('unsupported (symlink): test/link.test.ts');
|
|
expect(r.stderr).toContain('unsupported (submodule): vendor/dep');
|
|
const rows = r.stdout.trim().split('\n').slice(1).map((l) => JSON.parse(l));
|
|
expect(rows.filter((x) => x.unsupported).map((x) => [x.path, x.unsupported, x.level])).toEqual([
|
|
['test/fixtures.bin', 'binary', 'listed'],
|
|
['test/link.test.ts', 'symlink', 'listed'],
|
|
]);
|
|
});
|
|
|
|
test('[skip ci] in a commit on the branch is listed as RH-14 by message', () => {
|
|
const r = run(repo({ fixture: 'control-added-test', commit: true, commitMessage: 'test: add cases [skip ci]' }), ['main']);
|
|
const s = summary(r.stdout);
|
|
expect(s.tagged).toBe('RH-14:1');
|
|
expect(r.stdout).toMatch(/^\[[0-9a-f]{12}\] RH-14 \(commit [0-9a-f]{12}\) commit [0-9a-f]{40}$/m);
|
|
expect(r.stdout).toContain(' +test: add cases [skip ci]');
|
|
});
|
|
|
|
test('--commit <sha> scans one commit without the working tree; the candidate is that sha', () => {
|
|
const dir = repo({ fixture: 'rh15-timeout', commit: true, untracked: { 'test/noise.test.ts': 'it.skip("x", () => {});\n' } });
|
|
const head = gitIn(dir, ['rev-parse', 'HEAD']).trim();
|
|
const r = run(dir, ['--commit', 'HEAD', '--format', 'jsonl']);
|
|
expect(r.status).toBe(0);
|
|
const s = summary(r.stdout);
|
|
expect(s.candidate).toBe(head.slice(0, 12));
|
|
expect(s).toMatchObject({ listed: 2, tagged: 'RH-15:2' });
|
|
expect(r.stdout).not.toContain('noise.test.ts');
|
|
expect(run(dir, ['--commit', 'deadbeef']).stdout).toStartWith('GATE_ERROR=no_base ref=deadbeef ');
|
|
});
|
|
|
|
test('--commit on a shallow boundary is GATE_ERROR=no_base, never the whole tree as added', () => {
|
|
const src = repo({ fixture: 'rh15-timeout', commit: true });
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-shallow-'));
|
|
gitIn(dir, ['clone', '-q', '--depth', '1', '--branch', 'feature', `file://${src}`, '.']);
|
|
const head = gitIn(dir, ['rev-parse', 'HEAD']).trim();
|
|
const r = run(dir, ['--commit', 'HEAD']);
|
|
expect(r.status).toBe(2);
|
|
expect(r.stdout.trim()).toBe(`GATE_ERROR=no_base ref=${head.slice(0, 12)}^ fix=git fetch --deepen=50 origin`);
|
|
const root = run(src, ['--commit', gitIn(src, ['rev-list', '--max-parents=0', 'HEAD']).trim(), '--format', 'jsonl']);
|
|
expect(root.status).toBe(0);
|
|
expect(summary(root.stdout).listed).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('base defaults to main when no ref is given and no origin/HEAD exists', () => {
|
|
const r = run(repo({ fixture: 'rh1-skip' }), []);
|
|
expect(r.status).toBe(0);
|
|
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
|
});
|
|
|
|
test('the CLAUDE.md ## Gate Integrity block extends the floor', () => {
|
|
const dir = repo();
|
|
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# p\n\n## Gate Integrity\nExtra test paths: features/\nSnapshot paths: fixtures/golden/\n');
|
|
fs.mkdirSync(path.join(dir, 'features'), { recursive: true });
|
|
fs.mkdirSync(path.join(dir, 'fixtures/golden'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'features/login.feature'), 'Feature: login\n');
|
|
fs.writeFileSync(path.join(dir, 'fixtures/golden/out.json'), '{}\n');
|
|
const r = run(dir, ['main', '--format', 'jsonl']);
|
|
const rows = r.stdout.trim().split('\n').slice(1).map((l) => JSON.parse(l));
|
|
expect(rows.map((x) => [x.path, x.tags])).toEqual([['features/login.feature', []], ['fixtures/golden/out.json', ['RH-3?']]]);
|
|
});
|
|
});
|