diff --git a/CHANGELOG.md b/CHANGELOG.md index 2946d21bc..0c95b691a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # Changelog +## [1.92.76.0] - 2026-11-02 + +**/retro now remembers its history across git worktrees, so a run in a linked worktree compares against last week instead of announcing "First retro recorded".** Retro snapshots used to live in `.context/retros/` inside the working tree. A worktree (Conductor, Claude Code) saw none of the main checkout's history, and its own snapshot was deleted along with the worktree (#3749). + +### Fixed +- **`bin/gstack-retro-metrics --history [--next ]`**: keeps retro history in `~/.gstack/projects//retros/` (the state root), which every worktree of a repository shares. It lists earlier snapshots as `RETRO_PRIOR` lines, newest first, and reserves the next `-.json` as `RETRO_FILE`, so two retros running at once in different worktrees never write the same file. +- **Moving old history**: snapshots in `.context/retros/` of the current worktree and the main checkout move to the shared folder on the first run. A copy identical to a stored snapshot from the same date is dropped, and a different snapshot with the same name takes the next free number, so nothing is overwritten. A `.context/retros` that points outside its worktree (a symlink to the shared folder or anywhere else) is left alone, and moves never replace an existing file. +- **`retro/SKILL.md`**: Steps 12 and 13 use the helper, the gbrain prior-retros query reads the shared folder, and a history error is reported to the user instead of being mistaken for an outdated helper. `docs/skills.md` says where snapshots are saved. +- `gstack-retro-metrics` no longer loops forever when an option such as `--next` is given without a value. + +### Tests +- `test/regression-3749-retro-history-worktree.test.ts` runs the real Step 12 block in a main checkout with a linked worktree: the worktree sees the main checkout's snapshot, its new snapshot path is outside the worktree and survives `git worktree remove`, migration keeps every snapshot, a symlinked `.context/retros` cannot delete history, and two runs get different snapshot paths. It failed 4 of 4 on the code before the fix. + +**A stale local `main` no longer turns a /ship review into a full-depth gate review (GST-883).** `gstack-review-plan` handed the raw base name to `gstack-gate-diff`, which diffed against the local branch. With a local `main` four commits behind `origin/main`, the gate scan read a test edit that was already on `origin/main`, so a plain code diff planned as gate class at full depth. On gstack-evals#123 that cost about 15-20M tokens of readers the change did not need (GEVA-117). + +### Fixed +- **`gstack-gate-diff `** resolves a branch name to its remote-tracking branch (`origin/main` for `main`, origin first), the way `gstack-review-plan`'s own diff and review dispatch already did (#3780). It uses the local branch only when no remote has the branch, and says so on stderr: `no remote-tracking branch for `, or `/ shares no history with HEAD`. A repository with no remote at all uses the local branch without a note. Branches are read by their full ref names, so a tag named `main` or `origin/main` can no longer stand in for the base and hide committed edits from the scan. +- **`gstack-review-plan`** passes the merge base its own diff used to `gstack-diff-scope` and `gstack-gate-diff`, so the scope flags, the gate scan and the line counts all read the same commits. Its diff reads `refs/remotes/origin/` and `refs/heads/` by full name too. + +### Tests +- `test/review-plan-origin-base.test.ts`: a fixture whose `origin/main` is one commit ahead of the local `main`, with a test edit on `origin/main` and a branch forked from it. `gstack-review-plan --base main` and `gstack-gate-diff main` plan the same class, depth and gate-tagged count as with a fresh `main` (before the fix: gate, full, `RH-1:1` against code, light, none). A tag named `origin/main` or `main` at HEAD leaves the plan and the scanned range unchanged. Also covers the stderr note for a branch no remote has, an unrelated `origin/main`, and the quiet path with no remote. + +- **One hostile line no longer stalls review classification** (GST-898). R51's `rm -rf` rule in `lib/review-risk.ts` took quadratic time on a long flag run. One added line of `rm -` and 100,000 `r` characters held `matchRisk` for about 3.7 s, and each doubling took four times as long. The `rmSync(… recursive: true)` rule did the same on a line of repeated `rmSync(` calls. Both now run in linear time (the same line takes about 1 ms). The `rm` rule is rewritten with lookaheads and matches exactly the lines it matched before. The `rmSync` rule looks for `recursive: true` in the first 500 characters of the call, and a call with more than 500 characters before its closing parenthesis now counts as security on its own, so padding can't push the flag out of view. A slow match never lowered review depth: `gstack-review-dispatch pass` stops when the plan times out, and the queue recomputes depth itself. +- A new test holds every risk pattern under 100 ms on 100,000-character hostile lines and checks both rules against R51's on 60,000 seeded lines each. Over the last 100 main commits, no commit changes depth, class or risk class, and none of the 69,023 changed lines gets a different verdict from either rule. + +### Fixed + +- **/ship no longer reviews a results write-up as if it were gate code.** Three planner misreads on gstack-evals#132 (GST-911) pushed a docs PR to the five-reader full panel. A chart SVG made of drawing elements now counts as data, like the same chart as a PNG; an SVG with a script, `foreignObject`, event handler or `javascript:` URL stays code and now gets the security class. A data file named for sessions (`recovered-sessions.jsonl`) no longer sets the auth scope, so it adds no auth reader and no gate class; session code and session config still do. +- **`gstack-diff-scope` and the receipt depth read `origin/` by its full ref name (GST-884).** A local `main` behind `origin/main` no longer adds upstream files to the scope, and a tag named `main` or `origin/main` can no longer stand in for the branch. + +### Tests + +- `test/review-planner-misreads.test.ts`: a 600-point matplotlib-style chart plans docs, light, runtime 0 (before: code, 636 runtime lines); six ways an SVG can run script keep it code with the security class, and other non-drawing constructs (external images, any CSS beyond declarations and `url(#id)`, animations, entity declarations) keep it code; session data files leave `SCOPE_AUTH` false while session code and config set it; a stale local `main` and tags named `main` or `origin/main` leave the scope and the receipt base unchanged. + +- `gstack-gate-diff` and `gstack-diff-scope` now read a bare base name such as `main` as origin's tracking branch (`refs/remotes/origin/main`) when it exists and shares history with HEAD. That's the base /ship fetches and merges against. Before, a local `main` that was behind `origin/main` set the merge base. That happens in a linked worktree, and on a Capy machine, where local `main` never moves. So the gate scan and the specialist scope also read commits already on `origin/main`. One /ship run in #3650 listed a `.github/workflows/ci.yml` hunk from upstream and dispatched an extra performance reviewer. `gstack-gate-diff` now says `local main is not origin/main; base is origin/main` on stderr when that happens. A base you name with its remote (`upstream/main`), a commit or `HEAD` is used as given. A tag named `origin/main` or a fork's `origin/upstream/main` branch can't stand in for the base, and another remote's branch only replaces a local base that is missing or unrelated (#3650). + +### Fixed + +- **The SVG drawing check runs in linear time.** One changed SVG with a long run of spaces inside `url(` could stall `gstack-review-plan` and the queue's depth check for hours (80,000 spaces took 0.8 s, a 1 MB run over 230 s). The check now reads it in about a millisecond and refuses exactly what it refused before. +- **An SVG whose DOCTYPE points at an outside document stays code.** Only a bare `` or W3C's own SVG DTD still counts as a drawing, so an SVG with a `SYSTEM` DTD keeps its full review. +- **`gstack-diff-scope` picks the same base as `gstack-gate-diff`.** When `origin/` is missing or unrelated, a related local `` now comes before another remote's branch in both tools. + +## [1.92.75.0] - 2026-11-02 + +- **The /ship receipt counts every blocker its readers found** (GST-951). `gstack-review-scope summary` read only the review-log rows, but /ship's readers report through the dispatch ledgers (`gstack-review-dispatch collect` and `judge`). So a blocker that only the native adversarial pass raised, a CRITICAL logged fixed without classify's blocking stamp, and findings logged before they were classified all left `blocking_found` at 0. That happened on #3841, #3838 and #3837. The summary now also reads each ledger's collected and judged CRITICAL findings, pairs each one with at most one row finding from the same reviewer at the same path:line so a blocker held in both places counts once and one classified below the floor does not, and counts an unclassified CRITICAL row as blocking. `unrecorded=N` names blockers no row gives a disposition, and `ledgers_read=N` says how many it read. +- **The summary refuses rather than undercount.** It now needs `--run ` (it finds every `review-ledger.jsonl` under it), `--ledger `, or `--no-ledger` when no reader ran through `gstack-review-dispatch`. A missing choice, a run directory with no ledger, or an unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER` with nothing written. /ship on Capy (`ship/DRIVER.md`) passes `--run`; the Claude Code flow passes `--no-ledger`. + +## [1.92.74.0] - 2026-11-02 + +- **`gstack-pregate clear --run-url` needs a green run.** On a lane with no run profile, a run of the lane's own workflow that failed, was cancelled or timed out now refuses `PREGATE_RECEIPT_INVALID` (`run_not_success`), and one still queued or running refuses `run_in_progress`. The fix line says to rerun the workflow and clear with the green run. The check reads the run's newest attempt, so a job or attempt link is judged by the whole run, and the lane records the attempt it judged (`…/actions/runs//attempts/`) instead of the link you passed. A green run still clears. docs/pregate.md now names `windows-smoke.yml/windows-smoke` as the free tier's Windows smoke lane, beside the narrower `free-tests.yml/cso-windows-launcher` and `windows-setup-e2e.yml/windows-setup` lanes. `test/pregate-clear-runs.test.ts` failed before the fix. + +## [1.92.73.0] - 2026-11-02 + +### Fixed + +- **A reviewer that wrote `printed: REVIEWED_TREE: …` is no longer failed.** Two readers in GBRA-218 echoed the command and then wrote the fingerprint after a `printed:` label, so /ship read no line, failed them and restarted the pass twice. The label is now read, plain, bold or inside the line's backticks, and the tree and checkout it names are still compared exactly. A wrong tree or checkout is still refused. +- **The Capy pass program stops before it sends a reader into the wrong checkout.** Capy starts every shared reader in the thread's working directory and cannot start one anywhere else. In GBRA-218 an outside voice ignored its `cd` and reviewed that checkout, so Step 11 lost its outside coverage. Now, before a stage takes a start token or sends its first reader, the program compares that directory with the checkout /ship ships. It stops when they differ or the directory can't be read. **Run /ship in the thread's own checkout**, not in a worktree beside it. A start directory outside any checkout, such as a multi-repo workspace root, still dispatches. +- **Step 11 takes the right start token.** Reusing the pass input's `logStart: ["review"]` for `stage: "voices"` used to run the voices with no `adversarial-review` start, which left Step 11.5 nothing to bind. Step 11 now takes `adversarial-review` by default and stops before anything runs when given any other list. The gstack Capy skill says Step 11 swaps `logStart` for `stage: "voices"`. + +## [1.92.72.0] - 2026-11-02 + +**The sealed guard can now refuse a published record that puts a label next to an identifier.** gstack-evals hides which test units are decoys. Its /ship on garrytan/gstack-evals#99 published a panel where each unit's `stratum`, `tags` and `decoy_set` sat next to its `head`, `tree_sha256`, `plan_bytes` and `files`, values a model under test can compute from its own copy of the work. A model could look itself up and learn it was the decoy. No answer leaked, so `gstack-pregate sealed` said `clear` (GSTA-2, GST-620). The new rule is opt-in. A repo that sets `sealed.label_keys` gets it, and every other repo sees the same output as before, byte for byte. + +### Added +- **`sealed.label_keys` and `sealed.identifier_keys` in `.gstack/pregate.json`.** With `label_keys` set, `sealed.label_join` checks every .json, .jsonl, .ndjson, .csv, .tsv and Markdown pipe table a published commit adds, modifies, renames or copies. A record that holds a label field and an identifier (a field matching an `identifier_keys` glob, by default `head`, `*_sha256`, `*_bytes`, `files` and similar names, or any 7 to 64 character hex string, as a value, in a list, or as the key of an object) is a join. The guard fingerprints each join in memory and compares it with the parent's version of the file, so unchanged rows and reformatting stay clear while a replaced, added or widened row refuses even when the row count stays the same. Findings name the file, the commit and the field names (`keys=stratum,tags,head,plan_bytes`), never the values, on stderr, in `--json` and in the pre-push hook. +- **`sealed.label_join_unchecked`.** A file whose text never names a label key, ignoring double quotes, cannot hold a join and is skipped without parsing, so a commented `tsconfig.json` passes. A file with a `\u` escape is always parsed. A supported file over 4 MiB, one that names a label key and does not parse, one nested deeper than 64 levels, or a blob git cannot read refuses instead of passing unread. The `SEALED:` line counts these as `unchecked=`. Like every sealed finding, it can be waived through the base branch's `.gstack/sealed-allow.tsv`; `allow_paths` never exempt either rule. +- **The policy only tightens.** A field can count as both a label and an identifier, so adding identifier names to `label_keys` never hides a join. Both lists are the union of the base branch's and the pushed policy's, each resolved to its default first, so a pushed config can turn the rule on or widen it but cannot turn it off or narrow it. + +### Changed +- **docs/pregate.md#sealed** documents the rule, its normalization (`Tree SHA-256` matches as `tree_sha_256`) and its known limits: joins across files, joins by reference, columnar JSON, labels in file names and prose. Its version-skew note now names the new keys. A gstack older than this release refuses them as unknown, so raise `min_gstack_version` before adopting them. The unknown-key error now lists `label_keys` and `identifier_keys` among the valid keys. + +### Tests +- `test/sealed-guard.test.ts`: the GSTA-2 panel refuses through `gstack-pregate sealed`, `--json` and the pre-push hook, and warns on a private destination, with no value in any output. It failed on main. A golden captured from main 2d1b55d76 covers seven pushes (refused, clear, exempt, waived, warned, error, no policy) without `label_keys`; stdout, stderr, `--json`, the hook's stderr and every exit code match it byte for byte. +- `test/sealed-join.test.ts`: each format, human table headers, hex values under unlisted keys, maps keyed by sha, quoted TSV, a CSV header split by quotes (`"Strat"um`), a commented tsconfig.json that stays clear, dotted key names refused in the config, identifier names added to a pushed `label_keys`, decimal strings that stay clear, replacement at the same count, delete plus add, an added identifier field, labels then identifiers a commit later, renames, merges, five unchecked cases, waivers, `allow_paths`, pushed attempts to narrow or disable the rule, and `label_keys: []` matching an absent key. + ## [1.92.71.0] - 2026-11-02 **Four free-suite waits now end when the thing they wait for happens: the six files involved spend 18–29 s less in summed per-file runtime, with every assertion intact.** `./setup` returns from a finished child up to ~0.9 s sooner too. diff --git a/VERSION b/VERSION index 2d527513b..730cd3e00 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.71.0 +1.92.76.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 181ad1acd..f297fb296 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.71.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.76.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, diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 3ec81cf09..7609aefbf 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -36,6 +36,46 @@ _default_base() { BASE="${1:-$(_default_base)}" HEAD_REF="" if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi +# A bare branch name reads origin's tracking branch, by its full ref, when that +# shares history with HEAD: /ship merges against origin/, and a linked +# worktree or a Capy clone never moves the local branch, so a stale one widens +# the scope (#3650). A remote-qualified name is taken as given. +_NOT_BRANCH='^[0-9a-f]{7,64}$|[~^]|@\{|^[A-Z_]*HEAD$|^refs/' +_QUALIFIED=false +for _remote in $(git remote 2>/dev/null); do + case "$BASE" in "$_remote"/*) _QUALIFIED=true ;; esac +done +if [[ ! "$BASE" =~ $_NOT_BRANCH ]] && [ "$_QUALIFIED" = false ] \ + && git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1 \ + && git merge-base "refs/remotes/origin/${BASE}" "${HEAD_REF:-HEAD}" >/dev/null 2>&1; then + BASE="refs/remotes/origin/${BASE}" +fi + +# The base as a commit (GST-884, mirroring lib/gate-diff/git.ts resolveBase from +# GST-883): a branch name reads refs/remotes/ (origin/main as given), then +# refs/remotes/origin/ that shares history with the head, then refs/heads/ +# that does, then another remote's twin that does, then refs/heads/ as it is, +# all by full ref name and in resolveBase's order, so a stale local main never widens +# the scope and a tag named main or origin/main cannot stand in for the branch. +# A sha, a relative ref (HEAD~1, main^2, @{u}) or a full refs/ name resolves as +# given. Prints nothing when nothing resolves. +_is_branch_name() { + case "$1" in refs/*|*~*|*^*|*@\{*|HEAD|*_HEAD) return 1 ;; esac + [[ ! "$1" =~ ^[0-9a-f]{7,64}$ ]] +} +_commit() { git rev-parse --verify -q "$1^{commit}" 2>/dev/null; } +_resolve_base() { + local ref="$1" head="$2" sha loc remote + if ! _is_branch_name "$ref"; then _commit "$ref"; return; fi + sha=$(_commit "refs/remotes/$ref") && { echo "$sha"; return; } + loc=$(_commit "refs/heads/$ref") + for remote in $(git remote 2>/dev/null | grep -x origin) ${loc:+:local} $(git remote 2>/dev/null | grep -vx origin); do + if [ "$remote" = :local ]; then sha=$loc; else sha=$(_commit "refs/remotes/$remote/$ref") || continue; fi + git merge-base "$sha" "$head" >/dev/null 2>&1 && { echo "$sha"; return; } + done + [ -n "$loc" ] && { echo "$loc"; return; } + _commit "$ref" +} FRONTEND=false BACKEND=false @@ -53,6 +93,19 @@ AUTH=false # the root-anchored globs alone (#3335). TEST_PATH_RE='(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' +# Data files (lib/review-depth.ts DATA_PATH) outside the config locations its +# DATA_CONFIG and agent-prose rules keep code: .github/, config/, dotfiles, +# *.config.*, .gstack/. Case-insensitive, as DATA_PATH is. +DATA_FILE_RE='\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$' +DATA_CONFIG_RE='(^|/)\.github/|(^|/)config/|(^|/)\.[^/]+$|(^|/)[^/]+\.config\.[^/]+$|^\.gstack/' +_session_data() { + local r=1 + shopt -s nocasematch + if [[ "$1" =~ $DATA_FILE_RE ]] && [[ ! "$1" =~ $DATA_CONFIG_RE ]]; then r=0; fi + shopt -u nocasematch + return "$r" +} + _print_flags() { echo "SCOPE_FRONTEND=$FRONTEND" echo "SCOPE_BACKEND=$BACKEND" @@ -68,7 +121,8 @@ _print_flags() { # Base reachability (#2526): a shallow CI checkout or an unfetched ref makes # `git diff` return an empty list — all-false with exit 0, a green that means # "we could not look". Distinguish it before diffing. -if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then +BASE_SHA=$(_resolve_base "$BASE" "${HEAD_REF:-HEAD}" || true) +if [ -z "$BASE_SHA" ]; then _print_flags echo "SCOPE_ERROR=no_base" echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}" @@ -91,16 +145,16 @@ if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then echo "# --head '${HEAD_REF}' is not a commit in this repository" exit 2 fi - if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then + if ! git merge-base "${BASE_SHA}" "${HEAD_REF}" >/dev/null 2>&1; then _print_flags echo "SCOPE_ERROR=no_merge_base" echo "# '${BASE}' and '${HEAD_REF}' share no merge base" exit 2 fi - _collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null) + _collect < <(git diff -z "${BASE_SHA}...${HEAD_REF}" --name-only 2>/dev/null) else # Committed diff vs base (merge-base form; two-dot fallback when no merge base). -_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true) +_collect < <(git diff -z "${BASE_SHA}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE_SHA}" --name-only 2>/dev/null || true) # Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo # with no commits; tolerated. _collect < <(git diff -z HEAD --name-only 2>/dev/null || true) @@ -190,9 +244,12 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do *.graphql|*.gql|openapi.*|swagger.*) m_api=true ;; esac - # Auth: authentication, authorization, sessions, permissions + # Auth: authentication, authorization, sessions, permissions. A data file + # named for sessions (recovered-sessions.jsonl, GST-911) holds rows, not + # session handling, so it is not auth; session config still is. case "$f" in - *auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;; + *auth*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;; + *session*) _session_data "$f" || m_auth=true ;; esac # Backend: code that isn't a frontend component/view file. Includes ESM/CJS diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 190c8aae6..c5f6396db 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -325,6 +325,7 @@ gstack_result_anchor() { REGEN_DEPS_MISSING) printf '%s' 'regen-deps-missing' ;; REVIEW_SCOPE_INPUT) printf '%s' 'review-scope-input' ;; REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;; + REVIEW_SCOPE_LEDGER) printf '%s' 'review-scope-ledger' ;; RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;; PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;; REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;; diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff index 0a1af715c..400a9a56d 100755 --- a/bin/gstack-gate-diff +++ b/bin/gstack-gate-diff @@ -39,7 +39,8 @@ const BIN_DIR = path.dirname(Bun.fileURLToPath(import.meta.url)); const USAGE = `usage: gstack-gate-diff [base-ref] [--format table|jsonl] [--commit ] [--help] Lists every hunk in a test, CI, runner/lint-config, snapshot or golden path of the -candidate (merge-base of and HEAD -> working tree + untracked files), +candidate (merge-base of and HEAD -> working tree + untracked files; +a branch name resolves to origin/, the local ref only when no remote has it), tags the gate edits it recognises (RH-1 RH-3 RH-4 RH-12 RH-13 RH-14 RH-15 RH-16), and prints the read-level listing after one GATE_SUMMARY: line. --commit scans that commit's first-parent delta instead of the working tree. @@ -148,7 +149,8 @@ function main(argv: string[]): number { process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`); return 2; } - if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : `no local branch ${baseRef}`}; base is ${base.ref}`); + if (base.local === 'fallback') console.error(`gstack-gate-diff: ${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${baseRef}`}; base is the local ${baseRef}, which may lag its remote`); + else if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : base.local === 'differs' ? `local ${baseRef} is not ${base.ref}` : `no local branch ${baseRef}`}; base is ${base.ref}`); const mb = mergeBase(base.sha, head, root); if (!mb) { process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`); diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 772bec1b1..cfee7e707 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -274,8 +274,9 @@ function cmdSealed(root: string, rest: string[], json: boolean): number { const blocks = report.status === 'refused' || report.status === 'error'; if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; } process.stderr.write(renderSealed(report, 'gstack-pregate sealed')); + const unchecked = report.findings.filter(f => f.rule === 'sealed.label_join_unchecked').length; const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`; - process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`); + process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length}${unchecked ? ` unchecked=${unchecked}` : ''} policy=${policy}\n`); return blocks ? EXIT.fail : EXIT.ok; } diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics index 40bb4f43c..d49abd3cb 100755 --- a/bin/gstack-retro-metrics +++ b/bin/gstack-retro-metrics @@ -28,6 +28,19 @@ # --since midnight-aligned ISO date ("2026-03-11T00:00:00") or a relative # expression ("24 hours ago"). Default: "7 days ago". # --until optional window end (compare mode's prior window). +# +# gstack-retro-metrics --history [--next ] +# +# Retro history for this project (#3749): prints RETRO_HISTORY_DIR (the +# state root's projects//retros, shared by every worktree of the +# repository) and one RETRO_PRIOR line per snapshot, newest first. Moves any +# legacy .context/retros/*.json from this worktree and the main checkout +# into it first: a copy identical to a stored snapshot of the same date +# collapses, a same-name snapshot that differs takes the next free number +# for its date, and a .context/retros that resolves outside its worktree +# is left alone. --next also reserves the next unused +# -.json (written as {} so a concurrent run cannot take it) and +# prints it as RETRO_FILE. GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac @@ -36,9 +49,11 @@ SINCE="7 days ago" UNTIL="" while [ $# -gt 0 ]; do case "$1" in - --base) BASE="$2"; shift 2 ;; - --since) SINCE="$2"; shift 2 ;; - --until) UNTIL="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 || break ;; + --since) SINCE="$2"; shift 2 || break ;; + --until) UNTIL="$2"; shift 2 || break ;; + --history) HISTORY=1; shift ;; + --next) NEXT_DATE="$2"; shift 2 || break ;; *) shift ;; esac done @@ -51,6 +66,62 @@ _BIN="$_SCRIPT_DIR" gstack_state_root_select _GH="$_gstack_sr_root" +if [ -n "${HISTORY:-}" ]; then + _SLUG=$("$_BIN/gstack-slug" --get SLUG 2>/dev/null || true) + if [ -z "$_SLUG" ]; then + echo "RETRO_HISTORY_ERROR: could not resolve the project slug (gstack-slug failed). fix: reinstall with ./setup or /gstack-upgrade" + exit 0 + fi + _HD="$_GH/projects/$_SLUG/retros" + mkdir -p "$_HD" || { echo "RETRO_HISTORY_ERROR: cannot create $_HD"; exit 0; } + if [ -n "${NEXT_DATE:-}" ]; then + case "$NEXT_DATE" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; + *) echo "RETRO_HISTORY_ERROR: --next takes a YYYY-MM-DD date, got '$NEXT_DATE'"; exit 0 ;; + esac + fi + _HDR=$(cd -P "$_HD" && pwd) + _TOP=$(git rev-parse --show-toplevel 2>/dev/null || true) + _MAIN=$(git worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p') + _MOVED=0 + for _TREE in "$_TOP" "$_MAIN"; do + [ -n "$_TREE" ] && [ -d "$_TREE/.context/retros" ] || continue + _SRC=$(cd -P "$_TREE/.context/retros" 2>/dev/null && pwd) || continue + _TR=$(cd -P "$_TREE" 2>/dev/null && pwd) || continue + case "$_SRC" in "$_TR"/*) ;; *) continue ;; esac + [ "$_SRC" != "$_HDR" ] || continue + for _F in "$_SRC"/*.json; do + [ -f "$_F" ] && [ ! -L "$_F" ] || continue + _B=$(basename "$_F") + _DUP="" + for _E in "$_HD/$_B" "$_HD/${_B%-*}"-*.json; do + [ -f "$_E" ] && cmp -s "$_F" "$_E" && { _DUP=1; break; } + done + if [ -n "$_DUP" ]; then rm -f "$_F"; continue; fi + _DEST="$_HD/$_B" + _N=1 + while [ -e "$_F" ] && [ "$_N" -le 1000 ]; do + [ -e "$_DEST" ] || mv -n "$_F" "$_DEST" 2>/dev/null + [ -e "$_F" ] || { _MOVED=$((_MOVED + 1)); break; } + _N=$((_N + 1)); _DEST="$_HD/${_B%-*}-$_N.json" + done + done + rmdir "$_TREE/.context/retros" 2>/dev/null || true + done + echo "RETRO_HISTORY_DIR: $_HD" + [ "$_MOVED" -gt 0 ] && echo "RETRO_HISTORY_MIGRATED: $_MOVED from .context/retros" + ls -t "$_HD"/*.json 2>/dev/null | sed 's/^/RETRO_PRIOR: /' + if [ -n "${NEXT_DATE:-}" ]; then + _N=1 + until ( set -C; printf '{}\n' > "$_HD/$NEXT_DATE-$_N.json" ) 2>/dev/null; do + _N=$((_N + 1)) + [ "$_N" -le 1000 ] || { echo "RETRO_HISTORY_ERROR: cannot reserve a snapshot file in $_HD"; exit 0; } + done + echo "RETRO_FILE: $_HD/$NEXT_DATE-$_N.json" + fi + exit 0 +fi + echo "RETRO_METRICS_PROTO: 1" if ! git rev-parse --git-dir >/dev/null 2>&1; then diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index 93f6aee5e..40610335b 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -116,8 +116,9 @@ function main(): number { const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? ''); return g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null; }; - const scope = scopeOf([base]); - let gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([base]); + const diffBase = collected.mergeBase ?? base; + const scope = scopeOf([diffBase]); + let gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([diffBase]); if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null; const force = flag('--force') === 'all' ? 'all' as const : list('--force'); const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 }); @@ -149,7 +150,7 @@ function main(): number { const gateScan = { tagged: gateTagged, head }; if (has('--json')) { const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; - process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n'); + process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings }) + '\n'); return EXIT.ok; } const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, diff --git a/bin/gstack-review-scope b/bin/gstack-review-scope index fb2f2388e..402090911 100755 --- a/bin/gstack-review-scope +++ b/bin/gstack-review-scope @@ -5,7 +5,7 @@ * gstack-review-scope classify --base --pass [--range-base ] [--policy frozen|diff-plus-blocking|all] [--findings |-] [--json] * gstack-review-scope defer --findings [--pr ] [--repo ] [--branch ] [--out ] [--dry-run] [--json] * gstack-review-scope render --deferred [--findings ] - * gstack-review-scope summary --since [--out ] [--json] + * gstack-review-scope summary --since (--run | --ledger ... | --no-ledger) [--out ] [--json] * * `classify` reads a JSON array of findings (the Step 9.3 result) and stamps * each with `locus` (diff | fix-range | outside), `kind` (defect | test-gap | @@ -30,6 +30,7 @@ import { parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop, ALREADY_FILED_REASON, DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding, } from '../lib/review-scope'; +import { ledgerBlockers, type LedgerBlocker } from '../lib/review-ledger'; const HELP = `gstack-review-scope — finding scope for /ship's review loop (docs/ship-policy.md#review-scope) @@ -42,8 +43,12 @@ const HELP = `gstack-review-scope — finding scope for /ship's review loop (doc render --deferred [--findings ] the PR-body sections: ## Deferred findings (references, never closing keywords) and ## Review notes (scope=note findings) - summary --since [--out ] the invocation's passes from this branch's review log: REVIEW_LOOP: passes=… - blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input) + summary --since (--run | --ledger ... | --no-ledger) [--out ] + the invocation's passes from this branch's review log: REVIEW_LOOP: passes=… + blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input); + blocking_found also counts every CRITICAL the dispatch ledgers collected or judged + (--run finds each review-ledger.jsonl under the run directory); unrecorded=N names + blockers no row gives a disposition. An unreadable ledger: REVIEW_SCOPE_LEDGER Defaults: --policy from the base branch's .gstack/ship-policy.json review_scope (frozen); an unresolvable base refuses (POLICY_SOURCE_UNAVAILABLE) unless --policy is passed; --findings - reads stdin; --branch the current branch. @@ -117,7 +122,7 @@ function readFindings(spec: string | undefined, what: string): ScopeFinding[] { if (!list || !list.every((f: unknown) => f && typeof f === 'object' && !Array.isArray(f))) throw new Error(`${what}: expected a JSON array of finding objects`); return list as ScopeFinding[]; } -function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE', detail: string): number { +function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE' | 'REVIEW_SCOPE_LEDGER', detail: string): number { process.stderr.write(resultLineWithDocs(`gstack-review-scope ${cmd}`, code, detail) + '\n'); return EXIT.fail; } @@ -245,7 +250,26 @@ function cmdSummary(rest: string[], json: boolean): number { if (!line.trim() || line === 'NO_REVIEWS') continue; try { rows.push(JSON.parse(line)); } catch { /* malformed row: gstack-review-read already warned */ } } - const summary = summarizeReviewLoop(rows, since); + const files: string[] = []; + for (let i = 0; i < rest.length; i++) { + if (rest[i] !== '--ledger') continue; + if (!rest[i + 1] || rest[i + 1]!.startsWith('--')) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--ledger needs a file'); + files.push(rest[++i]!); + } + const run = flag(rest, '--run'); + if (run) { + let found: string[]; + try { found = (fs.readdirSync(run, { recursive: true }) as string[]).filter(p => path.basename(p) === 'review-ledger.jsonl').sort().map(p => path.join(run, p)); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run}: ${e.code ?? e.message}`); } + if (!found.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run} holds no review-ledger.jsonl: name the run directory the passes wrote, or each --ledger`); + files.push(...found); + } + const noLedger = rest.includes('--no-ledger'); + if (noLedger && files.length) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--no-ledger contradicts --run and --ledger'); + // The ledgers hold every reader's findings, the voices' too, before any row classifies them; a summary without them can undercount. + if (!noLedger && !files.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', 'name the review\'s dispatch ledgers (--run or --ledger ), or --no-ledger when no reader ran through gstack-review-dispatch'); + let ledgers: LedgerBlocker[][]; + try { ledgers = files.map(ledgerBlockers); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', e.message); } + const summary = summarizeReviewLoop(rows, since, ledgers); const line = reviewLoopLine(summary); // A /ship invocation always runs Step 9 once; zero matching rows means the wrong --since or a review log written elsewhere, never a loop to record. if (summary.passes === 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `no review-log row of this branch is a via:ship review at or after ${since}; check REVIEW_SINCE (${line})`); diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index c2c82992c..1e3263109 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -30,7 +30,7 @@ With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session che For Capy running GPT-6 Astra, read `${CODEX_HOME:-$HOME/.codex}/skills/gstack/SKILL.md` for routing, then `${CODEX_HOME:-$HOME/.codex}/skills/gstack-/SKILL.md` for the selected workflow. These are generated for GPT-6 Astra, not the Claude source files. -For Claude Code, read `~/.claude/skills/gstack/SKILL.md` for routing and `~/.claude/skills//SKILL.md` for the selected workflow. When launching a Claude Code session, tell it to load gstack and name the intended workflow. For Capy running a Claude model, the Claude instruction files can be read directly with the Capy adaptations below; that does not turn Capy into the Claude Code harness. Run `scripts/install.sh` with `GSTACK_MODEL=` in front (an installer that predates it ignores the variable); when it prints `SKILL_ROOT: `, read skills from `` instead (`docs/loadouts.md`). +For Claude Code, read `~/.claude/skills/gstack/SKILL.md` for routing and `~/.claude/skills//SKILL.md` for the selected workflow. When launching a Claude Code session, tell it to load gstack and name the intended workflow. For Capy running a Claude model, the Claude instruction files can be read directly with the Capy adaptations below; that does not turn Capy into the Claude Code harness. If no specific skill was requested, use the router's task-to-skill mapping. Read only the selected workflow and its referenced files. For other models, use the closest applicable instruction profile without claiming a model-specific profile was generated or evaluated for them. @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each an execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family, pinned in `voice-models.json`); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (14.5 once). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Else re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your transcript since /ship began. A small waiting child saves nothing. +/ship on Capy is about a dozen calls, each an execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family, pinned in `voice-models.json`); Step 11 swaps `logStart` for `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (14.5 once). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Else re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your transcript since /ship began. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/docs/designs/HONEST_WORK_GATE_INTEGRITY.md b/docs/designs/HONEST_WORK_GATE_INTEGRITY.md index adfad8d8c..28a8563d9 100644 --- a/docs/designs/HONEST_WORK_GATE_INTEGRITY.md +++ b/docs/designs/HONEST_WORK_GATE_INTEGRITY.md @@ -180,9 +180,12 @@ only history need is calibration, which `--commit ` covers by scanning one commit's first-parent delta without the working tree.) Positional base defaults the way `gstack-diff-scope` does (the remote's `HEAD` target, origin first, then the other remotes → `main` → `master`); an -already-qualified ref is used as given, and a branch name with no local -branch resolves through its remote-tracking twin (`origin/main` for `main`: -the merge-base is the same commit), noted once on stderr. stdout: one +already-qualified ref is used as given, and a branch name resolves through +its remote-tracking twin (`origin/main` for `main`), so a stale local `main` +never adds commits the branch only inherited (GST-883). Only when no remote +has the branch does the local ref stand, noted once on stderr (a repository +with no remote at all uses it quietly). Branches are read by full ref name, +so a tag named `main` or `origin/main` cannot stand in for them. stdout: one `GATE_SUMMARY: listed=N read=N tagged={RH-1:2,RH-15:1} unmatched=N coverage= languages_unlisted=<…> candidate=` line, then the read-level listing (bounded; the full listing goes to `--out`). diff --git a/docs/pregate.md b/docs/pregate.md index c7238d475..e0e18d938 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -383,7 +383,7 @@ The record is `.gstack/tmp/pregate.json` (`gstack-artifact validate --as pregate (`PREGATE_STALE`: rerun after the final stamp), green (`PREGATE_LANE_FAILED`, `PREGATE_INCOMPLETE`, or `PREGATE_ENVIRONMENT` when every incomplete check is the machine's) and every `requires-remote` obligation carries a receipt -(`PREGATE_REMOTE_UNCLEARED`: dispatch the job, then +(`PREGATE_REMOTE_UNCLEARED`: dispatch the job, wait for it to finish green, then `gstack-pregate clear --remote --run-url `). The ship receipt then carries `pregate` and `requires_remote`. `gstack-pregate verify` runs the same check standalone. @@ -754,17 +754,24 @@ reads `remote-gate tree= tests= runs=/,… profiles=

