Files
gstack/test/decision-trail.test.ts
Garry Tanandcapy-ai[bot] 76aa68fc9e v1.91.268.0 feat(bin): gstack-decision-trail and gstack-worktree-audit, ported from pstack (GSTA-123) (#3494)
* feat(bin): gstack-decision-trail and gstack-worktree-audit, ported from pstack (GSTA-123)

Two MIT ports from Lauren Tan's pstack (licenses/MIT-pstack.txt, NOTICE.md):
a TSV decision trail with the header-once append, the formula guard, a start
row per run and a --run admission check; and a read-only worktree audit with
size, merge state, dirty, remote, PR, last chat and a suggested bucket.
Docs: docs/unattended.md (the decision trail and its end-of-run audit),
autoplan/DRIVER.md, docs/CONTRIBUTOR_REFERENCE.md, docs/standalone-binaries.md,
docs/troubleshooting.md, one line in landing-report.

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

* test(egress): reasoned scanner exemption for gstack-worktree-audit's base fetch

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

* fix: pre-landing review of the pstack ports

bin/gstack-worktree-audit: a worktree path with a space is read whole (sed
on the porcelain 'worktree ' line, not awk's second field); a registered
worktree whose directory is gone prints a 'prunable' row instead of being
skipped; 'safe' now requires a clean tree and either merged into the base
or a closed/merged PR with every commit pushed, so unpushed commits or
untracked files on a merged branch read 'review' (the port's rule called
them safe); fork PRs are dropped and an open PR wins over an older closed
one on a reused branch name; GIT_TERMINAL_PROMPT=0 on the base fetch.

bin/gstack-decision-trail: only a leading -h/--help is help, so a '-h'
cell is appended as data; a trail without a final newline, with CRLF rows
or with a repeated header line is appended to correctly; a run id is
limited to [A-Za-z0-9._:@/-] (exit 2) so a tab or quote cannot break the
start-row idempotence check.

Tests cover each case; the 'without gh' test resolves git from
'git --exec-path' instead of a PATH rebuilt from a tool list. Docs,
CHANGELOG and --help text describe the stricter buckets.

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

* fix: pre-landing review fixes, pass 2

bin/gstack-worktree-audit: a worktree whose own .git link is gone (git
marks it prunable, or its toplevel is another repository) prints a 'broken'
row in 'review' instead of describing the containing repository as clean
and safe; a locked entry whose directory is gone reads 'review', not
'prunable', since prune would not touch it; a clean tree names its
gitignored entries as 'clean,ignored:<n>' without changing the bucket; the
branch name is read from refs/heads/ by hand so a tag of the same name
cannot hide the PR; porcelain blocks are read with IFS=tab so paths with
leading or trailing spaces survive; a failed git status reads 'unknown',
never 'clean'; the base fetch also sets GIT_ASKPASS=true and ssh BatchMode.

bin/gstack-decision-trail: a file whose first line is not the header (a
blank file, rows written by hand) gets the header appended, and a UTF-8
BOM before the header no longer turns it into a data row.

Tests: the fake gh records its argv and runs the audit's own --jq over
canned JSON with a fork PR when jq is installed; row counts are asserted
before the every() checks; each test starts from the same gh and closed
worktree; the snapshot includes the worktree registry; new fixtures for
locked, broken, tag-shadowed and ignored-only worktrees, a blank trail and
a BOM. Docs and CHANGELOG describe the new DIRTY values and buckets.

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

* fix: pre-landing review fixes, pass 3

bin/gstack-decision-trail: hasHeader looks for a header line anywhere in
the file, so a blank or hand-written trail gets exactly one header rather
than one per append. bin/gstack-worktree-audit: the broken-worktree check
uses rev-parse --show-cdup and the presence of /.git instead of
comparing two spellings of the same path, which differ on Git for Windows.
Test: the blank-trail case appends twice and asserts a single header.

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

* fix: adversarial review, pass 5 (rule-23 grant, one finding)

bin/gstack-worktree-audit: git status runs with --untracked-files=normal
--ignore-submodules=none, so a user's status.showUntrackedFiles=no config
cannot hide untracked files and let a worktree holding them read clean and
safe. Test: the fresh worktree under that config via GIT_CONFIG_* still
reads scratch:1 / review (red before the fix).

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

* docs: documentation audit corrections for the pstack ports

docs/standalone-binaries.md, docs/CONTRIBUTOR_REFERENCE.md,
docs/troubleshooting.md: the audit's safe condition is stated in full
(clean and merged, or PR closed/merged with every commit pushed), the
DIRTY list names missing, broken and unknown, start is a reserved phase
name, and 'read-only' names the base fetch as the one write --no-fetch
skips. CHANGELOG.md says the same (the audit's one decision item).
Audit id gsta123-docs-20261026T221657Z, status updated, no blockers.

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

* Stamp 1.91.268.0 for #3494 (train slot 1, base d9b0d600)

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>
2026-10-27 06:36:56 +00:00

161 lines
8.2 KiB
TypeScript

/**
* Subprocess tests for bin/gstack-decision-trail: the TSV decision trail
* (header once, append-only, one-line cells, spreadsheet formula guard), the
* start-row rule and its idempotent rerun, and the --run admission check.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const ROOT = path.resolve(import.meta.dir, "..");
const BIN = path.join(ROOT, "bin", "gstack-decision-trail");
const TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
let tmp: string;
let file: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-decision-trail-"));
file = path.join(tmp, "run", "decisions.tsv");
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
function run(...args: string[]) {
const r = spawnSync(process.execPath, [BIN, ...args], { cwd: ROOT, encoding: "utf8", timeout: 20_000 });
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
}
const rows = () => fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map(l => l.split("\t"));
describe("gstack-decision-trail append", () => {
test("first append creates the parent directory, the header and one stamped row", () => {
const r = run(file, "frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out");
expect(r.status).toBe(0);
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
const [header, row] = rows();
expect(header).toEqual(["ts", "phase", "decision", "why", "evidence", "result"]);
expect(row!.slice(1)).toEqual(["frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out"]);
expect(row![0]).toMatch(TS_RE);
});
test("later appends add rows and never a second header; an empty existing file gets the header", () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "");
run(file, "a", "b", "c", "d", "e");
run(file, "f", "g", "h", "i", "j");
const all = rows();
expect(all).toHaveLength(3);
expect(all.filter(r => r[0] === "ts")).toHaveLength(1);
expect(all[2]!.slice(1)).toEqual(["f", "g", "h", "i", "j"]);
});
test("cells lose tabs and line breaks, and a leading =, +, - or @ gets a quote so a spreadsheet cannot run it", () => {
run(file, "probe", "=HYPERLINK(\"http://evil\")", "two\nlines\there\r", "-", "@SUM(A1)");
const row = rows()[1]!;
expect(row).toHaveLength(6);
expect(row.slice(1)).toEqual(["probe", "'=HYPERLINK(\"http://evil\")", "two lines here ", "'-", "'@SUM(A1)"]);
run(file, "+1", "plain", "plain", "plain", "plain");
expect(rows()[2]![1]).toBe("'+1");
});
test("a cell equal to -h or --help is data, a trail without a final newline or with CRLF rows is repaired on append, and a repeated header is never a row", () => {
const r = run(file, "phase", "--help", "why", "evidence", "-h");
expect(r.status).toBe(0);
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
expect(rows()[1]!.slice(1)).toEqual(["phase", "'--help", "why", "evidence", "'-h"]);
fs.appendFileSync(file, "x\ty\tz");
run(file, "p2", "d2", "w", "e", "r");
const all = rows();
expect(all[2]).toEqual(["x", "y", "z"]);
expect(all[3]!.slice(1)).toEqual(["p2", "d2", "w", "e", "r"]);
const crlf = path.join(tmp, "crlf.tsv");
fs.writeFileSync(crlf, "ts\tphase\tdecision\twhy\tevidence\tresult\r\n2026-01-01T00:00:00Z\ta\tb\tc\td\te\r\nts\tphase\tdecision\twhy\tevidence\tresult\r\n");
const start = run("start", crlf, "run-1");
expect(start.stdout).toBe(`appended start row 2 for run run-1 to ${crlf}\n`);
expect(fs.readFileSync(crlf, "utf8").split("\n").filter(Boolean).pop()!.split("\t")[3]).toBe("1 earlier row (2026-01-01T00:00:00Z..2026-01-01T00:00:00Z) written by other runs");
});
test("a blank file gets a header, and a BOM before the header does not make it a row", () => {
const blank = path.join(tmp, "blank.tsv");
fs.writeFileSync(blank, "\n");
expect(run(blank, "p", "d", "w", "e", "r").status).toBe(0);
expect(run(blank, "p2", "d", "w", "e", "r").status).toBe(0);
const lines = fs.readFileSync(blank, "utf8").split("\n");
expect(lines[1]).toBe("ts\tphase\tdecision\twhy\tevidence\tresult");
expect(lines[2]!.split("\t").slice(1)).toEqual(["p", "d", "w", "e", "r"]);
expect(lines.filter(l => l === "ts\tphase\tdecision\twhy\tevidence\tresult")).toHaveLength(1);
const bom = path.join(tmp, "bom.tsv");
fs.writeFileSync(bom, "\uFEFFts\tphase\tdecision\twhy\tevidence\tresult\n");
expect(run("--run", "r1", bom, "p", "d", "w", "e", "r").status).toBe(0);
expect(fs.readFileSync(bom, "utf8").split("\n").filter(Boolean)).toHaveLength(2);
});
test("a run id with whitespace or quotes is a usage error on start and on --run", () => {
expect(run("start", file, "a\tb").status).toBe(2);
expect(run("start", file, "a b").status).toBe(2);
expect(run("--run", "x'y", file, "a", "b", "c", "d", "e").status).toBe(2);
expect(run("start", file, "jam_01ABC.turn-2:x/y@z").status).toBe(0);
expect(fs.readFileSync(file, "utf8").split("\n")).toHaveLength(3);
});
test("wrong arity, an unknown option and a bare call are usage errors; --help exits 0", () => {
expect(run(file, "only", "four", "cells", "here").status).toBe(2);
expect(run("--bogus", file, "a", "b", "c", "d", "e").status).toBe(2);
expect(run().status).toBe(2);
expect(run(file, "start", "a", "b", "c", "d").status).toBe(2);
const help = run("--help");
expect(help.status).toBe(0);
expect(help.stdout).toContain("gstack-decision-trail start <file> <run-id>");
expect(fs.existsSync(file)).toBe(false);
});
});
describe("gstack-decision-trail start", () => {
test("a start row names the earlier rows and the run; rerunning it for the same run is a no-op", () => {
run(file, "a", "b", "c", "d", "e");
run(file, "f", "g", "h", "i", "j");
const first = run("start", file, "jam_A");
expect(first.status).toBe(0);
expect(first.stdout).toBe(`appended start row 3 for run jam_A to ${file}\n`);
const start = rows()[3]!;
expect(start[1]).toBe("start");
expect(start[2]).toBe("run jam_A joins this trail");
expect(start[3]).toMatch(/^2 earlier rows \(\d{4}-.*\.\.\d{4}-.*\) written by other runs$/);
expect(start[4]).toBe("run:jam_A");
expect(start[5]).toBe("open");
const again = run("start", file, "jam_A");
expect(again.status).toBe(0);
expect(again.stdout).toBe(`start row for run jam_A already present at ${file}:4\n`);
expect(rows()).toHaveLength(4);
});
test("a start row on an empty trail opens it, and another run's start row follows a prior run's", () => {
expect(run("start", file, "run-1").stdout).toBe(`appended start row 1 for run run-1 to ${file}\n`);
expect(rows()[1]!.slice(1)).toEqual(["start", "run run-1 opens this trail", "no earlier rows", "run:run-1", "open"]);
run("--run", "run-1", file, "work", "did a thing", "because", "commit 1", "ok");
expect(run("start", file, "run-2").status).toBe(0);
expect(rows().map(r => r[1])).toEqual(["phase", "start", "work", "start"]);
// run-1's row is older than run-2's start, so run-1 is a new run again and needs a new start row.
expect(run("start", file, "run-1").stdout).toContain("appended start row 4");
});
test("--run refuses an append until the run's start row is the newest one, and admits a fresh trail", () => {
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
run("start", file, "other");
const refused = run("--run", "r9", file, "a", "b", "c", "d", "e");
expect(refused.status).toBe(3);
expect(refused.stderr).toContain("DECISION_TRAIL_START_REQUIRED");
expect(refused.stderr).toContain(`gstack-decision-trail start ${file} r9`);
expect(rows()).toHaveLength(3);
run("start", file, "r9");
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
expect(rows()).toHaveLength(5);
});
test("start with the wrong arity is a usage error", () => {
expect(run("start", file).status).toBe(2);
expect(run("start", file, "r", "extra").status).toBe(2);
});
});