mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
v1.91.363.0 Evidence ignores Bun's .bun/ cache, --check-prose skips the Approvals table, pr-body drops stale intros (GST-421, GST-412) (#3675)
* fix(evidence): leave an untracked root .bun/ out of the fingerprint and name mid-run changes (GST-421) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-receipt): --check-prose skips the rendered Approvals block (GST-412) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gstack-post): pr-body with sections replaces stale text above the first heading (GST-412) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): withhold flagged dropped-preamble excerpts, keep forged Approvals rows checked, sanitize wtree_changed (GST-412, GST-421) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: GST-421/GST-412 review follow-ups in TODOS, fragment and troubleshooting Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
71af16c60b
commit
807aaa8da5
17 files changed
+367
-48
No files matched your search
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.363.0] - 2026-10-31
|
||||
|
||||
**A passing test lane that leaves Bun's `.bun/` folder behind now grades FRESH, `--check-prose` no longer reads the Approvals table as a claim, and `gstack-post pr-body` drops a stale intro.** Three /ship false alarms from GST-421 and GST-412 (HARN-1, HARN-12, GSTA-2, GEVA-69, gstack-evals#90).
|
||||
|
||||
### Fixed
|
||||
- **GST-421: `gstack-wtree` leaves an untracked `.bun/` folder at the repo root out of the fingerprint.** A test that created it made `gstack-evidence` treat the run as a mid-run edit, so `check` graded the lane STALE with "record has no content fingerprint" and named no file. Tracked files under `.bun/` still count, and a tree without that folder hashes exactly as before.
|
||||
- **GST-421: `gstack-evidence` names what changed during a run.** When the fingerprint before and after a run differ, the record stores up to five changed paths as `wtree_changed`, the stderr warning lists them, and `check` grades STALE with `content changed during the run: <paths>`. A record without them (or with entries that are not plain paths) still says "record has no content fingerprint".
|
||||
- **GST-412: `gstack-ship-receipt read --check-prose` skips the `## Approvals` block `gstack-approvals` renders** (the heading, the table and its `Source: gstack-approvals record` line), so "one Step 11 pass" in the owner's quoted words is no longer read as `gate.<lane>.pass body=11`. Text after that block, a row the renderer would not write, a hand-written Approvals list and an example inside a code fence are still checked, the text on either side of the block never joins into a new claim, and CRLF bodies work.
|
||||
|
||||
### Changed
|
||||
- **GST-412: `gstack-post pr-body` manages the text above the first heading when the file has a `## ` section.** The current body's opening text, any text between the receipt and the first heading, or a whole body with no `## ` heading, is replaced by the file's own text above its first heading, or dropped when the file has none. The stderr line names each dropped piece with its first line (`; dropped leading text ("…")`), or withholds that line when the redaction scan flags it, so a removed secret never reaches the terminal. A receipt-only or text-only file merges as before, and reposting the same body gives the same bytes.
|
||||
|
||||
### Tests
|
||||
- `test/evidence.test.ts`: a lane that writes `.bun/x` grades FRESH; one that writes `src/new.ts` grades STALE naming it; a tracked file under `.bun/` still moves the fingerprint; a tree without `.bun/` hashes like a plain `git add -A`.
|
||||
- `test/ship-receipt-prose.test.ts`: the rendered Approvals table, text after its footer, a fenced example, a hand-written list and a CRLF body.
|
||||
- `test/gstack-post.test.ts`: stale lead text and stale text under the receipt are dropped and named; receipt-only and text-only files keep the old merge; reposts are byte-identical; an attribution callout is never doubled.
|
||||
|
||||
## [1.91.362.0] - 2026-10-31
|
||||
|
||||
**A /ship review pass on Capy is one call to a checked-in program, and reviewer briefs no longer pass through the model.** In the GSTA-189 replay one pass took about 10 parent calls to plan, render, create, check and poll its readers, and the Red Team brief went out wrong twice because a file read cut its long lines. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged, and the new path renders all 16 briefs of that replay byte for byte.
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## NEXT PRIORITY
|
||||
|
||||
### P3: GST-421 and GST-412 follow-ups (filed 2026-10-31, from the PR B /ship review)
|
||||
|
||||
- **Partial `gstack-post pr-body` posts drop the intro.** Since GST-412, any body file with a `## ` section replaces the text above the current body's first heading, so a lane that posts only `## Screenshots` deletes a human intro or a `Review-loop exception:` line written there (stderr names it; GitHub's edit history keeps it). Decide whether the preamble should be managed only when the file brings its own lead text or a receipt, or have /ship put the exception line under a named section. **Priority:** P3.
|
||||
- **Check the Approvals table's own cells.** `--check-prose` skips the whole rendered Approvals block, including the Asked, Covers and Still pending cells the lane types. Narrow the skip to the Exact words column. **Priority:** P3.
|
||||
- **The root `.bun/` blind spot.** `gstack-wtree` leaves every untracked file under a root `.bun/` out of the fingerprint, so an edit there never makes evidence STALE. Narrow the exclusion to Bun's cache subfolder if anything starts loading code from it. **Priority:** P3.
|
||||
|
||||
### P1: review-depth receipt follow-ups (filed 2026-10-28, from the #3567 /ship plan audit)
|
||||
|
||||
- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.362.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.363.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+11
-3
@@ -14,7 +14,7 @@
|
||||
* `run` is a TRANSPARENT wrapper: it streams the child's output through
|
||||
* unchanged, tees it to a 0600 log (2MB cap with a truncation marker), and
|
||||
* appends {ts, label, command, cmd_sha256, exit, duration_s, commit, tree,
|
||||
* dirty, wtree, log_path, tests_ran} to ~/.gstack/projects/<slug>/<branch>-evidence.jsonl.
|
||||
* dirty, wtree or wtree_changed, log_path, tests_ran} to ~/.gstack/projects/<slug>/<branch>-evidence.jsonl.
|
||||
*
|
||||
* `tests_ran` (zero-run green, W3 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md)
|
||||
* is parsed from a bounded tail of the streamed output for the supported
|
||||
@@ -89,6 +89,8 @@ interface EvidenceRecord {
|
||||
tree?: string;
|
||||
dirty?: boolean;
|
||||
wtree?: string;
|
||||
/** Up to five paths whose content changed between the before-run and after-run fingerprints (set instead of wtree). */
|
||||
wtree_changed?: string[];
|
||||
log_path?: string;
|
||||
redacted?: boolean;
|
||||
/** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */
|
||||
@@ -524,7 +526,10 @@ function record(
|
||||
if (wtreeBefore && wtreeAfter && wtreeBefore === wtreeAfter) {
|
||||
rec.wtree = wtreeAfter;
|
||||
} else if (wtreeBefore || wtreeAfter) {
|
||||
warn("working-tree content changed during the run — evidence recorded without a content fingerprint (will grade STALE)");
|
||||
const changed = wtreeBefore && wtreeAfter ? (git(["diff", "--name-only", wtreeBefore, wtreeAfter]) ?? "").split("\n").map((s) => s.trim()).filter(Boolean) : [];
|
||||
if (changed.length) rec.wtree_changed = changed.slice(0, 5);
|
||||
const named = changed.length ? `: ${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ", ..." : ""}` : "";
|
||||
warn(`working-tree content changed during the run${named} — evidence recorded without a content fingerprint (will grade STALE)`);
|
||||
}
|
||||
}
|
||||
if (logPath) rec.log_path = logPath;
|
||||
@@ -599,6 +604,7 @@ function cmdCheck(argv: string[]): number {
|
||||
continue;
|
||||
}
|
||||
const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown";
|
||||
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
|
||||
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}`;
|
||||
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
|
||||
let reason = "";
|
||||
@@ -631,7 +637,9 @@ function cmdCheck(argv: string[]): number {
|
||||
// forged/corrupt ledger line must degrade, never inject options.
|
||||
verdict = "STALE";
|
||||
reason = !latest.wtree
|
||||
? "record has no content fingerprint"
|
||||
? changedDuringRun.length
|
||||
? `content changed during the run: ${changedDuringRun.join(", ")}`
|
||||
: "record has no content fingerprint"
|
||||
: !/^[0-9a-f]{40}$/.test(latest.wtree)
|
||||
? "record has malformed fingerprint"
|
||||
: "current fingerprint unavailable";
|
||||
|
||||
+11
-6
@@ -1,10 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-wtree — print a working-tree CONTENT fingerprint (a git tree hash).
|
||||
#
|
||||
# Builds a temp index, stages the full working tree into it (`git add -A`, so
|
||||
# .gitignore'd scratch stays out and UNTRACKED source is included), and prints
|
||||
# `git write-tree` of that index. Properties that make this the right
|
||||
# staleness fingerprint, vs `git rev-parse HEAD^{tree}`:
|
||||
# Builds a temp index, stages the full working tree into it (`git add -u` for
|
||||
# every tracked file, then `git add -A` for the rest, so .gitignore'd scratch
|
||||
# stays out and UNTRACKED source is included), and prints `git write-tree` of
|
||||
# that index. An untracked `.bun/` folder at the repository root (Bun's install
|
||||
# cache, which a test run can create) is left out so a passing run does not
|
||||
# look like a mid-run edit (GST-421); tracked files under it still count, and a
|
||||
# tree without that folder hashes exactly as a plain `git add -A` would.
|
||||
# Properties that make this the right staleness fingerprint, vs
|
||||
# `git rev-parse HEAD^{tree}`:
|
||||
#
|
||||
# - Committing identical content does NOT change the fingerprint, so a
|
||||
# record made on a dirty tree stays valid after the exact same content is
|
||||
@@ -15,7 +20,7 @@
|
||||
#
|
||||
# Performance: the temp index is seeded by COPYING the real index (git writes
|
||||
# it atomically via rename, so the copy is a consistent snapshot). That
|
||||
# preserves the stat cache, so `git add -A` only re-hashes files whose stat
|
||||
# preserves the stat cache, so staging only re-hashes files whose stat
|
||||
# changed — measured 40x faster than a `read-tree HEAD` seed, which zeroes
|
||||
# stat data and forces a full re-hash of every tracked file. Both seeds
|
||||
# produce the identical write-tree hash. Fallback: `read-tree HEAD` when the
|
||||
@@ -82,7 +87,7 @@ if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ] && cp "$REAL_INDEX" "$TMPIDX" 2>
|
||||
else
|
||||
git -C "$TOP" read-tree HEAD 2>/dev/null || exit 1
|
||||
fi
|
||||
if ! ERR=$(git -C "$TOP" add -A 2>&1 >/dev/null); then
|
||||
if ! ERR=$(git -C "$TOP" add -u 2>&1 >/dev/null) || ! ERR=$(git -C "$TOP" add -A -- . ':(exclude,top).bun' 2>&1 >/dev/null); then
|
||||
echo "gstack-wtree: cannot stage the working tree: $(printf '%s' "$ERR" | head -1)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+6
-1
@@ -970,7 +970,12 @@ not a claim; a number that is the value of a `key=` or `key:` pair is that
|
||||
key's claim alone, so `passes=2 blocking_found=1` is two claims, never a
|
||||
`2 blocking_found` one, #3513). A body that quotes a different number is
|
||||
`RECEIPT_PROSE_MISMATCH` (exit 1) and the `PROSE:` line names each field with
|
||||
both values; prose that quotes no counter is not a claim. Data PRs (evals,
|
||||
both values; prose that quotes no counter is not a claim. The `## Approvals`
|
||||
block `gstack-approvals` renders (its heading, the Date | Asked | Exact words
|
||||
table and the `Source: gstack-approvals record` line) quotes the owner's
|
||||
words, not this PR's numbers, so it is skipped: "one Step 11 pass" in that table
|
||||
is not a `pass` claim. Text you write under the heading after that block, and
|
||||
a `## Approvals` example inside a code fence, are still checked. Data PRs (evals,
|
||||
measurements, sweeps) run it before READY so the body's numbers are the run's.
|
||||
|
||||
<a id="review-scope"></a>
|
||||
|
||||
@@ -17,7 +17,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
||||
| `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. |
|
||||
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs <token>` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. |
|
||||
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). |
|
||||
| `gstack-evidence` | **Verification-evidence ledger** — `run --label <lane> -- <cmd>` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint; `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. |
|
||||
| `gstack-evidence` | **Verification-evidence ledger** — `run --label <lane> -- <cmd>` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint (tracked and untracked files that are not gitignored, minus an untracked `.bun/` folder at the repo root, which Bun can create during a run); a run whose files changed while it ran is recorded without a fingerprint and with up to five of the changed paths, which `check` names in its STALE reason. `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. |
|
||||
| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait <pr> [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60] [--exclude CHECK,...]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete; `--exclude` leaves a named non-required check out of the verdict as `gstack-ci-gate --exclude` does (a required name is refused and gates). Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `<state root>/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). |
|
||||
| `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue <n>`, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. |
|
||||
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](howto-ios-testing-with-gstack.md). |
|
||||
|
||||
+15
-6
@@ -2517,11 +2517,19 @@ section instead of overwriting it: a section in the file replaces the
|
||||
same-titled section (title compared case-insensitively), the
|
||||
````gstack-ship-receipt```` block is a section of its own so a receipt
|
||||
rewrite replaces only the receipt, sections the file does not rewrite stay in
|
||||
place, new sections are appended after them, and a current body with no
|
||||
`## ` headings is kept as the leading text. One stderr line names what was
|
||||
replaced, kept and added (`gstack-post: pr-body merged by section: replaced
|
||||
What changed; kept ship receipt, Why, Approvals; added Tests`). A body file
|
||||
holding only its first section therefore no longer wipes the rest of the PR.
|
||||
place, and new sections are appended after them. The text above the first
|
||||
`## ` heading belongs to the file whenever the file has a `## ` section: the
|
||||
current body's text there (the opening lines, and any lines between the receipt
|
||||
and the first heading, or the whole body when it has no `## ` heading) is
|
||||
replaced by the file's own text above its first heading, or dropped when the
|
||||
file has none. A file with no `## ` section (a receipt-only or text-only post)
|
||||
leaves that text alone. One stderr line names what was replaced, kept, added
|
||||
and dropped, a dropped piece with its first line, or with that line withheld
|
||||
when the redaction scan flags it (`gstack-post: pr-body merged
|
||||
by section: replaced What changed; kept ship receipt, Why, Approvals; added
|
||||
Tests; dropped leading text ("Draft from gh pr create")`). A body file holding
|
||||
only its first section therefore no longer wipes the rest of the PR, and an
|
||||
old intro no longer sits above a fresh /ship body.
|
||||
The redaction scan runs on the merged bytes, so a MEDIUM or HIGH finding can
|
||||
come from a kept section; the `RULE:` line's `LINE:` is in the merged text.
|
||||
When the current body cannot be read (`gh pr view --json body` fails) nothing
|
||||
@@ -5066,7 +5074,8 @@ body that names a counter the receipt records (`passes=3` against
|
||||
`review_loop.passes: 2`, `1 deferred` against `deferred: 2`, `passed: 40`
|
||||
against `gate.passed: 41`) with a different value. The receipt is written from
|
||||
the run; the prose was typed, so a reader of the body would learn a number the
|
||||
run did not produce. The `PROSE:` line names each field with both values.
|
||||
run did not produce. The `PROSE:` line names each field with both values. The
|
||||
`## Approvals` table `gstack-approvals` renders is never read as a claim.
|
||||
|
||||
**Fix.** Correct the body to the receipt's number. When the receipt is the
|
||||
stale one (the run was repeated), rerun `/ship` so the receipt is rewritten
|
||||
|
||||
@@ -219,16 +219,20 @@ export function cell(text: string, cap = 400): string {
|
||||
.replace(/(approved)(\s+)(by)/gi, '$1 $3');
|
||||
}
|
||||
|
||||
/** The rendered table's header row and the footer's closing words; lib/ship-receipt-prose.ts recognizes the block by them. */
|
||||
export const APPROVALS_TABLE_HEADER = '| Date | Asked | Exact words | Outcome | Covers | Still pending |';
|
||||
export const APPROVALS_FOOTER_END = '(display only; not an approval citation).';
|
||||
|
||||
export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading?: boolean } = {}): string {
|
||||
const head = o.heading === false ? [] : ['## Approvals', ''];
|
||||
if (read.state === 'unavailable') return [...head, `Record unavailable on this machine: no approval record for branch ${cell(loc.branch)} here, and the state root is ephemeral with no Drive mirror, so this is not proof that nothing was approved.`, ''].join('\n');
|
||||
if (!read.rows.length) return [...head, `No approvals recorded for branch ${cell(loc.branch)}.`, ''].join('\n');
|
||||
const lines = ['| Date | Asked | Exact words | Outcome | Covers | Still pending |', '|---|---|---|---|---|---|'];
|
||||
const lines = [APPROVALS_TABLE_HEADER, '|---|---|---|---|---|---|'];
|
||||
for (const r of read.rows) {
|
||||
const outcome = r.outcome === 'standing' ? 'standing approval' : r.outcome;
|
||||
lines.push(`| ${r.date} | ${cell(r.ask)} | “${cell(r.words, 280)}” (${cell(r.by)}) | ${outcome} | ${cell(r.scope)} | ${cell(r.pending)} |`);
|
||||
}
|
||||
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n');
|
||||
return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} ${APPROVALS_FOOTER_END}`, ''].join('\n');
|
||||
}
|
||||
|
||||
interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string;
|
||||
|
||||
+44
-13
@@ -9,7 +9,8 @@
|
||||
* `pr-body` merges by `## ` section into the PR's current body (read through
|
||||
* the same runner): a section in the file replaces the same-titled one, the
|
||||
* ship-receipt block is a section of its own, sections the file does not
|
||||
* rewrite are kept, new ones are appended; `--replace` overwrites instead.
|
||||
* rewrite are kept, new ones are appended, and a file with a `## ` section
|
||||
* replaces the text above the first heading; `--replace` overwrites instead.
|
||||
* The scan runs on the merged bytes, which are the bytes sent.
|
||||
*
|
||||
* Exit codes: 0 posted; 1 HIGH finding (or oversize input) refused;
|
||||
@@ -211,24 +212,54 @@ function splitSections(body: string): Section[] {
|
||||
* A file section whose title the current body holds more than once (an old
|
||||
* description pasted under the new one) matches no single section, so the
|
||||
* merge refuses instead of replacing one copy and keeping the stale other.
|
||||
* A file with at least one `## ` section manages the preamble (GST-412): every
|
||||
* non-blank text section before the current body's first heading (the leading
|
||||
* text, text after a receipt block) is dropped, the file's own text before its
|
||||
* first heading takes its place (leading text first, text after the file's
|
||||
* receipt right after the receipt), a dropped piece the file brings no text for
|
||||
* is listed in `dropped` with its first line (withheld when the redaction scan
|
||||
* flags it, since the merged bytes no longer hold it), and the receipt keeps the rules
|
||||
* above. A file without a `## ` section merges as described above.
|
||||
*/
|
||||
export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[] } {
|
||||
export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[]; dropped: string[] } {
|
||||
const now = splitSections(current);
|
||||
const next = splitSections(incoming).filter(n => !(n.key.startsWith("text:") && n.text.trim() === ""));
|
||||
const ambiguous = next.filter(n => now.filter(s => s.key === n.key).length > 1).map(n => `"${n.label}"`);
|
||||
if (ambiguous.length) throw new UsageError(`the PR's current body holds ${[...new Set(ambiguous)].join(", ")} more than once, so the file's copy matches no single section; nothing was posted. Rerun with --replace and the whole body`);
|
||||
const used = new Set<Section>();
|
||||
const replaced: string[] = [], kept: string[] = [], added: string[] = [];
|
||||
const merged: Section[] = now.map(s => {
|
||||
const managed = next.some(n => n.key.startsWith("h2:"));
|
||||
const preamble = (list: Section[]) => {
|
||||
const end = list.findIndex(s => s.key.startsWith("h2:"));
|
||||
return list.slice(0, end < 0 ? list.length : end).filter(s => s.key.startsWith("text:") && s.text.trim() !== "");
|
||||
};
|
||||
const stale = managed ? preamble(now) : [];
|
||||
const ours = managed ? preamble(next) : [];
|
||||
const role = (s: Section) => (s.key === "text:lead" ? "lead" : "trailing");
|
||||
const paired = new Set<Section>();
|
||||
for (const r of ["lead", "trailing"]) {
|
||||
const theirs = ours.filter(n => role(n) === r);
|
||||
stale.filter(s => role(s) === r).slice(0, theirs.length).forEach((s, i) => { paired.add(s); paired.add(theirs[i]!); });
|
||||
}
|
||||
const used = new Set<Section>(ours);
|
||||
const replaced: string[] = [], kept: string[] = [], added: string[] = [], dropped: string[] = [];
|
||||
const merged: Section[] = [];
|
||||
for (const s of now) {
|
||||
if (stale.includes(s)) {
|
||||
const line = s.text.trim().split(/\r?\n/)[0]!;
|
||||
if (paired.has(s)) replaced.push(s.label);
|
||||
else if (scan(line).findings.length) dropped.push(`${s.label} (first line withheld: the redaction scan flagged it)`);
|
||||
else dropped.push(`${s.label} ("${line.length > 60 ? line.slice(0, 59).trimEnd() + "…" : line}")`);
|
||||
continue;
|
||||
}
|
||||
const match = s.key === "unclosed-receipt" ? undefined : next.find(n => !used.has(n) && n.key === s.key);
|
||||
if (!match) { if (s.text.trim()) kept.push(s.label); return s; }
|
||||
used.add(match); replaced.push(s.label); return match;
|
||||
});
|
||||
if (!match) { if (s.text.trim()) kept.push(s.label); merged.push(s); continue; }
|
||||
used.add(match); replaced.push(s.label); merged.push(match);
|
||||
}
|
||||
const fresh = next.filter(n => !used.has(n));
|
||||
for (const n of fresh) added.push(n.label);
|
||||
for (const n of next) if (fresh.includes(n) || (ours.includes(n) && !paired.has(n))) added.push(n.label);
|
||||
const first = fresh.filter(n => !n.key.startsWith("h2:"));
|
||||
if (now.length === 0) return { body: incoming, replaced, kept, added };
|
||||
const all = [...first, ...merged, ...fresh.filter(n => n.key.startsWith("h2:"))];
|
||||
if (now.length === 0) return { body: incoming, replaced, kept, added, dropped };
|
||||
const all = [...ours.filter(n => role(n) === "lead"), ...first, ...merged, ...fresh.filter(n => n.key.startsWith("h2:"))];
|
||||
for (const t of ours.filter(n => role(n) === "trailing").reverse()) all.splice(all.indexOf(next[next.indexOf(t) - 1]!) + 1, 0, t);
|
||||
const fromFile = (s: Section) => next.includes(s);
|
||||
const body = all.map((s, i) => {
|
||||
const after = all[i + 1];
|
||||
@@ -236,7 +267,7 @@ export function mergeBodySections(current: string, incoming: string): { body: st
|
||||
const seam = (fromFile(s) || fromFile(after)) && s.text.trim() !== "" && !/^\r?\n/.test(after.text);
|
||||
return seam ? s.text.replace(/\n*$/, "\n\n") : s.text.endsWith("\n") ? s.text : s.text + "\n";
|
||||
}).join("");
|
||||
return { body, replaced, kept, added };
|
||||
return { body, replaced, kept, added, dropped };
|
||||
}
|
||||
|
||||
/** The PR's current body for a merge; GitHub's `--jq .body` prints it raw, GitLab's JSON carries `description`. */
|
||||
@@ -508,7 +539,7 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config
|
||||
const m = mergeBodySections(current.body, body!);
|
||||
body = m.body;
|
||||
const list = (names: string[]) => (names.length ? names.join(", ") : "none");
|
||||
env.err(`gstack-post: pr-body merged by section: replaced ${list(m.replaced)}; kept ${list(m.kept)}; added ${list(m.added)}\n`);
|
||||
env.err(`gstack-post: pr-body merged by section: replaced ${list(m.replaced)}; kept ${list(m.kept)}; added ${list(m.added)}${m.dropped.length ? `; dropped ${m.dropped.join(", ")}` : ""}\n`);
|
||||
}
|
||||
if (body !== undefined && req.attribute) body = attributionCallout(req.attribute) + body;
|
||||
const parts: Array<[Part, string]> = [];
|
||||
|
||||
@@ -243,8 +243,8 @@ export type WtreeResult = { tree: string } | { error: string; kind: 'launch' | '
|
||||
|
||||
/**
|
||||
* The working-tree content fingerprint (bin/gstack-wtree): tracked and
|
||||
* untracked non-ignored files, as a 40- or 64-hex git tree id (SHA-1 or
|
||||
* SHA-256 repositories). `launch` means gstack-wtree never ran (no bash, a
|
||||
* untracked non-ignored files except an untracked root .bun/ folder, as a
|
||||
* 40- or 64-hex git tree id (SHA-1 or SHA-256 repositories). `launch` means gstack-wtree never ran (no bash, a
|
||||
* spawn error; `tried` lists each Windows bash candidate when none was
|
||||
* found); `exit` means it ran and failed (its stderr says why).
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* `gstack-ship-receipt read --check-prose` runs it; a disagreement is
|
||||
* RECEIPT_PROSE_MISMATCH. Prose that quotes no receipt counter is not a claim.
|
||||
*/
|
||||
import { APPROVALS_FOOTER_END, APPROVALS_TABLE_HEADER } from './approval-record';
|
||||
import { RECEIPT_FENCE } from './ship-receipt';
|
||||
|
||||
export interface ProseClaim { field: string; prose: number; receipt: number }
|
||||
@@ -26,10 +27,47 @@ export function receiptCounters(receipt: Record<string, unknown>): Map<string, n
|
||||
|
||||
const BLOCK_RE = new RegExp('```' + RECEIPT_FENCE + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
|
||||
const ESCAPE = /[.*+?^${}()|[\]\\]/g;
|
||||
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
||||
|
||||
/**
|
||||
* The body without the `## Approvals` blocks gstack-approvals renders (lib/approval-record.ts renderRecord): the
|
||||
* heading, the Date | Asked | Exact words table and its `Source: gstack-approvals record for …` footer. Those rows
|
||||
* quote the owner's words (`one Step 11 pass`), not this PR's counters (GST-412). A heading inside a code fence is
|
||||
* not a heading, with the fence rules of splitSections in lib/gstack-post.ts (a fence closes only on a run of its own
|
||||
* character at least as long as the opener with nothing after it), and CRLF reads as LF. A heading not followed by
|
||||
* exactly that table and footer, a row the renderer would not write (six cells, a date first), and any text after
|
||||
* the footer stay prose. A marker line takes the block's place so the text on either side never joins into a claim.
|
||||
*/
|
||||
function withoutApprovalsBlocks(body: string): string {
|
||||
const lines = body.split(/(?<=\n)/);
|
||||
const bare = (i: number) => (lines[i] ?? '').replace(/\r?\n$/, '');
|
||||
const skipBlank = (i: number) => { while (i < lines.length && bare(i).trim() === '') i++; return i; };
|
||||
const blockEnd = (heading: number): number | undefined => {
|
||||
let i = skipBlank(heading + 1);
|
||||
if (bare(i) !== APPROVALS_TABLE_HEADER || !/^\|(?:---\|)+$/.test(bare(i + 1))) return undefined;
|
||||
for (i += 2; bare(i).startsWith('|'); i++) if (!/^\| \d{4}-\d{2}-\d{2} \|/.test(bare(i)) || bare(i).split(/(?<!\\)\|/).length !== 8) return undefined;
|
||||
i = skipBlank(i);
|
||||
return bare(i).startsWith('Source: gstack-approvals record for ') && bare(i).endsWith(APPROVALS_FOOTER_END) ? i : undefined;
|
||||
};
|
||||
const out: string[] = [];
|
||||
let fence: string | null = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = bare(i);
|
||||
if (fence !== null) {
|
||||
if (new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(line)) fence = null;
|
||||
} else if (FENCE_OPEN.test(line)) fence = FENCE_OPEN.exec(line)![1]!;
|
||||
else if (/^ {0,3}## Approvals\s*$/.test(line)) {
|
||||
const end = blockEnd(i);
|
||||
if (end !== undefined) { out.push('(gstack-approvals block)\n'); i = end; continue; }
|
||||
}
|
||||
out.push(lines[i]!);
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body
|
||||
* without its receipt block), where `<name>` is a counter's leaf key with `_`
|
||||
* without its receipt block and its rendered Approvals block), where `<name>` is a counter's leaf key with `_`
|
||||
* matched as `_`, space or `-` and not part of a longer word (`3 pass-through`
|
||||
* is not a `pass` claim), compared with the receipt. A leaf key shared by
|
||||
* several counters (`pass` under every gate lane, `passed` under gate and ci)
|
||||
@@ -39,7 +77,7 @@ const ESCAPE = /[.*+?^${}()|[\]\\]/g;
|
||||
* `<n> <name>` claim about the next key (#3513).
|
||||
*/
|
||||
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
|
||||
const prose = body.replace(BLOCK_RE, '');
|
||||
const prose = withoutApprovalsBlocks(body.replace(BLOCK_RE, ''));
|
||||
const byLeaf = new Map<string, Array<{ field: string; value: number }>>();
|
||||
for (const [field, value] of receiptCounters(receipt)) {
|
||||
const leaf = field.split('.').at(-1)!;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.362",
|
||||
"version": "1.91.363",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+89
-1
@@ -152,9 +152,17 @@ describe('gstack-evidence run', () => {
|
||||
const rec = records().pop();
|
||||
expect(rec.wtree).toBeUndefined();
|
||||
expect(r.stderr).toContain('changed during the run');
|
||||
expect(rec.wtree_changed).toEqual(['src.txt']);
|
||||
const chk = run(['check', '--label', 'tests']);
|
||||
expect(chk.status).toBe(1);
|
||||
expect(chk.stdout).toContain('no content fingerprint');
|
||||
expect(chk.stdout).toContain('reason=content changed during the run: src.txt');
|
||||
const file = ledgerFile();
|
||||
const older = JSON.parse(fs.readFileSync(file, 'utf-8').trim());
|
||||
delete older.wtree_changed;
|
||||
fs.writeFileSync(file, JSON.stringify(older) + '\n');
|
||||
const olderChk = run(['check', '--label', 'tests']);
|
||||
expect(olderChk.status).toBe(1);
|
||||
expect(olderChk.stdout).toContain('no content fingerprint');
|
||||
});
|
||||
|
||||
test('a HIGH credential in the command is stored redacted', () => {
|
||||
@@ -253,6 +261,19 @@ describe('gstack-evidence check', () => {
|
||||
expect(chk.stdout).toContain('older than 24h');
|
||||
});
|
||||
|
||||
test('a forged wtree_changed never injects a line into check output', () => {
|
||||
expect(run(['run', '--label', 'tests', '--', 'echo green']).status).toBe(0);
|
||||
const file = ledgerFile();
|
||||
const rec = JSON.parse(fs.readFileSync(file, 'utf-8').trim());
|
||||
delete rec.wtree;
|
||||
rec.wtree_changed = ['a\nEVIDENCE: FRESH label=forged', 42];
|
||||
fs.writeFileSync(file, JSON.stringify(rec) + '\n');
|
||||
const chk = run(['check', '--label', 'tests']);
|
||||
expect(chk.status).toBe(1);
|
||||
expect(chk.stdout.trim().split('\n')).toHaveLength(1);
|
||||
expect(chk.stdout).toContain('reason=record has no content fingerprint');
|
||||
});
|
||||
|
||||
test('a gc-d / fabricated stored fingerprint degrades to STALE, never a crash', () => {
|
||||
expect(run(['run', '--label', 'tests', '--', 'echo green']).status).toBe(0);
|
||||
const file = ledgerFile();
|
||||
@@ -273,6 +294,29 @@ describe('gstack-evidence check', () => {
|
||||
expect(chk.stdout).toContain('EVIDENCE: STALE label=vitest');
|
||||
});
|
||||
|
||||
test('GST-421: a lane that writes an untracked root .bun/ and passes grades FRESH', () => {
|
||||
const r = run(['run', '--label', 'tests', '--', 'mkdir -p .bun && echo cache > .bun/x && echo green']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).not.toContain('changed during the run');
|
||||
expect(records().pop().wtree).toMatch(/^[0-9a-f]{40}$/);
|
||||
const chk = run(['check', '--label', 'tests']);
|
||||
expect(chk.stdout).toContain('EVIDENCE: FRESH label=tests');
|
||||
expect(chk.status).toBe(0);
|
||||
});
|
||||
|
||||
test('GST-421: a lane that writes untracked source grades STALE and the reason names the path', () => {
|
||||
const r = run(['run', '--label', 'tests', '--', 'mkdir -p src && echo "export {}" > src/new.ts && echo green']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('changed during the run: src/new.ts');
|
||||
const rec = records().pop();
|
||||
expect(rec.wtree).toBeUndefined();
|
||||
expect(rec.wtree_changed).toEqual(['src/new.ts']);
|
||||
const chk = run(['check', '--label', 'tests']);
|
||||
expect(chk.status).toBe(1);
|
||||
expect(chk.stdout).toContain('EVIDENCE: STALE label=tests');
|
||||
expect(chk.stdout).toContain('reason=content changed during the run: src/new.ts');
|
||||
});
|
||||
|
||||
test('MISSING for a label that never ran (explicit labels prove expected lanes)', () => {
|
||||
expect(run(['run', '--label', 'tests', '--', 'echo green']).status).toBe(0);
|
||||
const chk = run(['check', '--label', 'tests', '--label', 'never-ran']);
|
||||
@@ -610,3 +654,47 @@ describe('gstack-evidence tests_ran — zero-run green is a third state, never m
|
||||
expect(chk.stdout).toContain('tests_ran=unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-wtree and the root .bun/ folder (GST-421)', () => {
|
||||
const WTREE = path.join(ROOT, 'bin', 'gstack-wtree');
|
||||
const fingerprint = () => {
|
||||
const r = runBin(WTREE, [], { cwd: repoDir });
|
||||
expect(r.status).toBe(0);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
|
||||
test('an untracked root .bun/ leaves the fingerprint unchanged; a nested .bun/ still counts', () => {
|
||||
const before = fingerprint();
|
||||
fs.mkdirSync(path.join(repoDir, '.bun'));
|
||||
fs.writeFileSync(path.join(repoDir, '.bun', 'x'), 'cache\n');
|
||||
expect(fingerprint()).toBe(before);
|
||||
fs.mkdirSync(path.join(repoDir, 'pkg', '.bun'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoDir, 'pkg', '.bun', 'x'), 'not the root\n');
|
||||
expect(fingerprint()).not.toBe(before);
|
||||
});
|
||||
|
||||
test('a tracked file under .bun/ still changes the fingerprint when modified or deleted', () => {
|
||||
fs.mkdirSync(path.join(repoDir, '.bun'));
|
||||
fs.writeFileSync(path.join(repoDir, '.bun', 'tracked.txt'), 'v1\n');
|
||||
git('add .bun/tracked.txt');
|
||||
git('commit -q -m tracked-bun');
|
||||
const committed = fingerprint();
|
||||
fs.writeFileSync(path.join(repoDir, '.bun', 'tracked.txt'), 'v2 longer\n');
|
||||
const modified = fingerprint();
|
||||
expect(modified).not.toBe(committed);
|
||||
fs.rmSync(path.join(repoDir, '.bun', 'tracked.txt'));
|
||||
const deleted = fingerprint();
|
||||
expect(deleted).not.toBe(committed);
|
||||
expect(deleted).not.toBe(modified);
|
||||
});
|
||||
|
||||
test('a tree without .bun/ hashes to the same tree as a plain git add -A', () => {
|
||||
fs.writeFileSync(path.join(repoDir, 'untracked.ts'), 'export {}\n');
|
||||
fs.writeFileSync(path.join(repoDir, 'src.txt'), 'edited\n');
|
||||
fs.rmSync(path.join(repoDir, '.gitignore'));
|
||||
const idx = path.join(gstackHome, 'plain-index');
|
||||
const env = { ...process.env, GIT_INDEX_FILE: idx };
|
||||
const plain = execSync('git read-tree HEAD && git add -A && git write-tree', { cwd: repoDir, env, encoding: 'utf-8', timeout: 10000 }).trim();
|
||||
expect(fingerprint()).toBe(plain);
|
||||
});
|
||||
});
|
||||
@@ -446,11 +446,69 @@ describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
expect(after.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, Why; kept unclosed receipt fence; added none\n");
|
||||
});
|
||||
|
||||
// Value: protects=a file with sections owns the text above the first heading (GST-412): stale lead text and stale text between the receipt and the
|
||||
// first heading are dropped and named on stderr with their first line, the file's own preamble takes their place, a receipt-only or text-only file
|
||||
// keeps the older merge, a repost is byte-identical and an attribution callout is never doubled; fails_when=pr-body keeps a stale intro above the
|
||||
// receipt or a stale note under it after /ship reposts the sections; why_new=lead text was kept unless the file brought its own; seam=fake run;
|
||||
// no_claim=real gh
|
||||
test("a file with sections replaces every text section above the current first heading and names each dropped piece", () => {
|
||||
const stale = withBody("Draft intro from gh pr create, long enough to need truncation in the summary line\nsecond line\n\n" + CURRENT, { "/b": RECEIPT("bbbb") + "\n## What changed\n\n- new\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], stale)).toBe(EXIT.posted);
|
||||
expect(sent(stale).input).toBe(RECEIPT("bbbb") + "\n## What changed\n\n- new\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n");
|
||||
expect(stale.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, What changed; kept Why, Approvals; added none; dropped leading text (\"Draft intro from gh pr create, long enough to need truncati…\")\n");
|
||||
|
||||
const between = withBody(RECEIPT("aaaa") + "Old note under the receipt\n\n## What changed\n\n- old\n", { "/b": "## What changed\n\n- new\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], between)).toBe(EXIT.posted);
|
||||
expect(sent(between).input).toBe(RECEIPT("aaaa") + "\n## What changed\n\n- new\n");
|
||||
expect(between.stderr()).toBe("gstack-post: pr-body merged by section: replaced What changed; kept ship receipt; added none; dropped trailing text (\"Old note under the receipt\")\n");
|
||||
|
||||
const own = withBody("Stale intro\n\n" + RECEIPT("aaaa") + "Stale note\n\n## Why\n\nold\n", { "/b": "Fresh intro\n\n" + RECEIPT("bbbb") + "Fresh note\n\n## Why\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], own)).toBe(EXIT.posted);
|
||||
expect(sent(own).input).toBe("Fresh intro\n\n" + RECEIPT("bbbb") + "\nFresh note\n\n## Why\n\nnew\n");
|
||||
expect(mergeBodySections(sent(own).input!, "Fresh intro\n\n" + RECEIPT("bbbb") + "Fresh note\n\n## Why\n\nnew\n").body).toBe(sent(own).input!);
|
||||
expect(own.stderr()).toBe("gstack-post: pr-body merged by section: replaced leading text, ship receipt, trailing text, Why; kept none; added none\n");
|
||||
});
|
||||
|
||||
// Value: protects=the dropped-piece excerpt on stderr never repeats a secret the merge removed from the body (the scan only reads the merged bytes);
|
||||
// fails_when=a stale intro holding a credential is echoed to stderr while it leaves the PR; why_new=the excerpt is new in GST-412; seam=fake run; no_claim=real gh
|
||||
test("a dropped piece whose first line the redaction scan flags is named without its text", () => {
|
||||
const leak = withBody(`Deploy key ${AWS} for the demo\n\n` + CURRENT, { "/b": RECEIPT("bbbb") + "\n## What changed\n\n- new\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], leak)).toBe(EXIT.posted);
|
||||
expect(leak.stderr()).not.toContain(AWS);
|
||||
expect(leak.stderr()).toContain("dropped leading text (first line withheld: the redaction scan flagged it)");
|
||||
});
|
||||
|
||||
test("receipt-only and text-only files keep the older merge; a repost is byte-identical; a callout in the preamble is never doubled", () => {
|
||||
const lead = "Human intro\n\n" + CURRENT;
|
||||
const receiptOnly = withBody(lead, { "/b": RECEIPT("bbbb") });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], receiptOnly)).toBe(EXIT.posted);
|
||||
expect(sent(receiptOnly).input).toBe(lead.replace(RECEIPT("aaaa"), RECEIPT("bbbb")));
|
||||
expect(receiptOnly.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt; kept leading text, What changed, Why, Approvals; added none\n");
|
||||
|
||||
const textOnly = withBody(RECEIPT("aaaa") + "Old note\n\n## Why\n\nx\n", { "/b": "New intro\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], textOnly)).toBe(EXIT.posted);
|
||||
expect(sent(textOnly).input).toBe("New intro\n\n" + RECEIPT("aaaa") + "Old note\n\n## Why\n\nx\n");
|
||||
expect(textOnly.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept ship receipt, trailing text, Why; added leading text\n");
|
||||
|
||||
const composed = "Intro\n\n" + RECEIPT("bbbb") + "\n## Summary\n\nnew\n\n## Test plan\n\n- [x] bun test\n";
|
||||
const again = mergeBodySections(composed, composed);
|
||||
expect(again.body).toBe(composed);
|
||||
expect([again.added, again.dropped]).toEqual([[], []]);
|
||||
|
||||
const callout = attributionCallout("Claude Opus on behalf of octocat");
|
||||
const withCallout = callout + RECEIPT("bbbb") + "\n## Summary\n\nnew\n";
|
||||
const reposted = mergeBodySections(callout + RECEIPT("aaaa") + "\n## Summary\n\nold\n", withCallout);
|
||||
expect(reposted.body).toBe(withCallout);
|
||||
expect(reposted.body.split("[!NOTE]")).toHaveLength(2);
|
||||
expect(mergeBodySections(reposted.body, withCallout).body).toBe(withCallout);
|
||||
});
|
||||
|
||||
// Value: protects=--replace keeps the plain overwrite for a caller that composed the whole body, a current body with no H2 headings is kept as the leading
|
||||
// section, an empty current body posts the new body byte for byte, the scan runs on the merged bytes, and an unreadable current body is exit 3 with
|
||||
// the --replace hint rather than an overwrite; fails_when=--replace merges, headingless text is dropped, the merge changes a lone new body, a secret in a
|
||||
// kept section passes the scan, or a failed read overwrites; why_new=first pr-body merge tests; seam=fake run; no_claim=real gh
|
||||
test("--replace overwrites; a body without H2 headings is kept as a leading section; the merged bytes are what is scanned; a failed read refuses", () => {
|
||||
// section by a file without sections and dropped (and named) by a file with sections (GST-412), an empty current body posts the new body byte for byte,
|
||||
// the scan runs on the merged bytes, and an unreadable current body is exit 3 with the --replace hint rather than an overwrite; fails_when=--replace
|
||||
// merges, headingless text is dropped by a receipt-only post or kept under new sections, the merge changes a lone new body, a secret in a kept section
|
||||
// passes the scan, or a failed read overwrites; why_new=first pr-body merge tests; seam=fake run; no_claim=real gh
|
||||
test("--replace overwrites; a body without H2 headings is kept by a file without sections and dropped by one with sections; the merged bytes are what is scanned; a failed read refuses", () => {
|
||||
const replaced = withBody(CURRENT, { "/b": "## What changed\n\nonly this\n" });
|
||||
expect(run(["pr-body", "5", "--replace", "--body-file", "/b"], replaced)).toBe(EXIT.posted);
|
||||
expect(sent(replaced).input).toBe("## What changed\n\nonly this\n");
|
||||
@@ -459,8 +517,12 @@ describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
|
||||
const plain = withBody("Just a paragraph.\nAnother line.", { "/b": "## Tests\n\n- one\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], plain)).toBe(EXIT.posted);
|
||||
expect(sent(plain).input).toBe("Just a paragraph.\nAnother line.\n\n## Tests\n\n- one\n");
|
||||
expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text; added Tests\n");
|
||||
expect(sent(plain).input).toBe("## Tests\n\n- one\n");
|
||||
expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept none; added Tests; dropped leading text (\"Just a paragraph.\")\n");
|
||||
const plainText = withBody("Just a paragraph.\nAnother line.", { "/b": RECEIPT("bbbb") });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], plainText)).toBe(EXIT.posted);
|
||||
expect(sent(plainText).input).toBe(RECEIPT("bbbb") + "\nJust a paragraph.\nAnother line.");
|
||||
expect(plainText.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text; added ship receipt\n");
|
||||
|
||||
const empty = withBody("", { "/b": "new body" });
|
||||
expect(run(["pr-body", "6", "--body-file", "/b"], empty)).toBe(EXIT.posted);
|
||||
@@ -593,8 +655,8 @@ describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
expect(fresh.added).toEqual(["leading text"]);
|
||||
const receipt = "```gstack-ship-receipt\n{\n \"head\": \"a\"\n}\n```\n";
|
||||
const afterReceipt = mergeBodySections("Intro line\n\n## Summary\n\nold\n", receipt + "note\n\n## Summary\n\nnew\n");
|
||||
expect(afterReceipt.body).toContain("Intro line\n");
|
||||
expect(afterReceipt.body).toContain("note\n");
|
||||
expect(afterReceipt.body).toBe(receipt + "\nnote\n\n## Summary\n\nnew\n");
|
||||
expect(afterReceipt.replaced).toEqual(["Summary"]);
|
||||
expect(afterReceipt.dropped).toEqual(['leading text ("Intro line")']);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import { checkReceiptProse, proseLine, receiptCounters } from '../lib/ship-receipt-prose';
|
||||
import { renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { renderRecord, type ApprovalRow, type StoreLocation } from '../lib/approval-record';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin/gstack-ship-receipt');
|
||||
@@ -72,6 +73,51 @@ describe('checkReceiptProse', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// GST-412: the `## Approvals` block gstack-approvals renders quotes the owner's words, which can hold
|
||||
// numbers (`one Step 11 pass` read as a gate `pass` claim of 11). Only that rendered block is skipped.
|
||||
describe('the rendered Approvals block is not prose', () => {
|
||||
const lanes = { ...receipt, gate: { unit: { pass: 1181, fail: 0, skip: 3 } } };
|
||||
const row = (words: string): ApprovalRow => ({
|
||||
schema_version: 1, id: 'a1', date: '2026-10-30', ask: 'Ship it after the gate', words, by: 'garry', scope: 'PR B',
|
||||
pending: 'none', outcome: 'approved', source: 'thread', branch: 'b', recorded_at: '2026-10-30T00:00:00Z',
|
||||
} as ApprovalRow);
|
||||
const loc = { file: '/nonexistent', branch: 'gsta195-evidence-prose', slug: 's', ephemeral: false } as StoreLocation;
|
||||
const block = renderRecord({ rows: [row('do it, one Step 11 pass is enough')], state: 'present', restored: false }, loc);
|
||||
|
||||
test('a number inside the rendered table is not a claim; the same sentence outside the block still is', () => {
|
||||
expect(block).toContain('one Step 11 pass');
|
||||
const body = 'Unit lane: 1181 pass.\n\n' + block + '\n' + renderReceiptBlock(lanes);
|
||||
expect(checkReceiptProse(lanes, body)).toEqual({ checked: 1, mismatches: [] });
|
||||
const outside = checkReceiptProse(lanes, 'Garry said one Step 11 pass is enough.\n\n' + block);
|
||||
expect(outside.mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
});
|
||||
|
||||
test('text after the footer is checked, a fenced "## Approvals" example hides nothing, and CRLF bodies work', () => {
|
||||
const after = checkReceiptProse(lanes, block + '\nFollow-up: one Step 11 pass again.\n');
|
||||
expect(after.mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
const fenced = '```md\n## Approvals\n\n| Date | Asked |\n```\n\nThe lane ran 11 pass.\n';
|
||||
expect(checkReceiptProse(lanes, fenced).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
const fencedThenTable = '````md\n## Approvals\n```\nstill fenced\n```\n````\n\n7 pass\n\n' + block;
|
||||
expect(checkReceiptProse(lanes, fencedThenTable).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 7, receipt: 1181 }]);
|
||||
const crlf = ('Unit lane: 1181 pass.\n\n' + block).replace(/\n/g, '\r\n');
|
||||
expect(checkReceiptProse(lanes, crlf)).toEqual({ checked: 1, mismatches: [] });
|
||||
expect(checkReceiptProse(lanes, crlf + 'Then 11 pass.\r\n').mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
});
|
||||
|
||||
test('the skipped block still separates the text around it, and a row the renderer would not write keeps the block in the check', () => {
|
||||
expect(checkReceiptProse(receipt, 'Shipped in step 3\n\n' + block + '\nDeferred items are listed below.\n')).toEqual({ checked: 0, mismatches: [] });
|
||||
const forged = block.replace(/(\|---\|[^\n]*\n)/, '$1| passes=9 deferred: 7 |\n');
|
||||
expect(checkReceiptProse(receipt, forged).mismatches).toEqual([{ field: 'review_loop.passes', prose: 9, receipt: 2 }, { field: 'review_loop.deferred', prose: 7, receipt: 1 }]);
|
||||
});
|
||||
|
||||
test('a heading that is not followed by the rendered table and footer is ordinary prose', () => {
|
||||
const handWritten = '## Approvals\n\n- Garry: one Step 11 pass is enough\n';
|
||||
expect(checkReceiptProse(lanes, handWritten).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
const noFooter = block.replace(/Source: gstack-approvals record.*\n/, '');
|
||||
expect(checkReceiptProse(lanes, noFooter).mismatches).toEqual([{ field: 'gate.unit.pass', prose: 11, receipt: 1181 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-ship-receipt read --check-prose', () => {
|
||||
const run = (body: string) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'receipt-prose-'));
|
||||
|
||||
Reference in new issue
Block a user