From 2f5088dc9489a99c58765491ce50083bf63f584a Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 08:40:21 +0000 Subject: [PATCH] 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> --- bin/gstack-evidence | 13 +++++-- bin/gstack-wtree | 17 +++++--- docs/standalone-binaries.md | 2 +- lib/script-invocation.ts | 4 +- test/evidence.test.ts | 77 ++++++++++++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 13 deletions(-) diff --git a/bin/gstack-evidence b/bin/gstack-evidence index d36ae3391..2a9291548 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -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//-evidence.jsonl. + * dirty, wtree or wtree_changed, log_path, tests_ran} to ~/.gstack/projects//-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; @@ -631,7 +636,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" + ? Array.isArray(latest.wtree_changed) && latest.wtree_changed.length + ? `content changed during the run: ${latest.wtree_changed.join(", ")}` + : "record has no content fingerprint" : !/^[0-9a-f]{40}$/.test(latest.wtree) ? "record has malformed fingerprint" : "current fingerprint unavailable"; diff --git a/bin/gstack-wtree b/bin/gstack-wtree index 341268623..a843f75d4 100755 --- a/bin/gstack-wtree +++ b/bin/gstack-wtree @@ -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 diff --git a/docs/standalone-binaries.md b/docs/standalone-binaries.md index 2aab76988..0c4bc8e52 100644 --- a/docs/standalone-binaries.md +++ b/docs/standalone-binaries.md @@ -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 ` captures the working-tree fingerprint before a diff review; `'' --finish ` 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 ` 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 -- ` 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 -- ` 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 [--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 `/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 `, `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). | diff --git a/lib/script-invocation.ts b/lib/script-invocation.ts index 65648c1d3..7ce46cc4d 100644 --- a/lib/script-invocation.ts +++ b/lib/script-invocation.ts @@ -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). */ diff --git a/test/evidence.test.ts b/test/evidence.test.ts index 8b9678418..8bcb2b896 100644 --- a/test/evidence.test.ts +++ b/test/evidence.test.ts @@ -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', () => { @@ -273,6 +281,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 +641,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); + }); +});