mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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>
This commit is contained in:
1 parent
047dad78ba
commit
2f5088dc94
5 files changed
+100
-13
No files matched your search
+10
-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;
|
||||
@@ -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";
|
||||
|
||||
+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
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
+76
-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', () => {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user