,… heads= verified=

&& gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. - Step 20: the metrics row via `gstack-review-log`. - Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index cefeda67f..f7b98d4c8 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -12,7 +12,7 @@ gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names the next act. Read a section only when a phase or the list at the end sends you, never up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish it there, come back. Unattended, take a question's recommended option (log it); gate -dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, then fill each as its phase ends: **Checks** +dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **Checks** (Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line, start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7: @@ -117,7 +117,7 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an ## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22 - Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. -- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. +- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. - Step 20: the metrics row via `gstack-review-log`. - Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index 64e2f50cc..79af0c5d9 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -220,7 +220,7 @@ per-lane counts (`{"":{"pass":N,"fail":N,"skip":N}}`) to ```bash rm -f .gstack/tmp/review-loop.json -~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json && +~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json && ~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --base --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json --body "$PR_BODY_FILE" ``` @@ -233,7 +233,9 @@ When the owner accepted a stop after a fix-check edit, add and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`. The summary reads this branch's review log for the invocation's passes (its -`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&` +`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). Here every +finding reaches a row through Step 9.4; a run whose readers were collected by +`gstack-review-dispatch` (ship/DRIVER.md) passes `--run ` instead. The `&&` matters: a summary that refuses (no pass matched `REVIEW_SINCE`) must not let `write` record a stale or empty loop file. diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 8786e9daa..611c1e553 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -203,7 +203,7 @@ per-lane counts (`{"":{"pass":N,"fail":N,"skip":N}}`) to ```bash rm -f .gstack/tmp/review-loop.json -~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json && +~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json && ~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --base --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json --body "$PR_BODY_FILE" ``` @@ -216,7 +216,9 @@ When the owner accepted a stop after a fix-check edit, add and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`. The summary reads this branch's review log for the invocation's passes (its -`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&` +`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). Here every +finding reaches a row through Step 9.4; a run whose readers were collected by +`gstack-review-dispatch` (ship/DRIVER.md) passes `--run ` instead. The `&&` matters: a summary that refuses (no pass matched `REVIEW_SINCE`) must not let `write` record a stale or empty loop file. diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index e33469c1a..dc2f75835 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -9,6 +9,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'fs' import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; +import { resolveBase } from '../lib/gate-diff/git'; const SCRIPT = join(import.meta.dir, '..', 'bin', 'gstack-diff-scope'); @@ -330,6 +331,70 @@ describe('exit-code contract (#2526)', () => { }); }); +describe('a bare base name reads origin/ when it exists (#3650)', () => { + function behindBase(base: string) { + const dir = createRepo(['README2.md']); + const git = (...args: string[]) => spawnSync('git', args, { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString().trim(); + const seed = git('rev-parse', 'main'); + git('checkout', '-q', '-B', base, 'main'); + writeFileSync(join(dir, 'service.py'), 'print(1)\n'); + git('add', '.'); + git('commit', '-qm', 'upstream backend change'); + git('update-ref', `refs/remotes/origin/${base}`, 'HEAD'); + git('checkout', '-q', 'feature/test'); + git('rebase', '-q', `origin/${base}`); + git('branch', '-f', base, seed); + expect(git('diff', '--name-only', `${base}...HEAD`).split('\n').sort()).toEqual(['README2.md', 'service.py']); + return { dir, git }; + } + const scopeAt = (dir: string, base: string) => Object.fromEntries(spawnSync('bash', [SCRIPT, base], { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString().trim().split('\n').map(l => l.split('='))); + + test('a local main behind origin/main does not widen the scope to upstream commits', () => { + const { dir } = behindBase('main'); + const scope = runScope(dir); + expect([scope.SCOPE_BACKEND, scope.SCOPE_DOCS]).toEqual(['false', 'true']); + }); + + test('a slash-named base reads its remote-tracking twin too, and HEAD or a sha stays as given', () => { + const { dir, git } = behindBase('release/x'); + expect(scopeAt(dir, 'release/x').SCOPE_BACKEND).toBe('false'); + git('update-ref', 'refs/remotes/origin/HEAD', git('rev-parse', 'release/x')); + expect(scopeAt(dir, 'HEAD').SCOPE_DOCS).toBe('false'); + git('update-ref', `refs/remotes/origin/${git('rev-parse', 'release/x')}`, 'HEAD'); + expect(scopeAt(dir, git('rev-parse', 'release/x')).SCOPE_BACKEND).toBe('true'); + }); + + test('a colliding tag, a fork branch over an explicit remote base, or an unrelated twin never replaces the base (#3650 review)', () => { + const { dir, git } = behindBase('main'); + const upstreamCommit = git('rev-parse', 'origin/main'); + git('tag', 'origin/main', 'HEAD'); + expect([scopeAt(dir, 'main').SCOPE_BACKEND, scopeAt(dir, 'main').SCOPE_DOCS]).toEqual(['false', 'true']); + git('remote', 'add', 'upstream', 'file:///nonexistent-upstream'); + git('update-ref', 'refs/remotes/upstream/main', git('rev-parse', 'main')); + git('update-ref', 'refs/remotes/origin/upstream/main', 'HEAD'); + expect(scopeAt(dir, 'upstream/main').SCOPE_BACKEND).toBe('true'); + git('tag', '-d', 'origin/main'); + git('checkout', '-q', '--orphan', 'island'); + git('commit', '-q', '--allow-empty', '-m', 'unrelated'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('checkout', '-q', 'feature/test'); + expect(scopeAt(dir, 'main').SCOPE_BACKEND).toBe('true'); + expect(upstreamCommit).not.toBe(''); + }); + + test('with origin/main unrelated, a related local main comes before a related other remote, the base resolveBase picks', () => { + const { dir, git } = behindBase('main'); + git('remote', 'add', 'upstream', 'file:///nonexistent-upstream'); + git('update-ref', 'refs/remotes/upstream/main', git('rev-parse', 'origin/main')); + git('checkout', '-q', '--orphan', 'island'); + git('commit', '-q', '--allow-empty', '-m', 'unrelated'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('checkout', '-q', 'feature/test'); + expect(resolveBase('main', dir, 'HEAD')?.sha).toBe(git('rev-parse', 'main')); + expect(scopeAt(dir, 'main').SCOPE_BACKEND).toBe('true'); + }); +}); + describe('uncommitted work is visible (#2299)', () => { test('uncommitted change on a branch with no commits sets the scope', () => { const dir = mkdtempSync(join(tmpdir(), 'diff-scope-dirty-')); diff --git a/test/fixtures/context-budget.json b/test/fixtures/context-budget.json index 7c13fcf6a..619885589 100644 --- a/test/fixtures/context-budget.json +++ b/test/fixtures/context-budget.json @@ -9,7 +9,7 @@ "browser-skills/hackernews-frontpage": 371, "canary": 7738, "captain": 6551, - "capy/skill/gstack": 5363, + "capy/skill/gstack": 5315, "careful": 1239, "codex": 9890, "context-restore": 6287, diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index cc4991e9f..d8512f7e1 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -170,6 +170,7 @@ { "file": "lib/pregate/checks/timing.ts", "movedFrom": "(new: pre-gate timing-assertion check, field list 2 W4)" }, { "file": "lib/pregate/runtime.ts", "movedFrom": "(new: the verified launch Bun for repo commands, field wave CD1)" }, { "file": "lib/sealed-guard.ts", "movedFrom": "(new: the sealed-output guard shared by the pre-push hook and gstack-pregate sealed, field wave CD4)" }, + { "file": "lib/sealed-join.ts", "movedFrom": "(new: the sealed.label_join rule, label fields published beside identifiers, GST-620)" }, { "file": "lib/pregate/regate.ts", "movedFrom": "(new: the regate verdict from a fresh tree receipt plus migration collisions, field wave CD3)" }, { "file": "lib/autoplan-scope.ts", "movedFrom": "(new: UI evidence from rendered surfaces and the plan scope marker, shared by the runner and the snapshot tool, field wave B2c)" }, { "file": "lib/source-root.ts", "movedFrom": "(new: the source checkout behind an install root, from its recorded .source-path, field wave B2e)" }, diff --git a/test/fixtures/sealed-guard-off-golden.json b/test/fixtures/sealed-guard-off-golden.json new file mode 100644 index 000000000..ebe386370 --- /dev/null +++ b/test/fixtures/sealed-guard-off-golden.json @@ -0,0 +1,65 @@ +{ + "refused": { + "code": 1, + "stdout": "SEALED: refused findings=2 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of /origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit \n sealed.answer runs/c1/summary.md:1 commit \nRemove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's .gstack/pregate.json, or waive one reviewed finding with a `rulepathfull-commit-shareason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n", + "jsonCode": 1, + "json": "{\"status\":\"refused\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\"},{\"rule\":\"sealed.answer\",\"file\":\"runs/c1/summary.md\",\"line\":1,\"commit\":\"\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":false}\n", + "hookCode": 1, + "hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of /origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit \n sealed.answer runs/c1/summary.md:1 commit \nRemove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's .gstack/pregate.json, or waive one reviewed finding with a `rulepathfull-commit-shareason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n" + }, + "clear": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "" + }, + "exempt": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=1 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: exempt sealed.path sealed/s1/MANIFEST.json commit (sealed.allow_paths **/sealed/*/MANIFEST.json)\n", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[{\"rule\":\"sealed.path\",\"file\":\"sealed/s1/MANIFEST.json\",\"commit\":\"\",\"glob\":\"**/sealed/*/MANIFEST.json\"}],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: exempt sealed.path sealed/s1/MANIFEST.json commit (sealed.allow_paths **/sealed/*/MANIFEST.json)\n" + }, + "waived": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=1 exempt=0 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: waived sealed.transcript runs/c1/transcript.jsonl commit (.gstack/sealed-allow.tsv: synthetic fixture)\n", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\",\"reason\":\"synthetic fixture\"}],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: waived sealed.transcript runs/c1/transcript.jsonl commit (.gstack/sealed-allow.tsv: synthetic fixture)\n" + }, + "warned": { + "code": 0, + "stdout": "SEALED: warned findings=1 commits=1 visibility=private waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit \n", + "jsonCode": 0, + "json": "{\"status\":\"warned\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"private\",\"visibilitySource\":\"sealed.visibility override\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit \n" + }, + "error": { + "code": 1, + "stdout": "SEALED: error findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n", + "stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of /origin.git — the sealed policy in commit is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n", + "jsonCode": 1, + "json": "{\"status\":\"error\",\"error\":\"the sealed policy in commit is unreadable: sealed.answer_markers[0]: \\\"(\\\" is not a valid regex (Invalid regular expression: missing ))\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"/origin.git\",\"ok\":false}\n", + "hookCode": 1, + "hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of /origin.git — the sealed policy in commit is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n" + }, + "no-policy": { + "code": 0, + "stdout": "SEALED: no-policy findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n", + "stderr": "", + "jsonCode": 0, + "json": "{\"status\":\"no-policy\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "" + } +} diff --git a/test/gate-diff-cli.test.ts b/test/gate-diff-cli.test.ts index 3a8545e09..315bfccda 100644 --- a/test/gate-diff-cli.test.ts +++ b/test/gate-diff-cli.test.ts @@ -153,6 +153,59 @@ describe('gstack-gate-diff arguments and exit contract', () => { // fetches from it; fails_when=remoteNames or resolveBase hard-code origin, or noBaseFix falls back to the literal 'origin' // while a real remote exists; why_new=every existing fixture has either an origin remote or none; seam=none; // no_claim=nothing about the order when origin and another remote both carry the branch. + // Value: protects=a bare base name scans from the same merge base /ship merges against (origin/) when the local + // branch is related but behind (#3650); fails_when=resolveBase prefers any related local branch, so a stale local main + // widens the scan to commits already upstream; why_new=the stale-main test above covers only unrelated history; + // seam=none; no_claim=nothing about a local base that is ahead of its remote-tracking twin. + test('a related local main that is behind origin/main yields to origin/main (#3650)', () => { + const src = repo({ fixture: 'rh1-skip', commit: true }); + const seed = gitIn(src, ['rev-parse', 'main']).trim(); + gitIn(src, ['checkout', '-q', 'main']); + fs.mkdirSync(path.join(src, '.github', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(src, '.github', 'workflows', 'ci.yml'), 'on: push\njobs: {}\n'); + gitIn(src, ['add', '-A']); + gitIn(src, ['commit', '-q', '-m', 'upstream ci change']); + gitIn(src, ['checkout', '-q', 'feature']); + gitIn(src, ['rebase', '-q', 'main']); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-behind-')); + repos.push(dir); + gitIn(dir, ['clone', '-q', '--branch', 'feature', `file://${src}`, '.']); + gitIn(dir, ['branch', '-f', 'main', seed]); + const bare = run(dir, ['main']); + const remote = run(dir, ['origin/main']); + expect([bare.status, remote.status]).toEqual([0, 0]); + expect(summary(bare.stdout).candidate).toBe(summary(remote.stdout).candidate); + expect(bare.stdout).toBe(remote.stdout); + expect(bare.stderr).toContain('gstack-gate-diff: local main is not origin/main; base is origin/main'); + }); + + // Value: protects=a ref a contributor controls (a fork's origin/upstream/main branch, a tag named origin/main, another + // remote's main) never replaces the base the caller named or a related local base, so it cannot hide a gate edit; + // fails_when=resolveBase rewrites remote-qualified names, resolves the twin by its short name, or lets any remote beat a + // related local base; why_new=the cases above have one remote and no colliding refs; seam=none; no_claim=nothing about + // a remote's HEAD symref. + test('a fork branch, a colliding tag or another remote never replaces the base (#3650 review)', () => { + const src = repo({ fixture: 'rh1-skip', commit: true }); + const seed = gitIn(src, ['rev-parse', 'main']).trim(); + const clone = (remote: string) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-refs-')); + repos.push(dir); + gitIn(dir, ['clone', '-q', '-o', remote, '--branch', 'feature', `file://${src}`, '.']); + return dir; + }; + const named = clone('upstream'); + gitIn(named, ['remote', 'add', 'origin', 'file:///nonexistent-fork']); + gitIn(named, ['update-ref', 'refs/remotes/origin/upstream/main', 'HEAD']); + expect(summary(run(named, ['upstream/main']).stdout).tagged).toBe('RH-1:1'); + const tagged = clone('origin'); + gitIn(tagged, ['tag', 'origin/main', 'HEAD']); + expect(summary(run(tagged, ['main']).stdout).tagged).toBe('RH-1:1'); + const fork = clone('zfork'); + gitIn(fork, ['branch', '-f', 'main', seed]); + gitIn(fork, ['update-ref', 'refs/remotes/zfork/main', 'HEAD']); + expect(summary(run(fork, ['main']).stdout).tagged).toBe('RH-1:1'); + }); + test('a clone whose only remote is upstream resolves the base to upstream/main, and the fix line fetches from upstream', () => { const src = repo({ fixture: 'rh1-skip', commit: true }); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-upstream-')); diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index fdec940ec..ed3261e35 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -210,6 +210,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'], 'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'], 'test/fixtures/review-wrong-tree/**': ['test/review-dispatch.test.ts'], + 'test/fixtures/sealed-guard-off-golden.json': ['test/sealed-guard.test.ts'], 'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'], 'test/fixtures/shadow-fake-model.ts': ['test/autoplan-shadow-driver.test.ts', 'test/autoplan-shadow.test.ts'], 'test/fixtures/shard-cli-parity/**': ['test/shard-cli-parity.test.ts'], diff --git a/test/helpers/ship-frozen-scope-actor.ts b/test/helpers/ship-frozen-scope-actor.ts index 85c6dca67..19a2a8d28 100644 --- a/test/helpers/ship-frozen-scope-actor.ts +++ b/test/helpers/ship-frozen-scope-actor.ts @@ -190,7 +190,7 @@ function reviewRows(repo: string, env: NodeJS.ProcessEnv) { } function loopSummary(repo: string, env: NodeJS.ProcessEnv, since: string) { - return JSON.parse(command(repo, env, path.join(ROOT, 'bin/gstack-review-scope'), ['summary', '--since', since, '--json']).stdout.trim()); + return JSON.parse(command(repo, env, path.join(ROOT, 'bin/gstack-review-scope'), ['summary', '--no-ledger', '--since', since, '--json']).stdout.trim()); } export async function shipFrozenScopeFailures(fixture: ReturnType, result: Pick) { diff --git a/test/pregate-clear-runs.test.ts b/test/pregate-clear-runs.test.ts index 3107a13b4..20136d729 100644 --- a/test/pregate-clear-runs.test.ts +++ b/test/pregate-clear-runs.test.ts @@ -73,12 +73,12 @@ function check(repo: FixtureRepo, runs: FakeRun[], over: Partial = {}) return { repo: 'fx/fx', deps, profiles: PROFILES, gateRuns: GATE_RUNS, now: NOW, inventory: () => ['test/a.test.ts', 'test/b.test.ts'], ...over }; } -/** A check whose only readable run is 300, a successful run of `workflow` on `head`. */ -function runOfWorkflow(repo: FixtureRepo, head: string, workflow: string): RunCheck { +/** A check whose only readable run is 300, a run of `workflow` on `head` (completed with success unless `state` says otherwise). */ +function runOfWorkflow(repo: FixtureRepo, head: string, workflow: string, state: Pick & Partial> = { status: 'completed', conclusion: 'success' }): RunCheck { const c = check(repo, []); const run = (id: number): ApiRun => { if (id !== 300) throw new Error('HTTP 404'); - return { id, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'pull_request', head_sha: head, path: `.github/workflows/${workflow}`, repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } }; + return { id, run_attempt: 1, ...state, event: 'pull_request', head_sha: head, path: `.github/workflows/${workflow}`, repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } }; }; return { ...c, deps: { ...c.deps, run } }; } @@ -168,7 +168,7 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo expect(profiled.receipt).toStartWith(`run tree=${file.tree.slice(0, 12)} tests=5 runs=200/1 profiles=${PLATFORM} `); const failed = clearDecision(file, PLATFORM, { runUrl: url, evidence: check(repo, [{ id: 200, profile: 'test', head, files: ['test/a.test.ts'], conclusion: 'failure' }], { profiles: { ...PROFILES, [PLATFORM]: PROFILES.test! } }) }, repo.work); expect('refused' in failed && failed.refused).toBe('PREGATE_RECEIPT_INVALID'); - expect(clearDecision(file, PLATFORM, { runUrl: url, evidence: check(repo, [{ id: 200, profile: 'test', head, files: ['test/a.test.ts'] }]) }, repo.work)).toEqual({ receipt: url, attested: false }); + expect(clearDecision(file, PLATFORM, { runUrl: url, evidence: check(repo, [{ id: 200, profile: 'test', head, files: ['test/a.test.ts'] }]) }, repo.work)).toEqual({ receipt: `${url}/attempts/1`, attested: false }); const bundled = clearDecision(file, PLATFORM, { bundleLane: 'macos', evidence: check(repo, [], { profiles: { ...PROFILES, [PLATFORM]: PROFILES.test! } }) }, repo.work); expect('refused' in bundled && `${bundled.refused}: ${bundled.message}`).toBe(`usage: ${PLATFORM} has a run profile on the base branch; clear it with --run , which verifies the run`); expect(clearDecision(file, PLATFORM, { bundleLane: 'macos', evidence: check(repo, []) }, repo.work)).toEqual({ receipt: 'bundle-lane:macos', attested: false }); @@ -199,13 +199,30 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo const url = 'https://github.com/fx/fx/actions/runs/300'; const msg = (d: ReturnType) => ('refused' in d ? `${d.refused}: ${d.message}; fix: ${d.fix}` : `cleared ${d.receipt}`); expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'b.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=workflow_mismatch — run 300 attempt 1 ran .github/workflows/b.yml, not .github/workflows/a.yml; ${PLATFORM} stays uncleared; fix: clear ${PLATFORM} with a run of its own workflow: gh workflow run a.yml --ref , then gstack-pregate clear --remote ${PLATFORM} --run-url `); - expect(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work)).toEqual({ receipt: url, attested: false }); - for (const copied of [`${url}/job/9?pr=3806`, `${url}/job/9#step:3:1`]) expect(clearDecision(file, PLATFORM, { runUrl: copied, evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work)).toEqual({ receipt: copied, attested: false }); + expect(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work)).toEqual({ receipt: `${url}/attempts/1`, attested: false }); + for (const copied of [`${url}/job/9?pr=3806`, `${url}/job/9#step:3:1`]) expect(clearDecision(file, PLATFORM, { runUrl: copied, evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work)).toEqual({ receipt: `${url}/attempts/1`, attested: false }); expect(msg(clearDecision(file, PLATFORM, { runUrl: 'https://github.com/fx/fx/actions/runs/301', evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=api_error — reading run 301 failed: HTTP 404; ${PLATFORM} stays uncleared; fix: check gh auth status and that gh can read fx/fx's Actions runs, then rerun the same clear`); expect(msg(clearDecision(file, PLATFORM, { runUrl: 'https://ci.example.com/runs/300', evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=run_ref_invalid — "https://ci.example.com/runs/300" is not a run id or a run URL on fx/fx; ${PLATFORM} stays uncleared; fix: pass the run's numeric id or https://github.com/fx/fx/actions/runs/`); expect(clearDecision(file, PLATFORM, { runUrl: url }, repo.work)).toEqual({ receipt: url, attested: false }); }, 120_000); + test('a lane with no profile refuses a --run-url run of its own workflow that failed, was cancelled or is still running, and still clears from a green one (GSTA-203 PR 8)', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const base = await pregateAt(repo); + const PLATFORM = 'a.yml/job'; + const file: PregateFile = { ...base, requires_remote: [...base.requires_remote!, { lane: PLATFORM, platform: 'windows', workflow: 'a.yml', job: 'job', tests: ['test/a.test.ts'], receipt: null }] }; + const url = 'https://github.com/fx/fx/actions/runs/300'; + const msg = (d: ReturnType) => ('refused' in d ? `${d.refused}: ${d.message}; fix: ${d.fix}` : `cleared ${d.receipt}`); + const rerun = `fix: rerun a.yml (gh run rerun 300, or gh workflow run a.yml --ref ), then gstack-pregate clear --remote ${PLATFORM} --run-url `; + for (const conclusion of ['failure', 'cancelled', 'timed_out']) { + expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'a.yml', { status: 'completed', conclusion }) }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=run_not_success — run 300 attempt 1 concluded ${conclusion}; ${PLATFORM} stays uncleared; ${rerun}`); + } + expect(msg(clearDecision(file, PLATFORM, { runUrl: `${url}/job/9?pr=1`, evidence: runOfWorkflow(repo, head, 'a.yml', { status: 'in_progress', conclusion: null }) }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=run_in_progress — run 300 attempt 1 is in_progress; ${PLATFORM} stays uncleared; fix: wait for run 300 to finish green (or rerun a.yml if it fails), then gstack-pregate clear --remote ${PLATFORM} --run-url `); + expect(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work)).toEqual({ receipt: `${url}/attempts/1`, attested: false }); + expect(clearDecision(file, PLATFORM, { runUrl: `${url}/attempts/1?pr=1`, evidence: runOfWorkflow(repo, head, 'a.yml', { status: 'completed', conclusion: 'success', run_attempt: 2 }) }, repo.work)).toEqual({ receipt: `${url}/attempts/2`, attested: false }); + }, 120_000); + test('on this repository\'s own workflows the Windows obligation is windows-smoke.yml/windows-smoke: a windows-smoke.yml run clears it, a run of any other workflow is refused (GSTA-203)', async () => { const lanes = discoverLanes(path.resolve(import.meta.dir, '..'), 'linux'); const owed = lanes.filter(l => l.tier === 'platform' && l.platform === 'windows' && l.runsTests && !l.manual); @@ -218,7 +235,7 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo const base = await pregateAt(repo); const file: PregateFile = { ...base, requires_remote: [...base.requires_remote!, { lane: smoke!.id, platform: 'windows', workflow: smoke!.workflow, job: smoke!.job, tests: smoke!.testFiles, receipt: null }] }; const url = 'https://github.com/fx/fx/actions/runs/300'; - expect(clearDecision(file, smoke!.id, { runUrl: url, evidence: runOfWorkflow(repo, head, 'windows-smoke.yml') }, repo.work)).toEqual({ receipt: url, attested: false }); + expect(clearDecision(file, smoke!.id, { runUrl: url, evidence: runOfWorkflow(repo, head, 'windows-smoke.yml') }, repo.work)).toEqual({ receipt: `${url}/attempts/1`, attested: false }); for (const other of ['windows-free-tests.yml', 'free-tests.yml']) { const d = clearDecision(file, smoke!.id, { runUrl: url, evidence: runOfWorkflow(repo, head, other) }, repo.work); expect('refused' in d && `${d.refused}: ${d.message}`).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=workflow_mismatch — run 300 attempt 1 ran .github/workflows/${other}, not .github/workflows/windows-smoke.yml; windows-smoke.yml/windows-smoke stays uncleared`); diff --git a/test/regression-3749-retro-history-worktree.test.ts b/test/regression-3749-retro-history-worktree.test.ts new file mode 100644 index 000000000..af3daa419 --- /dev/null +++ b/test/regression-3749-retro-history-worktree.test.ts @@ -0,0 +1,155 @@ +/** + * Regression for #3749: /retro kept its history in .context/retros/ inside the + * working tree, so a run in a linked worktree (Conductor, Claude Code) found + * none of the main checkout's snapshots, reported "First retro recorded", and + * wrote its own snapshot where `git worktree remove` deletes it. History now + * lives in the state root, keyed by project slug (shared by every worktree of + * a repository), and an existing .context/retros/ is migrated on first use. + * + * Runs the real Step 12 bash block (history and the Step 13 snapshot path) from the generated + * retro/SKILL.md with the gstack install pointed at this checkout. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SKILL = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8'); +const TMPL = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md.tmpl'), 'utf-8'); + +function bashBlockAfter(heading: string, contains: string): string { + const start = SKILL.indexOf(heading); + if (start < 0) throw new Error(`retro/SKILL.md: heading not found: ${heading}`); + const end = SKILL.indexOf('\n### ', start + heading.length); + const re = /```bash\n([\s\S]*?)```/g; + re.lastIndex = start; + for (let m = re.exec(SKILL); m && m.index < end; m = re.exec(SKILL)) { + if (m[1].includes(contains)) { + return m[1].replaceAll('$HOME/.claude/skills/gstack', ROOT).replaceAll('~/.claude/skills/gstack', ROOT); + } + } + throw new Error(`retro/SKILL.md: no bash block containing ${contains} under ${heading}`); +} + +const LOAD = bashBlockAfter('### Step 12: Load History & Compare', 'retro').replace('', '2026-11-01'); + +let tmp: string; +let home: string; + +/** Git Bash on Windows prints the state root as given plus `/projects/...`; compare with forward slashes. */ +const fwd = (p: string) => p.replaceAll('\\', '/'); + +function sh(script: string, cwd: string) { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v; + for (const k of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR', 'GSTACK_PROJECT_SLUG', 'CLAUDE_PLUGIN_DATA', 'CLAUDE_PROJECT_DIR']) delete env[k]; + const r = spawnSync('bash', ['-c', script], { cwd, env: { ...env, HOME: tmp, GSTACK_HOME: home }, encoding: 'utf-8', timeout: 30_000 }); + return { stdout: fwd(r.stdout ?? ''), stderr: r.stderr ?? '', status: r.status ?? -1 }; +} + +function git(cwd: string, ...args: string[]) { + const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 30_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); +} + +function snapshot(dir: string, name: string, body: object): string { + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, name); + fs.writeFileSync(file, JSON.stringify(body)); + return file; +} + +/** A main checkout with one commit (fixture-local identity) and a linked worktree under it. */ +function layout() { + const main = path.join(tmp, 'widget'); + fs.mkdirSync(main); + git(main, 'init', '-q', '-b', 'main'); + git(main, 'remote', 'add', 'origin', 'https://github.com/acme/widget.git'); + git(main, 'config', 'user.name', 'Fixture'); + git(main, 'config', 'user.email', 'fixture@example.com'); + git(main, 'commit', '-q', '--allow-empty', '-m', 'init'); + git(main, 'worktree', 'add', '-q', '-b', 'demo', path.join(main, '.claude', 'worktrees', 'demo')); + const wt = fs.realpathSync(path.join(main, '.claude', 'worktrees', 'demo')); + const slug = sh(`'${ROOT}/bin/gstack-slug' --get SLUG`, main).stdout.trim(); + expect(slug).not.toBe(''); + return { main, wt, history: path.join(home, 'projects', slug, 'retros') }; +} + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'retro-3749-'))); + home = path.join(tmp, '.gstack'); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('#3749 retro history is shared across linked worktrees', () => { + test('Step 12 in a linked worktree finds the main checkout\'s earlier snapshot', () => { + const { main, wt, history } = layout(); + snapshot(path.join(main, '.context', 'retros'), '2026-10-25-1.json', { date: '2026-10-25', window: '7d' }); + + const r = sh(LOAD, wt); + expect(r.status).toBe(0); + expect(r.stdout).toContain(fwd(path.join(history, '2026-10-25-1.json'))); + expect(fs.existsSync(path.join(history, '2026-10-25-1.json'))).toBe(true); + expect(fs.existsSync(path.join(main, '.context', 'retros'))).toBe(false); + }); + + test('the snapshot path from a linked worktree is outside the worktree, and the main checkout sees it', () => { + const { main, wt, history } = layout(); + const save = sh(LOAD, wt); + expect(save.status).toBe(0); + const file = save.stdout.match(/^RETRO_FILE: (.+)$/m)?.[1]; + expect(file).toBe(fwd(path.join(history, '2026-11-01-1.json'))); + expect(file!.startsWith(fwd(wt))).toBe(false); + fs.writeFileSync(file!, JSON.stringify({ date: '2026-11-01', window: '7d' })); + + git(main, 'worktree', 'remove', '--force', wt); + const fromMain = sh(LOAD, main).stdout; + expect(fromMain).toContain(file!); + expect(fromMain).toContain(`RETRO_FILE: ${fwd(path.join(history, '2026-11-01-2.json'))}`); + }); + + test('migration keeps every snapshot: duplicates collapse, same-name differing snapshots get the next number', () => { + const { main, wt, history } = layout(); + for (const tree of [main, wt]) snapshot(path.join(tree, '.context', 'retros'), '2026-10-18-1.json', { date: '2026-10-18', window: '7d' }); + snapshot(path.join(main, '.context', 'retros'), '2026-10-25-1.json', { date: '2026-10-25', from: 'main' }); + snapshot(path.join(wt, '.context', 'retros'), '2026-10-25-1.json', { date: '2026-10-25', from: 'worktree' }); + + const r = sh(LOAD, wt); + expect(r.status).toBe(0); + expect(fs.readdirSync(history).sort()).toEqual(['2026-10-18-1.json', '2026-10-25-1.json', '2026-10-25-2.json', '2026-11-01-1.json']); + const froms = ['2026-10-25-1.json', '2026-10-25-2.json'].map((f) => JSON.parse(fs.readFileSync(path.join(history, f), 'utf-8')).from).sort(); + expect(froms).toEqual(['main', 'worktree']); + for (const tree of [main, wt]) expect(fs.existsSync(path.join(tree, '.context', 'retros'))).toBe(false); + }); + + test('a .context/retros symlinked to the shared history is left alone, so migration cannot delete it', () => { + const { wt, history } = layout(); + const kept = snapshot(history, '2026-10-25-1.json', { date: '2026-10-25', window: '7d' }); + fs.mkdirSync(path.join(wt, '.context'), { recursive: true }); + fs.symlinkSync(history, path.join(wt, '.context', 'retros')); + + const r = sh(LOAD, wt); + expect(r.status).toBe(0); + expect(fs.readFileSync(kept, 'utf-8')).toContain('2026-10-25'); + expect(r.stdout).toContain(`RETRO_PRIOR: ${fwd(kept)}`); + }); + + test('RETRO_FILE is reserved, so two runs in different worktrees never get the same path', () => { + const { main, wt, history } = layout(); + const first = sh(LOAD, wt).stdout.match(/^RETRO_FILE: (.+)$/m)?.[1]; + const second = sh(LOAD, main).stdout.match(/^RETRO_FILE: (.+)$/m)?.[1]; + expect(first).toBe(fwd(path.join(history, '2026-11-01-1.json'))); + expect(second).toBe(fwd(path.join(history, '2026-11-01-2.json'))); + expect(fs.readFileSync(first!, 'utf-8').trim()).toBe('{}'); + }); + + test('the gbrain prior-retros query reads the same state-root history', () => { + expect(TMPL).toContain('glob: "{gstack_state_root}/projects/{repo_slug}/retros/*.json"'); + expect(TMPL).not.toContain('glob: ".context/retros/*.json"'); + }); +}); diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index f6874e886..d0c01e345 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -794,3 +794,42 @@ describe('reading the REVIEWED_TREE line (GST-482 review fixes)', () => { expect(r.stderr).toContain('BRIEF_RENDER_FAILED: cannot read HEAD^{tree} and the toplevel of'); }); }); + +describe('the REVIEWED_TREE line as GBRA-218 readers wrote it (GST-776)', () => { + const top = fs.realpathSync(spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim()); + const tree = spawnSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + const command = `cd '${top}' && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"`; + // Value: protects=a reader that reviewed the right checkout is not failed for how it wrote its fingerprint; fails_when=a reply that echoes the command and then writes `printed: REVIEWED_TREE: …` (two Sonnet 5.5 low readers) is failed as reported="none", or a `printed:` label lets a wrong tree, toplevel or other label through; + // why_new=GBRA-218 pass restarts on gstack 1.92.25.0; seam=lib/review-brief.ts checkReviewedTree via gstack-review-dispatch collect; no_claim=the brief's wording, which stays main's until a no-loss replay + test('the echoed command then a printed: line is read; the same shape with a wrong tree or checkout is still refused', () => { + const reply = (fingerprint: string) => `${command}\nprinted: REVIEWED_TREE: ${fingerprint}\nNO FINDINGS\n`; + const oneLine = (fingerprint: string) => `${command} printed: REVIEWED_TREE: ${fingerprint}\n\nNO FINDINGS\n`; + expect(checkReviewedTree(oneLine(`${tree} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: true }); + expect(checkReviewedTree(oneLine(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true }); + expect(checkReviewedTree(oneLine(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false }); + expect(checkReviewedTree(`${command} Note: REVIEWED_TREE: ${tree} ${top}`, `${tree} ${top}`).reported).toBe('none'); + expect(checkReviewedTree(reply(`${tree} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: true }); + expect(checkReviewedTree(reply(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true, reported: `${'b'.repeat(40)} ${top}` }); + expect(checkReviewedTree(reply(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false, reported: `${tree} /elsewhere` }); + expect(checkReviewedTree(`Note: REVIEWED_TREE: ${tree} ${top}\nNO FINDINGS`, `${tree} ${top}`).reported).toBe('none'); + for (const line of ['`printed: REVIEWED_TREE: ' + tree + ' ' + top + '`', `**Printed:** REVIEWED_TREE: ${tree} ${top}`, `**printed: REVIEWED_TREE: ${tree} ${top}**`]) { + expect(checkReviewedTree(line, `${tree} ${top}`).ok, line).toBe(true); + expect(checkReviewedTree(line.replace(tree, 'c'.repeat(40)), `${tree} ${top}`).ok, line).toBe(false); + } + + const out = tmp(); + const ledger = path.join(out, 'review-ledger.jsonl'); + const merged = path.join(out, 'merged.jsonl'); + const file = field(run(['render', '--role', 'specialist', '--name', 'testing', '--out', out, '--diff-base', SHA]).stdout, 'file'); + run(['sent', '--ledger', ledger, '--role', 'specialist', '--name', 'testing', '--sent', file]); + const collectReply = (text: string) => { + const f = path.join(out, 'reply.out'); + fs.writeFileSync(f, text); + return spawnSync(BIN, ['collect', '--ledger', ledger, '--role', 'specialist', '--name', 'testing', '--output', f, '--merged', merged], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); + }; + const wrong = collectReply(reply(`${'b'.repeat(40)} ${top}`)); + expect([wrong.status, wrong.stdout.startsWith('COLLECT_TREE_MOVED: reader=specialist/testing')]).toEqual([1, true]); + const right = collectReply(reply(`${tree} ${top}`)); + expect(right.stdout).toStartWith('COLLECT: reader=specialist/testing findings=0 '); + }); +}); diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts index c8ab8f706..1ae42964e 100644 --- a/test/review-packets-removed.test.ts +++ b/test/review-packets-removed.test.ts @@ -57,7 +57,10 @@ const NO_LOSS_REPLAYS = { 'L': "GSTA-167 variant G Arm L: live no-loss replay of one batched reader (testing and security plus scoped checklists) and the Red Team on its findings, 2 passes, scored L∘G1 against G1 on the paired rule (part 2)", 'GST-257': "GST-257 PR 1 dispatch-superset no-loss check (test/review-targeted.test.ts): on every fixture diff the branch dispatches main's reviewers at 8be7f21 unchanged plus only the targeted reader, and over the last 100 non-release commits on main it lost none; adds the targeted role and brief to the G1 and L bytes, whose existing reviewer text is unchanged", 'GST-850': "GST-850 dispatch-superset no-loss check: on the 26 GSTA-167 no-loss units the branch plan (wider security and money classes, write-up text in the batched diff) drops no reviewer role main dispatches, so no saved blocker can be lost, and over the last 100 main commits no commit with a gate or risk class drops depth (test/review-risk-routing.test.ts, test/review-risk-linear.test.ts); reviewer text is unchanged", + 'GST-911-redos': "GST-911 ReDoS fix verdict-identity no-loss check (wave review-e7b551ebd865): SVG_REFUSED's url() entry, rewritten to run in linear time, refuses exactly the strings #3838's pattern refused (test/review-risk-redos.test.ts: 60,000 seeded strings and the hand-picked edges, and every SVG blob in the repository's history, with zero differences); SVG_MARKUP now refuses a DOCTYPE naming any document but W3C's SVG DTD (test/review-planner-misreads.test.ts), which only keeps more SVGs code and so drops no reviewer; GST-911's no-loss replay holds unchanged; reviewer text is unchanged", + 'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged", 'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged", + 'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged", } as const; type Replay = keyof typeof NO_LOSS_REPLAYS; const REVIEWER_BRIEFS: Record = { @@ -79,8 +82,8 @@ const REVIEWER_BRIEFS: Record = { 'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' }, 'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' }, 'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' }, - 'lib/review-depth.ts': { sha: '839205a409014fb2cca6988a01aa04c98c495d8d24bd94895a86bf7794627338', replay: 'GST-850' }, - 'lib/review-risk.ts': { sha: '18f0330ddfda0032eb93516b0eb941fa4244e0a68b028e19c436c74609093b03', replay: 'GST-850' }, + 'lib/review-depth.ts': { sha: '166dd7b1148a272b345dd9d2ceccd14348cc59c52ef9c88109436f6f61cf6b69', replay: 'GST-911-redos' }, + 'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-911' }, }; describe('reviewer briefs stay at their no-loss-tested bytes (GSTA-167)', () => { diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index dc367ddc0..10ffa4f8d 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -332,6 +332,27 @@ describe('Capy pass program (fake host, real bins)', () => { expect(h2.starts).toEqual([[1]]); }, 240_000); + // Value: protects=Step 11.5 always has an adversarial-review start token to bind; fails_when=Step 11 reuses the pass input's logStart: ["review"] and runs the voices with no adversarial-review start, or a missing token is found only after the voices ran; + // why_new=GBRA-218 reran a whole pass after the loader example's logStart was reused for stage voices; seam=lib/capy-review-pass.js log starts; no_claim=gstack-review-read's binding itself + test('Step 11 takes the adversarial-review start token by default and refuses a list without it before anything runs', async () => { + const { dir, env } = repo(); + const out = path.join(dir, '.state/run/p1'); + const h = host(dir, env); + const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' } }; + const base = { base: 'main', out, pass: 'x12p1', planFlags: ['--depth-floor', 'full'], logStart: ['review'] }; + expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); + const created = h.creates.length; + const reused = await runPass(h, { ...base, stage: 'voices', models }); + expect(reused).toStartWith('STOP: Step 11 (stage "voices") takes the adversarial-review start token'); + expect(h.creates.length).toBe(created); + const voices = await runPass(h, { ...base, logStart: undefined, stage: 'voices', models }); + expect(voices).toMatch(/REVIEW_LOG_START: adversarial-review token=\S+/); + expect(voices.indexOf('REVIEW_LOG_START: adversarial-review')).toBeLessThan(voices.indexOf('DISPATCHED:')); + expect(voices).toContain('NEXT: judge the voices'); + const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8')); + expect(Object.keys(m.log_starts ?? {}).sort()).toEqual(['pass:review', 'voices:adversarial-review']); + }, 240_000); + test('Step 11 voices run from the same manifest; the outside voice needs a model of another family', async () => { const { dir, env } = repo(); const out = path.join(dir, '.state/run/p1'); @@ -630,11 +651,10 @@ describe('Capy pass program (fake host, real bins)', () => { // why_new=replay C2-34 (GSTA-193) on PR 2; seam=none; no_claim=the host's status timing beyond one late summary test('extras as JSON, the checkout as cwd, and a late summary all run through without a STOP', async () => { const { dir, env } = repo(); - const elsewhere = tmp('rp-cwd-'); const out = path.join(dir, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'plan.prompt.md'), 'Plan completion brief'); - const h = host(elsewhere, env, { lateSummary: true }); + const h = host(dir, env, { lateSummary: true }); const res = await runPass(h, { base: 'main', out, pass: 'x13p1', cwd: dir, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'plan', prompt_file: path.join(out, 'plan.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] }); expect(res).not.toContain('STOP:'); @@ -914,12 +934,46 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 sh('for i in $(seq 1 20); do echo "export const w$i = $i;" >> src/a.ts; done && git commit -qam allow', wt); return { dir, env, wt: fs.realpathSync(wt) }; } + // Value: protects=no reader is started where Capy will put it (the shared machine's start checkout) when /ship ships another checkout; fails_when=the program dispatches readers that start in the primary checkout while the pass ships a worktree (GBRA-218's outside voice ignored `cd &&` and reviewed the primary checkout, losing Step 11 outside coverage); + // why_new=GBRA-218 on gstack 1.92.25.0; Capy subagents take no working directory; seam=lib/capy-review-pass.js before any log start or create; no_claim=that Capy can start a reader in a chosen directory + test('a shared machine whose start checkout is not the worktree refuses before any start token or reader; a start directory outside any checkout dispatches', async () => { + const { dir, env, wt } = worktree(); + const out = path.join(wt, '.state/run/p1'); + const h = host(dir, env); + const res = await runPass(h, { base: 'main', out, pass: 'w0p1', cwd: wt, planFlags: ['--depth-floor', 'full'], logStart: ['review'] }); + expect(res).toContain(`STOP: readers on the shared machine start in ${fs.realpathSync(dir)}, not in ${wt}`); + expect(res).not.toContain('REVIEW_LOG_START:'); + expect(h.creates.length).toBe(0); + const voices = await runPass(h, { base: 'main', out, pass: 'w0p1', cwd: wt, planFlags: ['--depth-floor', 'full'], stage: 'voices', models: { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' } } }); + expect(voices).toContain('STOP: readers on the shared machine start in '); + expect(h.creates.length).toBe(0); + const started = host(wt, env); + expect(await runPass(started, { base: 'main', out: path.join(wt, '.state/run/p2'), pass: 'w0p2', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)'); + expect(started.creates.length).toBeGreaterThan(0); + const outside = host(path.dirname(wt), env); + expect(await runPass(outside, { base: 'main', out: path.join(wt, '.state/run/p3'), pass: 'w0p3', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)'); + const broken = tmp('rp-broken-'); + fs.writeFileSync(path.join(broken, '.git'), 'garbage\n'); + const unreadable = host(broken, env); + expect(await runPass(unreadable, { base: 'main', out: path.join(wt, '.state/run/p4'), pass: 'w0p4', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('STOP: cannot read the directory readers on the shared machine start in: fatal: invalid gitfile format'); + expect(unreadable.creates.length).toBe(0); + const slow = host(wt, env, { pollsToFinish: 30 }); + let probes = 0; + const bash = slow.tools.capy.bash; + slow.tools.capy.bash = async (a) => { if (a.command.startsWith('if d=$(git rev-parse')) probes++; return bash(a); }; + const input = { base: 'main', out: path.join(wt, '.state/run/p5'), pass: 'w0p5', cwd: wt, planFlags: ['--depth-floor', 'full'] }; + expect(await runPass(slow, input)).toContain('NEXT: poll'); + expect(await runPass(slow, input)).toContain('NEXT: poll'); + expect(probes).toBe(1); + expect(await runPass(host(wt, env), { ...input, logStart: [7] })).toStartWith('STOP: input.logStart must be a list'); + }, 240_000); + // Value: protects=a reader that never left the machine's default checkout cannot hand /ship a review of another tree; fails_when=collect merges a reply whose REVIEWED_TREE names the primary checkout while the pass ships a worktree, or a reply with no such line; // why_new=GBRA-218: pass 1's specialist reviewed /workspace/gbrain while /ship shipped ~/.capy/work/wt-allow; seam=gstack-review-dispatch collect; no_claim=a reader that fingerprints the right checkout but reads another test('a reader that reviews the primary checkout instead of the worktree is refused and nothing it said is merged', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: () => dir, output: t => (t.includes('specialist') ? '[CRITICAL] (confidence: 9/10) src/other.ts:3 a file outside this PR' : cleanReply(t)) }); + const h = host(wt, env, { reportFrom: () => dir, output: t => (t.includes('specialist') ? '[CRITICAL] (confidence: 9/10) src/other.ts:3 a file outside this PR' : cleanReply(t)) }); const res = await runPass(h, { base: 'main', out, pass: 'w1p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).toContain(`reported="${treeLine(dir).slice(15).trim()}"`); @@ -934,20 +988,20 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('a reply that names no checkout is refused the same way', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const res = await runPass(host(dir, env, { reportFrom: () => '' }), { base: 'main', out, pass: 'w2p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); + const res = await runPass(host(wt, env, { reportFrom: () => '' }), { base: 'main', out, pass: 'w2p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).toContain('reported="none"'); expect(res).not.toContain('NEXT: judge'); }, 240_000); - // Value: protects=/ship from a worktree on a shared machine works when readers follow their brief, though they start in another checkout; fails_when=a brief does not name the shipped checkout, or a correct reply is refused; + // Value: protects=/ship from a worktree that is the shared machine's start checkout works, its readers' briefs naming it (GST-776 refuses any other start checkout up front); fails_when=a brief does not name the shipped checkout, or a correct reply is refused; // why_new=GBRA-218 (no brief named a directory); seam=lib/review-pass.ts renderStage and the Capy pass program; no_claim=model compliance with the paragraph test('a reader that works in the checkout its brief names passes, and every brief names the worktree', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'coverage.prompt.md'), 'Run Step 7 as written.\n'); - const h = host(dir, env); + const h = host(wt, env); const res = await runPass(h, { base: 'main', out, pass: 'w3p1', cwd: wt, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'coverage', prompt_file: path.join(out, 'coverage.prompt.md'), first: true }] }); expect(res).not.toContain('STOP:'); @@ -962,7 +1016,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const out = path.join(wt, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'docs.prompt.md'), 'Run the Step 14.5 audit as written.\n'); - const h = host(dir, env, { reportFrom: t => (t.endsWith('audit/docs') ? dir : briefDirOf(h, t)) }); + const h = host(wt, env, { reportFrom: t => (t.endsWith('audit/docs') ? dir : briefDirOf(h, t)) }); const res = await runPass(h, { base: 'main', out, pass: 'w4p1', cwd: wt, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'docs', prompt_file: path.join(out, 'docs.prompt.md'), first: true }] }); expect(res).toContain('COLLECT_WRONG_TREE: reader=audit/docs'); @@ -973,7 +1027,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('a rerun after a wrong-tree STOP stops on the failed reader and never collects its reply twice', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: () => dir }); + const h = host(wt, env, { reportFrom: () => dir }); const input = { base: 'main', out, pass: 'w5p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }; expect(await runPass(h, input)).toContain('COLLECT_WRONG_TREE: reader='); const again = await runPass(h, input); @@ -987,7 +1041,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const wt = path.join(tmp('rp-wt-'), 'same'); expect(spawnSync('git', ['worktree', 'add', '-q', '--detach', wt, 'feat'], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }).status).toBe(0); const out = path.join(wt, '.state/run/p1'); - const res = await runPass(host(dir, env, { reportFrom: () => dir }), { base: 'main', out, pass: 'w6p1', cwd: fs.realpathSync(wt), planFlags: ['--depth-floor', 'full'] }); + const res = await runPass(host(wt, env, { reportFrom: () => dir }), { base: 'main', out, pass: 'w6p1', cwd: fs.realpathSync(wt), planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).not.toContain('COLLECT_TREE_MOVED'); }, 240_000); @@ -996,7 +1050,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); let moved = false; - const h = host(dir, env, { reportFrom: t => { + const h = host(wt, env, { reportFrom: t => { if (!moved) { moved = true; spawnSync('bash', ['-c', 'echo "export const late = 1;" >> src/a.ts && git commit -qam late'], { cwd: wt, env, encoding: 'utf8', timeout: 60_000 }); } return briefDirOf(h, t); } }); @@ -1008,7 +1062,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('an outside voice that reviewed another tree is recorded as unavailable coverage, never clean, and the voices still finish', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: t => (t.endsWith('outside-voice') ? dir : briefDirOf(h, t)) }); + const h = host(wt, env, { reportFrom: t => (t.endsWith('outside-voice') ? dir : briefDirOf(h, t)) }); const base = { base: 'main', out, pass: 'w8p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }; expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' }, 'adversarial-native': { model_id: 'anthropic/claude-opus-5-5', reasoning_mode: 'high' } }; diff --git a/test/review-plan-origin-base.test.ts b/test/review-plan-origin-base.test.ts new file mode 100644 index 000000000..cb5f27bce --- /dev/null +++ b/test/review-plan-origin-base.test.ts @@ -0,0 +1,95 @@ +/** + * gstack-review-plan and gstack-gate-diff diff against origin/ (GST-883): with a local that lags + * origin/, review-plan handed the raw name to gstack-gate-diff, which merged against the stale local ref and + * scanned upstream commits the branch only inherited, so a code diff planned as gate class at full depth. + */ +import { afterEach, 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 PLAN = path.join(import.meta.dir, '..', 'bin', 'gstack-review-plan'); +const GATE_DIFF = path.join(import.meta.dir, '..', 'bin', 'gstack-gate-diff'); +const FIXTURES = path.join(import.meta.dir, 'fixtures', 'gate-diff'); +const dirs: string[] = []; +afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); }); + +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rp-origin-'))); + dirs.push(root); + const work = path.join(root, 'work'); + const env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') }; + fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n'); + const sh = (cwd: string, c: string) => { const r = spawnSync('bash', ['-c', c], { cwd, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(`${c}: ${r.stderr}`); return r.stdout.trim(); }; + sh(root, 'git init -q --bare -b main origin.git && git clone -q origin.git work 2>/dev/null'); + sh(work, `git apply '${FIXTURES}/rh1-skip.base.patch' && git add -A && git commit -qm base && git push -q origin main`); + sh(root, `git clone -q origin.git other && cd other && git apply '${FIXTURES}/rh1-skip.patch' && git commit -qam upstream && git push -q origin main`); + sh(work, "git fetch -q origin && git checkout -qb feat origin/main && echo 'export const v = 1;' > src/v.ts && git add -A && git commit -qm feat"); + return { work, env, sh }; +} + +const planOf = (work: string, env: NodeJS.ProcessEnv) => { + const r = spawnSync(PLAN, ['--base', 'main', '--json'], { cwd: work, env, encoding: 'utf8', timeout: 180_000 }); + expect(r.status, r.stdout + r.stderr).toBe(0); + const p = JSON.parse(r.stdout) as { class: string; depth: string; gate_scan: unknown }; + return { class: p.class, depth: p.depth, gate_scan: p.gate_scan }; +}; +const gateDiff = (work: string, env: NodeJS.ProcessEnv, base: string) => { + const r = spawnSync(GATE_DIFF, [base], { cwd: work, env, encoding: 'utf8', timeout: 120_000 }); + expect(r.status, r.stdout + r.stderr).toBe(0); + return { tagged: /tagged=\{([^}]*)\}/.exec(r.stdout)?.[1], candidate: /candidate=(\S+)/.exec(r.stdout)?.[1], stderr: r.stderr }; +}; + +describe('review-plan and gate-diff read the origin merge base (GST-883)', () => { + // Value: protects=a stale local never turns inherited upstream commits into gate hunks that force full depth; + // fails_when=gstack-gate-diff merges against the local while origin/ is ahead, or review-plan hands it a base the plan's own diff did not use; + // why_new=GEVA-117 paid ~15-20M tokens of full-depth readers for a gate hunk already on origin/main; seam=bin/gstack-review-plan, bin/gstack-gate-diff; + // no_claim=which gate rules fire + test('local main behind origin/main plans the same class, depth and gate-tagged count as a fresh main', () => { + const { work, env, sh } = fixture(); + expect(sh(work, 'git merge-base main HEAD')).not.toBe(sh(work, 'git merge-base origin/main HEAD')); + const stale = { plan: planOf(work, env), tags: gateDiff(work, env, 'main').tagged }; + sh(work, 'git branch -f main origin/main'); + const fresh = { plan: planOf(work, env), tags: gateDiff(work, env, 'main').tagged }; + expect(fresh).toEqual({ plan: { class: 'code', depth: 'light', gate_scan: expect.anything() }, tags: '' }); + expect(stale).toEqual(fresh); + }, 240_000); + + // Value: protects=a tag cannot stand in for the base branch, so it cannot move the merge base to HEAD and hide committed edits; + // fails_when=resolveBase or collectLive read `origin/main` or `main` by short name while a tag of that name exists; + // why_new=outside-voice finding on the whole diff (shadow tag reproduced); seam=bin/gstack-review-plan, bin/gstack-gate-diff + for (const [tag, branch] of [['origin/main', 'refs/remotes/origin/main'], ['main', 'refs/heads/main']] as const) { + test(`a tag named ${tag} at HEAD does not shadow ${branch}`, () => { + const { work, env, sh } = fixture(); + if (branch === 'refs/heads/main') sh(work, 'git remote remove origin'); + const before = { plan: planOf(work, env), candidate: gateDiff(work, env, 'main').candidate }; + sh(work, `git tag '${tag}' HEAD`); + const after = { plan: planOf(work, env), candidate: gateDiff(work, env, 'main').candidate }; + expect(after).toEqual(before); + expect(after.candidate?.startsWith(sh(work, `git merge-base ${branch} HEAD`).slice(0, 12))).toBe(true); + }, 240_000); + } + + // Value: protects=without a remote-tracking twin the local base still works and the fallback is named on stderr; + // fails_when=the origin preference turns a remote-less branch into no_base, or falls back silently; why_new=the fallback is new; seam=bin/gstack-gate-diff + test('no origin/: gate-diff falls back to the local ref and says so', () => { + const { work, env, sh } = fixture(); + sh(work, 'git branch trunk origin/main'); + const r = gateDiff(work, env, 'trunk'); + expect(r.tagged).toBe(''); + expect(r.stderr).toContain('gstack-gate-diff: no remote-tracking branch for trunk; base is the local trunk, which may lag its remote'); + }, 240_000); + + // Value: protects=the fallback note names why the local ref stands, and a remote-less repository stays quiet; + // fails_when=an unrelated origin/ is reported as missing, or a repository with no remote prints a lag note; why_new=review finding on the fallback; seam=bin/gstack-gate-diff + test('an unrelated origin/ is named in the note; no remote at all uses the local base quietly', () => { + const { work, env, sh } = fixture(); + sh(work, 'git branch -f main origin/main && git update-ref refs/remotes/origin/main "$(git commit-tree "HEAD^{tree}" -m orphan)"'); + expect(gateDiff(work, env, 'main').stderr).toContain('gstack-gate-diff: origin/main shares no history with HEAD; base is the local main, which may lag its remote'); + sh(work, 'git remote remove origin'); + const none = gateDiff(work, env, 'main'); + expect(none.tagged).toBe(''); + expect(none.stderr).not.toContain('base is'); + }, 240_000); +}); diff --git a/test/review-planner-misreads.test.ts b/test/review-planner-misreads.test.ts new file mode 100644 index 000000000..0c34036a0 --- /dev/null +++ b/test/review-planner-misreads.test.ts @@ -0,0 +1,291 @@ +/** + * Three /ship planner misreads from GST-911 (gstack-evals#132, a results write-up) and GST-884: + * a plotting-script SVG counted as runtime code, a data file named for sessions read as auth code, and + * gstack-diff-scope (plus the receipt depth) resolving a bare base name, so a stale local main or a tag + * named main or origin/main moved the diff. Every real signal stays: an SVG that can run code is + * security, session code is auth, and a credential reference is a secret wherever it is. + */ +import { afterEach, 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'; +import { collectCommit, collectLive } from '../lib/review-collect'; +import { depthOfCommitInput, svgDrawing } from '../lib/review-depth'; +import { targetedClasses } from '../lib/review-targeted'; +import { receiptDepthAtHead, receiptDepthVerdict } from '../lib/ship-receipt-depth'; + +const BIN = path.join(import.meta.dir, '..', 'bin'); +const SCOPE = path.join(BIN, 'gstack-diff-scope'); +const dirs: string[] = []; +afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); }); + +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'planner-misreads-'))); + dirs.push(root); + const work = path.join(root, 'work'); + const env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; + fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); + const sh = (cwd: string, c: string) => { const r = spawnSync('bash', ['-c', c], { cwd, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(`${c}: ${r.stderr}`); return r.stdout.trim(); }; + sh(root, 'git init -q --bare origin.git && git clone -q origin.git work 2>/dev/null'); + const write = (files: Record) => { for (const [p, text] of Object.entries(files)) { const f = path.join(work, p); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); } }; + const commit = (files: Record) => { write(files); sh(work, 'git add -A && git commit -qm change'); return sh(work, 'git rev-parse HEAD'); }; + write({ 'README.md': '# evals\n', 'src/run.ts': 'export const run = () => 1;\n' }); + sh(work, 'git add -A && git commit -qm base && git push -q origin main && git checkout -qb feat'); + const base = sh(work, 'git rev-parse HEAD'); + const scope = (args: string[]) => { + const r = spawnSync('bash', [SCOPE, ...args], { cwd: work, env, encoding: 'utf8', timeout: 30_000 }); + return { status: r.status, out: r.stdout, vars: Object.fromEntries(r.stdout.split('\n').filter(l => /^SCOPE_\w+=/.test(l)).map(l => l.split('=') as [string, string])) }; + }; + return { root, work, env, sh, write, commit, base, scope }; +} + +/** A matplotlib-style chart: XML declaration, DOCTYPE, RDF metadata, CSS, markers by internal reference, a clip path. */ +const chart = (points: number, extra = '') => [ + '', + '', + '', + ' ', + ' ', + ' ', + ' ', + ' 2026-10-31T12:00:00', + ' image/svg+xml', + ' ', + ' ', + ' Matplotlib v3.9.2, https://matplotlib.org/', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ...Array.from({ length: points }, (_, i) => ` `), + ' ', + ' cost per run & pass rate', + ' ', + ...(extra ? [extra] : []), + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + '', +].join('\n'); + +const plan = (fx: ReturnType, head: string) => { + const c = collectCommit(fx.work, fx.base, head); + const d = depthOfCommitInput({ ...c, gateTagged: 0 }); + return { class: d.class, depth: d.depth, runtime: d.runtimeLines, risk: d.risk }; +}; + +describe('GST-911 misread 1: a chart SVG is data, an SVG that can run code is security', () => { + // Value: protects=a results write-up whose chart is an SVG plans like the same chart as a PNG (docs, light, no runtime lines); + // fails_when=an SVG with only drawing elements, internal references and CSS counts as runtime code; seam=collectCommit + depthOfCommitInput, collectLive + test('a plotting-script SVG no code names plans as docs, light, runtime 0, like the PNG', () => { + const fx = fixture(); + const svg = plan(fx, fx.commit({ 'results/2026-10-31/chart.svg': chart(600), 'results/2026-10-31/README.md': '# Results\n\n![chart](chart.svg)\n' })); + expect(svg).toEqual({ class: 'docs', depth: 'light', runtime: 0, risk: [] }); + fx.sh(fx.work, 'git rm -q results/2026-10-31/chart.svg && git commit -qm drop'); + const png = plan(fx, fx.commit({ 'results/2026-10-31/chart.png': '\x89PNG\r\n\x1a\n\0\0\0\rIHDR' })); + expect(png).toEqual({ class: 'docs', depth: 'light', runtime: 0, risk: [] }); + fx.write({ 'results/live/chart.svg': chart(40) }); + const live = collectLive(fx.work, 'main'); + expect(depthOfCommitInput({ ...live, gateTagged: 0 }).runtimeLines).toBe(0); + }, 60_000); + + // Value: protects=an SVG that can execute script stays code and gets the security class (full depth), however it spells the script; + // fails_when=a script element, a namespaced script, an event handler, a javascript: URL (plain, entity-encoded or zero-padded) or a + // foreignObject plans as docs or loses the security class, or a handler or URL split across lines loses it; seam=collectCommit + depthOfCommitInput + test.each([ + ['a script element', ' '], + ['a namespaced script', ' window.x = 42'], + ['an event handler', ' '], + ['a javascript: URL', ' x'], + ['an entity-encoded javascript: URL', ' x'], + ['a foreignObject', '
x
'], + ['a zero-padded javascript: URL', ' x'], + ['an event handler split across lines', ' '], + ['a javascript: URL split across lines', ' x'], + ])('%s stays code with the security class', (_name, line) => { + const fx = fixture(); + expect(plan(fx, fx.commit({ 'results/chart.svg': chart(5, line) }))).toMatchObject({ class: 'code', depth: 'full', risk: ['security'] }); + }, 60_000); + + // Value: protects=only an SVG made of drawing elements is data: anything that loads, links or declares outside that set stays code; + // fails_when=an external image or stylesheet, an entity declaration, an external DTD, an animation or a CSS url() to another document reads as docs, + // including through a CSS escape or a zero-padded character reference (review pass 1 findings) or behind a stray quote that + // pairs with a later one (review pass 2 finding), or any CSS beyond declarations and url(#id) (Step 11 finding: image-set()); + // seam=collectCommit + depthOfCommitInput + test.each([ + ['an external image', ' '], + ['a CSS import', ' '], + ['a CSS url() to another document', ' '], + ['an animation', ' '], + ['a stylesheet processing instruction', ''], + ['an entity declaration', ' ]>'], + ['an external DTD', ''], + ['an external DTD behind the SVG public identifier', ''], + ['a CSS-escaped import', ' '], + ['a CSS-escaped url()', ' '], + ['a zero-padded character reference import', ' '], + ['a zero-padded hex character reference url()', ' '], + ['an external image after a stray quote in text', " z=''"], + ['an external use after a stray quote in a comment', " "], + ['an external xlink:href after a stray quote in a desc', " z=''"], + ['an external image after a stray href in text', " href='#'"], + ['a CSS image-set() in a style element', ' '], + ['a CSS image-set() in a style attribute', " "], + ['a CSS image() with a string', ' '], + ['a CSS element() reference', ' '], + ['a CSS import of a bare string', ' '], + ['a style element import', ' '], + ['a style attribute cross-fade()', ' '], + ['a style element whose hides in a comment', ' -->image-set("https://x.test/p.png" 1x)}'], + ['a style element whose hides in CDATA', ' */ svg{fill:image-set("https://x.test/p.png" 1x)} ]]>'], + ])('%s keeps the SVG code', (_name, line) => { + const fx = fixture(); + expect(plan(fx, fx.commit({ 'results/chart.svg': chart(5, line) })).class).toBe('code'); + }, 60_000); + + // Value: protects=a passive edit to an SVG that already carries a script is still code, because the whole file decides, not the + // changed lines; and an SVG code names stays code like any consumed data file; fails_when=either plans as docs; seam=collectCommit + test('an edit inside a scripted SVG, and an SVG code names, stay code', () => { + const fx = fixture(); + fx.commit({ 'assets/live.svg': chart(5, ' ') }); + fx.sh(fx.work, 'git checkout -q main && git merge -q --ff-only feat && git checkout -q feat'); + const scripted = { ...fx, base: fx.sh(fx.work, 'git rev-parse HEAD') }; + expect(plan(scripted, fx.commit({ 'assets/live.svg': chart(6, ' ') })).class).toBe('code'); + const named = plan(fx, fx.commit({ 'assets/icon.svg': chart(3), 'src/icon.ts': "export const icon = new URL('./icon.svg', import.meta.url);\n" })); + expect(named.class).toBe('code'); + expect(named.runtime).toBeGreaterThan(3); + }, 60_000); + + // Value: protects=the drawing check reads a file up to the collector's 8 MB cap in linear time; fails_when=a run of unclosed + // style tags (480 KB) takes over 100 ms or reads as a drawing (review pass notes on #3838); seam=svgDrawing + test('a 480 KB run of unclosed ' + fill('') + '')).toBe(true); + expect(svgDrawing(H + '')).toBe(true); + for (const svg of [ + H + fill('') + '', + H + '', + H + '', + H + ' */ ' + fill('rect{fill:#000} ') + 'svg{fill:' + bad + '} ]]>', + H + ' -->svg{fill:' + bad + '}', + H + '', + H + "z='" + fill('abc ') + "'", + H + fill(' { + // Value: protects=a data file whose name says session never sets SCOPE_AUTH, while session code and session config still do; + // fails_when=recovered-sessions.jsonl sets SCOPE_AUTH, or session.ts, a session store, a sessions controller or config/sessions.json stop setting it; + // seam=bin/gstack-diff-scope + test('gstack-diff-scope: session data files are not auth; session code and config are', () => { + for (const [file, auth] of [ + ['results/recovered-sessions.jsonl', 'false'], ['data/sessions.csv', 'false'], ['logs/session-costs.json', 'false'], ['shots/session.PNG', 'false'], + ['src/auth/session.ts', 'true'], ['lib/session-store.ts', 'true'], ['app/controllers/sessions_controller.rb', 'true'], ['config/sessions.json', 'true'], + ['.github/session.json', 'true'], ['app/.session.json', 'true'], ['session.config.json', 'true'], ['data/oauth-tokens.json', 'true'], + ] as const) { + const fx = fixture(); + fx.commit({ [file]: '{"a": 1}\n', 'src/run.ts': 'export const run = () => 2;\n' }); + expect([file, fx.scope(['main']).vars.SCOPE_AUTH]).toEqual([file, auth]); + } + }, 120_000); + + // Value: protects=the planner adds no auth reader and no gate class for a session-named data file, and a credential in it is still a secret; + // fails_when=targetedClasses returns auth for recovered-sessions.jsonl next to a code change, classifyDiff raises it to gate through SCOPE_AUTH, + // or the secret class misses an env key reference in that file; seam=gstack-diff-scope + collectCommit + targetedClasses + depthOfCommitInput + test('the planner: no auth class and no gate for session rows; real session code is auth; a key reference is a secret', () => { + const fx = fixture(); + const rows = Array.from({ length: 20 }, (_, i) => `{"run": ${i}, "tokens": ${1000 + i}}`).join('\n') + '\n'; + const run = (files: Record) => { + const head = fx.commit(files); + const c = collectCommit(fx.work, fx.base, head); + const scope = fx.scope(['main', '--head', head]).vars; + return { targeted: targetedClasses({ ...c, scope, gateTagged: 0 }).classes, class: depthOfCommitInput({ ...c, scope, gateTagged: 0 }).class }; + }; + expect(run({ 'results/recovered-sessions.jsonl': rows, 'src/run.ts': 'export const run = () => 3;\n' })).toEqual({ targeted: [], class: 'code' }); + expect(run({ 'lib/session-store.ts': 'export const cookie = (sid: string) => `sid=${sid}; HttpOnly`;\n' })).toEqual({ targeted: ['auth'], class: 'gate' }); + expect(run({ 'results/recovered-sessions.jsonl': rows + '{"key": "${OPENAI_API_KEY}"}\n' }).targeted).toEqual(['auth', 'secret']); + }, 120_000); +}); + +describe('GST-884: gstack-diff-scope and the receipt depth read origin/ by its full ref name', () => { + function staleFixture() { + const fx = fixture(); + fx.sh(fx.root, 'git clone -q origin.git other && cd other && mkdir -p db/migrate && echo "create table t;" > db/migrate/001.sql && git add -A && git commit -qm upstream && git push -q origin main'); + fx.sh(fx.work, 'git fetch -q origin && git checkout -q -B feat origin/main && echo "a{}" > styles.css && git add -A && git commit -qm feat'); + return fx; + } + + // Value: protects=a local main behind origin/main never adds upstream files to the scope; fails_when=gstack-diff-scope diffs against the + // stale local main and reports SCOPE_MIGRATIONS for a migration the branch only inherited; seam=bin/gstack-diff-scope + test('a stale local main scopes the same as a fresh one', () => { + const fx = staleFixture(); + expect(fx.sh(fx.work, 'git merge-base main HEAD')).not.toBe(fx.sh(fx.work, 'git merge-base origin/main HEAD')); + const stale = fx.scope(['main']); + fx.sh(fx.work, 'git branch -f main origin/main'); + const fresh = fx.scope(['main']); + expect(fresh.vars).toMatchObject({ SCOPE_FRONTEND: 'true', SCOPE_MIGRATIONS: 'false' }); + expect(stale.out).toBe(fresh.out); + }, 60_000); + + // Value: protects=a tag named main or origin/main cannot stand in for the branch and hide the change; fails_when=gstack-diff-scope resolves + // the base by its short name while a tag of that name exists at HEAD (an empty scope); seam=bin/gstack-diff-scope + for (const [tag, arg] of [['main', 'main'], ['origin/main', 'main'], ['origin/main', 'origin/main']] as const) { + test(`a tag named ${tag} at HEAD does not shadow the base ${arg}`, () => { + const fx = staleFixture(); + fx.sh(fx.work, 'git branch -f main origin/main'); + const before = fx.scope([arg]); + fx.sh(fx.work, `git tag '${tag}' HEAD`); + const after = fx.scope([arg]); + expect(before.vars.SCOPE_FRONTEND).toBe('true'); + expect(after.out).toBe(before.out); + const head = fx.sh(fx.work, 'git rev-parse HEAD'); + expect(fx.scope([arg, '--head', head]).out).toBe(before.out); + }, 60_000); + } + + // Value: protects=the receipt's depth and the queue's recomputation diff against refs/remotes/origin/, never a tag named origin/main; + // fails_when=receiptDepthAtHead or receiptDepthVerdict rev-parse the short name origin/; seam=lib/ship-receipt-depth.ts + test('a tag named origin/main does not move the receipt depth base', () => { + const fx = staleFixture(); + const origin = fx.sh(fx.work, 'git rev-parse refs/remotes/origin/main'); + const head = fx.sh(fx.work, 'git rev-parse HEAD'); + fx.sh(fx.work, "git tag 'origin/main' HEAD && git tag main HEAD"); + const stub = path.join(fx.root, 'stub-bin'); + fs.mkdirSync(stub); + fs.writeFileSync(path.join(stub, 'gstack-diff-scope'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + fs.writeFileSync(path.join(stub, 'gstack-gate-diff'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + expect(receiptDepthAtHead(fx.work, 'main', stub, head).base).toBe(origin); + const verdict = receiptDepthVerdict(fx.work, 'origin/main', { gstack_version: '1.0.0', review_depth: { depth: 'full', gate_scan: { tagged: 0, head } } } as any, head); + expect(verdict.line).toContain(`inputs=base@${origin.slice(0, 12)}`); + expect(verdict.line).toContain('class=code'); + }, 60_000); +}); diff --git a/test/review-risk-redos.test.ts b/test/review-risk-redos.test.ts new file mode 100644 index 000000000..7d40d9df3 --- /dev/null +++ b/test/review-risk-redos.test.ts @@ -0,0 +1,102 @@ +/** + * GST-898: R51's rm and rmSync rules (lib/review-risk.ts) ran in quadratic time on one hostile added line ('rm -' and + * 100,000 r's took about 3.2 s); every risk pattern finishes in linear time on its anchor followed by a long run of the + * class it repeats, and the rewritten rm rule matches exactly the lines R51's pattern matched. + */ +import { describe, expect, test } from 'bun:test'; +import { SVG_REFUSED, svgDrawing } from '../lib/review-depth'; +import { DATA_LINE, MONEY_LINE, SECRET_LINE, SECRET_PATH, SECURITY_LINE, matchRisk } from '../lib/review-risk'; + +const L = 100_000; +const ms = (f: () => unknown) => { const t = performance.now(); f(); return performance.now() - t; }; +const run = (anchor: string, unit: string, tail = '') => [JSON.stringify(anchor) + ' + ' + JSON.stringify(unit) + '*n' + (tail ? ' + ' + JSON.stringify(tail) : ''), anchor + unit.repeat(Math.ceil(L / unit.length)) + tail] as const; +const security = (line: string) => matchRisk({ codePaths: ['x.sh'], added: { 'x.sh': [line] }, removed: {}, scopeAuth: false }).has('security'); + +describe('linear time on hostile input', () => { + // Value: protects=gstack-review-plan, the targeted pass and the queue depth check never stall on one added line; + // fails_when=any risk pattern backtracks quadratically on its anchor followed by a long run of the class it repeats, + // or on a line of repeated anchors; seam=every pattern in SECURITY_LINE, DATA_LINE, SECRET_LINE, MONEY_LINE and SECRET_PATH + const hostile = [ + run('rm -', 'r'), run('rm -', 'f'), run('rm -r', 'r'), run('rm -f', 'a'), run('rm\t-', 'r'), run('rm ', ' '), run('rm -', 'r', '1'), run('rm -', 'rf', '_'), run('', 'rm -r '), + run('', 'rmSync('), run('rmSync(', ' '), run('rmSync(', 'recursive '), run('rmSync(', 'recursive: '), + run('shell', ' '), run('shell:', ' '), run('shell=', ' '), run('', '; '), run(';', ' '), run('; eval', ' '), run('eval', ' '), run('', ';eval '), + run('execSync', ' '), run('child_process.exec', ' '), run('os.system', ' '), run('new', ' '), run('new Function', ' '), + run('spawn(', ' '), run('Bun.spawn(', ' '), run('Bun.spawn([', ' '), run('innerHTML', ' '), + run('tar -', 'A'), run('tar x', 'A'), run('tar', ' '), run('redirect', ' '), run('redirect:', ' '), run('maxRedirects', ' '), + run('DELETE', ' '), run('drop', ' '), run('truncate', ' '), run('delete from', ' '), run('delete from ', 'a.'), run('', 'delete from a '), run('.deleteMany', ' '), + run('', 'a_'), run('', 'aU'), run('a', 'A'), run('spend', '_'), run('new Anthropic', ' '), run('.messages.create', ' '), + run('$', 'A_'), run('${', 'A_'), run('', '$A_'), run('process.env.', 'A_'), run('process.env[', ' '), run('os.environ[', ' '), run('os.getenv(', ' '), run('ENV[', ' '), + ]; + test.each(Object.entries({ SECURITY_LINE, DATA_LINE, SECRET_LINE, MONEY_LINE, SECRET_PATH: [SECRET_PATH] }))('every %s pattern finishes under 100 ms on each 100k-character hostile line', (_n, patterns) => { + const slow = patterns.flatMap(r => hostile.map(([label, s]) => [r.source.slice(0, 50), label, Math.round(ms(() => r.test(s)))] as const)).filter(([, , t]) => t >= 100); + expect(slow).toEqual([]); + }, 120_000); + test("matchRisk on 'rm -' and 100,000 r's (the GST-898 repro) returns in under 250 ms and raises no class", () => { + const line = 'rm -' + 'r'.repeat(100_000); + let hit = true; + expect(ms(() => { hit = security(line); })).toBeLessThan(250); + expect(hit).toBe(false); + }, 60_000); +}); + +describe('the rm rule matches what R51 matched', () => { + // Value: protects=the rewrite changes no verdict; fails_when=the linear rm pattern matches a line R51's pattern did not, or misses + // one it did; seam=SECURITY_LINE's rm pattern against R51's original source + const r51 = /\brm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\b/; + const rm = SECURITY_LINE.find(r => r.source.startsWith(String.raw`\brm\s+-`))!; + let x = 898; + const next = (n: number) => { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; return (x >>> 0) % n; }; + test('the same verdict on 60,000 seeded short lines and the hand-picked edges', () => { + x = 898; + const prefixes = ['rm -', 'rm -', 'xrm -', ' rm\t-', 'rm-', 'rm -- ', '"rm -', 'brm -', 'rm -r ', '']; + const alphabet = 'rfrfrfFRa1_- .x/m'; + const lines = Array.from({ length: 60_000 }, () => prefixes[next(prefixes.length)] + Array.from({ length: next(11) }, () => alphabet[next(alphabet.length)]).join('')); + const edges = ['rm -rf /', 'rm -fr x', 'rm -Rf x', 'rm -rF x', 'rm -rvf x', 'rm -frv', 'rm -r -f x', 'rm -rf1', 'rm -rf_x', 'rm -rfx', 'rm -rf-x', 'rm -f', 'rm -r', 'rm -rrf', 'rm -ffr', 'rm -rfr', 'farm -rf x', 'rm-rf x', 'rm\t\t-rf', 'sudo rm -rf "$DIR"']; + const differ = [...lines, ...edges].filter(s => rm.test(s) !== r51.test(s)); + expect(differ).toEqual([]); + expect(lines.filter(s => r51.test(s)).length).toBeGreaterThan(1000); + expect(edges.filter(s => rm.test(s))).toEqual(['rm -rf /', 'rm -fr x', 'rm -rvf x', 'rm -rf-x', 'rm -rrf', 'rm -ffr', 'rm -rfr', 'rm\t\t-rf', 'sudo rm -rf "$DIR"']); + }); + test('the rmSync rule matches what R51 matched on 60,000 seeded short lines, and padding past its window still raises security', () => { + x = 898; + const r51Sync = /\brmSync\s*\([^)]*recursive\s*:\s*true/; + const rmSync = SECURITY_LINE.find(r => r.source.startsWith(String.raw`\brmSync`))!; + const parts = ['rmSync(', 'rmSync (', 'xrmSync(', 'recursive', ':', ' ', 'true', ')', 'a', 'recursive: true', 'rmSync', '(', '\t']; + const lines = Array.from({ length: 60_000 }, () => Array.from({ length: next(9) + 1 }, () => parts[next(parts.length)]).join('')); + expect(lines.filter(s => rmSync.test(s) !== r51Sync.test(s))).toEqual([]); + expect(lines.filter(s => r51Sync.test(s)).length).toBeGreaterThan(1000); + expect(security('fs.rmSync(dir, { recursive: true, force: true });')).toBe(true); + expect(security('fs.rmSync(' + 'a'.repeat(480) + ', { recursive: true })')).toBe(true); + expect(security('fs.rmSync(dir, /*' + 'x'.repeat(501) + '*/ { recursive: true, force: true });')).toBe(true); + expect(security('fs.rmSync(dir, /*' + 'x'.repeat(5_000_000) + '*/ { recursive: true });')).toBe(true); + expect(security('fs.rmSync(dir, { force: true });')).toBe(false); + }); +}); + +describe('svgDrawing in linear time', () => { + // Value: protects=gstack-review-plan and the commit collector behind ship-queue ready, train build and land never stall on one + // changed SVG; fails_when=a whole-text SVG pattern backtracks quadratically on a long whitespace run, or the linear url() + // refusal reads any url( differently from #3838's pattern; seam=SVG_REFUSED's url() entry and svgDrawing + const old = /url\(\s*(?!['"]?\s*#)/i; + test('a 1 MB whitespace run in url(), an attribute, a style element and an href returns in under 250 ms each', () => { + const pad = ' '.repeat(1_000_000); + const cases = [ + ['', true], ['', false], + ['', true], ['', true], + ['', true], ['' + pad + '', true], + ] as const; + const wrong = cases.map(([s, want], i) => { let got = !want; const t = ms(() => { got = svgDrawing(s); }); return [i, got === want, Math.round(t)] as const; }).filter(([, ok, t]) => !ok || t >= 250); + expect(wrong).toEqual([]); + }, 60_000); + test("the url() refusal matches #3838's pattern on 60,000 seeded short strings and the hand-picked edges", () => { + const url = SVG_REFUSED.find(r => r.source.startsWith('url'))!; + let x = 911; + const next = (n: number) => { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; return (x >>> 0) % n; }; + const parts = ['url(', 'URL(', ' ', '\t', '\n', '#', "'", '"', 'a', '#a', ')', 'http:', '(', 'u']; + const lines = Array.from({ length: 60_000 }, () => Array.from({ length: next(8) + 1 }, () => parts[next(parts.length)]).join('')); + const edges = ['url(#a)', 'url( #a)', "url('#a')", "url( '#a')", 'url( "#a")', 'url(" #a")', 'url(\t#a)', 'url(x)', 'url( x)', "url('http://x')", 'url(', 'url( ', "url('')"]; + expect([...lines, ...edges].filter(s => url.test(s) !== old.test(s))).toEqual([]); + expect(lines.filter(s => old.test(s)).length).toBeGreaterThan(1000); + expect(lines.filter(s => /url\(/i.test(s) && !old.test(s)).length).toBeGreaterThan(100); + }); +}); diff --git a/test/review-scope-ledger-count.test.ts b/test/review-scope-ledger-count.test.ts new file mode 100644 index 000000000..955d57a61 --- /dev/null +++ b/test/review-scope-ledger-count.test.ts @@ -0,0 +1,249 @@ +/** + * GST-951: the /ship receipt's review_loop counters (gstack-review-scope summary) missed real blockers three times on + * 2026-11-02, because summary read only the review-log rows while /ship's readers report through the dispatch ledgers + * (gstack-review-dispatch collect and judge). Each case below is a minimal reconstruction of that PR's rows and ledgers, + * built from its public PR body and receipt (the rows and ledgers themselves stayed on the lanes' machines): the rows + * reproduce the receipt's published counters on the old summary, and the ledgers hold what the PR body says the readers found. + * Ledgers are written with the real collect and judge, so the parser and judge rules apply as in a run. + */ +import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { collect, readJsonl, recordJudgment, type JudgedLine, type MergedLine } from '../lib/review-ledger'; +import { parseReviewLoop } from '../lib/ship-receipt'; +import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; + +setDefaultTimeout(60_000); +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const SINCE = '2026-11-02T01:00:00Z'; +const at = (min: number) => new Date(Date.parse(SINCE) + min * 60_000).toISOString(); +const review = (min: number, pass: number, findings: object[]) => ({ skill: 'review', via: 'ship', timestamp: at(min), status: findings.length ? 'issues_found' : 'clean', review_scope: 'frozen', pass, findings }); +const fixCheck = (min: number, findings: object[]) => ({ skill: 'review-fix-check', via: 'ship', timestamp: at(min), review_scope: 'frozen', findings }); +const note = (fp: string, raised_by: string) => ({ fingerprint: fp, severity: 'INFORMATIONAL', action: 'noted', blocking: false, raised_by }); + +/** One pass directory: each reader's output collected into its ledger, then lead judgment (default: every label kept). */ +function pass(dir: string, readers: Array<[string, string]>, judgeWith = (merged: MergedLine[]): JudgedLine[] => merged.map(m => ({ id: m.id, severity: m.label }))): string { + fs.mkdirSync(dir, { recursive: true }); + const ledger = path.join(dir, 'review-ledger.jsonl'); + const mergedFile = path.join(dir, 'merged.jsonl'); + fs.writeFileSync(mergedFile, ''); + for (const [reader, output] of readers) expect(collect(ledger, reader, output, mergedFile).review).toBe(true); + const merged = readJsonl(mergedFile); + const judged = judgeWith(merged); + const judgedFile = path.join(dir, 'judged.jsonl'); + fs.writeFileSync(judgedFile, judged.map(j => JSON.stringify(j) + '\n').join('')); + expect(recordJudgment(ledger, merged, judged, judgedFile, mergedFile).ok).toBe(true); + return ledger; +} + +function branchWith(rows: object[]): FixtureRepo { + const repo = makeFixtureRepo({ prefix: 'gst951-' }); + repos.push(repo); + repo.git('checkout', '-qb', 'feat'); + const slug = runBin(repo, 'gstack-slug', ['--get', 'SLUG']).out.trim(); + const logDir = path.join(repo.home, 'projects', slug); + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(path.join(logDir, 'feat-reviews.jsonl'), rows.map(r => JSON.stringify(r) + '\n').join('')); + return repo; +} +const summary = (repo: FixtureRepo, ...args: string[]) => runBin(repo, 'gstack-review-scope', ['summary', '--since', SINCE, '--json', ...args]); +const loop = (r: { status: number | null; out: string; err: string }) => { expect(r.err).not.toContain('(REVIEW_SCOPE_'); expect(r.status).toBe(EXIT.ok); return JSON.parse(r.out); }; + +const CLEAN = 'NO FINDINGS'; +const OUTSIDE_CLEAN = 'No issues found in the diff.\nRecommendation: ship because no finding reaches the floor'; + +describe('GST-951: the receipt counts every blocker the readers found', () => { + // #3841 (reconstruction; GSTA-203 PR 8 trail rows 9 and 14): Step 11's native adversarial FIXABLE (CRITICAL) finding was + // fixed in round 2, but no row recorded it (pass 2's row held no findings), so summary printed blocking_found=0 fixed=0 and + // the lane hand-set blocking_found=1 fixed=1. Its two other findings are the noted pair. + const ADVERSARIAL_3841 = [ + '1. lib/pregate-clear.ts:140 The receipt stores the raw run URL, not the attempt it judged. FIXABLE', + '2. lib/pregate.ts:88 The refusal text does not name the attempt. INVESTIGATE', + '3. docs/pregate.md:12 The docs do not say a rerun replaces the attempt. INVESTIGATE', + 'Recommendation: fix 1 because a later attempt would be judged by the first', + ].join('\n'); + test('#3841 as /ship logs it: the queued adversarial blocker in pass 2\'s row counts as 1 found and 1 fixed', () => { + const repo = branchWith([ + review(0, 1, []), + review(40, 2, [{ fingerprint: 'lib/pregate-clear.ts:140:security', action: 'fixed', raised_by: 'adversarial-native' }, + note('lib/pregate.ts:88:docs', 'adversarial-native'), note('docs/pregate.md:12:docs', 'adversarial-native')]), + ]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]); + pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', ADVERSARIAL_3841]]); + pass(path.join(run, 'p2'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]); + expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 2, fixed: 1 }); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 1, ledgers_read: 3, rounds: 2 }); + expect(s.unrecorded).toBeUndefined(); + }); + + test('#3841 as the lane logged it: an adversarial-only blocker counts, and its missing fix record shows as unrecorded', () => { + const repo = branchWith([ + review(0, 1, []), + review(40, 2, [note('lib/pregate.ts:88:docs', 'adversarial-native'), note('docs/pregate.md:12:docs', 'adversarial-native')]), + ]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]); + pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', ADVERSARIAL_3841]]); + expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 2, fixed: 0 }); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 0, unrecorded: 1, ledgers_read: 2, rounds: 2 }); + }); + + // #3838 (reconstruction): the rows reproduce its receipt (passes=2 blocking_found=0 noted=8 fixed=4 fix_checks=3). Pass 2's + // red-team CRITICAL was logged fixed without classify's blocking stamp; Step 11's native adversarial CRITICAL (CSS + // image-set loading an outside image) was fixed in 0f61362c1 and no row holds it. + test('#3838: CRITICALs fixed without a classified row count', () => { + const repo = branchWith([ + review(0, 1, ['a', 'b', 'c'].map(x => ({ fingerprint: `lib/review-depth.ts:1${x}:style`, severity: 'INFORMATIONAL', action: 'fixed', blocking: false, raised_by: 'security' }))), + review(50, 2, [{ fingerprint: 'lib/review-depth.ts:212:security', severity: 'CRITICAL', action: 'fixed', raised_by: 'red-team' }, + note('lib/review-depth.ts:220:style', 'red-team'), note('lib/review-depth.ts:230:style', 'red-team')]), + fixCheck(60, [note('lib/review-depth.ts:240:style', 'fix-check'), note('lib/review-depth.ts:241:style', 'fix-check')]), + fixCheck(90, [note('lib/review-depth.ts:250:style', 'fix-check')]), + fixCheck(120, [note('lib/review-depth.ts:260:perf', 'fix-check'), note('lib/review-depth.ts:261:perf', 'fix-check'), note('lib/review-depth.ts:262:perf', 'fix-check')]), + ]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [['specialist/security', '- [INFORMATIONAL] lib/review-depth.ts:1a a CSS escape\n- [INFORMATIONAL] lib/review-depth.ts:1b a zero-padded reference\n- [INFORMATIONAL] lib/review-depth.ts:1c a split handler line'], ['red-team', CLEAN]]); + pass(path.join(run, 'p2'), [['red-team', '[CRITICAL] lib/review-depth.ts:212 a stray quote hides an external href from the drawing check']]); + pass(path.join(run, 'p2-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', 'lib/review-depth.ts:300 CSS image-set() loads an outside image. FIXABLE\nRecommendation: fix because the drawing loads a remote URL']]); + expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 1, noted: 8, fixed: 4, fix_checks: 3, rounds: 5 }); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ passes: 2, blocking_found: 2, deferred: 0, noted: 8, fixed: 4, fix_checks: 3, rounds: 5, unrecorded: 1, ledgers_read: 3 }); + }); + + // #3837 (reconstruction): pass 1's row was logged before the outside voice's seven findings were classified, so they sit in the + // row with no severity, blocking stamp or action; the fix batch under the Captain's exception fixed them, and the receipt said + // blocking_found=0 fixed=0 noted=11 fix_checks=3. + test('#3837: blockers logged before classification count', () => { + const seven = Array.from({ length: 7 }, (_, i) => ({ fingerprint: `lib/inventory.ts:${10 + i}:logic`, raised_by: 'outside-voice' })); + const repo = branchWith([ + review(0, 1, [...seven, ...Array.from({ length: 9 }, (_, i) => note(`docs/inventory.md:${i + 1}:docs`, 'specialist'))]), + review(60, 2, [note('lib/inventory.ts:90:docs', 'red-team'), note('lib/inventory.ts:91:docs', 'red-team')]), + fixCheck(80, []), fixCheck(100, []), fixCheck(120, []), + ]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [['specialist-batched/1', CLEAN], ['red-team', CLEAN]]); + pass(path.join(run, 'p1-voices'), [['adversarial-native', CLEAN], ['outside-voice', [ + ...seven.map((f, i) => `${i + 1}. ${f.fingerprint.split(':').slice(0, 2).join(':')} finding ${i + 1} Severity: High Blocks: yes`), + 'Recommendation: fix 1-6 because a self-grandfathered row skips every plan rule', + ].join('\n')]]); + expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 11, fixed: 0, fix_checks: 3, rounds: 5 }); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ passes: 2, blocking_found: 7, noted: 11, fixed: 0, unrecorded: 7, ledgers_read: 2, rounds: 5 }); + const line = runBin(repo, 'gstack-review-scope', ['summary', '--since', SINCE, '--run', run]); + expect(line.out.trim()).toBe('REVIEW_LOOP: passes=2 blocking_found=7 deferred=0 noted=11 fixed=0 scope=frozen unrecorded=7 ledgers_read=2 fix_checks=3'); + expect(parseReviewLoop(s)).toMatchObject({ blocking_found: 7, unrecorded: 7, ledgers_read: 2, rounds: 5 }); + }); + + test('a blocker both sources hold counts once, and a disposed one is not unrecorded', () => { + const repo = branchWith([review(0, 1, [{ fingerprint: 'src/a.ts:3:logic', severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' }])]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted']]); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ blocking_found: 1, fixed: 1, ledgers_read: 1 }); + expect(s.unrecorded).toBeUndefined(); + }); + + test('a CRITICAL a row classified below the floor is not a blocker; distinct blockers from one reviewer each count', () => { + const repo = branchWith([review(0, 1, [ + { fingerprint: 'src/a.ts:3:testing', path: 'src/a.ts', line: 3, severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'testing' }, + { fingerprint: 'src/b.ts:9:security', path: 'src/b.ts', line: 9, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'security' }, + { fingerprint: 'src/c.ts:1:logic', severity: 'CRITICAL', advisory: true, action: 'noted', raised_by: 'security' }, + ])]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [['specialist/testing', '[CRITICAL] src/a.ts:3 the denied path has no test\n[CRITICAL] src/d.ts:7 the retry loop never ends']]); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ blocking_found: 2, fixed: 1, noted: 2, unrecorded: 1, ledgers_read: 1 }); + }); + + test('Step 11 review cases: a row finding pairs with one ledger CRITICAL; the newest classification and a mapped severity decide; a bare fixed row is a disposition', () => { + const row = (fp: string, f: object) => ({ fingerprint: fp, ...f }); + const repo = branchWith([ + review(0, 1, [ + row('src/auth.ts:42:testing', { path: 'src/auth.ts', line: 42, severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'testing' }), + row('src/m.ts:5:state-matrix', { path: 'src/m.ts', line: 5, severity: 'INFORMATIONAL', raw_severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'state-machines' }), + row('src/f.ts:3:logic', { action: 'fixed', raised_by: 'outside-voice' }), + row('src/n.ts:8:logic', { severity: 'CRITICAL', raised_by: 'red-team' }), + row('src/g.ts:7:logic', { action: 'fixed', raised_by: 'core' }), + ]), + review(30, 2, [row('src/n.ts:8:logic', { severity: 'CRITICAL', blocking: false, action: 'noted', raised_by: 'red-team' })]), + ]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [ + ['specialist/testing', '[CRITICAL] src/auth.ts:42 the denied path has no test\n[CRITICAL] src/m.ts:5 untested cell'], + ['outside-voice', '1. src/auth.ts:42 an unauthenticated caller reaches the handler. Severity: High Blocks: yes\n2. src/f.ts:3 the guard is inverted. Blocks: yes\nRecommendation: fix 1 because it is an open handler'], + ['red-team', '[CRITICAL] src/n.ts:8 looked wrong, classified below the floor later'], + ], m => [...m.map(l => ({ id: l.id, severity: l.label })), { id: 'lead#1', severity: 'CRITICAL', reason: 'src/g.ts:7 the lead found an unchecked write' }]); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ passes: 2, blocking_found: 3, fixed: 2, unrecorded: 1, ledgers_read: 1 }); + }); + + test('round 2 cases: another reviewer at a below-floor line, an extensionless path, one finding in two ledgers, no locus', () => { + const repo = branchWith([ + review(0, 1, [ + { fingerprint: 'src/auth.ts:42:testing', path: 'src/auth.ts', line: 42, severity: 'CRITICAL', blocking: false, action: 'noted', raised_by: 'testing' }, + { fingerprint: 'bin/gstack-review-scope:262:logic', path: 'bin/gstack-review-scope', line: 262, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' }, + { fingerprint: 'x.ts:5:logic', path: 'x.ts', line: 5, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' }, + ]), + fixCheck(30, []), + ]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [ + ['specialist/testing', '[CRITICAL] src/auth.ts:42 the denied path has no test'], + ['outside-voice', '1. src/auth.ts:42 an unauthenticated caller reaches the handler. Blocks: yes\nRecommendation: fix 1 because it is open'], + ['red-team', '[CRITICAL] bin/gstack-review-scope:262 the refusal is skipped\n[CRITICAL] x.ts:5 the guard is inverted'], + ]); + pass(path.join(run, 'fix-check'), [['fix-check', '[CRITICAL] x.ts:5 the guard is still inverted\n[CRITICAL] the retry loop never ends']]); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ blocking_found: 4, fixed: 2, noted: 1, unrecorded: 2, ledgers_read: 2 }); + }); + + test('judgment decides: a folded duplicate counts once, a lead addition counts, a rejected judgment keeps the collected labels', () => { + const repo = branchWith([review(0, 1, [])]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'fold'), [['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted\n[CRITICAL] src/a.ts:4 the guard is inverted again']], + m => [{ id: m[0]!.id, severity: 'CRITICAL' }, { id: m[1]!.id, severity: 'CRITICAL', duplicate_of: m[0]!.id }, { id: 'lead#1', severity: 'CRITICAL', reason: 'src/e.ts:2 the lead found an unchecked write' }]); + expect(loop(summary(repo, '--run', run))).toMatchObject({ blocking_found: 2, unrecorded: 2, ledgers_read: 1 }); + const rejected = path.join(repo.root, 'rejected'); + fs.mkdirSync(rejected); + const ledger = path.join(rejected, 'review-ledger.jsonl'); + const mergedFile = path.join(rejected, 'merged.jsonl'); + fs.writeFileSync(mergedFile, ''); + collect(ledger, 'red-team', '[CRITICAL] src/a.ts:3 the guard is inverted', mergedFile); + const merged = readJsonl(mergedFile); + const judgedFile = path.join(rejected, 'judged.jsonl'); + fs.writeFileSync(judgedFile, JSON.stringify({ id: merged[0]!.id, severity: 'INFORMATIONAL' }) + '\n'); + expect(recordJudgment(ledger, merged, [{ id: merged[0]!.id, severity: 'INFORMATIONAL' }], judgedFile, mergedFile).ok).toBe(false); + expect(loop(summary(repo, '--ledger', ledger))).toMatchObject({ blocking_found: 1, unrecorded: 1, ledgers_read: 1 }); + }); + + test('summary refuses REVIEW_SCOPE_LEDGER instead of counting without a ledger it needs, and writes nothing', () => { + const repo = branchWith([review(0, 1, [])]); + const run = path.join(repo.root, 'run'); + const ledger = pass(path.join(run, 'p1'), [['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted']]); + const out = path.join(repo.root, 'loop.json'); + const refused = (r: { status: number | null; err: string }, why: string) => { expect(r.status).toBe(EXIT.fail); expect(r.err).toContain('(REVIEW_SCOPE_LEDGER;'); expect(r.err).toContain(why); expect(fs.existsSync(out)).toBe(false); }; + refused(summary(repo, '--out', out), '--no-ledger when no reader ran through gstack-review-dispatch'); + fs.mkdirSync(path.join(repo.root, 'empty')); + refused(summary(repo, '--run', path.join(repo.root, 'empty'), '--out', out), 'holds no review-ledger.jsonl'); + refused(summary(repo, '--ledger', path.join(run, 'nope.jsonl'), '--out', out), 'is unreadable'); + const judged = path.join(run, 'p1', 'judged.jsonl'); + const kept = fs.readFileSync(judged, 'utf8'); + fs.writeFileSync(judged, kept.replace('CRITICAL', 'INFORMATIONAL')); + refused(summary(repo, '--ledger', ledger, '--out', out), 'changed since judge'); + fs.writeFileSync(judged, kept); + fs.rmSync(path.join(run, 'p1', 'merged.jsonl')); + refused(summary(repo, '--run', run, '--out', out), 'is unreadable'); + refused(summary(repo, '--run', path.join(repo.root, 'nonesuch'), '--out', out), 'ENOENT'); + const bare = summary(repo, '--out', out, '--ledger'); + expect(bare.err).toContain('(REVIEW_SCOPE_INPUT;'); + expect(bare.err).toContain('--ledger needs a file'); + const both = summary(repo, '--run', run, '--no-ledger'); + expect(both.status).toBe(EXIT.fail); + expect(both.err).toContain('(REVIEW_SCOPE_INPUT;'); + }); +}); diff --git a/test/review-scope-windows-launch.test.ts b/test/review-scope-windows-launch.test.ts index 68c077bd5..5a7405455 100644 --- a/test/review-scope-windows-launch.test.ts +++ b/test/review-scope-windows-launch.test.ts @@ -50,7 +50,7 @@ test('summary reads the review log through gstack-review-read (#3541)', () => { ]; fs.writeFileSync(path.join(state, 'projects', SLUG, 'feat-reviews.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n'); const out = path.join(tmp, 'review-loop.json'); - const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-review-scope'), ['summary', '--since', '2026-10-26T09:00:00Z', '--out', out, '--json'], { + const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-review-scope'), ['summary', '--no-ledger', '--since', '2026-10-26T09:00:00Z', '--out', out, '--json'], { cwd: repo, env, encoding: 'utf8', timeout: 120_000, }); expect(r.error).toBeUndefined(); diff --git a/test/review-scope.test.ts b/test/review-scope.test.ts index e6bdc2ace..8b40ad3af 100644 --- a/test/review-scope.test.ts +++ b/test/review-scope.test.ts @@ -423,30 +423,30 @@ describe('gstack-review-scope (bin)', () => { fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n'); const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 }; const out = path.join(repo.root, 'review-loop.json'); - const r = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']); + const r = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']); expect(r.status).toBe(EXIT.ok); expect(r.err).toContain('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen'); expect(JSON.parse(r.out)).toEqual(expected); expect(JSON.parse(fs.readFileSync(out, 'utf8'))).toEqual(expected); - const plain = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T08:00:00Z']); + const plain = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T08:00:00Z']); expect(plain.status).toBe(EXIT.ok); expect(plain.out.trim()).toBe('REVIEW_LOOP: passes=3 blocking_found=3 deferred=1 noted=1 fixed=2 scope=frozen'); const noSince = runBin(repo, 'gstack-review-scope', ['summary', '--json']); expect(noSince.status).toBe(EXIT.fail); expect(noSince.err).toContain('(REVIEW_SCOPE_INPUT;'); expect(noSince.out).toBe(''); - const badSince = runBin(repo, 'gstack-review-scope', ['summary', '--since', 'yesterday']); + const badSince = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', 'yesterday']); expect(badSince.status).toBe(EXIT.fail); expect(badSince.err).toContain('(REVIEW_SCOPE_INPUT;'); // zero matching passes is a wrong --since or a log written elsewhere, never a loop to record: refused, nothing written const bare = fixture(); const noneOut = path.join(bare.root, 'review-loop.json'); - const none = runBin(bare, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', noneOut, '--json']); + const none = runBin(bare, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T09:30:00Z', '--out', noneOut, '--json']); expect(none.status).toBe(EXIT.fail); expect(none.err).toContain('(REVIEW_SCOPE_INPUT;'); expect(none.err).toContain('REVIEW_LOOP: passes=0'); expect(fs.existsSync(noneOut)).toBe(false); - const late = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-27T00:00:00Z']); + const late = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-27T00:00:00Z']); expect(late.status).toBe(EXIT.fail); }); }); diff --git a/test/review-targeted.test.ts b/test/review-targeted.test.ts index 375cf1d01..9ef2217d3 100644 --- a/test/review-targeted.test.ts +++ b/test/review-targeted.test.ts @@ -324,7 +324,7 @@ describe('the targeted role end to end on a fixture repository', () => { fs.mkdirSync(logDir, { recursive: true }); const row = { skill: 'review', via: 'ship', timestamp: '2026-10-30T10:00:00Z', status: 'clean', review_scope: 'frozen', findings: [], dispatches: [{ role: 'specialist' }, { role: 'adversarial-native' }, { role: 'targeted' }] }; fs.writeFileSync(path.join(logDir, 'feat-reviews.jsonl'), JSON.stringify(row) + '\n'); - const summary = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-30T09:00:00Z', '--json']); + const summary = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-30T09:00:00Z', '--json']); expect(summary.status, summary.err).toBe(0); expect(JSON.parse(summary.out).dispatches).toEqual({ specialist: 1, 'adversarial-native': 1, targeted: 1 }); diff --git a/test/sealed-guard.test.ts b/test/sealed-guard.test.ts index 878f36dc0..f93e1eaac 100644 --- a/test/sealed-guard.test.ts +++ b/test/sealed-guard.test.ts @@ -506,3 +506,64 @@ describe('waivers name the full commit sha (GST-268)', () => { expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).ignoredWaivers).toEqual([]); }); }); + +const PANEL = JSON.stringify({ units: [ + { stratum: 'decoy', tags: ['W:T1'], plan_bytes: 3760, files: 5, head: '5b7db3aa' + '1'.repeat(32), tree_sha256: '15762a' + '0'.repeat(56) + 'aa' }, + { stratum: 'seeded', tags: ['B:T2'], plan_bytes: 4112, files: 7, head: '9c1e0bbb' + '2'.repeat(32), tree_sha256: '88aa11' + '0'.repeat(56) + 'bb' }, +] }, null, 2) + '\n'; +const PANEL_VALUES = [/5b7db3aa/, /15762a0/, /9c1e0bbb/, /88aa110/, /\b3760\b/, /\b4112\b/, /W:T1/, /B:T2/, /\bdecoy\b/, /\bseeded\b/]; + +describe('sealed.label_join: labels published beside self-computable identifiers (GST-620)', () => { + test('the GSTA-2 panel (stratum and tags beside head, tree_sha256, plan_bytes, files) refuses on a public destination and warns on a private one, naming fields, never values', () => { + const labeled = { paths: ['**/heldout*/**'], label_keys: ['stratum', 'tags', 'decoy_set'] }; + const s = scratch({ '.gstack/pregate.json': policy(labeled) }); + const sha = commit(s, { 'wiki/board/panel/panel.json': PANEL }); + const keys = ['stratum', 'tags', 'files', 'head', 'plan_bytes', 'tree_sha256']; + const text = preflight(s); + expect(text.code).toBe(1); + expect(text.err).toContain(`sealed.label_join wiki/board/panel/panel.json commit ${sha.slice(0, 12)} keys=${keys.join(',')}`); + expect(text.out).toMatch(/^SEALED: refused findings=1 commits=1 /m); + const json = preflight(s, ['--json']); + expect(JSON.parse(json.out).findings).toEqual([{ rule: 'sealed.label_join', file: 'wiki/board/panel/panel.json', commit: sha, keys }]); + const h = hook(s); + expect(h.code).toBe(1); + expect(h.err).toContain(`sealed.label_join wiki/board/panel/panel.json commit ${sha.slice(0, 12)} keys=${keys.join(',')}`); + for (const out of [text.out, text.err, json.out, h.err]) for (const value of PANEL_VALUES) expect(out).not.toMatch(value); + const priv = scratch({ '.gstack/pregate.json': policy({ ...labeled, visibility: 'private' }) }); + commit(priv, { 'wiki/board/panel/panel.json': PANEL }); + const p = preflight(priv); + expect(p.code).toBe(0); + expect(p.err).toMatch(/warning.*sealed\.label_join\s+wiki\/board\/panel\/panel\.json/s); + }); +}); + +const OFF_GOLDEN = path.join(ROOT, 'test', 'fixtures', 'sealed-guard-off-golden.json'); +/** Representative pushes under policies without label_keys, each read three ways: the preflight's text and --json, and the pre-push hook. */ +function offRuns(): Record { + const table = '| unit | stratum | head |\n|---|---|---|\n| u1 | decoy | 5b7db3a |\n'; + const fixtures: Record Scratch> = { + refused: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); commit(s, { 'wiki/board/panel/panel.json': PANEL, 'runs/c1/transcript.jsonl': '{}\n', 'runs/c1/summary.md': 'SEALED ANSWER: x\n' + table }); return s; }, + clear: () => { const s = scratch({ '.gstack/pregate.json': policy({ paths: ['heldout/**'] }) }); commit(s, { 'wiki/board/panel/panel.json': PANEL, 'wiki/board.md': table, 'wiki/units.csv': 'stratum,head\ndecoy,5b7db3a\n' }); return s; }, + exempt: () => { const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); commit(s, { 'sealed/s1/MANIFEST.json': PANEL, 'wiki/board/panel/panel.json': PANEL }); return s; }, + waived: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); const sha = commit(s, { 'runs/c1/transcript.jsonl': '{}\n', 'wiki/panel.json': PANEL }); landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\truns/c1/transcript.jsonl\t${sha}\tsynthetic fixture\n` }); return s; }, + warned: () => { const s = scratch({ '.gstack/pregate.json': policy({ ...SEALED, visibility: 'private' }) }); commit(s, { 'runs/c1/transcript.jsonl': '{}\n', 'wiki/panel.json': PANEL }); return s; }, + error: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); commit(s, { '.gstack/pregate.json': policy({ ...SEALED, answer_markers: ['('] }), 'wiki/panel.json': PANEL }); return s; }, + 'no-policy': () => { const s = scratch({}); commit(s, { 'wiki/panel.json': PANEL }); return s; }, + }; + const runs: Record = {}; + for (const [name, make] of Object.entries(fixtures)) { + const s = make(); + const norm = (t: string) => t.split(s.root).join('').replace(/[0-9a-f]{12,64}/g, ''); + const text = preflight(s); const json = preflight(s, ['--json']); const h = hook(s); + runs[name] = { code: text.code, stdout: norm(text.out), stderr: norm(text.err), jsonCode: json.code, json: norm(json.out), hookCode: h.code, hookStderr: norm(h.err) }; + } + return runs; +} + +describe('off unless configured (GST-620 item 13)', () => { + test('without sealed.label_keys, stdout, stderr, --json, the hook and every exit code match the capture from main 2d1b55d76 byte for byte', () => { + const runs = offRuns(); + if (process.env.SEALED_GUARD_GOLDEN_CAPTURE) { fs.writeFileSync(process.env.SEALED_GUARD_GOLDEN_CAPTURE, JSON.stringify(runs, null, 2) + '\n'); return; } + expect(runs).toEqual(JSON.parse(fs.readFileSync(OFF_GOLDEN, 'utf8'))); + }, 120_000); +}); diff --git a/test/sealed-join.test.ts b/test/sealed-join.test.ts new file mode 100644 index 000000000..3b92ce1a8 --- /dev/null +++ b/test/sealed-join.test.ts @@ -0,0 +1,237 @@ +/** + * GST-620: `sealed.label_join`, the sealed guard's rule for a published + * record that puts a label field (`sealed.label_keys`) next to an identifier + * the model under test can compute from its own copy of the work. The repro + * and the byte-for-byte "off" golden live in test/sealed-guard.test.ts; this + * file covers formats, the per-record content diff against every parent, + * fail-closed unchecked files, waivers, allow_paths and the monotonic policy. + */ +import { afterEach, 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'; +import { sealedErrors } from '../lib/sealed-guard'; +import { DEFAULT_IDENTIFIER_KEYS, joinFindings } from '../lib/sealed-join'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const PREGATE = path.join(ROOT, 'bin', 'gstack-pregate'); +const HEAD_A = '5b7db3aa' + '1'.repeat(32); +const HEAD_B = '9c1e0bbb' + '2'.repeat(32); +const LABELED = { label_keys: ['stratum', 'tags', 'decoy_set'] }; +const P = { label_keys: ['stratum', 'tags'], identifier_keys: DEFAULT_IDENTIFIER_KEYS }; + +interface Scratch { root: string; work: string; env: Record } +const scratches: Scratch[] = []; +afterEach(() => { while (scratches.length) fs.rmSync(scratches.pop()!.root, { recursive: true, force: true }); }); + +function git(s: Scratch, args: string[], ok = true): string { + const r = spawnSync('git', args, { cwd: s.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...s.env } }); + if (ok && r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return (r.stdout ?? '').trim(); +} +function write(s: Scratch, files: Record): void { + for (const [rel, body] of Object.entries(files)) { + const abs = path.join(s.work, rel); + if (body === null) { fs.rmSync(abs, { force: true }); continue; } + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + } +} +function commit(s: Scratch, files: Record, msg = 'results'): string { + write(s, files); + git(s, ['add', '-A']); git(s, ['commit', '-q', '-m', msg]); + return git(s, ['rev-parse', 'HEAD']); +} +/** A bare origin whose main carries the base files, and a clone on branch `results`. */ +function scratch(baseFiles: Record): Scratch { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-join-'))); + const work = path.join(root, 'work'); const origin = path.join(root, 'origin.git'); const state = path.join(root, 'state'); + fs.mkdirSync(work); fs.mkdirSync(state); + const env = { HOME: root, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; + fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@example.com\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); + const s = { root, work, env }; + scratches.push(s); + spawnSync('git', ['init', '-q', '--bare', origin], { env: { ...process.env, ...env }, timeout: 30_000 }); + git(s, ['init', '-q', '-b', 'main']); + commit(s, { 'README.md': '# evals\n', ...baseFiles }, 'base'); + git(s, ['remote', 'add', 'origin', origin]); git(s, ['push', '-q', 'origin', 'main']); + git(s, ['checkout', '-q', '-b', 'results']); + return s; +} +function landOnMain(s: Scratch, files: Record): void { + git(s, ['checkout', '-q', 'main']); commit(s, files, 'base change'); git(s, ['push', '-q', 'origin', 'main']); git(s, ['checkout', '-q', 'results']); +} +const policy = (sealed: unknown) => JSON.stringify({ sealed }, null, 2) + '\n'; +const panel = (units: object[]) => JSON.stringify({ units }, null, 2) + '\n'; +const unit = (name: string, head: string, extra: object = {}) => ({ name, stratum: name === 'u1' ? 'decoy' : 'seeded', tags: ['W:T1'], head, plan_bytes: 3760, ...extra }); +const found = (s: Scratch) => joinFindings(s.work, git(s, ['rev-list', 'origin/main..HEAD']).split('\n').filter(Boolean), P); +function preflight(s: Scratch, args: string[] = []) { + const r = spawnSync(PREGATE, ['sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } }); + return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' }; +} + +describe('formats and matching', () => { + test('a JSONL line, a nested meta.head, CSV, TSV, a markdown table with human headers and a hex value under an unlisted key each join; decimal-only strings, labels alone and identifiers alone do not', () => { + const s = scratch({}); + const sha = commit(s, { + 'a.jsonl': '{"x":1}\n{"stratum":"decoy","meta":{"head":"5b7db3a"}}\n', + 'b.csv': 'unit,Stratum,Plan Bytes\nu1,decoy,"3,760"\n', + 'c.tsv': 'stratum\ttree_sha256\ndecoy\tabc\n', + 'd.md': '# board\n\n| Unit | Stratum | `head` | Tree SHA-256 |\n|---|:---:|---|---|\n| u1 | decoy | x | y |\n', + 'e.json': '[{"tags":["B:T2"],"id":"5b7db3a"}]\n', + 'f.json': '[{"stratum":"decoy","ts":"202610311200","name":"unit-1","count":12}]\n', + 'g.json': '[{"stratum":"decoy"},{"head":"5b7db3aa"}]\n', + 'h.md': '| head | size |\n|---|---|\n| 5b7db3a | 3 |\n', + 'i.txt': '{"stratum":"decoy","head":"5b7db3a"}\n', + 'j.json': `{"units":{"${HEAD_A}":{"stratum":"decoy","tags":["t"]}}}\n`, + 'k.jsonl': `{"${HEAD_B}":{"stratum":"seeded"}}\n`, + 'l.json': '{"units":{"unit-one":{"stratum":"decoy"}}}\n', + 'm.tsv': '"Stratum"\t"head"\n"decoy"\t"5b7db3a"\n', + 'n.csv': '"Strat"um,head\ndecoy,5b7db3a\n', + 'tsconfig.json': '{\n // a comment, so not strict JSON\n "compilerOptions": { "strict": true }\n}\n', + }); + expect(found(s).sort((a, b) => a.file.localeCompare(b.file))).toEqual([ + { rule: 'sealed.label_join', file: 'a.jsonl', commit: sha, keys: ['stratum', 'meta.head'] }, + { rule: 'sealed.label_join', file: 'b.csv', commit: sha, keys: ['stratum', 'plan_bytes'] }, + { rule: 'sealed.label_join', file: 'c.tsv', commit: sha, keys: ['stratum', 'tree_sha256'] }, + { rule: 'sealed.label_join', file: 'd.md', commit: sha, keys: ['stratum', 'head', 'tree_sha_256'] }, + { rule: 'sealed.label_join', file: 'e.json', commit: sha, keys: ['tags', 'id'] }, + { rule: 'sealed.label_join', file: 'j.json', commit: sha, keys: ['stratum', 'tags', '(key)'] }, + { rule: 'sealed.label_join', file: 'k.jsonl', commit: sha, keys: ['stratum', '(key)'] }, + { rule: 'sealed.label_join', file: 'm.tsv', commit: sha, keys: ['stratum', 'head'] }, + { rule: 'sealed.label_join', file: 'n.csv', commit: sha, keys: ['stratum', 'head'] }, + ]); + }); + + test('without label_keys the rule reads nothing: no git call, even outside a repository', () => { + expect(joinFindings(os.tmpdir(), ['0'.repeat(40)], { label_keys: [], identifier_keys: DEFAULT_IDENTIFIER_KEYS })).toEqual([]); + }); + + test('label_keys must be bare field names and identifier_keys allow only *; neither takes a dotted path; both must be string arrays', () => { + expect(sealedErrors({ label_keys: ['stratum'], identifier_keys: ['*_sha', 'head'] })).toEqual([]); + expect(sealedErrors({ label_keys: ['strat*'] }).map(e => e.path)).toEqual(['sealed.label_keys[0]']); + expect(sealedErrors({ identifier_keys: ['he?d', ''] }).map(e => e.path)).toEqual(['sealed.identifier_keys[0]', 'sealed.identifier_keys[1]']); + expect(sealedErrors({ label_keys: 'stratum' })).toEqual([{ path: 'sealed.label_keys', message: 'must be an array of strings' }]); + expect(sealedErrors({ label_keys: ['meta.stratum'], identifier_keys: ['meta.head'] }).map(e => e.path)).toEqual(['sealed.label_keys[0]', 'sealed.identifier_keys[0]']); + }); +}); + +describe('content diff against every parent', () => { + test('an unchanged record and a formatting-only edit stay clear; a replacement at the same count, a delete plus add and an added identifier field refuse', () => { + const s = scratch({ 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', HEAD_B)]) }); + const reformat = commit(s, { 'wiki/panel.json': JSON.stringify({ units: [unit('u1', HEAD_A), unit('u2', HEAD_B), { note: 'no labels here' }] }) + '\n' }); + const replaced = commit(s, { 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', 'c'.repeat(39) + '3')]) }); + const swapped = commit(s, { 'wiki/panel.json': panel([unit('u2', 'c'.repeat(39) + '3'), unit('u3', 'd'.repeat(39) + '4')]) }); + const widened = commit(s, { 'wiki/panel.json': panel([unit('u2', 'c'.repeat(39) + '3'), unit('u3', 'd'.repeat(39) + '4', { tree_sha256: 'e'.repeat(63) + '5' })]) }); + const commits = found(s).map(f => f.commit); + expect(commits).not.toContain(reformat); + expect(commits.sort()).toEqual([replaced, swapped, widened].sort()); + }); + + test('labels in one commit and identifiers added to the same records in the next: the second commit refuses', () => { + const s = scratch({}); + const labels = commit(s, { 'units.jsonl': '{"name":"u1","stratum":"decoy"}\n' }); + const ids = commit(s, { 'units.jsonl': '{"name":"u1","stratum":"decoy","head":"5b7db3a"}\n' }); + expect(found(s).map(f => f.commit)).toEqual([ids]); + expect(found(s).map(f => f.commit)).not.toContain(labels); + }); + + test('a pure rename is clear and a rename that adds a unit refuses; a clean merge of two published sides is clear and a resolution that adds a joined row refuses', () => { + const units = ['u1', 'u2', 'u3', 'u4', 'u5', 'u6'].map((n, i) => unit(n, String(i + 1).repeat(4) + 'ab' + 'f'.repeat(34))); + const s = scratch({ 'wiki/panel.json': panel(units) }); + git(s, ['mv', 'wiki/panel.json', 'wiki/board.json']); git(s, ['commit', '-q', '-m', 'rename']); + write(s, { 'wiki/board.json': panel([...units, unit('u7', HEAD_A)]) }); + git(s, ['mv', 'wiki/board.json', 'wiki/board2.json']); git(s, ['add', '-A']); git(s, ['commit', '-q', '-m', 'rename and add']); + const renamedAndAdded = git(s, ['rev-parse', 'HEAD']); + expect(found(s).map(f => f.commit)).toEqual([renamedAndAdded]); + + const m = scratch({ 'wiki/panel.json': panel([unit('u1', HEAD_A)]) }); + git(m, ['checkout', '-q', '-b', 'side']); + const side = commit(m, { 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', HEAD_B)]) }); + git(m, ['checkout', '-q', 'results']); + const ours = commit(m, { 'wiki/panel.json': panel([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A)]) }); + const merge = (resolution: object[]) => { + git(m, ['merge', '-q', '--no-ff', '--no-commit', 'side'], false); + write(m, { 'wiki/panel.json': panel(resolution) }); + git(m, ['add', '-A']); git(m, ['commit', '-q', '--no-edit', '-m', 'merge']); + return git(m, ['rev-parse', 'HEAD']); + }; + const clean = merge([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A), unit('u2', HEAD_B)]); + expect(found(m).map(f => f.commit).sort()).toEqual([side, ours].sort()); + expect(found(m).map(f => f.commit)).not.toContain(clean); + git(m, ['reset', '-q', '--hard', ours]); + const added = merge([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A), unit('u2', HEAD_B), unit('u4', 'd'.repeat(39) + '4')]); + const atMerge = found(m).filter(f => f.commit === added); + expect(atMerge).toEqual([{ rule: 'sealed.label_join', file: 'wiki/panel.json', commit: added, keys: ['stratum', 'tags', 'head', 'plan_bytes'] }]); + }); +}); + +describe('fail closed, waivers, allow_paths and the policy union (gstack-pregate sealed)', () => { + test('an oversized valid JSON, malformed JSON, a malformed JSONL line, nesting deeper than 64 and an unreadable blob are each sealed.label_join_unchecked; an empty file is clear', () => { + const s = scratch({ '.gstack/pregate.json': policy(LABELED) }); + let deep: unknown = { stratum: 'decoy' }; + for (let i = 0; i < 70; i++) deep = { next: deep }; + const sha = commit(s, { + 'big.json': JSON.stringify({ pad: 'x'.repeat(4 * 1024 * 1024 + 10), units: [] }), + 'bad.json': '{"stratum": "decoy",', + 'bad.jsonl': '{"stratum":"decoy"}\nnot json\n', + 'deep.json': JSON.stringify(deep), + 'gone.json': '{"note":"this blob is removed after the commit"}\n', + 'empty.json': '', + }); + const blob = git(s, ['rev-parse', 'HEAD:gone.json']); + fs.rmSync(path.join(s.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2))); + const r = preflight(s); + expect(r.code).toBe(1); + for (const f of ['big.json', 'bad.json', 'bad.jsonl', 'deep.json', 'gone.json']) expect(r.err).toContain(`sealed.label_join_unchecked ${f} commit ${sha.slice(0, 12)}`); + expect(r.err).not.toContain('empty.json'); + expect(r.out).toMatch(/^SEALED: refused findings=5 commits=1 .* exempt=0 unchecked=5 policy=/m); + const json = JSON.parse(preflight(s, ['--json']).out); + expect(json.findings.map((f: { rule: string; file: string }) => `${f.rule} ${f.file}`).sort()).toEqual(['bad.json', 'bad.jsonl', 'big.json', 'deep.json', 'gone.json'].map(f => `sealed.label_join_unchecked ${f}`)); + }); + + test('allow_paths never exempt the rule; a short-sha waiver is ignored and the full sha waives', () => { + const s = scratch({ '.gstack/pregate.json': policy({ ...LABELED, paths: ['heldout/**'], allow_paths: ['wiki/board/panel.json'] }) }); + const sha = commit(s, { 'wiki/board/panel.json': panel([unit('u1', HEAD_A)]) }); + const r = preflight(s); + expect(r.code).toBe(1); + expect(r.out).toMatch(/findings=1 .* exempt=0 policy=/); + expect(r.err).not.toContain('sealed.allow_paths glob'); + landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.label_join\twiki/board/panel.json\t${sha.slice(0, 12)}\tsynthetic fixture\n` }); + const short = preflight(s); + expect(short.code).toBe(1); + expect(short.err).toContain('ignored waiver sealed.label_join wiki/board/panel.json'); + landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.label_join\twiki/board/panel.json\t${sha}\tsynthetic fixture\n` }); + const full = preflight(s); + expect(full.code).toBe(0); + expect(full.err).toContain('waived sealed.label_join wiki/board/panel.json'); + }); + + test('a push can enable or widen the rule but never disable or narrow it', () => { + const leak = { 'wiki/panel.json': panel([unit('u1', HEAD_A)]) }; + for (const pushed of [{ paths: ['heldout/**'] }, { ...LABELED, identifier_keys: ['zzz'] }, { label_keys: [] as string[], identifier_keys: [] as string[] }, { label_keys: [...LABELED.label_keys, 'head', 'plan_bytes'] }]) { + const s = scratch({ '.gstack/pregate.json': policy(LABELED) }); + commit(s, { '.gstack/pregate.json': policy(pushed), ...leak }); + const r = preflight(s); + expect(r.code).toBe(1); + expect(r.err).toContain('sealed.label_join wiki/panel.json'); + expect(r.err).toContain('head,plan_bytes'); + } + const enable = scratch({ '.gstack/pregate.json': policy({ paths: ['heldout/**'] }) }); + commit(enable, { '.gstack/pregate.json': policy(LABELED), ...leak }); + expect(preflight(enable).err).toContain('sealed.label_join wiki/panel.json'); + }); + + test('label_keys: [] reports exactly what an absent label_keys reports', () => { + const run = (sealed: object) => { + const s = scratch({ '.gstack/pregate.json': policy(sealed) }); + commit(s, { 'wiki/panel.json': panel([unit('u1', HEAD_A)]), 'runs/c1/transcript.jsonl': '{}\n' }); + const norm = (t: string) => t.split(s.root).join('').replace(/[0-9a-f]{12,64}/g, ''); + const text = preflight(s); const json = preflight(s, ['--json']); + return [text.code, norm(text.out), norm(text.err), json.code, norm(json.out)]; + }; + const base = { transcripts: ['runs/**/transcript*.jsonl'] }; + expect(run({ ...base, label_keys: [] })).toEqual(run(base)); + }); +}); diff --git a/test/size-ledger/ship/gst-951-receipt-counts-blockers.json b/test/size-ledger/ship/gst-951-receipt-counts-blockers.json new file mode 100644 index 000000000..7509a6666 --- /dev/null +++ b/test/size-ledger/ship/gst-951-receipt-counts-blockers.json @@ -0,0 +1,4 @@ +{ + "bytes": 177, + "why": "GST-951: summary names its ledgers (--run in DRIVER, --no-ledger in pr-body)" +}