diff --git a/.github/workflows/windows-scoped-tests.yml b/.github/workflows/windows-scoped-tests.yml new file mode 100644 index 000000000..1675d15e7 --- /dev/null +++ b/.github/workflows/windows-scoped-tests.yml @@ -0,0 +1,206 @@ +name: Windows Scoped Tests + +# The path-scoped Windows free shard (ship-90-90 plan D). windows-smoke.yml is +# still the required Windows check on every pull request, and the full +# curated suite (windows-free-tests.yml) still runs nightly on main. This +# workflow sits between them: when a pull request touches path, file-name or +# process code, one windows-latest job runs the test files the pull request +# changed that are in the curated Windows-safe subset. +# +# "Path, file-name or process code" (the windows-scope filter step): +# - file names: anything under a bin/ directory (shell entry points and +# skill hooks such as freeze/bin/check-freeze.sh, #3758), shell and +# PowerShell scripts, hosts/*/hooks, setup, bun.lock, the Windows +# curation (scripts/test-free-shards.ts, scripts/lib/windows-curation.ts, +# the Windows duration seed, windows-free-tests.yml), and code files whose +# names say path, spawn, exec, process, shell, launch, win32, windows, +# tmp, temp, symlink, state-root, slug, claude-bin, bun-isolation or +# script-invocation; +# - added or removed code lines that spawn processes or spell paths: +# child_process, spawn/exec calls, Bun.spawn, Bun.$, path.win32 and the +# other separator-sensitive path calls, process.platform/execPath, win32, +# tmpdir(), /dev/null, /tmp/, symlink, realpath, chmod, cygpath, +# USERPROFILE, homedir(), fileURLToPath and .exe. #3829's Windows break was +# one such line in a test it added: spawnSync(process.execPath, [..., +# '--config=/dev/null']), which native Windows Bun cannot open. +# test/windows-path-scope-workflow.test.ts proves the filter catches #3758 +# and #3829 and passes over a docs-only and a pure-logic change. +# +# The windows-scoped-tests job is the check to read: it passes when nothing +# is in scope (no Windows job ran) and otherwise requires the Windows job, so +# a skipped run reports success. It is not one of the required checks, but +# gstack-ship-queue ready and land refuse a head with any failed check, so a +# red Windows job blocks the pull request it runs on: in a pull request that +# touches path, file-name or process code, a changed curated test file has to +# pass on Windows. A change to path or process code with no changed curated +# test selects nothing; the nightly suite covers it. +# gstack-pregate reads a job keyed on a dispatch input as manual, never a PR +# obligation: this check runs on the pull request itself. A workflow_dispatch +# run with the files input reruns chosen Windows-safe files on a branch. + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + files: + description: Space-separated Windows-safe free test files to run (from bun run test:windows --list) + type: string + required: true + +concurrency: + group: windows-scoped-tests-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +# Test-only lane — no token writes. +permissions: + contents: read + +jobs: + windows-scope: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + files: ${{ steps.select.outputs.files }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: github.event_name == 'pull_request' + with: + fetch-depth: 2 + persist-credentials: false + - id: filter + name: Does the diff touch path, file-name or process code? + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + git diff --name-only --no-renames HEAD^1 HEAD > "$RUNNER_TEMP/changed-files" + code_re='\.(ts|tsx|js|mjs|cjs|sh|bash|ps1|cmd|bat)$|(^|/)bin/[^/.]+$|(^|/)setup$' + path_re='(^|/)bin/|\.(sh|bash|ps1|cmd|bat)$|(^|/)hosts/[^/]+/hooks/|(^|/)setup$|^bun\.lock$|^scripts/test-free-shards\.ts$|^scripts/lib/windows-curation\.ts$|^scripts/free-test-durations-windows\.json$|^\.github/workflows/windows-free-tests\.yml$' + name_re='(path|spawn|exec|process|shell|launch|win32|windows|tmp|temp|symlink|state-root|slug|claude-bin|bun-isolation|script-invocation)[^/]*$' + content_re='child_process|\b(spawn|spawnSync|execSync|execFile|execFileSync)[[:space:]]*\(|Bun\.(spawn|spawnSync|\$)|\bpath\.(win32|posix|sep|delimiter|relative|isAbsolute|normalize)\b|process\.(platform|execPath)|\bwin32\b|tmpdir\(|/dev/null|/tmp/|symlink|realpath|chmod|cygpath|USERPROFILE|homedir\(|fileURLToPath|\.exe\b' + { + grep -E "$path_re" "$RUNNER_TEMP/changed-files" | sed 's/^/path: /' || true + grep -E "$code_re" "$RUNNER_TEMP/changed-files" | grep -E "$name_re" | sed 's/^/name: /' || true + { grep -E "$code_re" "$RUNNER_TEMP/changed-files" || true; } | while IFS= read -r file; do + git diff -U0 --no-renames HEAD^1 HEAD -- "$file" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' | grep -m1 -E "$content_re" | awk -v f="$file" '{ print "content: " f ": " $0 }' || true + done + } > "$RUNNER_TEMP/windows-scope" + if [ -s "$RUNNER_TEMP/windows-scope" ]; then + echo 'windows=true' >> "$GITHUB_OUTPUT" + { echo 'This pull request touches path, file-name or process code:'; echo '~~~'; head -n 40 "$RUNNER_TEMP/windows-scope"; echo '~~~'; } >> "$GITHUB_STEP_SUMMARY" + else + echo 'windows=false' >> "$GITHUB_OUTPUT" + echo 'This pull request touches no path, file-name or process code, so no Windows job runs. windows-smoke still runs on every pull request.' >> "$GITHUB_STEP_SUMMARY" + fi + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.filter.outputs.windows == 'true' + with: + bun-version: 1.4.2 + - id: select + name: Pick the changed test files in the curated Windows-safe subset + if: steps.filter.outputs.windows == 'true' + run: | + set -euo pipefail + GITHUB_STEP_SUMMARY=/dev/null bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/curated.json" --shards 1 > /dev/null + jq -r '.shards[].files[]' "$RUNNER_TEMP/curated.json" | LC_ALL=C sort -u > "$RUNNER_TEMP/curated" + LC_ALL=C sort -u "$RUNNER_TEMP/changed-files" | LC_ALL=C comm -12 - "$RUNNER_TEMP/curated" > "$RUNNER_TEMP/selected" + if [ ! -s "$RUNNER_TEMP/selected" ]; then + echo 'No changed test file is in the curated Windows-safe subset, so no Windows job runs.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + { echo 'files<> "$GITHUB_OUTPUT" + { echo "The Windows job runs $(wc -l < "$RUNNER_TEMP/selected" | tr -d ' ') changed Windows-safe test file(s):"; sed 's/^/- /' "$RUNNER_TEMP/selected"; } >> "$GITHUB_STEP_SUMMARY" + + windows-scoped-shard: + needs: windows-scope + if: ${{ (github.event_name == 'workflow_dispatch' && inputs.files != '') || needs.windows-scope.outputs.files != '' }} + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24.18.0 + + # The same inputs as a windows-free-tests.yml shard, with no bun install + # cache (a cold install beats restoring it on windows-latest). + - name: Configure git identity (required by tests that init temp repos) + run: | + git config --global user.email "windows-ci@gstack.test" + git config --global user.name "Windows CI" + git config --global init.defaultBranch main + shell: bash + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build server-node.mjs (required by Windows browse path) + run: bash browse/scripts/build-node-server.sh + shell: bash + + - name: Generate host SKILL.md outputs (.agents, .factory) + run: bun run gen:skill-docs --host all + shell: bash + + - name: Install Chromium for the Node worker smoke + run: bunx playwright install chromium + + - name: Run the scoped Windows-safe test files + env: + FILES: ${{ inputs.files || needs.windows-scope.outputs.files }} + GSTACK_FREE_RETRY_FLAKY: '1' + GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl + TEMP: ${{ runner.temp }} + TMP: ${{ runner.temp }} + run: | + set -euo pipefail + printf '%s\n' "$FILES" | tr -d '\r' | tr ' ' '\n' | sed '/^$/d' > "$RUNNER_TEMP/windows-scoped-files.txt" + bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-scoped-files.txt" + shell: bash + + - name: Upload full test logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: windows-scoped-test-logs + path: | + .context/free-test-logs/gstack-free-test-*.log + ${{ runner.temp }}/gstack-free-test-*.log + include-hidden-files: true + if-no-files-found: ignore + + - name: Upload flake ledger + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: flake-ledger-windows-scoped + path: ${{ runner.temp }}/flake-ledger.jsonl + if-no-files-found: ignore + retention-days: 90 + + windows-scoped-tests: + needs: [windows-scope, windows-scoped-shard] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Pass when nothing is in scope, else require the Windows job + env: + SCOPE_RESULT: ${{ needs.windows-scope.result }} + SHARD_RESULT: ${{ needs.windows-scoped-shard.result }} + FILES: ${{ inputs.files || needs.windows-scope.outputs.files }} + run: | + set -eu + test "$SCOPE_RESULT" = success + if [ -z "$FILES" ]; then + echo 'Nothing in scope (no changed Windows-safe test file in a pull request that touches path, file-name or process code); passing without a Windows run.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + test "$SHARD_RESULT" = success diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 2ca74e7b4..37cee929a 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -32,6 +32,13 @@ name: Windows Setup E2E # Separate from windows-free-tests.yml because that one runs a curated # unit-test subset; this one exercises the install path itself. # +# Runs by path (ship-90-90 plan D): the paths below are the install files +# this workflow runs or ./setup sources or runs. The windows-scope job then +# drops a pull request whose only install change is package.json's version +# line, which is what the merge-train stamp commit puts on every PR head. +# windows-scope's list must match the paths list (pinned by +# test/windows-path-scope-workflow.test.ts). +# # Runner: GitHub-hosted free windows-latest, two parallel jobs. ~10-25 min. on: @@ -39,6 +46,7 @@ on: branches: [main] paths: - 'package.json' + - 'bun.lock' - 'scripts/build.sh' - 'scripts/build-cso.sh' - 'scripts/build-cso-windows.ps1' @@ -64,6 +72,18 @@ on: - 'bin/gstack-claude-root.sh' - 'bin/gstack-browser-ensure' - 'bin/gstack-path-spelling.sh' + - 'bin/gstack-config' + - 'bin/gstack-patch-names' + - 'bin/gstack-state-root.sh' + - 'bin/gstack-skill-name.sh' + - 'bin/gstack-install-registry.sh' + - 'bin/gstack-render-claude.sh' + - 'bin/gstack-host-renders.sh' + - 'bin/gstack-migrate-claude-code' + - 'bin/gstack-settings-hook' + - 'bin/gstack-relink' + - 'bin/gstack-harness' + - 'scripts/preflight-codex-overlap.ts' - 'lib/path-spelling.ts' - 'hosts/claude/hooks/**' - 'gstack-upgrade/**' @@ -81,7 +101,94 @@ permissions: contents: read jobs: + windows-scope: + # The paths filter above starts this workflow; this job decides whether + # the Windows jobs run. A workflow_dispatch run always runs them. + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + install: ${{ steps.scope.outputs.install }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: github.event_name == 'pull_request' + with: + fetch-depth: 2 + persist-credentials: false + - id: scope + name: Run unless the only install change is package.json's version line + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo 'install=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + pkg_version_only=true + if git diff -U0 --no-renames HEAD^1 HEAD -- package.json | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' \ + | grep -vE '^[+-] "version": "[^"]*",?$' > /dev/null; then + pkg_version_only=false + fi + git diff --name-only --no-renames HEAD^1 HEAD | while IFS= read -r file; do + if [ "$file" = package.json ] && [ "$pkg_version_only" = true ]; then continue; fi + while IFS= read -r glob; do + # shellcheck disable=SC2254 + case "$file" in ${glob//\*\*/*}) echo "$file"; break ;; esac + done <<'PATHS' + package.json + bun.lock + scripts/build.sh + scripts/build-cso.sh + scripts/build-cso-windows.ps1 + scripts/write-version-files.sh + setup + setup.ps1 + test/setup-ps1.test.ts + docs/troubleshooting.md + browse/src/cli.ts + browse/src/find-browse.ts + browse/src/server.ts + browse/src/meta-commands.ts + browse/scripts/build-node-server.sh + design/src/cli.ts + make-pdf/src/cli.ts + bin/gstack-global-discover.ts + bin/gstack-paths + bin/gstack-doctor + bin/gstack-launch-probe.sh + bin/gstack-hook-check + bin/gstack-session-update + bin/gstack-ff-update + bin/gstack-claude-root.sh + bin/gstack-browser-ensure + bin/gstack-path-spelling.sh + bin/gstack-config + bin/gstack-patch-names + bin/gstack-state-root.sh + bin/gstack-skill-name.sh + bin/gstack-install-registry.sh + bin/gstack-render-claude.sh + bin/gstack-host-renders.sh + bin/gstack-migrate-claude-code + bin/gstack-settings-hook + bin/gstack-relink + bin/gstack-harness + scripts/preflight-codex-overlap.ts + lib/path-spelling.ts + hosts/claude/hooks/** + gstack-upgrade/** + .github/workflows/windows-setup-e2e.yml + PATHS + done > "$RUNNER_TEMP/install-files" + if [ -s "$RUNNER_TEMP/install-files" ]; then + echo 'install=true' >> "$GITHUB_OUTPUT" + { echo 'Install files changed, so the Windows jobs run:'; sed 's/^/- /' "$RUNNER_TEMP/install-files"; } >> "$GITHUB_STEP_SUMMARY" + else + echo 'install=false' >> "$GITHUB_OUTPUT" + echo "The only install change is package.json's version line (the merge-train stamp), so the Windows jobs do not run." >> "$GITHUB_STEP_SUMMARY" + fi + windows-setup: + needs: windows-scope + if: needs.windows-scope.outputs.install == 'true' runs-on: windows-latest timeout-minutes: 35 @@ -377,6 +484,8 @@ jobs: # docs/troubleshooting.md, against the runner's real ~/.claude install # path ($HOME in PowerShell). GSTACK_E2E_POWERSHELL and # GSTACK_E2E_UPDATER_PATH_PREFIX exist only to run these steps off Windows. + needs: windows-scope + if: needs.windows-scope.outputs.install == 'true' runs-on: windows-latest timeout-minutes: 50 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7fd0818..b371c3a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## [1.94.15.0] - 2026-11-03 + +- **A stacked PR keeps its /ship receipt after its parent squash-merges, even when the receipt was written against main (GST-1199).** `gstack-ship-receipt carry` used to refuse with `RECEIPT_CARRY_PRODUCT_CHANGED` once the parent's hunks moved into main, forcing a full re-review of unchanged code. The carry now finds the newest reviewed commit that main already holds (`git merge-tree` into main changes no product path) and compares from there: an unchanged child carries as `stack parent landed`, base edits next to the child's hunks go to `RECEIPT_CARRY_SCOPED_REVIEW`, and any other change still refuses. A parent that never landed never counts, even if the child's merge dropped its change. + +## [1.94.14.0] - 2026-11-03 + +**`review_exclude_paths` keeps code it used to exclude, and the docs say exactly what the receipt check stops (GST-972).** Three ways to get code or a credential out of review under a base pattern are closed, each with a failing-first test in `test/review-exclude.test.ts`, and the receipt docs no longer over-claim. + +### Fixed +- **Native binaries are code.** A `.node`, `.so`, `.dylib` or `.dll` file under a base pattern was excluded as binary content, and so was a compiled executable with a data extension. A binary file that starts with ELF, Mach-O (universal binaries included), PE, WebAssembly or `#!` magic is now kept as `code` whatever its extension. Binary here is git's own verdict on the bytes, read with no attributes from the tree as well as with the branch's, so a branch's `.gitattributes` (`*.png diff -binary`) cannot make an ELF read as text and skip the check (#3910). +- **A NUL byte no longer makes code excludable.** A binary file was content unless its extension was on a short source list, so `.gradle`, `.sql`, `.tf`, `.groovy`, a `.p12` key store or an extensionless private key with a NUL byte was excluded. A binary file is now content only under a known content extension (images, fonts, media, documents, compressed archives, `.bin`, `.dat` and data blobs); every other binary file is kept as `code`. +- **A branch's `.gitattributes` cannot get a file with a credential line excluded.** Marking `receipts/**` binary left the collector's added lines empty, so the secret-line keep rule saw nothing and the file was excluded. For a file git calls binary only because of its attributes, the rule now reads `git diff --text --no-textconv --no-ext-diff`, so the file stays in review and the plan lists it as `REVIEW_EXCLUDE_KEPT: secret-line`. Readers' own diffs still follow the branch's attributes and show that file as `Binary files differ`; reading such files as text in every reader's diff is a separate follow-up. +- **`gstack-ship-receipt write --review-exclude` docs are exact.** The help, `docs/troubleshooting.md`, `docs/ship-policy.md` and the queue's refusal line said a receipt write alone cannot clear `field=review_exclude`. Write checks the digest against the base policy and does not read the review ledger, so a write given the refusal's `expected=` digest passes without a review. A mismatch proves only that the `--review-exclude` argument (or the digest the receipt recorded from it) differs from the head's selection, not what the readers read. The text now says that, and that the review rerun is the fix. + +## [1.94.13.0] - 2026-11-03 + +- **A bare `--base main` reads origin/main in every review and ship bin (GST-1036).** `gstack-review-plan` now prints `REVIEW_BASE: ` and names a lagging local base on stderr. `gstack-banned-terms`, `gstack-prose-lint --base`, `gstack-docs-candidate`, `gstack-design-detect.ts scan --changed` and `gstack-pr-shots --base` no longer merge against a stale local main and report lines the branch only inherited. They all use the one resolver gstack-gate-diff already used. +- **`gstack-diff-scope` stops treating authorship files as auth (GST-1065).** `author-spec.md`, `AUTHORS`, `authority.ts` and `coauthors.ts` no longer raise a docs diff to gate class. Every other name with auth in it still does, including `auth.ts`, `/auth/`, `oauth`, `nextauthmiddleware.ts` and camelCase names like `useAuth.ts`. + +## [1.94.12.0] - 2026-11-03 + +### Fixed + +- **Six markup tricks no longer pass a remote-loading or scripted SVG off as a chart (GST-940).** `</style>` or `<!--` inside a `` let CSS that loads a remote image read as a plain drawing, and a non-ASCII namespace prefix such as `` hid a script. Any of these could get the SVG a light review. The style walker now reads the ``, comment that only an HTML parser ends early, quoted `>` or Unicode space can hide CSS. A non-ASCII character in an element or attribute name, such as a `gé:` prefix, keeps the SVG code too (GST-940); anything else, a script or `foreignObject` included, keeps it code. More than 2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed` and fails the depth closed. The scan first lists the files that contain a candidate's @@ -384,7 +387,7 @@ and matching is token-bounded: `costume` never reads as cost. |---|---| | money | a path segment naming cost, spend, budget, ledger, billing, price, fee or invoice; or a changed line with an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid provider `lib/pricing.ts` prices | | gate | a hunk the gate scan tagged: a relaxed threshold, a skipped test, a loosened check | -| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name; a data file named for sessions (`recovered-sessions.jsonl`) outside a config location does not set `SCOPE_AUTH` | +| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name; a data file named for sessions (`recovered-sessions.jsonl`) outside a config location does not set `SCOPE_AUTH`, and `SCOPE_AUTH` fails safe: any name token containing auth sets it (`auth.ts`, `/auth/`, `oauth`, `useAuth.ts`, `nextauthmiddleware.ts`) except the authorship words `author`, `authors`, `authored`, `authoring`, `authorship`, `authority`, `authorities`, `coauthor` and `coauthors` | | secret | a secrets, credentials, keychain or token path, or a changed line that reads a key-named environment variable (`OPENAI_API_KEY`, `GH_TOKEN`) or a credential store | An input the classifier cannot read (the diff, `gstack-diff-scope`, the gate @@ -493,6 +496,21 @@ through the docs stage. A path outside that scope and a gate-edited test still route as behavior, with `--reviewed-base` (as /ship passes it) so does a file too large for the scoped review, and without the record nothing changes. +A completed fix-check is a review, not an owner acceptance (GST-1130). Finish +reads the newest `review-fix-check` row from `gstack-review-read`. When it is a +completed clean read on committed content (freshness `CURRENT` against its own +tree) whose commit is on the branch, whose `range_base` is a branch commit +holding the reviewed tree, and for which an approved `review_loop_grant` in +`gstack-approvals` names a stop head inside `range_base..commit`, each code or test path inside `range_base..commit` +that is unchanged since routes as `fix_checked=` in `ROUTE:` and is listed on a +`FIX_CHECK: reviewed` line, a gate-edited test included. Authored docs keep the +stage 2.2 recheck, and a path edited after the fix-check still routes by the +rules above. Any other row prints +`FIX_CHECK: not counted ()` and changes nothing. `gstack-review-log` +refuses a `review-fix-check` row without boolean `completed` and `converged`, +before it consumes the start token, because without them the row reads +`UNVERIFIED` and finish cannot count it. + The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A receipt carried across a base merge or a test-only fix diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 004766f17..f25e55b85 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -58,7 +58,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. | | `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log ` from its run on the gated head. Needs `min_gstack_version`. | | `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. | -| `review_exclude_paths` | git glob pathspecs | `[]` | Content files (data, docs, binary, `.log`) `/ship`'s reviewers leave out; read from the base only, with code, gate, config, executable and named-by-code files kept. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. | +| `review_exclude_paths` | git glob pathspecs | `[]` | Content files `/ship`'s reviewers leave out: data, docs, `.log`, and binaries under a content extension (images, fonts, archives and the like) that do not start with executable magic. Read from the base only. Code (native libraries and every other binary outside the content extensions included), gate paths, agent prose, config, executables, links, `.gstack/` and `.github/` files, gate-tagged, secret-line and named-by-code files stay in review. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. | | `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | | `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. | | `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. | @@ -1287,8 +1287,8 @@ A pattern that matches no changed file is listed as `unmatched=` on the plan lin **What is excluded, and what is kept.** A matched file is excluded only when it is content: a data file (JSON, JSONL, CSV, TSV, images), a docs-class file, a -`.log` file, or a file git reports as binary (`-\t-` in `--numstat`; readers would only see `Binary files differ`) whose bytes hold a NUL in their first 8,000, so a branch's `.gitattributes` cannot make a text file binary content. A binary file is content even under -`config/` or in a skill directory; only `policy-dir`, `gate-path`, `executable`, `link`, `gate-tagged`, or a source or script extension (`code`) keep it. +`.log` file, or a binary file: one git reports as binary (`-\t-` in `--numstat`; readers would only see `Binary files differ`) under the branch's attributes or with no attributes from the tree, so a branch's `.gitattributes` cannot make an executable read as text, and whose bytes hold a NUL in their first 8,000, so it cannot make a text file binary content either. A binary file is content only under a known content extension (images, fonts, media, documents, compressed archives, `.bin`, `.dat`, and data blobs such as `.parquet`, `.npy` or `.sqlite`) and only when its bytes do not start with an executable image's magic (ELF, Mach-O including universal binaries, PE, WebAssembly, `#!`). Under those rules it is content even under +`config/` or in a skill directory, and only `policy-dir`, `gate-path`, `executable`, `link` or `gate-tagged` keep it. Every other binary file is `code`: source with a NUL, a native library (`.node`, `.so`, `.dylib`, `.dll`), a compiled executable, a key store, a pickle, or a file with no extension. Everything else stays in every reader's diff with one reason code: | Reason | Kept because | @@ -1300,8 +1300,8 @@ Everything else stays in every reader's diff with one reason code: | `agent-prose` | it is a skill, section, resolver, template or rule file | | `config` | it is a data-shaped config file (`package.json`, `tsconfig*.json`, `config/`, dotfiles) | | `gate-tagged` | the gate scan tagged one of its hunks | -| `code` | it is code or a test, not content; a binary file with a source or script extension (`.js`, `.py`, `.sh`, `.wasm`) counts as code | -| `secret-line` | an added line reads a credential (the targeted pass's secret rule) | +| `code` | it is code or a test, not content; a binary file outside the content extensions above, or one with executable magic, counts as code | +| `secret-line` | an added line reads a credential (the targeted pass's secret rule); for a file git calls binary only because of its attributes, the added lines come from `git diff --text`, which `.gitattributes` cannot change (readers' own diffs still follow the attributes and show the kept file as binary) | | `named-by-code` | a code or test file that stays in review (not docs, a data file, a `.log` or binary, and not itself excluded) names its full repository path, at the head or the merge base | | `consumer-scan-unavailable` | the `git grep` for those names failed, so every candidate is kept; also a live binary file whose bytes could not be read | | `gate-scan-unavailable` | the gate scan failed or did not run at the head, so no hunk's tag is known and every candidate is kept | @@ -1351,11 +1351,15 @@ paths and three hashes). The `digest` hashes the declared patterns and the excluded and kept lists; `source` is provenance only. Write, carry and the queue each recompute the digest at the current base and compare it, so a base merge or a moved base that leaves the patterns and the lists unchanged still carries -and binds. A different digest means the reviewers read another selection: +and binds. A different digest means the digest write was given (or the receipt recorded) names another selection than the head has now: write refuses with `RECEIPT_INVALID field=review_exclude`, carry with `RECEIPT_CARRY_REFUSED review_exclude changed`, and the queue with `RECEIPT_INVALID field=review_exclude expected= recorded=`. The -fix is to rerun the review, never a receipt write alone. +fix is to rerun the review and write with the digest its `verify` prints. Write +checks the digest against the base policy, so a stale selection cannot be +recorded by accident, but it does not read the review ledger: a write given the +`expected=` digest passes without a review, and only the review run shows that +the reviewers read that selection. **Adopting it.** (1) Open a PR on the base that adds the patterns and `min_gstack_version` at least `REVIEW_EXCLUDE_KEY_VERSION`, and ship it like any diff --git a/docs/support-tiers.md b/docs/support-tiers.md index 05b01b4aa..b69b507a9 100644 --- a/docs/support-tiers.md +++ b/docs/support-tiers.md @@ -18,7 +18,7 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native ## What we stop doing - No Windows-only or Gemini-only features, parity work, tuning or polish. -- No new Windows or Gemini CI jobs or eval cells. Windows CI tuning stops. +- No new Windows or Gemini CI jobs or eval cells, apart from the path-scoped Windows checks under Windows CI below (ship-90-90 plan D). Other Windows CI tuning stops. - A Windows-only or Gemini-only report that doesn't break install, startup or a core workflow closes as won't fix ([triage rules](triage.md#closing-an-issue)), with a link to this page. A report that does break one of them gets a small fix. ## Windows CI @@ -26,6 +26,7 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native - `windows-smoke` is the required Windows check on pull requests. - The six-shard Windows free suite (the `Windows Free Tests` workflow, `windows-free-tests`) runs nightly on main, or when someone starts it by hand, and never blocks a pull request. - A red nightly run gets a fix only when it shows an install, startup or core-workflow break. +- Windows checks run by path (ship-90-90 plan D, approved 2026-11-03): `Windows Scoped Tests` runs a pull request's changed Windows-safe test files on one Windows job only when it touches path, file-name or process code (a red run blocks that pull request, like any failed check), and `Windows Setup E2E` runs only when install files change (a version-only `package.json` change doesn't count). ## What doesn't change diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f220a5ea7..f0aca0832 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3340,10 +3340,13 @@ targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verif use this code when the receipt's review_exclude digest (or the `--review-exclude ` write was given) is not the digest HEAD has under the base policy's [`review_exclude_paths`](ship-policy.md#review-exclude-paths). The line -names `expected=` (now), `recorded=` (what the review read, `none` when it read -without exclusion) and `source=` (the base commit). The reviewers read another -selection than the one the head now has, so a receipt write alone cannot clear -it: rerun `/ship`'s review on the head (`gstack-review-dispatch pass --base `), +names `expected=` (now), `recorded=` (the `--review-exclude` argument, or the digest +the receipt recorded from it; `none` without one) and `source=` (the base commit). +A mismatch proves only that this argument differs from the selection the head has +now. Write compares the argument and does not read the review ledger, so the line +says nothing about what the readers read, and a write that passes the `expected=` digest clears +the field without a review. Do not do that. Rerun +`/ship`'s review on the head (`gstack-review-dispatch pass --base `), run `gstack-review-dispatch verify --ledger `, which prints `REVIEW_EXCLUDE_RECORD: digest=` from the briefs the readers got, then `gstack-ship-receipt write --pr --review-exclude --body `. diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts index adc46fbbf..daebe14d4 100644 --- a/lib/gate-diff/git.ts +++ b/lib/gate-diff/git.ts @@ -137,6 +137,27 @@ export function resolveBase(ref: string, cwd: string, head?: string): { ref: str return sha ? { ref, sha } : null; } +export type ResolvedBase = NonNullable>; + +/** Why resolveBase read another ref than the name given (`local main is behind origin/main; base is origin/main`); null when the name stood as given. */ +export function baseNote(name: string, base: ResolvedBase): string | null { + if (base.local === 'fallback') return `${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${name}`}; base is the local ${name}, which may lag its remote`; + if (!base.local) return null; + return `${{ unrelated: `local ${name} shares no history with HEAD`, missing: `no local branch ${name}`, behind: `local ${name} is behind ${base.ref}`, ahead: `local ${name} is ahead of ${base.ref}`, diverged: `local ${name} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`; +} + +/** + * A --base argument as the commit a review or ship bin reads (GST-1036): resolveBase against HEAD, so a bare `main` + * reads origin/main over a stale local main, with the note on stderr as `: ` when it read another ref than + * the name. An unresolvable name comes back as given, so the caller's own git error still names it. + */ +export function baseArg(bin: string, name: string, cwd: string): string { + const base = resolveBase(name, cwd, 'HEAD'); + const note = base && baseNote(name, base); + if (note) process.stderr.write(`${bin}: ${note}\n`); + return base?.sha ?? name; +} + /** A word for a printed shell line: as is when it is plain, single-quoted otherwise (ref names may hold `;`, `$`, `(`, `{1,2}`). */ function shellWord(s: string): string { return /^[A-Za-z0-9._\/@^~+:-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`; diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 1749944fc..faf23822f 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -83,6 +83,8 @@ export interface PregateCheck { /** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */ export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> } export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string; command?: string; substitution?: PregateReceiptSubstitution } +/** An owner-granted flaky exception (lib/pregate/flaky-exception.ts): the runs, cases and grant that cleared one obligation, bound to one head and tree. */ +export interface PregateException { lane: string; repo: string; pr: number; head: string; tree: string; runs: string[]; cases: string[]; grant: string; by: string; words: string; binding: string } export interface PregateFile { schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number; // C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict. @@ -93,6 +95,8 @@ export interface PregateFile { policy_user_path?: string; /** This run's scratch directory under the state root (logs, scan files). */ scratch?: string; + /** Obligations cleared from runs with failures under an owner's grant (`clear --flaky-exception`). */ + exceptions?: PregateException[]; } export interface ShipReceipt { schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string; @@ -123,6 +127,8 @@ export interface ShipReceipt { review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: string }; /** Step 5 failures gstack-base-red proved are the base's own (lib/base-red.ts): a conditional receipt the queue refuses until /ship --resume rewrites it without this block. */ base_red?: { cases: string[]; files: string[]; base: string; lane: string; expires_at: string }; + /** The pregate's owner-granted flaky exceptions (gstack-pregate clear --flaky-exception): pregate only, never a required CI check. */ + flaky_exception?: Array<{ lane: string; runs: string[]; cases: string[]; grant: string; by: string }>; /** The base policy's review_exclude_paths at the reviewed head (lib/review-exclude.ts); absent when the base declares none. Carry and the queue compare `digest`. */ review_exclude?: { source: string; patterns: string[]; applied: string[]; excluded: number; excluded_bytes: number; kept: number; gate_tagged: string[]; patterns_sha256: string; list_sha256: string; digest: string }; } @@ -234,6 +240,7 @@ export const ARTIFACT_SCHEMAS = { head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str, requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str, substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } }, + exceptions: { type: 'array', items: { type: 'object', required: ['lane', 'repo', 'pr', 'head', 'tree', 'runs', 'cases', 'grant', 'by', 'words', 'binding'], properties: { lane: str, repo: str, pr: { type: 'integer' }, head: str, tree: str, runs: strList, cases: strList, grant: str, by: str, words: str, binding: str } } }, }, }, 'ship-receipt': { @@ -250,6 +257,7 @@ export const ARTIFACT_SCHEMAS = { carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, head_tree: str, product_patch_id: str, gstack_version: str, base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } }, + flaky_exception: { type: 'array', items: { type: 'object', required: ['lane', 'runs', 'cases', 'grant', 'by'], properties: { lane: str, runs: strList, cases: strList, grant: str, by: str } } }, review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data'] } } } }, review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } }, review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } }, diff --git a/lib/pr-shots.ts b/lib/pr-shots.ts index 821569dc8..0e8c4659a 100644 --- a/lib/pr-shots.ts +++ b/lib/pr-shots.ts @@ -17,6 +17,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { resolveBase } from './gate-diff/git'; import { findSection } from './markdown-sections'; import { scan } from './redact-engine'; import type { ResultCodeName } from './result-codes'; @@ -55,9 +56,11 @@ export function readCapture(file: string, roots: string[], what: string): string return cleanText(fs.readFileSync(real), `${what} file`); } -/** Resolve `ref` to a commit sha without letting it be read as an option. */ +/** Resolve `ref` to a commit sha without letting it be read as an option; a bare branch name reads origin's tracking branch first (resolveBase, GST-1036). */ export function resolveCommit(cwd: string, ref: string): string | ShotError { if (!ref || ref.startsWith('-') || /\s/.test(ref)) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `ref ${JSON.stringify(ref)} is not a plain ref` }; + const base = resolveBase(ref, cwd, 'HEAD'); + if (base) return base.sha; const r = git(cwd, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]); return r.status === 0 ? r.stdout.trim() : { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: `base ref ${ref}` }; } diff --git a/lib/pregate/flaky-exception.ts b/lib/pregate/flaky-exception.ts new file mode 100644 index 000000000..107e4f125 --- /dev/null +++ b/lib/pregate/flaky-exception.ts @@ -0,0 +1,403 @@ +/** + * pregate flaky exception (GST-1096, docs/pregate.md#flaky-exception): an owner-granted exception that lets GitHub + * Actions runs with failures clear one requires-remote obligation. `gstack-pregate clear --remote --run + * --flaky-exception "::" … --pr --grant ` (lib/pregate/index.ts clearByRuns) takes + * the runs only when: + * + * - every failure is named from the run's own records: each job of the attempt reconciles (only test-shard jobs, and + * the aggregate that needs them, may fail), the plan and every shard's strict result are present and complete (a + * timed-out or unattributed shard is incomplete evidence), and each failed shard's log names no failure its result + * omits; + * - the named cases are exactly the failures, at most three, none in a paid test file or a test of a security scan, + * and each file is outside the PR's diff and the diff's import closure (lib/pregate/import-graph.ts); + * - the runs tested pregate.json's own head and tree (no stamp-only substitution), the PR's head is that head, and an + * approved gstack-approvals row in the owner's words carries the scope `exceptionBinding` computes over repository, + * PR, head, tree, lane, runs with their attempts and cases, so it cannot be rebound to another head or case list. + * + * It is pregate only: nothing here touches a required CI check, which still needs its own green run at the head. + * base_red is not used: it proves a failure by reproducing it on the base, and a timing flake passes there. + */ +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; +import { locate, readRecord } from '../approval-record'; +import type { PregateException, PregateFile } from '../headless-artifacts'; +import { parseRunRef, verifyRun, type ApiJob, type ApiRun, type RunArtifactDeps, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence'; +import { wtreeFingerprint } from '../script-invocation'; +import { resolveBaseBranch } from '../ship-policy'; +import type { FreeCiPlan, FreeCiResult, FreeShardOutcome } from '../../scripts/test-free-shards'; +import { isConfigUnavailable, loadConfigFromBase } from './config'; +import { buildGraph, testsFor } from './import-graph'; + +export type FailureDeps = RunArtifactDeps; +export interface GrantRow { id: string; outcome: string; scope: string; words: string; by: string } +export interface FlakyInput { cases: string[]; pr?: number; grant?: string; grantRow?: (id: string) => GrantRow | null } +export type FlakyRefusal = { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string }; +/** What a flaky exception is granted for; `exceptionBinding` hashes exactly these fields. */ +export type ExceptionSubject = Pick; +export interface FlakyRuns { evidence: RunEvidence[]; red: Array<{ evidence: RunEvidence; failures: string[] }> } + +export const MAX_FLAKY_CASES = 3; +export const FLAKY_SCOPE_PREFIX = 'flaky-exception:'; +/** The receipt field an exception clear adds; verify and verdictOf read it back. */ +export const EXCEPTION_RECEIPT = / exception=(\S+)/; + +/** The strict shard runner's artifacts and job names (scripts/test-free-shards.ts --ci-run) in the workflows that upload them. */ +interface ShardLayout { plan: string; result: string; logs: string; shardJob: string; aggregate: string } +const SHARD_LAYOUTS: Record = { + 'free-tests.yml': { plan: 'free-plan', result: 'free-result-', logs: 'free-test-shard-logs-', shardJob: 'free-suite', aggregate: 'free-tests' }, + 'windows-free-tests.yml': { plan: 'windows-plan', result: 'windows-result-', logs: 'windows-free-test-shard-logs-', shardJob: 'windows-free-shard', aggregate: 'windows-free-tests' }, +}; +/** A test of a security scanner (/cso, secrets, gitleaks, the sealed guard, redaction, OSV): never excepted. */ +const SECURITY_TEST = /(?:^|[-_.])(?:cso|secrets?|gitleaks|sealed|redact\w*|osv|security|sanitize\w*|injection|inject)(?=[-_.])/; +const SHA = /^[0-9a-f]{40}$/; +const LOG_FILE_HEADER = /^(\S.*\.test\.(?:[cm]?[jt]s|tsx|jsx)):$/; + +const sha256 = (text: string) => createHash('sha256').update(text).digest('hex'); +const short = (s: string | undefined) => (s ?? '').slice(0, 12); +const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +/** The gstack-approvals scope that grants one exception: a digest of every field it binds, cases in sorted order. */ +export function exceptionBinding(e: ExceptionSubject): string { + const lines = [`lane=${e.lane}`, `repo=${e.repo}`, `pr=${e.pr}`, `head=${e.head}`, `tree=${e.tree}`, ...e.runs.map(r => `run=${r}`), ...[...e.cases].sort().map(c => `case=${c}`)]; + return FLAKY_SCOPE_PREFIX + sha256(lines.join('\n')); +} + +/** The approvals rows of this checkout's branch record (lib/approval-record.ts), looked up by id. */ +export function approvalGrants(repoRoot: string, env: NodeJS.ProcessEnv = process.env): (id: string) => GrantRow | null { + return id => { + const loc = locate({ cwd: repoRoot, env }); + return 'code' in loc ? null : readRecord(loc).rows.find(r => r.id === id) ?? null; + }; +} + +/** The `--flaky-exception` values as `::` strings, or the usage refusal. */ +export function parseCases(cases: string[]): string[] | FlakyRefusal { + if (cases.length > MAX_FLAKY_CASES) return { refused: 'usage', message: `--flaky-exception names at most ${MAX_FLAKY_CASES} cases; got ${cases.length}` }; + const bad = cases.find(c => { const i = c.indexOf('::'); return i <= 0 || i + 2 >= c.length; }); + if (bad !== undefined) return { refused: 'usage', message: `--flaky-exception takes "::", the file and the test name as the run printed them; got ${JSON.stringify(bad)}` }; + if (new Set(cases).size !== cases.length) return { refused: 'usage', message: '--flaky-exception names each case once' }; + return cases; +} + +/** One-line summary for verify's PREGATE line: `pass-with-exception (; grant )`. */ +export function describeExceptions(file: PregateFile): string | null { + const list = file.exceptions ?? []; + if (!list.length) return null; + return `pass-with-exception (${list.flatMap(e => e.cases).join(', ')}; grant ${list.map(e => e.grant).join(', ')})`; +} + +/** READY's line for each exception: it waives the pregate obligation, never a required CI check. */ +export function flakyExceptionLines(file: PregateFile): string[] { + return (file.exceptions ?? []).map(e => `FLAKY_EXCEPTION: ${e.lane} cases=${e.cases.join(' | ')} run=${e.runs.join(',')} grant=${e.grant} by=${e.by} (pregate only; every required CI check still needs its own green run at this head)`); +} + +/** verify's offline check: every exception receipt has its entry, bound to this head and tree, with an intact binding; null when all hold. */ +export function exceptionProblem(file: PregateFile): string | null { + const list = file.exceptions ?? []; + const remote = file.requires_remote ?? []; + for (const r of remote) { + const grant = EXCEPTION_RECEIPT.exec(r.receipt ?? '')?.[1]; + if (!grant) continue; + const e = list.find(x => x.lane === r.lane && x.grant === grant); + if (!e) return `${r.lane}'s receipt names flaky exception grant ${grant}, but pregate.json has no exceptions entry for it; clear again`; + if (e.head !== file.head || e.tree !== file.tree) return `${r.lane}'s flaky exception (grant ${grant}) is bound to head ${short(e.head)} tree ${short(e.tree)}, not pregate.json's head ${short(file.head)} tree ${short(file.tree)}; a new head needs a new run and a new grant`; + if (exceptionBinding(e) !== e.binding) return `${r.lane}'s flaky exception (grant ${grant}): the binding does not match its recorded fields (granted scope ${e.binding}); clear again`; + const runs = / runs=(\S+)/.exec(r.receipt ?? '')?.[1]; + if (runs !== e.runs.join(',')) return `${r.lane}'s receipt names runs ${runs ?? 'none'}, but its flaky exception (grant ${grant}) was granted for ${e.runs.join(',')}; clear again`; + } + const orphan = list.find(e => !remote.some(r => r.lane === e.lane && EXCEPTION_RECEIPT.exec(r.receipt ?? '')?.[1] === e.grant)); + return orphan ? `pregate.json records a flaky exception for ${orphan.lane} (grant ${orphan.grant}) that no receipt uses; clear again` : null; +} + +/** Why `file`'s exceptions cannot stand for this PR head: each binds the PR and head it was granted for, and a same-tree commit or another PR reusing the record never inherits it. */ +export function exceptionConsumerProblem(file: PregateFile, head: string, pr?: string): string | null { + const e = (file.exceptions ?? []).find(x => x.head !== head || (pr !== undefined && String(x.pr) !== pr)); + return e ? `flaky exception grant ${e.grant} is bound to PR #${e.pr} at ${short(e.head)}, not ${pr ? `PR #${pr} at ` : ''}${short(head)}; a new head needs a new run and a new grant` : null; +} + +/** Why red run `id` no longer stands as completed, failed attempt `attempt`; read again once the whole clear is assembled. */ +export function redRunChanged(deps: RunDeps, id: number, attempt: number): string | null { + const again = deps.run(id); + return again.run_attempt === attempt && again.status === 'completed' && again.conclusion === 'failure' ? null : `run ${id} moved to attempt ${again.run_attempt} (${again.status === 'completed' ? again.conclusion : again.status}) while it was being verified`; +} + +/** verify's ok-line suffix naming each exception. */ +export function exceptionSummary(file: PregateFile): string { + return (file.exceptions ?? []).map(e => `flaky exception ${e.cases.join(', ')} (grant ${e.grant}, by ${e.by}, run ${e.runs.join(',')})`).join('; '); +} + +const invalid = (lane: string, reason: string, detail: string, fix?: string): FlakyRefusal => ({ refused: 'PREGATE_RECEIPT_INVALID', message: `FLAKY_EXCEPTION: refused reason=${reason} — ${detail}; ${lane} stays uncleared`, ...(fix ? { fix } : {}) }); +const errText = (e: unknown) => String((e as Error)?.message ?? e).slice(0, 300); + +/** + * The runs for `names` (one per profile, matched by workflow): a green run verifies as `clear --run` verifies it, a red + * run through `readRunFailures`. At least one must be red, or no exception is needed. + */ +export function flakyRuns(lane: string, check: { repo: string; deps: RunDeps; profiles: Record; now: Date; failures?: FailureDeps }, names: string[], refs: string[], vetoHead: string | undefined): FlakyRuns | FlakyRefusal { + if (!check.failures) return { refused: 'usage', message: 'the run artifacts cannot be read here (no GitHub origin), so failures cannot be named' }; + const runs: Array<{ ref: string; id: number; run: ApiRun }> = []; + for (const ref of refs) { + const id = parseRunRef(ref, check.repo); + if (id === null) return invalid(lane, 'run_ref_invalid', `${JSON.stringify(ref)} is not a run id or a run URL on ${check.repo}`); + try { runs.push({ ref, id, run: check.deps.run(id) }); } + catch (e) { return invalid(lane, 'api_error', `reading run ${id} failed: ${errText(e)}`, `check gh auth status and that gh can read ${check.repo}'s Actions runs, then rerun the same clear`); } + } + if (runs.every(r => r.run.status === 'completed' && r.run.conclusion === 'success')) return invalid(lane, 'run_green', `${runs.map(r => `run ${r.id} attempt ${r.run.run_attempt}`).join(', ')} passed; clear with --run alone, no exception is needed`); + const out: FlakyRuns = { evidence: [], red: [] }; + const used = new Set(); + for (const name of names) { + const profile = check.profiles[name]!; + const pick = runs.find(r => !used.has(r.id) && r.run.path === `.github/workflows/${profile.workflow}`) ?? runs.find(r => !used.has(r.id)); + if (!pick) return { refused: 'usage', message: `${lane} needs one run per profile (${names.join(', ')})` }; + used.add(pick.id); + if (pick.run.conclusion === 'success') { + const v = verifyRun(check.deps, { repo: check.repo, runRef: pick.ref, profileName: name, profile, vetoHeads: vetoHead ? [vetoHead] : [], now: check.now }); + if (!v.ok) return invalid(lane, v.reason, `${name}: ${v.detail}`); + out.evidence.push(v.evidence); + continue; + } + const r = readRunFailures(check.deps, check.failures, { repo: check.repo, id: pick.id, run: pick.run, profileName: name, profile, now: check.now }); + if (!r.ok) return invalid(lane, r.reason, r.detail); + out.evidence.push(r.evidence); + out.red.push({ evidence: r.evidence, failures: r.failures }); + } + return out; +} + +type Read = { ok: true } & T | { ok: false; reason: string; detail: string }; +const fail = (reason: string, detail: string) => ({ ok: false as const, reason, detail }); + +/** The one JSON file of artifact `name`, parsed; a message when it is missing, ambiguous or unreadable. */ +function jsonArtifact(art: FailureDeps, id: number, name: string): T | { error: string } { + let files: Record; + try { files = art.artifactFiles(id, name); } catch (e) { return { error: errText(e) }; } + const json = Object.entries(files).filter(([f]) => f.endsWith('.json')); + if (json.length !== 1) return { error: `${name} holds ${json.length} JSON files, not one` }; + try { return JSON.parse(json[0]![1]) as T; } catch (e) { return { error: `${json[0]![0]} is not JSON: ${errText(e)}` }; } +} + +const isError = (v: unknown): v is { error: string } => !!v && typeof v === 'object' && 'error' in v && typeof (v as { error: unknown }).error === 'string'; +const escapeRe = (s: string) => s.replace(/[.*+?^${{}()|[\]\\]/g, '\\$&'); + +/** + * A red run under `profile` read from its own records (B8: a red run uploads no tree.json): every job of the attempt, the + * plan, every shard's strict result and each failed shard's logs. Returns the evidence (the plan's files, the tests the + * shards ran) and the failed cases as `::`, or the refusal naming what could not be reconciled. + */ +function readRunFailures(deps: RunDeps, art: FailureDeps, o: { repo: string; id: number; run: ApiRun; profileName: string; profile: RunProfile; now: Date }): Read<{ evidence: RunEvidence; failures: string[] }> { + const { run, profile: p, id } = o; + const layout = SHARD_LAYOUTS[p.workflow]; + if (!layout) return fail('no_shard_results', `${p.workflow} uploads no strict shard results gstack can read (only ${Object.keys(SHARD_LAYOUTS).join(' and ')} do), so its failures cannot be named`); + const at = `run ${id} attempt ${run.run_attempt}`; + try { + if ((run.repository?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return fail('other_repository', `${at} belongs to ${run.repository?.full_name ?? 'an unknown repository'}, not ${o.repo}`); + if ((run.head_repository?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return fail('fork_run', `${at} ran for ${run.head_repository?.full_name ?? 'an unknown repository'}, not ${o.repo}`); + if (run.path !== `.github/workflows/${p.workflow}`) return fail('workflow_mismatch', `${at} ran ${run.path ?? 'an unknown workflow'}, not .github/workflows/${p.workflow}`); + if (!p.events.includes(run.event)) return fail('event_mismatch', `${at} is a ${run.event} run; ${o.profileName} accepts ${p.events.join(', ')}`); + if (run.status !== 'completed') return fail('run_in_progress', `${at} is ${run.status}`); + if (run.conclusion !== 'failure') return fail('run_not_failure', `${at} concluded ${run.conclusion}; only a run that finished with test failures can carry an exception (a cancelled or timed-out run is incomplete evidence)`); + if (!SHA.test(run.head_sha)) return fail('api_error', `${at} has no head commit`); + const jobs = deps.jobs(id, run.run_attempt); + const finishedAt = jobs.map(j => j.completed_at).filter((t): t is string => !!t).sort().pop() ?? run.updated_at; + const finished = Date.parse(finishedAt ?? ''); + const maxAge = p.max_age_hours ?? 24; + if (!Number.isFinite(finished)) return fail('api_error', `${at} has no completion time`); + if (o.now.getTime() - finished > maxAge * 3600_000) return fail('run_stale', `${at} finished ${finishedAt}, more than ${maxAge} h before now`); + const newer = deps.runsForHead(p.workflow, run.head_sha).find(r => r.id !== id && (r.run_started_at ?? r.created_at ?? '') >= (run.created_at ?? '')); + if (newer) return fail('newer_run', `run ${newer.id} of ${p.workflow} on ${short(run.head_sha)} started after ${at}; clear with the newest run`); + const shardJobs = reconcileJobs(jobs, layout, p, at); + if ('reason' in shardJobs) return fail(shardJobs.reason, shardJobs.detail); + const plan = jsonArtifact(art, id, layout.plan); + if (isError(plan)) return fail('shard_incomplete', `${at} has no readable ${layout.plan} (${plan.error})`); + if (plan.version !== 1 || typeof plan.id !== 'string' || typeof plan.revision !== 'string' || !Array.isArray(plan.shards) || !plan.shards.length + || plan.shards.some((s, i) => s.shard !== i + 1 || !Array.isArray(s.files) || s.files.some(f => typeof f !== 'string'))) return fail('result_mismatch', `${at}'s ${layout.plan} is not a strict shard plan`); + const headTree = deps.commitTree(run.head_sha); + if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`); + if (plan.revision !== run.head_sha && (!SHA.test(plan.revision) || deps.commitTree(plan.revision) !== headTree)) return fail('tree_mismatch', `${at} tested ${short(plan.revision)}, whose tree is not its head ${short(run.head_sha)}'s tree ${short(headTree)}`); + if (shardJobs.size !== plan.shards.length || plan.shards.some(s => !shardJobs.has(s.shard))) return fail('shard_mismatch', `${at} has ${layout.shardJob} job(s) for shard(s) ${[...shardJobs.keys()].sort((a, b) => a - b).join(', ') || 'none'}, but its plan has ${plan.shards.length} shard(s)`); + const failures: string[] = []; + let tests = 0; + for (const s of plan.shards) { + const res = jsonArtifact(art, id, `${layout.result}${s.shard}`); + if (isError(res)) return fail('shard_incomplete', `shard ${s.shard} has no readable ${layout.result}${s.shard} (${res.error})`); + if (res.planId !== plan.id || res.revision !== plan.revision || res.outcome?.shard !== s.shard || JSON.stringify(res.outcome.files) !== JSON.stringify(s.files)) return fail('result_mismatch', `shard ${s.shard}'s result is not shard ${s.shard} of plan ${plan.id}`); + const named = shardFailures(res, s.shard); + if ('reason' in named) return fail(named.reason, named.detail); + const job = shardJobs.get(s.shard)!; + if ((job.conclusion === 'failure') !== (named.final.length > 0)) return fail('shard_mismatch', `shard ${s.shard}'s job concluded ${job.conclusion}, but its result names ${named.final.length} final failure(s)`); + if (job.conclusion === 'failure') { + const problem = logProblem(art, id, `${layout.logs}${s.shard}`, s.shard, s.files, named.all); + if (problem) return fail(problem.reason, problem.detail); + } + failures.push(...named.final); + tests += res.outcome.summary?.testsRan ?? 0; + } + const again = deps.run(id); + if (again.run_attempt !== run.run_attempt || again.status !== 'completed' || again.conclusion !== run.conclusion) return fail('run_changed', `run ${id} moved to attempt ${again.run_attempt} (${again.status === 'completed' ? again.conclusion : again.status}) while it was being verified`); + return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files: plan.shards.flatMap(s => s.files), tests }, failures: [...new Set(failures)] }; + } catch (e) { + return fail('api_error', `reading run ${id} failed: ${errText(e)}`); + } +} + +/** Shard number → job, once every job of the attempt is accounted for: only shard jobs and the aggregate may fail. */ +function reconcileJobs(jobs: ApiJob[], layout: ShardLayout, p: RunProfile, at: string): Map | { reason: string; detail: string } { + const shardRe = new RegExp(`^${escapeRe(layout.shardJob)} \\((\\d+)\\)$`); + const shards = new Map(); + for (const j of jobs) { + if (j.conclusion === null) return { reason: 'shard_incomplete', detail: `${at}'s ${j.name} job has not finished` }; + const m = shardRe.exec(j.name); + if (m) { + if (shards.has(Number(m[1]))) return { reason: 'shard_mismatch', detail: `${at} has two jobs named ${j.name}` }; + if (j.conclusion !== 'success' && j.conclusion !== 'failure') return { reason: 'shard_incomplete', detail: `${at}'s ${j.name} job concluded ${j.conclusion}` }; + shards.set(Number(m[1]), j); + continue; + } + if (j.name === layout.aggregate) continue; + const allowed = p.jobs.includes(j.name) ? ['success'] : ['success', 'skipped', 'neutral']; + if (!allowed.includes(j.conclusion)) return { reason: 'unnamed_failure', detail: `${at}'s ${j.name} job concluded ${j.conclusion}; only test-shard failures can be named` }; + } + const missing = p.jobs.find(n => !jobs.some(j => j.name === n)); + if (missing) return { reason: 'job_missing', detail: `${at} has no job named ${missing}` }; + const aggregate = jobs.filter(j => j.name === layout.aggregate); + if (aggregate.length !== 1) return { reason: 'job_missing', detail: `${at} has ${aggregate.length} jobs named ${layout.aggregate}` }; + if (![...shards.values()].some(j => j.conclusion === 'failure')) return { reason: 'unnamed_failure', detail: `${at} failed, but no ${layout.shardJob} job did, so the failure is not a test case` }; + if (aggregate[0]!.conclusion !== 'failure') return { reason: 'shard_mismatch', detail: `${at}'s ${layout.aggregate} job concluded ${aggregate[0]!.conclusion} although a shard failed` }; + return shards; +} + +function completeSummary(o: FreeShardOutcome): boolean { + const s = o.summary; + return !!s && Number.isInteger(s.testsRan) && s.testsRan! >= 0 && s.filesRan === o.files.length && (o.files.length === 0 || s.sawTerminalSummary === true); +} + +/** + * A shard's final failures (`::`) and every failure it recorded. A retry that passed recovered its files; a + * retry that failed replaces the first run's failures for the files it ran. Anything unattributed or incomplete refuses. + */ +function shardFailures(r: FreeCiResult, n: number): { final: string[]; all: Set } | { reason: string; detail: string } { + const o = r.outcome; + const key = (f: { file: string | null; testName: string }) => `${f.file}::${f.testName}`; + if (o.status === 'timed-out') return { reason: 'shard_incomplete', detail: `shard ${n} timed out, so the tests after the hang never ran` }; + if (!completeSummary(o)) return { reason: 'shard_incomplete', detail: `shard ${n}'s result has no complete run summary` }; + if (o.status === 'passed') { + if (o.exitCode !== 0 || o.failingFiles.length || o.unattributedFailures || r.retry) return { reason: 'result_mismatch', detail: `shard ${n} passed with failures recorded` }; + return { final: [], all: new Set() }; + } + if (o.status !== 'failed') return { reason: 'result_mismatch', detail: `shard ${n} has status ${String(o.status)}` }; + if (o.unattributedFailures) return { reason: 'unnamed_failure', detail: `shard ${n}: ${o.unattributedFailures} failure(s) the runner could not attribute to a test file` }; + const first = o.failures ?? []; + const unnamed = first.filter(f => !f.file || !o.files.includes(f.file) || !f.testName); + if (!first.length || unnamed.length) return { reason: 'unnamed_failure', detail: `shard ${n} failed ${first.length ? `with ${unnamed.length} failure(s) not attributed to one of its files` : 'without naming a failure'}` }; + if (JSON.stringify([...new Set(first.map(f => f.file))].sort()) !== JSON.stringify([...o.failingFiles].sort())) return { reason: 'result_mismatch', detail: `shard ${n}'s named failures and failing files disagree` }; + const all = new Set(first.map(key)); + const t = r.retry; + if (!t) return { final: [...all], all }; + if (t.status === 'timed-out' || !completeSummary(t)) return { reason: 'shard_incomplete', detail: `shard ${n}'s retry did not complete` }; + if (t.files.some(f => !o.failingFiles.includes(f))) return { reason: 'result_mismatch', detail: `shard ${n}'s retry ran files that had not failed` }; + const again = t.failures ?? []; + if (t.unattributedFailures || (t.status === 'failed' && (!again.length || again.some(f => !f.file || !t.files.includes(f.file))))) return { reason: 'unnamed_failure', detail: `shard ${n}'s retry failed without naming each failure` }; + if (t.status === 'passed' && (again.length || t.exitCode !== 0)) return { reason: 'result_mismatch', detail: `shard ${n}'s retry passed with failures recorded` }; + for (const f of again) all.add(key(f)); + const final = [...first.filter(f => !t.files.includes(f.file!)), ...again].map(key); + return { final: [...new Set(final)], all }; +} + +/** Why a failed shard's logs do not reconcile with its result: missing, an unhandled error between tests, or a failure the result omits. */ +function logProblem(art: FailureDeps, id: number, name: string, n: number, planned: string[], recorded: Set): { reason: string; detail: string } | null { + let files: Record; + try { files = art.artifactFiles(id, name); } catch (e) { return { reason: 'logs_missing', detail: `shard ${n} failed and has no readable ${name} (${errText(e)})` }; } + const logs = Object.entries(files).filter(([f]) => f.endsWith('.log')); + if (!logs.length) return { reason: 'logs_missing', detail: `shard ${n} failed and its ${name} holds no .log file` }; + const canonical = (printed: string) => { + const stripped = printed.replace(/\\/g, '/').replace(/^(?:\.{1,2}\/)+/, ''); + return planned.includes(stripped) ? stripped : planned.find(p => stripped.endsWith(`/${p}`) || p.endsWith(`/${stripped}`)) ?? stripped; + }; + const { classifyBunTestOutputLine, parseBunFailureResult, stripAnsiLine } = require('../../scripts/lib/shard-engine') as typeof import('../../scripts/lib/shard-engine'); + for (const [, text] of logs) { + let file: string | null = null; + let recap = false; + let unhandled = 0; + for (const raw of text.split('\n')) { + const line = stripAnsiLine(raw).replace(/^::group::/, '').replace(/\r$/, ''); + if (/^\d+ tests? failed:$/.test(line)) { recap = true; file = null; continue; } + if (classifyBunTestOutputLine(line) === 'unhandled-between-tests') { unhandled++; continue; } + const header = LOG_FILE_HEADER.exec(line); + if (header) { if (!recap) file = canonical(header[1]!); continue; } + const failed = recap ? null : parseBunFailureResult(line); + if (failed !== null && !recorded.has(`${file}::${failed}`)) return { reason: 'unnamed_failure', detail: `shard ${n}'s log names ${file}::${failed}, which its result does not` }; + } + if (unhandled) return { reason: 'unnamed_failure', detail: `shard ${n}'s log shows ${unhandled} unhandled error(s) between tests` }; + } + return null; +} + +/** The PR's changed paths against the merge base with origin/, and each test the diff reaches through the import graph. */ +function prScope(repoRoot: string, file: PregateFile): { diff: Set; closure: Map } | { error: string } { + const git = (args: string[]) => { const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; + const base = resolveBaseBranch(repoRoot); + const mb = git(['merge-base', `refs/remotes/origin/${base}`, '--end-of-options', file.head!])?.trim(); + if (!mb) return { error: `git cannot find the merge base of origin/${base} and ${short(file.head)}` }; + const listed = git(['diff', '--name-status', '-z', '--no-renames', '--end-of-options', mb, file.head!]); + if (listed === null) return { error: `git diff ${short(mb)} ${short(file.head)} failed` }; + const parts = listed.split('\0').filter(Boolean); + const diff = new Set(); + const deleted: string[] = []; + for (let i = 0; i + 1 < parts.length; i += 2) { diff.add(parts[i + 1]!); if (parts[i] === 'D') deleted.push(parts[i + 1]!); } + const loaded = loadConfigFromBase(repoRoot, base); + if (isConfigUnavailable(loaded) || loaded.errors.length) return { error: `the base branch's .gstack/pregate.json cannot be read, so its dependency declarations are unknown` }; + const at = () => { const fp = wtreeFingerprint({ cwd: repoRoot }); return 'tree' in fp ? fp.tree : null; }; + if (at() !== file.tree) return { error: `the working tree is not pregate.json's tree ${short(file.tree)}; the import closure must be read from that tree` }; + const declarations = loaded.config.dependencies; + const graph = buildGraph(repoRoot, { declarations, extraFiles: deleted }); + const closure = new Map(); + for (const changed of diff) for (const t of testsFor(graph, changed, declarations).tests) closure.set(t, [...(closure.get(t) ?? []), changed]); + if (at() !== file.tree) return { error: 'the working tree changed while the import closure was read' }; + return { diff, closure }; +} + +/** + * After the runs verified and covered the obligation: the named cases must be exactly the failures, in no paid or + * security test, outside the PR's diff and import closure, the PR's head must be pregate.json's, and the grant row must + * carry this exception's binding in the owner's words. Returns the exception to record. + */ +export function flakyDecision(file: PregateFile, lane: string, check: { repo: string; failures?: FailureDeps }, runs: FlakyRuns, input: FlakyInput & { cases: string[] }, repoRoot: string): { exception: PregateException } | FlakyRefusal { + const label = runs.red.map(r => `run ${r.evidence.run_id} attempt ${r.evidence.run_attempt}`).join(', '); + const failures = [...new Set(runs.red.flatMap(r => r.failures))]; + const extra = failures.filter(f => !input.cases.includes(f)); + if (extra.length) return invalid(lane, 'unnamed_failure', `${label} failed ${extra.slice(0, 5).join(', ')}${extra.length > 5 ? ` and ${extra.length - 5} more` : ''}, which no --flaky-exception names`); + const notFailed = input.cases.find(c => !failures.includes(c)); + if (notFailed) return invalid(lane, 'case_not_failed', `${notFailed} did not fail in ${label}; its failures are ${failures.join(', ')}`); + const files = [...new Set(input.cases.map(c => c.slice(0, c.indexOf('::'))))]; + const { isPaidTestFile } = require('../../test/helpers/paid-test-set') as typeof import('../../test/helpers/paid-test-set'); + const paid = files.find(f => isPaidTestFile(f)); + if (paid) return invalid(lane, 'case_paid', `${paid} is a paid test file; the paid gate covers it, never an exception`); + const security = files.find(f => SECURITY_TEST.test(path.posix.basename(f))); + if (security) return invalid(lane, 'case_security', `${security} tests a security scan, which an exception never covers`); + let pr: { head_sha: string; state: string; head_repo: string | null }; + try { pr = check.failures!.pull(input.pr!); } + catch (e) { return invalid(lane, 'api_error', `reading PR #${input.pr} failed: ${errText(e)}`); } + if (pr.state !== 'open') return invalid(lane, 'pr_mismatch', `PR #${input.pr} is ${pr.state}`); + if ((pr.head_repo ?? '').toLowerCase() !== check.repo.toLowerCase()) return invalid(lane, 'pr_mismatch', `PR #${input.pr}'s head is on ${pr.head_repo ?? 'an unknown repository'}, not ${check.repo}`); + if (pr.head_sha !== file.head) return invalid(lane, 'pr_mismatch', `PR #${input.pr}'s head is ${short(pr.head_sha)}, not pregate.json's head ${short(file.head)}`); + const scope = prScope(repoRoot, file); + if ('error' in scope) return invalid(lane, 'closure_failed', scope.error); + const touched = files.find(f => scope.diff.has(f)); + if (touched) return invalid(lane, 'case_in_diff', `${touched} is in the PR's diff`); + const reached = files.find(f => scope.closure.has(f)); + if (reached) return invalid(lane, 'case_in_closure', `${reached} depends on a file the PR changes (${scope.closure.get(reached)!.slice(0, 5).join(', ')})`); + const subject: ExceptionSubject = { lane, repo: check.repo, pr: input.pr!, head: file.head!, tree: file.tree, runs: runs.evidence.map(e => `${e.run_id}/${e.run_attempt}`), cases: [...input.cases].sort() }; + const binding = exceptionBinding(subject); + if (!input.grant) { + const ask = `Accept flaky ${subject.cases.join(', ')} in run ${subject.runs.join(',')} for PR #${subject.pr} at ${short(subject.head)}? Pregate only; required CI still needs a green run.`.slice(0, 300); + return invalid(lane, 'grant_needed', `every failure in ${label} is named (${subject.cases.join(', ')}) and outside the PR's diff and its import closure; the owner must grant the exception`, + `ask the owner, record their words with gstack-approvals add --ask ${shellQuote(ask)} --words "" --by "" --scope "${binding}" --source pregate-flaky-exception, then rerun this clear with --grant `); + } + const row = input.grantRow?.(input.grant) ?? null; + if (!row) return invalid(lane, 'grant_missing', `no gstack-approvals row ${input.grant} in this branch's record`); + if (row.scope !== binding) return invalid(lane, 'grant_mismatch', `row ${row.id}'s scope ${JSON.stringify(row.scope.slice(0, 80))} is not this exception's binding ${binding}; a grant covers one repository, PR, head, tree, lane, run attempt and case list, so a new head needs a new grant`); + if (row.outcome !== 'approved') return invalid(lane, 'grant_not_approved', `row ${row.id} records outcome ${row.outcome}, not approved`); + if (/^agent:/i.test(row.by)) return invalid(lane, 'grant_not_owner', `row ${row.id} records an agent's words (${row.by}); only the owner grants an exception`); + return { exception: { ...subject, grant: row.id, by: row.by, words: row.words, binding } }; +} diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 70e614cec..fda383c78 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts'; +import { ARTIFACT_SCHEMAS, checkSchema, type PregateException, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts'; import { wtreeFingerprint, type WtreeResult } from '../script-invocation'; import { resolveStateRoot } from '../state-root'; import { guardsCheck } from './checks/guards'; @@ -28,6 +28,7 @@ import { timingCheck } from './checks/timing'; import { NON_DOWNGRADABLE, PREGATE_CONFIG_PATH, userPolicyAbsent } from './config'; import { sha256, withFingerprint } from './context'; import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt'; +import { EXCEPTION_RECEIPT, exceptionProblem, exceptionSummary, flakyDecision, flakyRuns, parseCases, redRunChanged, type FailureDeps, type FlakyInput } from './flaky-exception'; import { buildRunCheck } from './run-check'; import { resolveBaseBranch } from '../ship-policy'; import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiJob, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence'; @@ -134,6 +135,7 @@ export function verdictOf(file: PregateFile): string { if (statuses.has('incomplete') || statuses.has('not_installed')) return 'incomplete'; const uncleared = (file.requires_remote ?? []).filter(r => !r.receipt); if (uncleared.length) return `requires-remote (${uncleared.length} uncleared)`; + if ((file.requires_remote ?? []).some(r => EXCEPTION_RECEIPT.test(r.receipt ?? ''))) return 'pass-with-exception'; if (statuses.has('requires-remote')) return 'requires-remote cleared'; if (statuses.has('warn')) return 'warn'; return 'pass'; @@ -234,8 +236,12 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre // A run receipt was verified online at clear for its tree; offline it must still name this tree (or carry its substitution). const foreignRuns = (file.requires_remote ?? []).filter(r => { const run = parseRunReceipt(r.receipt ?? ''); return run && !r.substitution && run.tree !== file.tree.slice(0, 12); }); if (foreignRuns.length) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: `${foreignRuns.length} run receipt(s) verified for another tree: ${foreignRuns.map(r => `${r.lane} (${r.receipt})`).join(', ')}; clear again with runs on tree ${file.tree.slice(0, 12)}`, current, uncleared: foreignRuns }; + // A flaky exception was granted for one head, tree, run attempt and case list; recorded for another, or edited, it clears nothing. + const exception = exceptionProblem(file); + if (exception) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: exception, current, uncleared: [] }; const substituted = (file.requires_remote ?? []).filter(r => r.substitution).map(r => `${r.lane}: ${describeSubstitution(r.substitution!)}`); - return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}`, current, uncleared }; + const excepted = exceptionSummary(file); + return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}${excepted ? `; ${excepted}` : ''}`, current, uncleared }; } /** @@ -259,10 +265,10 @@ function userPolicyChange(file: PregateFile): string | null { return `${absent} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`; } -export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck } +export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck; flaky?: FlakyInput } /** What a run clear needs: the base branch's run profiles, the verifier's dependencies, the clock, and the remote gate's inventory. */ -export interface RunCheck { repo: string; deps: RunDeps; profiles: Record; gateRuns: RemoteGateRuns | null; now: Date; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string } } -export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string }; +export interface RunCheck { repo: string; deps: RunDeps; profiles: Record; gateRuns: RemoteGateRuns | null; now: Date; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string }; failures?: FailureDeps } +export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution; exception?: PregateException } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string }; /** * What `clear` may record for one obligation. A `pregate.json/remote_gate` @@ -284,12 +290,21 @@ export type ClearDecision = { receipt: string; attested: boolean; substitution?: * `--run` (and `--run-url` on a lane the base branch profiles) goes to * clearByRuns, which accepts only verified Actions runs; a profiled lane * refuses `--bundle-lane`, and the remote gate clears by runs only when the - * base declares remote_gate_runs. + * base declares remote_gate_runs. `flaky` (`--flaky-exception`) takes runs with failures the same way under an owner's + * grant (lib/pregate/flaky-exception.ts). */ export function clearDecision(file: PregateFile, lane: string, input: ClearInput, repoRoot: string): ClearDecision { const list = file.requires_remote ?? []; const target = list.find(r => r.lane === lane); if (!target) return { refused: 'usage', message: `no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}` }; + if (input.flaky) { + const cases = parseCases(input.flaky.cases); + if (!Array.isArray(cases)) return cases; + const refs = [...(input.runs ?? []), ...(input.runUrl ? [input.runUrl] : [])]; + if (!refs.length) return { refused: 'usage', message: '--flaky-exception needs --run : an exception reads GitHub Actions runs' }; + if (!input.flaky.pr) return { refused: 'usage', message: '--flaky-exception needs --pr , the pull request the grant binds' }; + return clearByRuns(file, lane, refs, input.evidence, repoRoot, { ...input.flaky, cases }); + } const profiled = input.evidence && (lane === REMOTE_GATE_LANE ? input.evidence.gateRuns : input.evidence.profiles[lane]); if (input.runs?.length || (input.runUrl && profiled)) return clearByRuns(file, lane, [...(input.runs ?? []), ...(input.runUrl ? [input.runUrl] : [])], input.evidence, repoRoot); if (input.bundleLane && profiled && lane !== REMOTE_GATE_LANE) return { refused: 'usage', message: `${lane} has a run profile on the base branch; clear it with --run , which verifies the run` }; @@ -372,7 +387,7 @@ function treeSubstitution(file: PregateFile, gatedTree: string, lane: string, re * does not run (never an inventory file); any other lane needs one verified run of the profile named after it. The run * tree must be pregate.json's tree or a stamp-only predecessor of it, exactly as a gate report's tree must. */ -function clearByRuns(file: PregateFile, lane: string, refs: string[], check: RunCheck | undefined, repoRoot: string): ClearDecision { +function clearByRuns(file: PregateFile, lane: string, refs: string[], check: RunCheck | undefined, repoRoot: string, flaky?: FlakyInput & { cases: string[] }): ClearDecision { if (!check) return { refused: 'usage', message: `--run needs the base branch's run_profiles; none could be read` }; const isGate = lane === REMOTE_GATE_LANE; const names = isGate ? check.gateRuns?.profiles ?? [] : check.profiles[lane] ? [lane] : []; @@ -404,13 +419,17 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run } return null; }; - const evidence = assign(0, new Set()); + const red = flaky ? flakyRuns(lane, check, names, refs, file.head) : null; + if (red && 'refused' in red) return red; + const evidence = red ? red.evidence : assign(0, new Set()); if (!evidence) { const failed = names.map(name => `${name}: ${refs.map(ref => { const v = verdict(name, ref); return v.ok ? `${ref} ok` : `${ref} reason=${v.reason} ${v.detail}`; }).join('; ')}`); return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=no_verified_run — no run per profile verifies (${failed.join(' | ')}); ${lane} stays uncleared` }; } const trees = [...new Set(evidence.map(e => e.tree))]; if (trees.length > 1) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=tree_mismatch — the runs tested different trees (${trees.map(t => t.slice(0, 12)).join(', ')}); ${lane} stays uncleared` }; + const off = red && evidence.find(e => e.head_sha !== file.head || e.tree !== file.tree); + if (off) return { refused: 'PREGATE_RECEIPT_INVALID', message: `FLAKY_EXCEPTION: refused reason=head_mismatch — run ${off.run_id} attempt ${off.run_attempt} tested head ${off.head_sha.slice(0, 12)} tree ${off.tree.slice(0, 12)}, not pregate.json's head ${(file.head ?? '').slice(0, 12)} tree ${file.tree.slice(0, 12)}; an exception binds the exact head and tree, never a stamp-only predecessor; ${lane} stays uncleared` }; const sub = treeSubstitution(file, trees[0]!, lane, repoRoot); if ('refused' in sub) return { ...sub, message: `REUSE: refused reason=tree_not_stamp_only — ${sub.message}` }; const idle = evidence.filter(e => (e.tests ?? e.files.length) === 0); @@ -433,24 +452,29 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run } const missing = uncoveredFiles([...required], evidence); if (missing.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${isGate ? 'inventory_uncovered' : 'tests_uncovered'} — ${missing.length} of the ${required.size} test file(s) ${lane} must cover appear in no run's tree.json (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}); ${lane} stays uncleared` }; + const granted = red ? flakyDecision(file, lane, check, red, flaky!, repoRoot) : null; + if (granted && 'refused' in granted) return granted; for (const e of evidence) { let changed: string | null; - try { changed = runChanged(check.deps, e.run_id, e.run_attempt); } + try { changed = red?.red.some(r => r.evidence === e) ? redRunChanged(check.deps, e.run_id, e.run_attempt) : runChanged(check.deps, e.run_id, e.run_attempt); } catch (err: any) { changed = `reading run ${e.run_id} again failed: ${String(err?.message ?? err).slice(0, 200)}`; } if (changed) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=run_changed — ${changed}; ${lane} stays uncleared` }; } const tests = evidence.reduce((n, e) => n + (e.tests ?? e.files.length), 0); const accepted = sub.substitution ? ` accepted-for=${file.tree.slice(0, 12)} stamp-only=${sub.substitution.files.length}` : ''; - return { receipt: `${isGate ? 'remote-gate' : 'run'} tree=${trees[0]!.slice(0, 12)} tests=${tests}${skippedCount ? ` skipped=${skippedCount}` : ''}${accepted} ${runReceiptFields(evidence, check.now)}`, attested: false, substitution: sub.substitution }; + const exception = granted?.exception; + return { receipt: `${isGate ? 'remote-gate' : 'run'} tree=${trees[0]!.slice(0, 12)} tests=${tests}${skippedCount ? ` skipped=${skippedCount}` : ''}${exception ? ` exception=${exception.grant} cases=${exception.cases.length}` : ''}${accepted} ${runReceiptFields(evidence, check.now)}`, attested: false, substitution: sub.substitution, ...(exception ? { exception } : {}) }; } /** Record the receipt that clears one obligation (a run URL, an evidence-bundle lane label, or a remote gate report, with the stamp-only substitution when one was accepted). */ -export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: PregateReceiptSubstitution): PregateFile { +export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: PregateReceiptSubstitution, exception?: PregateException): PregateFile { const list = file.requires_remote ?? []; const target = list.find(r => r.lane === lane); if (!target) throw new Error(`no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}`); target.receipt = receipt; target.cleared_by = by; if (substitution) target.substitution = substitution; else delete target.substitution; + const exceptions = [...(file.exceptions ?? []).filter(e => e.lane !== lane), ...(exception ? [exception] : [])]; + if (exceptions.length) file.exceptions = exceptions; else delete file.exceptions; file.verdict = verdictOf(file); return file; } diff --git a/lib/pregate/run-check.ts b/lib/pregate/run-check.ts index cd527c499..34db52967 100644 --- a/lib/pregate/run-check.ts +++ b/lib/pregate/run-check.ts @@ -11,7 +11,7 @@ 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 { ghRunDeps, runInventory } from '../run-evidence'; +import { ghArtifactDeps, ghRunDeps, runInventory } from '../run-evidence'; import { wtreeFingerprint } from '../script-invocation'; import { resolveBaseBranch } from '../ship-policy'; import { normalizeRemoteUrl } from '../ship-queue-train-record'; @@ -31,7 +31,7 @@ export function buildRunCheck(root: string, lane: string | null, allowRepoComman const repo = /^https:\/\/github\.com\/(.+)$/.exec(normalizeRemoteUrl(origin.status === 0 ? origin.stdout.trim() : ''))?.[1]; if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === null ? false : lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] }; return { - repo, deps: ghRunDeps(repo, root), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(), + repo, deps: ghRunDeps(repo, root), failures: ghArtifactDeps(repo), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(), inventory: (command, tree, selected) => { if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' }; const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; }; diff --git a/lib/review-collect.ts b/lib/review-collect.ts index ac16ba0d2..f5e10709f 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -10,6 +10,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { resolveBase, type ResolvedBase } from './gate-diff/git'; import { GENERATED_PATH, parseRegistry } from './regen'; import { RESULT_CODES } from './result-codes'; import { classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth'; @@ -31,6 +32,8 @@ export interface Collected extends ClassInput { untracked: string[]; consumed: string[] | 'all'; drawings: string[]; + /** collectLive only: the ref resolveBase read for the base name (GST-1036), null when nothing resolved. */ + base?: ResolvedBase | null; } function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } { @@ -255,16 +258,20 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings }); } -/** The working tree against the merge base with origin/ (or ; a remote-qualified base reads refs/remotes/ only), untracked files counted per path; the registry is the base ref's commit. */ +/** + * The working tree against the merge base with the base resolveBase reads (lib/gate-diff/git.ts: a bare name is + * origin/ when it shares history with HEAD, else the local branch; a remote-qualified base reads + * refs/remotes/ only), untracked files counted per path; the registry is the base ref's commit. + */ export function collectLive(root: string, base: string): Collected { const errors: DepthError[] = []; - const qualified = base.startsWith('origin/') || git(root, ['remote']).out.split('\n').some(r => r && base.startsWith(`${r}/`)); - const ref = (qualified ? [`refs/remotes/${base}`] : [`refs/remotes/origin/${base}`, `refs/heads/${base}`]).find(r => git(root, ['rev-parse', '--verify', '-q', `${r}^{commit}`]).ok) ?? base; + const resolved = resolveBase(base, root, 'HEAD'); + const ref = resolved?.sha ?? base; const registry = loadRegistryAt(root, ref); const mb = git(root, ['merge-base', ref, 'HEAD']); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` }); - return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); + return { ...result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }), base: resolved }; } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase], errors); @@ -284,5 +291,5 @@ export function collectLive(root: string, base: string): Collected { const drawings = drawingsAmong(diff.perPath, root, p => { try { return fs.statSync(path.join(root, p)).size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; } }); - return result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings }); + return { ...result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings }), base: resolved }; } diff --git a/lib/review-depth.ts b/lib/review-depth.ts index e8751cc8e..c337ebd98 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -93,7 +93,16 @@ const SVG_MARKUP = /^<(?:!--|!\[CDATA\[|\?xml\s|!DOCTYPE\s+svg(?:\s+PUBLIC\s+([" export const SVG_REFUSED = [/&(?!(?:amp|lt|gt|quot|apos);)/, /\\/, /@import/i, /url\((?!\s*#|['"]\s*#)/i, /expression\s*\(/i, /-moz-binding|\bbehavior\s*:/i]; /** A drawing's CSS, a style element's text or a style attribute's value, holds declarations only: no @ rule and no function but url(#id). */ const SVG_STYLE_OPEN = /<(?:[A-Za-z_][\w.-]*:)?style\b/gi; -const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style\s*>/i; +const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style[ \t\n\r]*>/i; +/** + * A style element's opening tag, read as quoted name="value" pairs separated by the ASCII whitespace XML and HTML share: any + * other shape (a value holding < or >, an unquoted or valueless attribute, />, another space) refuses, so it cannot hide a closing tag. + */ +const SVG_STYLE_TAG = /^<(?:[A-Za-z_][\w.-]*:)?style(?:[ \t\n\r]+[\w.:-]+[ \t\n\r]*=[ \t\n\r]*(?:"[^"<>]*"|'[^'<>]*'))*[ \t\n\r]*>/i; +/** An element name, ended by ASCII whitespace, / or >: a non-ASCII character in the name (a prefix such as gé:) refuses rather than reading as its ASCII start. */ +const SVG_TAG_NAME = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)(?=[ \t\n\r/>]|$)/; +/** Every name= in the text, non-ASCII letters included so a prefix such as xé:href is read whole and refused. */ +const SVG_ATTR = /(?=]+)))/g; const SVG_CSS_REF = /url\(\s*(['"]?)#[\w.:-]*\1\s*\)/gi; const SVG_CSS_REFUSED = /[@(]/; /** An href or src value a drawing may hold: a same-document reference, or a raster image inline. */ @@ -102,10 +111,12 @@ const SVG_HREF = /^\s*(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+ /** * Whether an SVG's whole text is a drawing (GST-911): every element in SVG_ELEMENTS, no event handler, every href or src a * same-document reference or an inline raster image, CSS of declarations only, and nothing SVG_REFUSED names, read after character references are - * decoded. Every name= in the text is read, so a stray quote cannot hide a later attribute inside its value; an unquoted value - * stops at the next =, which keeps the scan linear. A style element's CSS is walked with indexOf to its closing tag, dropping - * comments and reading CDATA as CSS, so neither can hide a closing tag; any other markup inside it, or a style element that never - * closes, refuses. Anything else, an SVG that can run code included, stays code. + * decoded. Every name= in the text is read (SVG_ATTR), so a stray quote cannot hide a later attribute inside its value, and a name + * holding a non-ASCII character refuses, like an element name (SVG_TAG_NAME); an unquoted value + * stops at the next =, which keeps the scan linear. A style element's opening tag is read quote by quote (SVG_STYLE_TAG), and its CSS is walked with indexOf to its closing tag over the raw + * text, so a character reference cannot fake a closing tag or a comment, and any & in it refuses (GST-940). Comments and CDATA are + * read as CSS, so neither can hide a closing tag and an HTML-only comment end ( or --!>) exposes nothing unread; any other + * markup inside it, or a style element that never closes, refuses. Anything else, an SVG that can run code included, stays code. */ export function svgDrawing(text: string): boolean { const t = decodeCharRefs(text); @@ -113,36 +124,36 @@ export function svgDrawing(text: string): boolean { const cssDrawing = (css: string) => !SVG_CSS_REFUSED.test(css.replace(SVG_CSS_REF, '')); for (let at = 0; ;) { SVG_STYLE_OPEN.lastIndex = at; - const open = SVG_STYLE_OPEN.exec(t); + const open = SVG_STYLE_OPEN.exec(text); if (!open) break; - let i = t.indexOf('>', open.index) + 1; - if (!i) return false; + const tag = SVG_STYLE_TAG.exec(text.slice(open.index, open.index + 4096)); + if (!tag) return false; + let i = open.index + tag[0].length; let css = ''; for (;;) { - const lt = t.indexOf('<', i); + const lt = text.indexOf('<', i); if (lt === -1) return false; - css += t.slice(i, lt); - if (t.startsWith('', lt + 4) + 3; - if (i === 2) return false; - } else if (t.startsWith('', lt + 9); + css += text.slice(i, lt); + const [start, close] = text.startsWith(''] : text.startsWith(''] : [-1, '']; + if (start !== -1) { + const end = text.indexOf(close, start); if (end === -1) return false; - css += t.slice(lt + 9, end); + css += ' ' + text.slice(start, end) + ' '; i = end + 3; - } else if (SVG_STYLE_CLOSE.test(t.slice(lt, lt + 256))) { + } else if (SVG_STYLE_CLOSE.test(text.slice(lt, lt + 256))) { at = lt + 1; break; } else return false; } - if (!cssDrawing(css)) return false; + if (css.includes('&') || !cssDrawing(css)) return false; } for (let at = t.indexOf('<'); at !== -1; at = t.indexOf('<', at + 1)) { if (SVG_MARKUP.test(t.slice(at, at + 512))) continue; - const tag = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/.exec(t.slice(at, at + 256)); + const tag = SVG_TAG_NAME.exec(t.slice(at, at + 256)); if (!tag || !SVG_ELEMENTS.has(tag[1]!.toLowerCase())) return false; } - for (const m of t.matchAll(/(?=]+)))/g)) { + for (const m of t.matchAll(SVG_ATTR)) { + if (/[^\x00-\x7f]/.test(m[1]!)) return false; const local = m[1]!.slice(m[1]!.lastIndexOf(':') + 1).toLowerCase(); const value = m[2] ?? m[3] ?? m[4] ?? ''; if (local.startsWith('on') || ((local === 'href' || local === 'src') && !SVG_HREF.test(value)) || (local === 'style' && !cssDrawing(value))) return false; @@ -153,7 +164,7 @@ export function svgDrawing(text: string): boolean { /** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */ export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({ RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, SVG_ELEMENTS: [...SVG_ELEMENTS], - patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), + patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_STYLE_TAG: [SVG_STYLE_TAG], SVG_TAG_NAME: [SVG_TAG_NAME], SVG_ATTR: [SVG_ATTR], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), })).digest('hex').slice(0, 16); /** diff --git a/lib/review-evidence.ts b/lib/review-evidence.ts index 60dc4e669..f4cd47549 100644 --- a/lib/review-evidence.ts +++ b/lib/review-evidence.ts @@ -272,6 +272,9 @@ export function bindReview(rec: Record, token: string, env = proces if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE; if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true'; stampGateFindings(rec.findings, env.GSTACK_STAMP_SESSION_KIND); + if (rec.skill === 'review-fix-check' && (typeof rec.completed !== 'boolean' || typeof rec.converged !== 'boolean')) { + throw new Error('review-log: a review-fix-check row must carry completed and converged as true or false (review-army item 6 fields; without them gstack-review-read marks the row UNVERIFIED and finish cannot count it)'); + } // A /ship pass whose findings were all fixed, or deferred or noted below the floor, left no open defect (review-army // item 6), so a claimed issues_found is recorded clean with zero counts and Step 11.5 can bind it (GST-430). Only the // logger-stamped action counts (never a caller's status), and the findings must account for every claimed issue. diff --git a/lib/review-exclude.ts b/lib/review-exclude.ts index 80533bc7d..78d9131f6 100644 --- a/lib/review-exclude.ts +++ b/lib/review-exclude.ts @@ -122,20 +122,38 @@ function select(i: ExcludeInput, patterns: readonly string[]): { byPattern: Map< return { byPattern, files }; } +/** Executable image magic: ELF, Mach-O (thin, universal and 64-bit universal; 0xcafebabe is also a Java class), PE, WebAssembly and a script's #!. */ +const NATIVE_MAGIC = [[0x7f, 0x45, 0x4c, 0x46], [0xfe, 0xed, 0xfa, 0xce], [0xfe, 0xed, 0xfa, 0xcf], [0xce, 0xfa, 0xed, 0xfe], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xca, 0xfe, 0xba, 0xbf], [0x4d, 0x5a], [0x00, 0x61, 0x73, 0x6d], [0x23, 0x21]]; + /** - * The matched paths git calls binary whose bytes hold a NUL in their first 8,000 (git's own test), read from the head blob, - * the file on a live tree, or the old blob for a deletion. A branch's .gitattributes can make git call a text file binary; - * only real bytes make it binary content here. + * The matched paths git calls binary by their bytes alone: `git diff --numstat` with attributes read from the empty tree, so + * a branch's .gitattributes (`diff`, `-binary`, `text`) cannot make an executable image read as text and skip the + * magic check. null when git cannot run it (a git without --attr-source); the caller then reads every matched file's bytes. */ -function binaryContent(i: ExcludeInput, files: ReadonlyMap, claimed: ReadonlySet, unread: Set): Set { +function bytesBinary(i: ExcludeInput, patterns: readonly string[]): string[] | null { + const empty = git(i.root, ['hash-object', '-t', 'tree', '--stdin'], ''); + if (!empty.ok) return null; + const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase]; + const r = git(i.root, [`--attr-source=${empty.out.trim()}`, 'diff', '--numstat', '-z', '--no-renames', ...range, '--', ...patterns.map(p => `:(top,glob)${p}`)]); + return r.ok ? r.out.split('\0').filter(l => l.startsWith('-\t-\t')).map(l => l.slice(4)) : null; +} +const nativeImage = (b: Buffer) => NATIVE_MAGIC.some(m => m.every((x, k) => b[k] === x)); + +/** + * The claimed paths (git calls them binary under the branch's attributes or under none: bytesBinary) whose bytes hold a NUL + * in their first 8,000 (git's own test), read from the head blob, the file on a live tree, or the old blob for a deletion. + * A branch's .gitattributes can make git call a text file binary; only real bytes make it binary content here. A binary file that starts with executable image magic also goes in `native`. + */ +function binaryContent(i: ExcludeInput, files: ReadonlyMap, claimed: ReadonlySet, unread: Set, native: Set): Set { const out = new Set(); + const sniff = (p: string, b: Buffer) => { if (!b.includes(0)) return; out.add(p); if (nativeImage(b)) native.add(p); }; const real = (sha: string) => /^[0-9a-f]{40,64}$/.test(sha) && !/^0+$/.test(sha); const bySha = new Map(); for (const p of claimed) { const m = files.get(p); if (!m) continue; if (!i.head && m.newMode !== '000000') { - try { const fd = fs.openSync(path.join(i.root, p), 'r'); const buf = Buffer.alloc(8000); const n = fs.readSync(fd, buf, 0, 8000, 0); fs.closeSync(fd); if (buf.subarray(0, n).includes(0)) out.add(p); } catch { unread.add(p); } + try { const fd = fs.openSync(path.join(i.root, p), 'r'); const buf = Buffer.alloc(8000); const n = fs.readSync(fd, buf, 0, 8000, 0); fs.closeSync(fd); sniff(p, buf.subarray(0, n)); } catch { unread.add(p); } continue; } const sha = real(m.newSha) ? m.newSha : real(m.oldSha) ? m.oldSha : ''; @@ -144,8 +162,36 @@ function binaryContent(i: ExcludeInput, files: ReadonlyMap, cla for (const [sha, paths] of bySha) { const r = spawnSync('git', ['cat-file', 'blob', sha], { cwd: i.root, timeout: 30_000, maxBuffer: 8000 }); const read = r.status === 0 || (r.error as NodeJS.ErrnoException | undefined)?.code === 'ENOBUFS'; - const binary = read && (r.stdout ?? Buffer.alloc(0)).subarray(0, 8000).includes(0); - for (const p of paths) if (!read) unread.add(p); else if (binary) out.add(p); + const head = (r.stdout ?? Buffer.alloc(0)).subarray(0, 8000); + for (const p of paths) if (!read) unread.add(p); else sniff(p, head); + } + return out; +} + +/** + * The added lines of matched files git calls binary only because of their attributes (the bytes hold no NUL): the + * collector's added lines are empty for them, so the secret-line rule reads `git diff --text` instead, which no + * .gitattributes, textconv or external diff driver changes. One git diff per KEEP_ARG_MAX_BYTES of literal pathspecs; + * git prints one section per path in byte order, so a chunk whose sections do not line up with its sorted paths, or a + * git failure, puts every path of that chunk in `unread`. + */ +function attributeBinaryLines(i: ExcludeInput, paths: readonly string[], unread: Set): Record { + const out: Record = {}; + const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase]; + const chunks: string[][] = []; + let bytes = Infinity; + for (const p of [...paths].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) { + const n = argBytes([p]); + if (bytes + n > KEEP_ARG_MAX_BYTES) { chunks.push([]); bytes = 0; } + chunks[chunks.length - 1]!.push(p); + bytes += n; + } + for (const chunk of chunks) { + const r = git(i.root, ['diff', '--text', '--no-textconv', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '-U0', ...range, '--', ...chunk.map(literal)]); + const sections = r.ok ? r.out.split(/^(?=diff --git )/m).filter(s => s.startsWith('diff --git ')) : []; + const aligned = sections.length === chunk.length && chunk.every((p, k) => /["\\\x00-\x1f\x7f]/.test(p) || sections[k]!.startsWith(`diff --git a/${p} b/${p}\n`)); + if (!aligned) { for (const p of chunk) unread.add(p); continue; } + chunk.forEach((p, k) => { let hunk = false; out[p] = sections[k]!.split('\n').filter(l => (hunk ||= l.startsWith('@@')) && l.startsWith('+')).map(l => l.slice(1)); }); } return out; } @@ -159,17 +205,21 @@ function contentKind(i: ExcludeInput, p: string, binary: ReadonlySet): C return classifyPath(p, i.classRoot) === 'docs' ? 'docs' : null; } -/** Source and script extensions: a NUL in one does not make it excludable binary content. */ -const CODE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|go|rs|java|kts?|scala|swift|exs?|php|cs|c|h|cc|cpp|hpp|mm?|dart|pl|pm|lua|sh|bash|zsh|fish|ps1|bat|cmd|vue|svelte|html?|wasm)$/i; +/** + * The only extensions a binary file may be excludable content under: images, fonts, media, documents, compressed archives + * and data blobs. Any other binary (source with a NUL, a native library or executable, a key store, a pickle, no + * extension) is code; so is one that starts with executable image magic whatever its extension. + */ +const BINARY_CONTENT_EXT = /\.(?:png|jpe?g|gif|webp|bmp|ico|avif|tiff?|heic|heif|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|ogv|webm|mov|avi|mkv|flac|aac|opus|pdf|docx|xlsx|pptx|odt|ods|odp|gz|tgz|zip|tar|bz2|xz|zst|7z|bin|dat|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|sqlite3?|db|safetensors)$/i; -/** Why a matched file is kept by its path, mode and lines alone; null for content the policy may exclude. A binary file readers would only see as "Binary files differ" is content unless its directory, mode or gate tags keep it. */ -function pathReason(i: ExcludeInput, p: string, m: Matched, binary: ReadonlySet): KeptReason | null { +/** Why a matched file is kept by its path, mode and lines alone; null for content the policy may exclude. A binary file readers would only see as "Binary files differ" is content unless its directory, mode, gate tags, extension or magic keep it. */ +function pathReason(i: ExcludeInput, p: string, m: Matched, binary: ReadonlySet, native: ReadonlySet): KeptReason | null { if (/^\.(gstack|github)\//.test(p)) return 'policy-dir'; if (isGatePath(p)) return 'gate-path'; if (m.oldMode === '100755' || m.newMode === '100755') return 'executable'; if ([m.oldMode, m.newMode].some(x => x === '120000' || x === '160000')) return 'link'; if (i.gateTagged?.has(p)) return 'gate-tagged'; - if (binary.has(p)) return CODE_EXT.test(p) ? 'code' : null; + if (binary.has(p)) return BINARY_CONTENT_EXT.test(p) && !native.has(p) ? null : 'code'; if (isAgentProse(p)) return 'agent-prose'; if (isDataConfig(p)) return 'config'; if (contentKind(i, p, binary) === null) return 'code'; @@ -227,9 +277,12 @@ export function reviewExclude(i: ExcludeInput): Exclusion { const sel = select(i, patterns); if ('error' in sel) return { state: 'refused', code: 'REVIEW_DIFF_UNREADABLE', lines: [`${i.context}: git could not list the files review_exclude_paths matches (${sel.error}); fix: git fetch origin ${base}, then rerun once; nothing was planned (REVIEW_DIFF_UNREADABLE)`] }; const unread = new Set(); - const binary = binaryContent(i, sel.files, new Set(i.perPath.filter(e => e.binary && sel.files.has(e.path)).map(e => e.path)), unread); + const native = new Set(); + const claimed = i.perPath.filter(e => e.binary && sel.files.has(e.path)).map(e => e.path); + const binary = binaryContent(i, sel.files, new Set([...claimed, ...(bytesBinary(i, patterns) ?? sel.files.keys())]), unread, native); + const text = { ...i, added: { ...i.added, ...attributeBinaryLines(i, claimed.filter(p => !binary.has(p) && !unread.has(p)), unread) } }; const reasons = new Map(); - for (const [p, m] of sel.files) reasons.set(p, unread.has(p) ? 'consumer-scan-unavailable' : pathReason(i, p, m, binary)); + for (const [p, m] of sel.files) reasons.set(p, unread.has(p) ? 'consumer-scan-unavailable' : pathReason(text, p, m, binary, native)); const candidates = new Set([...reasons].filter(([, r]) => r === null).map(([p]) => p)); const named = grepFixed(i, [...candidates].filter(p => !p.includes('\n')), candidates, [i.mergeBase]); for (const p of candidates) { diff --git a/lib/run-evidence.ts b/lib/run-evidence.ts index 9a66a99d9..bc4b576fe 100644 --- a/lib/run-evidence.ts +++ b/lib/run-evidence.ts @@ -52,6 +52,13 @@ export interface ApiRun { export interface ApiJob { name: string; conclusion: string | null; completed_at?: string | null } export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown } +/** What a red run's reader needs beyond RunDeps (lib/pregate/flaky-exception.ts): every file of one artifact, and a pull request's head. */ +export interface RunArtifactDeps { + /** Every file of the run's one unexpired artifact `name`, path → text; throws when it is missing, ambiguous or too large. */ + artifactFiles(id: number, name: string): Record; + pull(n: number): { head_sha: string; state: string; head_repo: string | null }; +} + /** Everything verifyRun reads, injectable so every refusal has a fixture. */ export interface RunDeps { run(id: number): ApiRun; @@ -74,6 +81,8 @@ const WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/; const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/; export const DEFAULT_MAX_AGE_HOURS = 24; const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024; +/** Shard logs are the whole test stream, so a red run's reader allows more than a tree.json. */ +const MAX_LOG_ARTIFACT_BYTES = 64 * 1024 * 1024; /** A numeric run id, or a run URL on this repository (`…/actions/runs/[/attempts/][/job/]`). */ export function parseRunRef(ref: string, repo: string): number | null { @@ -298,3 +307,37 @@ export function ghRunDeps(repo: string, repoRoot: string): RunDeps { }, }; } + +/** The gh-backed artifact and pull request reads for `repo` (lib/pregate/flaky-exception.ts): one unexpired artifact by name, every regular file in it under a byte cap. */ +export function ghArtifactDeps(repo: string): RunArtifactDeps { + const all = (p: string, key: string): T[] => gh(['api', '--paginate', p, '--jq', `.${key}[]`]).split('\n').filter(Boolean).map(l => JSON.parse(l) as T); + return { + artifactFiles: (id, name) => { + const listed = all<{ name: string; size_in_bytes: number; expired: boolean }>(`repos/${repo}/actions/runs/${id}/artifacts?name=${encodeURIComponent(name)}&per_page=100`, 'artifacts').filter(a => a.name === name && !a.expired); + if (listed.length !== 1) throw new Error(`run ${id} has ${listed.length} unexpired artifacts named ${name}`); + if (!(listed[0]!.size_in_bytes <= MAX_LOG_ARTIFACT_BYTES)) throw new Error(`artifact ${name} is ${listed[0]!.size_in_bytes} bytes, over ${MAX_LOG_ARTIFACT_BYTES}`); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-run-evidence-')); + try { + gh(['run', 'download', String(id), '--repo', repo, '--name', name, '--dir', dir], 300_000); + const out: Record = {}; + let bytes = 0; + const walk = (rel: string) => { + for (const entry of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) { + const child = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { walk(child); continue; } + if (!entry.isFile()) continue; + bytes += fs.statSync(path.join(dir, child)).size; + if (bytes > MAX_LOG_ARTIFACT_BYTES) throw new Error(`artifact ${name} unpacks to more than ${MAX_LOG_ARTIFACT_BYTES} bytes`); + out[child] = fs.readFileSync(path.join(dir, child), 'utf8'); + } + }; + walk(''); + return out; + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, + pull: n => { + const p = JSON.parse(gh(['api', `repos/${repo}/pulls/${n}`])) as { state: string; head: { sha: string; repo: { full_name: string } | null } }; + return { head_sha: p.head.sha, state: p.state, head_repo: p.head.repo?.full_name ?? null }; + }, + }; +} diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 0a95e7d66..47c81d564 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -29,6 +29,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { PregateFile } from './headless-artifacts'; import { CHECKS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index'; +import { exceptionConsumerProblem, flakyExceptionLines } from './pregate/flaky-exception'; import { ensureTmpExcluded, restamp, versionAtRef } from './restamp'; import type { ResultCodeName } from './result-codes'; import { extractReceipt } from './ship-receipt'; @@ -232,6 +233,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL); let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`; let pregateOk = false; + let accepted: PregateFile | null = null; const treeNow = wtreeFingerprint({ cwd: ctx.repoRoot }); // The mirror stands in for a record that is missing, unreadable, recorded for another tree, or current but // waiting on a remote receipt another checkout holds; a record whose own checks failed or stayed incomplete (the diff's doing @@ -242,6 +244,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate if (read.file) { const v = verifyPregate(ctx.repoRoot, read.file, treeNow); pregateOk = v.ok; + if (v.ok) accepted = read.file; useMirror = !v.ok && v.code !== 'PREGATE_LANE_FAILED' && v.code !== 'PREGATE_INCOMPLETE' && v.code !== 'PREGATE_ENVIRONMENT'; pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`; } else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`; @@ -253,9 +256,12 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate const v = read?.file && !gap ? verifyPregate(ctx.repoRoot, read.file, treeNow) : null; if (read?.file && v?.ok) { pregateOk = true; + accepted = read.file; pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from the state root's pregate/by-tree/${treeNow.tree.slice(0, 12)}.json, recorded by another checkout)`; } else if (gap) pregateLine += `; the state root's mirror for this tree cannot stand in: ${gap}`; } + const foreign = accepted && exceptionConsumerProblem(accepted, head, o.pr); + if (foreign) { pregateOk = false; accepted = null; pregateLine += ` — ${foreign} (PREGATE_REMOTE_UNCLEARED)`; } const receipt = bindShippedReceipt(ctx.repoRoot, `origin/${ctx.base}`, info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? '')); const attempt = currentAttempt(ctx.repoRoot, o.pr); const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head); @@ -268,6 +274,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate `ci: ${ci.line}`, ...(hint ? [hint] : []), pregateLine, + ...(accepted ? flakyExceptionLines(accepted) : []), gateLine(policy, attempt), receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : []), diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts index 7cd524523..165f56b93 100644 --- a/lib/ship-receipt-depth.ts +++ b/lib/ship-receipt-depth.ts @@ -224,7 +224,8 @@ function headInputs(root: string, baseRef: string, head: string, r?: ShipReceipt /** * The queue's exclusion check: the digest the receipt recorded (none when absent) against the one `head` has under the current * base policy. Only the digest counts (the policy's patterns and the excluded list), so a base merge or a moved base that leaves - * both unchanged binds; a changed selection needs the review rerun, which receipt write alone cannot stand in for. + * both unchanged binds; a changed selection needs the review rerun (write checks the same digest and does not read the + * review ledger). */ export function excludeMismatch(x: Exclusion, r: ShipReceipt, head: string, base: string): string | null { const expected = x.state === 'active' ? x.record.digest : 'none'; @@ -232,7 +233,7 @@ export function excludeMismatch(x: Exclusion, r: ShipReceipt, head: string, base if (expected === recorded) return null; const source = x.state === 'active' ? x.record.source : r.review_exclude?.source ?? base; const h = head.slice(0, 12); - return `field=review_exclude expected=${expected} recorded=${recorded} source=${source}: the reviewers read another selection than ${base}'s review_exclude_paths gives ${h}; the receipt is unchanged; fix: rerun /ship's review on ${h} (gstack-review-dispatch pass --base ${base.replace(/^(refs\/remotes\/)?origin\//, '')}), then gstack-ship-receipt write --pr --review-exclude --body with the digest gstack-review-dispatch verify --ledger prints on REVIEW_EXCLUDE_RECORD:; a receipt write alone does not clear this`; + return `field=review_exclude expected=${expected} recorded=${recorded} source=${source}: the receipt records another selection than ${base}'s review_exclude_paths gives ${h}; the receipt is unchanged; fix: rerun /ship's review on ${h} (gstack-review-dispatch pass --base ${base.replace(/^(refs\/remotes\/)?origin\//, '')}), then gstack-ship-receipt write --pr --review-exclude --body with the digest gstack-review-dispatch verify --ledger prints on REVIEW_EXCLUDE_RECORD: (write checks the digest against the base policy and does not read the review ledger, so only the rerun shows the reviewers read this selection)`; } /** The receipt's recorded gate scan when it names this head; otherwise null (unavailable). */ const boundScan = (r: ShipReceipt, head: string): number | null => { diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index bbb145358..3f9ff6dcd 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -20,7 +20,7 @@ const KEY_ORDER: Array = [ 'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'ci_carried', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', - 'review_loop_exception', 'review_targeted', 'review_exclude', 'carry', 'base_red', + 'review_loop_exception', 'review_targeted', 'review_exclude', 'carry', 'base_red', 'flaky_exception', ]; /** Keys in the fixed order so identifiers lead; undefined values are dropped. */ diff --git a/lib/ship-release.ts b/lib/ship-release.ts index 7c1941a5e..1cb9963fb 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -15,6 +15,8 @@ import { mergeChangelogs, normalizeChangelog } from './changelog-check'; import { versionAtRef } from './restamp'; import { classifyPath } from './review-depth'; import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch'; +import { locate, readRecord } from './approval-record'; +import { reviewFreshness } from './review-evidence'; import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth'; import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs'; import { resolveStateRoot } from './state-root'; @@ -183,8 +185,14 @@ function harnessRepin(cwd: string, from: string, to: string, p: string): boolean || (e?.since === b.version && typeof e?.nonBehavioral === 'string' && e.nonBehavioral.trim().length >= 10)); } -/** Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). */ -type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted', string[]>; +/** + * Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). + * A code or test path the newest completed clean review-fix-check row read (inside its range_base..commit range, where + * range_base holds the reviewed tree, and unchanged since) counts as fix_checked when an approved review_loop_grant names a stop + * head inside range_base..commit: the owner's granted fix-check after Step 11 is a review (GST-1130). Authored docs keep the + * stage 2.2 recheck. + */ +type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted' | 'fix_checked', string[]>; function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null { const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim(); if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return null; } @@ -204,7 +212,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string sides = { reviewedBase: rb, reviewed, currentBase: cb, current: now }; touched = new Set([...a, ...b]); } - const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [] }; + const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [], fix_checked: [] }; for (const p of [...changedSet].sort()) { const verdict = sides ? (touched.has(p) ? comparePath(cwd, sides, p) : 'same') : 'changed'; by[verdict === 'same' ? 'base' : release.includes(p) || harnessRepin(cwd, reviewed, now, p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p); @@ -219,6 +227,30 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string by.context = by.context.filter(p => !packetBody.over.includes(p)); by.tests = by.tests.filter(p => !packetBody.over.includes(p)); } + const fixRow = bin(cwd, 'gstack-review-read', [], 60_000).stdout.split('\n---CONFIG---')[0].split('\n') + .flatMap(l => { try { const v = JSON.parse(l); return v?.skill === 'review-fix-check' ? [v] : []; } catch { return []; } }).at(-1); + const commitOf = (c: unknown) => typeof c === 'string' && /^[0-9a-f]{7,64}$/.test(c) ? git(cwd, ['rev-parse', '--verify', '--quiet', `${c}^{commit}`]).stdout.trim() : ''; + const onBranch = (c: string) => !!c && git(cwd, ['merge-base', '--is-ancestor', c, 'HEAD']).status === 0; + const fixCommit = commitOf(fixRow?.commit_full), fixFrom = commitOf(fixRow?.range_base); + const fresh = fixRow && reviewFreshness(fixRow, fixRow.wtree); + const ancestor = (a: string, d: string) => git(cwd, ['merge-base', '--is-ancestor', a, d]).status === 0; + const grants = fixRow ? (loc => 'file' in loc ? readRecord(loc).rows : [])(locate({ cwd, env: process.env })) : []; + const grant = grants.find(g => g.outcome === 'approved' && `${g.ask} ${g.scope}`.includes('review_loop_grant') + && (`${g.ask} ${g.scope}`.match(/\b[0-9a-f]{7,40}\b/g) ?? []).some(s => { const c = commitOf(s); return !!c && !!fixFrom && !!fixCommit && ancestor(fixFrom, c) && ancestor(c, fixCommit); })); + const notCounted = !fixRow ? '' : fresh?.status !== 'CURRENT' ? fresh?.reason ?? 'not a review row' + : fixRow.tree !== fixRow.wtree || !onBranch(fixCommit) ? 'the row was logged on content no branch commit holds' + : !onBranch(fixFrom) ? 'the row names no range_base commit on this branch' + : git(cwd, ['rev-parse', `${fixFrom}^{tree}`]).stdout.trim() !== reviewed ? `range_base ${fixFrom.slice(0, 12)} does not hold the reviewed tree` + : !grant ? 'no approved review_loop_grant names a stop head inside its range_base..commit' : ''; + if (notCounted) r.add(`FIX_CHECK: not counted (${notCounted})`); + const counted = !!fixRow && !notCounted; + if (counted) { + const range = touchedPaths(cwd, fixFrom, fixCommit), after = touchedPaths(cwd, fixCommit, now); + if (!range || !after) { r.stop(`git could not diff the fix-check range ${fixFrom.slice(0, 12)}..${fixCommit.slice(0, 12)} against the reviewed and current trees: stage 2 is blocked`); return null; } + by.fix_checked = [...by.behavior, ...by.tests, ...by.context].filter(p => range.has(p) && !after.has(p)).sort(); + for (const k of ['behavior', 'tests', 'context'] as const) by[k] = by[k].filter(p => !by.fix_checked.includes(p)); + r.add(`FIX_CHECK: reviewed ${by.fix_checked.length ? short(by.fix_checked) : 'none'} (fix-check at ${fixCommit.slice(0, 12)}, range from ${fixFrom.slice(0, 12)}, grant ${grant!.id})`); + } const loop = opts.loop; if (loop) { const head = loop.fix_check.head; @@ -233,7 +265,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string by.behavior = by.behavior.filter(p => !by.accepted.includes(p)); by.docs = by.docs.filter(p => !by.accepted.includes(p)); } - r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${loop ? ` accepted=${by.accepted.length}` : ''}`); + r.add(`ROUTE: reviewed=${reviewed.slice(0, 12)} current=${now.slice(0, 12)} behavior=${by.behavior.length} docs=${by.docs.length} metadata=${by.metadata.length} tests=${by.tests.length} base=${sides ? by.base.length : 'unknown'} context=${by.context.length}${counted ? ` fix_checked=${by.fix_checked.length}` : ''}${loop ? ` accepted=${by.accepted.length}` : ''}`); if (loop) r.add(`REVIEW_LOOP_EXCEPTION: accepted ${by.accepted.length ? short(by.accepted) : 'none'} (changed since fix-check ${loop.fix_check.head.slice(0, 12)}; accepted by ${loop.accepted_by} ${loop.date} via ${loop.via})`); if (by.behavior.length) { r.stop(`behavior, tests or build inputs changed since review (${short(by.behavior.sort())}): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)`); return null; } const scoped = [...by.context, ...by.tests].sort(); diff --git a/package.json b/package.json index 59c88aee5..0f033e81f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.94.8", + "version": "1.94.15", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/harness-version.json b/scripts/harness-version.json index f1886c78e..c262bd9aa 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -17,7 +17,7 @@ "scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6}, "scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"}, "scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"}, - "scripts/test-pr-profile.ts": {"blob":"dd0ca92bcf8493fd5c1e9d399cc3bc74ec70c469","since":6,"nonBehavioral":"FREE_ONLY_PR_FILES gains USING_GBRAIN_WITH_GSTACK.md (a root guide no skill prompt or paid fixture reads); it changes only which PR paths select paid cases, never how a case runs or is judged (GST-1027)"}, + "scripts/test-pr-profile.ts": {"blob":"b552b8825d693493decef76b05b976858cab087e","since":6,"nonBehavioral":"test-pr-profile.ts lists .github/workflows/windows-scoped-tests.yml as free-lane only; the workflow runs no paid case, so no case selection or verdict can change"}, "scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6}, "test/helpers/agent-sdk-runner.ts": {"blob":"d66dac458b8c13250973e550edf8087d4044cd0b","since":6,"nonBehavioral":"Cost attribution only (GSTA-150 PR 2): a display-label record carries cost_case_id from runSkillTest's testName and the report binds its cost by it while the failed record and failure class still come from the named records (test/paid-report-cost.test.ts pins both); an aborted Agent SDK session logs its streamed usage as streamed_cost_usd, used only when a case has no record; a listed model-free case with no record or session is a known $0. No case's execution, outcome, failure class, rerun eligibility or panel verdict changes, so no pass-rate series resets. Also: runAgentSdkTest now validates systemPrompt before starting; every live caller already passed the claude_code preset, so the prompt the model sees is unchanged and no verdict can change"}, "test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6}, diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index dd0ca92bc..b552b8825 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -295,6 +295,7 @@ export const FREE_ONLY_PR_FILES = [ '.github/workflows/version-gate.yml', '.github/workflows/windows-free-tests.yml', '.github/workflows/windows-pregate-e2e.yml', + '.github/workflows/windows-scoped-tests.yml', '.github/workflows/windows-setup-e2e.yml', '.github/workflows/windows-smoke.yml', ] as const; diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index 882b04af7..8d1e12650 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -223,6 +223,28 @@ describe('gstack-diff-scope', () => { expect(scope.SCOPE_AUTH).toBe('true'); }); + // Value: protects=auth matches as a word or path segment, so docs about authors stay out of the gate class; + // fails_when=the auth match is a bare substring again (author-spec.md, AUTHORS, authority.ts raise SCOPE_AUTH); + // why_new=GST-1065 (author-spec.md raised a docs bundle to gate class with four full readers); seam=bin/gstack-diff-scope + for (const file of ['docs/author-spec.md', 'AUTHORS', 'lib/authority.ts', 'docs/authored-by.md', 'src/coauthors.ts', 'AUTHORS.md']) { + test(`${file} is not an auth path`, () => { + expect(runScope(createRepo([file])).SCOPE_AUTH).toBe('false'); + }); + } + + // Value: protects=real auth paths still raise SCOPE_AUTH after the word-boundary fix (negative controls for GST-1065); + // fails_when=the boundary drops a segment, a separator, a camelCase name, an authn/authz/oauth spelling or a glued token (auth0, basicauth, nextauth); + // why_new=GST-1065 and its review (the first word-only matcher dropped glued tokens the old glob caught); seam=bin/gstack-diff-scope + for (const file of ['src/auth.ts', 'app/auth/login.rb', 'lib/oauth.ts', 'src/oauth2/token.ts', 'lib/authn/check.go', 'src/authz.py', 'src/authService.ts', + 'src/useAuth.ts', 'lib/OAuthClient.ts', 'lib/authentication.rb', 'lib/authorization_policy.rb', 'src/Authorize.tsx', 'config/AUTH_KEYS.md', 'pkg/auth-middleware.go', + 'lib/auth0.ts', 'src/basicauth.go', 'src/authguard.ts', 'lib/reauth.ts', 'pages/api/nextauth.ts', 'src/authtoken.ts', 'lib/preauth.rb', 'src/HTTPAuth.ts', + 'src/unauthenticated.ts', 'lib/reauthentication.ts', 'src/deauthorize.ts', 'app/oauth2client.py', 'api/HTTPAuthorization.ts', + 'src/nextauthmiddleware.ts', 'src/reauthn.ts', 'src/noauthrequired.ts']) { + test(`${file} is an auth path`, () => { + expect(runScope(createRepo([file])).SCOPE_AUTH).toBe('true'); + }); + } + test('detects config via bun.lock (Bun v1.2+ text lockfile)', () => { const dir = createRepo(['bun.lock']); const scope = runScope(dir); diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts index 39388ac41..84a4e4e62 100644 --- a/test/gstack-design-detect.test.ts +++ b/test/gstack-design-detect.test.ts @@ -935,6 +935,34 @@ describe('scan', () => { expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: main (not a repository)`); expect(r.code).toBe(1); // a base that cannot be diffed is a failed target, never a silent clean scan }); + + test.skipIf(!POSIX)('--changed main reads origin/main over a stale local main (GST-1036)', () => { + // Value: protects=design review scans only the branch's frontend files; fails_when=a bare --changed base diffs against the stale local main + // and scans a stylesheet only upstream changed; why_new=GST-1036 (ship review-army passes a bare ); seam=bin/gstack-design-detect.ts + const bare = path.join(SANDBOX, 'stale-origin.git'); + const work = path.join(SANDBOX, 'stale-work'); + const other = path.join(SANDBOX, 'stale-other'); + const log = path.join(SANDBOX, 'argv-stale.log'); + try { + git(SANDBOX, 'clone', '-q', '--bare', REPO, bare); + git(SANDBOX, 'clone', '-q', bare, work); + git(SANDBOX, 'clone', '-q', bare, other); + for (const d of [work, other]) { git(d, 'config', 'user.email', 't@example.com'); git(d, 'config', 'user.name', 't'); } + fs.writeFileSync(path.join(other, 'src', 'styles.css'), '.hero { color: blue }\n'); + git(other, 'commit', '-qam', 'upstream'); + git(other, 'push', '-q', 'origin', 'HEAD:main'); + git(work, 'fetch', '-q', 'origin'); + git(work, 'checkout', '-q', '-b', 'feature', 'origin/main'); + fs.writeFileSync(path.join(work, 'src', 'components', 'Card.tsx'), 'export const Card = () =>

;\n'); + git(work, 'commit', '-qam', 'change'); + const r = run(['scan', '--changed', 'main', '--format', 'gstack'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log }, cwd: work }); + const argv = JSON.parse(fs.readFileSync(log, 'utf-8').trim().split('\n')[0]).argv as string[]; + expect(argv.slice(2).map(a => path.relative(fs.realpathSync(work), a)).sort()).toEqual(['src/components/Card.tsx']); + expect(r.err).toContain('gstack-design-detect: local main is behind origin/main; base is origin/main'); + } finally { + for (const p of [bare, work, other, log]) fs.rmSync(p, { recursive: true, force: true }); + } + }); }); describe('design-review REPORT_DIR agrees with the allow-list', () => { diff --git a/test/pregate-flaky-exception.test.ts b/test/pregate-flaky-exception.test.ts new file mode 100644 index 000000000..e9b0beba3 --- /dev/null +++ b/test/pregate-flaky-exception.test.ts @@ -0,0 +1,317 @@ +/** + * gstack-pregate clear --flaky-exception (GST-1096): a red GitHub Actions run clears a requires-remote obligation only + * when every failure is named from the run's own shard results and logs, each named file is outside the PR's diff and + * its import closure, at most three cases, none paid or a security scan, and an owner's gstack-approvals row binds the + * repository, PR, head, tree, lane, run, attempt and cases. verify prints pass-with-exception, ready prints + * FLAKY_EXCEPTION and still needs green CI (pregate only), and the receipt records it. The run API is a fake; trees + * come from the fixture repository's git. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT, type PregateFile } from '../lib/headless-artifacts'; +import { buildContext } from '../lib/pregate/context'; +import { clearDecision, clearObligation, runPregate, verifyPregate, type ClearDecision, type RunCheck } from '../lib/pregate/index'; +import { exceptionBinding, type FailureDeps, type GrantRow } from '../lib/pregate/flaky-exception'; +import type { ApiJob, ApiRun, RunDeps, RunProfile } from '../lib/run-evidence'; +import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; +import { cleanup as cleanupQueue, makeFixtureRepo as makeQueueRepo, makePr, runBin as runQueueBin } from './helpers/restamp-fixture'; +import { ghQueueShim } from './helpers/ship-queue-fixture'; +import { shippedBody } from './helpers/ship-queue-train-fixture'; + +const repos: FixtureRepo[] = []; +const queueRepos: Array> = []; +afterEach(() => { + while (repos.length) cleanup(repos.pop()!); + while (queueRepos.length) cleanupQueue(queueRepos.pop()!); +}); + +const LANE = 'pregate.json/remote_gate'; +const NOW = new Date('2026-10-29T12:00:00Z'); +const PROFILE_NAME = 'ci-reuse/free-tests'; +const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['free-plan', 'typecheck', 'free-tests'], artifact: 'tested-tree' }; +const GATE_RUNS = { profiles: [PROFILE_NAME], inventory: 'true' }; +const FLAKE = 'test/retry.test.js::retries three times'; +const WORDS = 'yes, accept the retry timing flake for this head'; + +function fixture(): FixtureRepo { + const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js' } }); + repos.push(repo); + commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'the real change'); + return repo; +} + +async function pregateAt(repo: FixtureRepo): Promise { + const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' }); + if (!('ctx' in loaded)) throw new Error('fixture context refused'); + const file = await runPregate(loaded.ctx, { stage: 'tests' }); + expect(file.requires_remote).toEqual([expect.objectContaining({ lane: LANE, receipt: null })]); + return file; +} + +interface Shard { files: string[]; failures?: Array<[string | null, string]>; status?: 'passed' | 'failed' | 'timed-out'; unattributed?: number; retry?: { files: string[]; failures: Array<[string, string]> }; log?: string | null } +interface RunOver { conclusion?: string; jobs?: (jobs: ApiJob[]) => ApiJob[]; missing?: string[]; pull?: { head_sha: string; state: string; head_repo: string | null }; newer?: boolean; attemptAfter?: number; api?: Partial } + +const DEFAULT_SHARDS: Shard[] = [ + { files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times']] }, + { files: ['test/b.test.ts', 'test/util.test.js', 'test/config-read.test.js', 'test/tool-cli.test.js'] }, +]; + +function logFor(s: Shard, names: Array<[string | null, string]>): string { + const lines: string[] = []; + for (const f of s.files) { + lines.push(`${f}:`); + for (const [file, name] of names) if (file === f) lines.push(`(fail) ${name} [5005.00ms]`); + } + lines.push(`Ran ${s.files.length * 3} tests across ${s.files.length} files. [12.00s]`); + return lines.join('\n') + '\n'; +} + +/** Run 400: a red workflow_dispatch Free Tests run on `head` whose shard artifacts and logs say what failed. */ +function redRun(repo: FixtureRepo, head: string, shards: Shard[] = DEFAULT_SHARDS, over: RunOver = {}): RunCheck & { failures: FailureDeps } { + const plan = { version: 1, revision: head, id: 'plan-1', shards: shards.map((s, i) => ({ shard: i + 1, files: s.files, predictedMs: 1000 })) }; + const statusOf = (s: Shard) => s.status ?? (s.failures?.length || s.unattributed ? 'failed' : 'passed'); + const finalFailed = (s: Shard) => statusOf(s) !== 'passed' && !(s.retry && s.retry.failures.length === 0); + const outcome = (s: Shard, i: number, files: string[], failures: Array<[string | null, string]>, status: string) => ({ + shard: i + 1, files, status, exitCode: status === 'passed' ? 0 : 1, elapsedMs: 1000, groupPid: null, + summary: { testsRan: files.length * 3, filesRan: files.length, sawTerminalSummary: status !== 'timed-out' }, + failingFiles: [...new Set(failures.map(([f]) => f).filter((f): f is string => !!f))], unattributedFailures: s.unattributed ?? 0, + failures: failures.map(([file, testName]) => ({ file, testName })), + }); + const artifacts: Record> = { 'free-plan': { 'free-plan.json': JSON.stringify(plan) } }; + shards.forEach((s, i) => { + const retry = s.retry ? outcome({ files: s.retry.files }, i, s.retry.files, s.retry.failures, s.retry.failures.length ? 'failed' : 'passed') : null; + artifacts[`free-result-${i + 1}`] = { [`shard-${i + 1}.json`]: JSON.stringify({ planId: 'plan-1', revision: head, outcome: outcome(s, i, s.files, s.failures ?? [], statusOf(s)), retry }) }; + if (statusOf(s) !== 'passed' && s.log !== null) artifacts[`free-test-shard-logs-${i + 1}`] = { [`gstack-free-test-${i + 1}.log`]: s.log ?? logFor(s, [...(s.failures ?? []), ...(s.retry?.failures ?? [])]) }; + }); + for (const m of over.missing ?? []) delete artifacts[m]; + const anyFailed = shards.some(finalFailed); + const at = '2026-10-29T10:30:00Z'; + const baseJobs: ApiJob[] = [ + { name: 'reuse', conclusion: 'skipped', completed_at: at }, + { name: 'free-plan', conclusion: 'success', completed_at: at }, + { name: 'typecheck', conclusion: 'success', completed_at: at }, + ...shards.map((s, i) => ({ name: `free-suite (${i + 1})`, conclusion: finalFailed(s) ? 'failure' : 'success', completed_at: at })), + { name: 'free-tests', conclusion: anyFailed ? 'failure' : 'success', completed_at: at }, + ]; + const jobs = over.jobs ? over.jobs(baseJobs) : baseJobs; + let reads = 0; + const api = (): ApiRun => ({ + id: 400, run_attempt: reads++ > 0 && over.attemptAfter ? over.attemptAfter : 1, status: 'completed', conclusion: over.conclusion ?? (anyFailed ? 'failure' : 'success'), event: 'workflow_dispatch', head_sha: head, + path: '.github/workflows/free-tests.yml', created_at: '2026-10-29T10:00:00Z', updated_at: at, repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' }, ...over.api, + }); + const deps: RunDeps = { + run: id => { if (id !== 400) throw new Error('HTTP 404'); return api(); }, + runsForHead: () => [{ ...api(), run_attempt: 1 }, ...(over.newer ? [{ ...api(), id: 401, run_attempt: 1, created_at: '2026-10-29T11:00:00Z', run_started_at: '2026-10-29T11:00:00Z' }] : [])], + jobs: () => jobs, + artifact: () => { throw new Error('run 400 has 0 unexpired artifacts named tested-tree'); }, + commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`), + }; + const failures: FailureDeps = { + artifactFiles: (_id, name) => { const a = artifacts[name]; if (!a) throw new Error(`run 400 has 0 unexpired artifacts named ${name}`); return a; }, + pull: () => over.pull ?? { head_sha: head, state: 'open', head_repo: 'fx/fx' }, + }; + return { repo: 'fx/fx', deps, profiles: { [PROFILE_NAME]: PROFILE }, gateRuns: GATE_RUNS, now: NOW, inventory: () => ['test/a.test.ts', 'test/b.test.ts'], failures }; +} + +const msg = (d: ClearDecision) => ('refused' in d ? `${d.refused}: ${d.message}${d.fix ? `; fix: ${d.fix}` : ''}` : `cleared ${d.receipt}`); + +/** The scope the grant-needed refusal names, as an approvals row the owner granted. */ +function grantFor(d: ClearDecision, over: Partial = {}): (id: string) => GrantRow | null { + const scope = /--scope "(flaky-exception:[0-9a-f]{64})"/.exec(msg(d))?.[1]; + if (!scope) throw new Error(`no grant scope in: ${msg(d)}`); + return id => (id === 'a-1' ? { id, outcome: 'approved', scope, words: WORDS, by: 'Garry', ...over } : null); +} + +describe.skipIf(process.platform === 'win32')('clear --flaky-exception: an owner-granted exception for named flaky cases', () => { + test('a red run whose only failure is the named case clears the gate once the bound grant exists; verify reports pass-with-exception', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const check = redRun(repo, head); + const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work); + expect(msg(asked)).toMatch(/^PREGATE_RECEIPT_INVALID: FLAKY_EXCEPTION: refused reason=grant_needed — .*test\/retry\.test\.js::retries three times.*; fix: .*gstack-approvals add --ask '.*' --words "" --by "" --scope "flaky-exception:[0-9a-f]{64}" --source pregate-flaky-exception/); + const d = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7, grant: 'a-1', grantRow: grantFor(asked) } }, repo.work); + if ('refused' in d) throw new Error(d.message); + expect(d.receipt).toBe(`remote-gate tree=${file.tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`); + expect(d.exception).toMatchObject({ lane: LANE, repo: 'fx/fx', pr: 7, head, tree: file.tree, runs: ['400/1'], cases: [FLAKE], grant: 'a-1', by: 'Garry', words: WORDS }); + const cleared = clearObligation(file, LANE, d.receipt, 'test', d.substitution, d.exception); + expect(cleared.verdict).toBe('pass-with-exception'); + expect(cleared.exceptions).toEqual([d.exception!]); + const v = verifyPregate(repo.work, cleared, { tree: file.tree } as any); + expect(v).toMatchObject({ ok: true }); + expect(v.message).toContain(`flaky exception ${FLAKE} (grant a-1, by Garry, run 400/1)`); + const tampered = (e: Partial[number]>) => verifyPregate(repo.work, { ...cleared, exceptions: [{ ...cleared.exceptions![0]!, ...e }] }, { tree: file.tree } as any); + expect(tampered({ head: 'f'.repeat(40) })).toMatchObject({ ok: false, code: 'PREGATE_REMOTE_UNCLEARED' }); + expect(tampered({ cases: [FLAKE, 'test/b.test.ts::other'] }).message).toContain('binding does not match'); + expect(verifyPregate(repo.work, { ...cleared, exceptions: [] }, { tree: file.tree } as any).message).toContain('no exceptions entry'); + const plain = clearObligation(structuredClone(cleared), LANE, `remote-gate tree=${file.tree.slice(0, 12)} tests=18 runs=401/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`, 'test'); + expect([plain.exceptions, plain.verdict]).toEqual([undefined, 'requires-remote cleared']); + }, 120_000); + + test('the grant binds the head, PR, run and cases: another scope, an unapproved row, an agent\'s words or an unknown id refuse', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const check = redRun(repo, head); + const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work); + const run = (grantRow: (id: string) => GrantRow | null, grant = 'a-1') => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7, grant, grantRow } }, repo.work)); + const otherHead = exceptionBinding({ lane: LANE, repo: 'fx/fx', pr: 7, head: 'e'.repeat(40), tree: file.tree, runs: ['400/1'], cases: [FLAKE] }); + expect(run(grantFor(asked, { scope: otherHead }))).toContain('reason=grant_mismatch'); + expect(run(grantFor(asked, { outcome: 'pending' }))).toContain('reason=grant_not_approved'); + expect(run(grantFor(asked, { by: 'agent:jam_123' }))).toContain('reason=grant_not_owner'); + expect(run(grantFor(asked), 'a-2')).toContain('reason=grant_missing'); + }, 120_000); + + test('every failure must be named: an extra failure, a case that did not fail, more than three cases, an unattributed failure refuse', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const clear = (check: RunCheck, cases = [FLAKE]) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases, pr: 7 } }, repo.work)); + const two: Shard[] = [{ files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'other']] }, DEFAULT_SHARDS[1]!]; + expect(clear(redRun(repo, head, two))).toContain('reason=unnamed_failure — run 400 attempt 1 failed test/a.test.ts::other, which no --flaky-exception names'); + expect(clear(redRun(repo, head), [FLAKE, 'test/a.test.ts::passed fine'])).toContain('reason=case_not_failed — test/a.test.ts::passed fine did not fail in run 400'); + expect(clear(redRun(repo, head), ['a.ts::1', 'b.ts::2', 'c.ts::3', 'd.ts::4'])).toBe('usage: --flaky-exception names at most 3 cases; got 4'); + expect(clear(redRun(repo, head), ['no-separator'])).toContain('usage: --flaky-exception takes "::"'); + expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, unattributed: 1 }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1: 1 failure(s) the runner could not attribute to a test'); + expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, failures: [[null, 'retries three times']] }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure'); + expect(clear(redRun(repo, head, [{ files: DEFAULT_SHARDS[0]!.files }, DEFAULT_SHARDS[1]!]))).toContain('reason=run_green'); + }, 120_000); + + test('every job, shard and log is reconciled: a failed non-shard job, a missing result or log, a timed-out shard, a log failure the result omits refuse', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const clear = (check: RunCheck) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work)); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'failure' } : j) }))).toContain('reason=unnamed_failure — run 400 attempt 1\'s typecheck job concluded failure; only test-shard failures can be named'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => j.name === 'reuse' ? { ...j, conclusion: 'cancelled' } : j) }))).toContain('reason=unnamed_failure — run 400 attempt 1\'s reuse job concluded cancelled'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.filter(j => j.name !== 'free-suite (2)') }))).toContain('reason=shard_mismatch'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { missing: ['free-result-2'] }))).toContain('reason=shard_incomplete — shard 2 has no readable free-result-2'); + expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: null }, DEFAULT_SHARDS[1]!]))).toContain('reason=logs_missing — shard 1 failed and has no readable free-test-shard-logs-1'); + expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, status: 'timed-out' }, DEFAULT_SHARDS[1]!]))).toContain('reason=shard_incomplete — shard 1 timed out'); + const extraInLog = logFor(DEFAULT_SHARDS[0]!, [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'hidden']]); + expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: extraInLog }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1\'s log names test/a.test.ts::hidden, which its result does not'); + expect(clear(redRun(repo, head, [{ ...DEFAULT_SHARDS[0]!, log: logFor(DEFAULT_SHARDS[0]!, [['test/retry.test.js', 'retries three times']]) + '# Unhandled error between tests\n' }, DEFAULT_SHARDS[1]!]))).toContain('reason=unnamed_failure — shard 1\'s log shows 1 unhandled error(s) between tests'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { conclusion: 'cancelled' }))).toContain('reason=run_not_failure'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { newer: true }))).toContain('reason=newer_run'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { attemptAfter: 2 }))).toContain('reason=run_changed'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { repository: { full_name: 'other/fx' } } }))).toContain('reason=other_repository'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { head_repository: { full_name: 'fork/fx' } } }))).toContain('reason=fork_run'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { path: '.github/workflows/evals.yml' } }))).toContain('reason=workflow_mismatch'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { event: 'push' } }))).toContain('reason=event_mismatch'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { api: { status: 'in_progress' } }))).toContain('reason=run_in_progress'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { jobs: js => js.map(j => ({ ...j, completed_at: '2026-10-27T10:30:00Z' })) }))).toContain('reason=run_stale'); + }, 120_000); + + test('a red run that moves while the clear is assembled refuses, and the printed grant command quotes case names for the shell', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const check = redRun(repo, head); + const asked = clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work); + let rerun = false; + const run = check.deps.run; + const moving: RunCheck = { ...check, deps: { ...check.deps, run: id => (rerun ? { ...run(id), run_attempt: 2, status: 'in_progress' } : run(id)) }, inventory: () => { rerun = true; return ['test/a.test.ts', 'test/b.test.ts'] } }; + expect(msg(clearDecision(file, LANE, { runs: ['400'], evidence: moving, flaky: { cases: [FLAKE], pr: 7, grant: 'a-1', grantRow: grantFor(asked) } }, repo.work))).toContain('reason=run_changed — run 400 moved to attempt 2 (in_progress)'); + const name = "times out $(touch pwned) `id` it's"; + const shard: Shard[] = [{ files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', name]] }, DEFAULT_SHARDS[1]!]; + const fix = msg(clearDecision(file, LANE, { runs: ['400'], evidence: redRun(repo, head, shard), flaky: { cases: [`test/retry.test.js::${name}`], pr: 7 } }, repo.work)); + const ask = /--ask ('(?:[^']|'\\'')*') --words/.exec(fix)?.[1]; + expect(ask).toBeDefined(); + const echoed = Bun.spawnSync(['bash', '-c', `printf %s ${ask}`], { cwd: repo.work, timeout: 10_000 }); + expect(echoed.stdout.toString()).toContain(`test/retry.test.js::${name}`); + expect(fs.existsSync(path.join(repo.work, 'pwned'))).toBe(false); + }, 120_000); + + test('a retry that passed is a recovered flake; a retry that failed again keeps only its own failures', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const shard1: Shard = { files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times'], ['test/a.test.ts', 'recovered']], retry: { files: ['test/retry.test.js', 'test/a.test.ts'], failures: [['test/retry.test.js', 'retries three times']] } }; + expect(msg(clearDecision(file, LANE, { runs: ['400'], evidence: redRun(repo, head, [shard1, DEFAULT_SHARDS[1]!]), flaky: { cases: [FLAKE], pr: 7 } }, repo.work))).toContain('reason=grant_needed'); + }, 120_000); + + test('a named file inside the PR diff or its import closure, a paid file or a security scan test refuses', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const one = (fileName: string, name: string) => { + const check = redRun(repo, head, [{ files: [fileName, 'test/retry.test.js', 'test/a.test.ts'].filter((f, i, a) => a.indexOf(f) === i), failures: [[fileName, name]] }, { files: DEFAULT_SHARDS[1]!.files.filter(f => f !== fileName) }]); + return msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [`${fileName}::${name}`], pr: 7 } }, repo.work)); + }; + expect(one('test/util.test.js', 'adds')).toContain('reason=case_in_closure — test/util.test.js depends on a file the PR changes (lib/util.js)'); + expect(one('test/skill-e2e-flaky.test.ts', 'judge')).toContain('reason=case_paid — test/skill-e2e-flaky.test.ts is a paid test file'); + expect(one('test/cso-docker-policy.test.ts', 'times out')).toContain('reason=case_security — test/cso-docker-policy.test.ts tests a security scan'); + expect(one('test/ship-template-redaction.test.ts', 'blocks')).toContain('reason=case_security'); + expect(one('browse/test/content-security.test.ts', 'blocks')).toContain('reason=case_security'); + + const touched = fixture(); + commit(touched, { 'test/retry.test.js': "const test = require('node:test');\ntest('retries three times', () => {});\n" }, 'touch the flaky test'); + const touchedHead = touched.git('rev-parse', 'HEAD'); + const touchedFile = await pregateAt(touched); + expect(msg(clearDecision(touchedFile, LANE, { runs: ['400'], evidence: redRun(touched, touchedHead), flaky: { cases: [FLAKE], pr: 7 } }, touched.work))).toContain('reason=case_in_diff — test/retry.test.js is in the PR\'s diff'); + }, 120_000); + + test('the run must be at pregate.json\'s own head and tree, and the PR head must be that head: no rebinding', async () => { + const repo = fixture(); + const older = repo.git('rev-parse', 'HEAD~1'); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const clear = (check: RunCheck) => msg(clearDecision(file, LANE, { runs: ['400'], evidence: check, flaky: { cases: [FLAKE], pr: 7 } }, repo.work)); + expect(clear(redRun(repo, older))).toContain('reason=head_mismatch'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { pull: { head_sha: older, state: 'open', head_repo: 'fx/fx' } }))).toContain('reason=pr_mismatch — PR #7\'s head is'); + expect(clear(redRun(repo, head, DEFAULT_SHARDS, { pull: { head_sha: head, state: 'closed', head_repo: 'fx/fx' } }))).toContain('reason=pr_mismatch — PR #7 is closed'); + }, 120_000); + + test('the bins: clear refuses --flaky-exception without --run or --pr; verify prints the PREGATE pass-with-exception line; the receipt records it', async () => { + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const file = await pregateAt(repo); + const out = path.join(repo.work, '.gstack/tmp/pregate.json'); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, JSON.stringify(file)); + const noRun = runBin(repo, 'gstack-pregate', ['clear', '--remote', LANE, '--flaky-exception', FLAKE, '--pr', '7', '--out', out]); + expect([noRun.status, noRun.err]).toEqual([EXIT.usage, expect.stringContaining('--flaky-exception needs --run ')]); + const noPr = runBin(repo, 'gstack-pregate', ['clear', '--remote', LANE, '--run', '400', '--flaky-exception', FLAKE, '--out', out]); + expect([noPr.status, noPr.err]).toEqual([EXIT.usage, expect.stringContaining('--flaky-exception needs --pr ')]); + const exception = { lane: LANE, repo: 'fx/fx', pr: 7, head, tree: file.tree, runs: ['400/1'], cases: [FLAKE] }; + const recorded = { ...exception, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(exception) }; + const cleared = clearObligation(file, LANE, `remote-gate tree=${file.tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z`, 'test', undefined, recorded); + fs.writeFileSync(out, JSON.stringify(cleared)); + const v = runBin(repo, 'gstack-pregate', ['verify', '--out', out]); + expect([v.status, v.err]).toEqual([EXIT.ok, '']); + expect(v.out).toContain(`PREGATE: pass-with-exception (${FLAKE}; grant a-1)\n`); + const bound = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'interactive', '--pregate', out, '--json']); + expect([bound.status, bound.err]).toEqual([EXIT.ok, expect.any(String)]); + expect(bound.out).toContain('"flaky_exception":[{"lane":"pregate.json/remote_gate","runs":["400/1"],"cases":["test/retry.test.js::retries three times"],"grant":"a-1","by":"Garry"}]'); + }, 180_000); +}); + +describe.skipIf(process.platform === 'win32')('ready with a flaky exception', () => { + test('ready accepts pass-with-exception, prints FLAKY_EXCEPTION, and still refuses a red required check (pregate only)', () => { + const repo = makeQueueRepo(); + queueRepos.push(repo); + const head = makePr(repo, 'pr-a'); + const tree = runQueueBin(repo, 'gstack-wtree', []).out.trim(); + const exception = { lane: LANE, repo: 'o/r', pr: 1, head, tree, runs: ['400/1'], cases: [FLAKE] }; + const pregate = path.join(repo.root, 'pregate.json'); + fs.writeFileSync(pregate, JSON.stringify({ + schema_version: 1, tree, head, verdict: 'pass-with-exception', checks: [{ id: 'lanes', status: 'requires-remote' }], + requires_remote: [{ lane: LANE, platform: 'remote', workflow: 'pregate.json', job: 'remote_gate', tests: ['test/a.test.ts'], receipt: `remote-gate tree=${tree.slice(0, 12)} tests=18 exception=a-1 cases=1 runs=400/1 profiles=${PROFILE_NAME} heads=${head.slice(0, 12)} verified=2026-10-29T12:00:00.000Z` }], + exceptions: [{ ...exception, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(exception) }], + })); + const queue = (checks: unknown) => runQueueBin(repo, 'gstack-ship-queue', ['ready', '--pr', '1', '--pregate', pregate, '--base', 'main'], ghQueueShim(repo, { prs: { '1': { headRefOid: head, url: 'https://github.com/o/r/pull/1', body: shippedBody('1', head) } }, checks: { [head]: checks } })); + const ok = queue({ check_runs: [{ status: 'completed', conclusion: 'success' }] }); + expect([ok.status, ok.err]).toEqual([EXIT.ok, '']); + expect(ok.out).toMatch(/^PREGATE: pass-with-exception tree=[0-9a-f]{12} /m); + expect(ok.out).toContain(`FLAKY_EXCEPTION: ${LANE} cases=${FLAKE} run=400/1 grant=a-1 by=Garry (pregate only; every required CI check still needs its own green run at this head)\n`); + const red = queue({ check_runs: [{ status: 'completed', conclusion: 'failure' }] }); + expect(red.status).toBe(EXIT.refused); + expect(red.err).toMatch(/CI is not green at .*\(QUEUE_NOT_READY\)/); + const other = { ...exception, pr: 2 }; + fs.writeFileSync(pregate, JSON.stringify({ ...JSON.parse(fs.readFileSync(pregate, 'utf8')), exceptions: [{ ...other, grant: 'a-1', by: 'Garry', words: WORDS, binding: exceptionBinding(other) }] })); + const moved = queue({ check_runs: [{ status: 'completed', conclusion: 'success' }] }); + expect(moved.status).toBe(EXIT.refused); + expect(moved.err).toContain('flaky exception grant a-1 is bound to PR #2'); + }, 120_000); +}); diff --git a/test/review-exclude.test.ts b/test/review-exclude.test.ts index aefa18c08..29e750d72 100644 --- a/test/review-exclude.test.ts +++ b/test/review-exclude.test.ts @@ -11,7 +11,7 @@ import { EXIT } from '../lib/headless-artifacts'; import { KEEP_ARG_MAX_BYTES, readSelection, reviewExclude, tooLargeLine, unreadableLine } from '../lib/review-exclude'; import { collectCommit, collectLive } from '../lib/review-collect'; import { extractReceipt } from '../lib/ship-receipt'; -import { bindShippedReceipt, receiptReviewAtHead } from '../lib/ship-receipt-depth'; +import { bindShippedReceipt, excludeMismatch, receiptReviewAtHead } from '../lib/ship-receipt-depth'; import { REVIEW_EXCLUDE_KEY_VERSION, isUnavailable } from '../lib/ship-policy'; import { loadPolicyAt } from '../lib/ship-policy-at'; import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; @@ -313,6 +313,31 @@ describe('the receipt binds the selection the reviewers read', () => { expect(removed.status).toBe(EXIT.fail); expect(removed.err).toContain(`field=review_exclude expected=none recorded=${digest}`); }, 240_000); + + // Value: protects=every place that describes write --review-exclude says what it checks: the digest HEAD has under the base + // policy, not that a review read that selection; fails_when=the help, troubleshooting, ship-policy or the queue's refusal + // line claims a receipt write alone cannot clear field=review_exclude while a write with the refusal's own expected= digest + // clears it with no review run; why_new=GST-972 item 2 (the #3852 cross-family trail review); seam=gstack-ship-receipt write + // and --help, excludeMismatch, the two docs; no_claim=binding the digest to the review ledger (write never reads it) + test('write binds --review-exclude to the base policy, not to a review, and no description claims more', () => { + const repo = census(); + const body = path.join(repo.root, 'body.md'); + const refused = writeReceipt(repo, body, []); + const expected = refused.err.match(/field=review_exclude expected=([0-9a-f]{64}) /)?.[1]; + expect(expected).toBeDefined(); + expect(writeReceipt(repo, body, ['--review-exclude', expected!, ...targetedAtHead(repo)]).status).toBe(EXIT.ok); + const doc = (rel: string) => fs.readFileSync(path.join(import.meta.dir, '..', rel), 'utf8').replace(/\s+/g, ' '); + const claims = { + help: runBin(repo, 'gstack-ship-receipt', ['--help']).out.replace(/\s+/g, ' '), + troubleshooting: doc('docs/troubleshooting.md'), + shipPolicy: doc('docs/ship-policy.md'), + queue: excludeMismatch({ state: 'none' }, { review_exclude: { digest: expected } } as any, repo.git('rev-parse', 'HEAD'), 'origin/main')!, + }; + for (const [where, text] of Object.entries(claims)) { + expect({ where, overClaim: /write alone (cannot|does not) clear|never a receipt write alone/i.test(text) }).toEqual({ where, overClaim: false }); + expect({ where, says: text.includes('does not read the review ledger') }).toEqual({ where, says: true }); + } + }, 240_000); }); describe('a branch cannot hide code under a base pattern', () => { @@ -348,6 +373,84 @@ describe('a branch cannot hide code under a base pattern', () => { expect(p).toMatchObject({ depth: 'full', errors: expect.arrayContaining([expect.objectContaining({ code: 'binary_code_path', input: 'assets/loader.js' })]) }); }, 120_000); + // Value: protects=a compiled binary under a base pattern stays in review, because it is code; fails_when=a .node, .so, .so.N, + // .dylib or .dll file, or an ELF, Mach-O or PE image under a content extension (.bin, .dat), is excluded as binary content; + // why_new=GST-972 item 3; seam=bin/gstack-review-plan; no_claim=what a reader can see inside a binary + test('native binaries are code: .node, .so, .dylib, .dll and executable images under a content extension stay kept', () => { + const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo); + fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true }); + const elf = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), Buffer.alloc(24)]); + const macho = Buffer.concat([Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), Buffer.alloc(28)]); + const pe = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(30)]); + const files = { 'assets/addon.node': elf, 'assets/libx.dylib': macho, 'assets/libx.so': elf, 'assets/libx.so.1': elf, 'assets/setup.dat': pe, 'assets/tool.bin': elf, 'assets/x.dll': pe, 'assets/real.gz': GZ }; + for (const [rel, bytes] of Object.entries(files)) fs.writeFileSync(path.join(repo.work, rel), bytes); + commit(repo, {}); + const p = JSON.parse(plan(repo, ['--json']).out); + expect(p.review_exclude.kept_files).toEqual(Object.keys(files).filter(f => f !== 'assets/real.gz').map(f => ({ path: f, reason: 'code' }))); + expect(p.review_exclude).toMatchObject({ excluded: 1, content: { binary: 1 } }); + }, 120_000); + + // Value: protects=a branch's .gitattributes cannot get an executable image excluded by making git read it as text, and a + // 64-bit universal Mach-O is an executable image; fails_when=only files git reports as binary under the branch's own + // attributes get the magic check, so an ELF named .png under '*.png diff -binary' is excluded as data, or 0xcafebabf + // is not executable magic; why_new=GST-972 item 3 follow-up (#3910, #3902 outside voice); seam=bin/gstack-review-plan and + // reviewExclude in head mode; no_claim=a NUL-free script under a content extension + test('a branch .gitattributes that makes git read an ELF as text cannot get it excluded; fat64 Mach-O is native', () => { + const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo); + fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'assets/native.png'), Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), Buffer.alloc(24)])); + fs.writeFileSync(path.join(repo.work, 'assets/fat64.dat'), Buffer.concat([Buffer.from([0xca, 0xfe, 0xba, 0xbf]), Buffer.alloc(28)])); + fs.writeFileSync(path.join(repo.work, 'assets/real.gz'), GZ); + commit(repo, { '.gitattributes': '*.png diff -binary\n' }); + const p = JSON.parse(plan(repo, ['--json']).out); + const kept = [{ path: 'assets/fat64.dat', reason: 'code' as const }, { path: 'assets/native.png', reason: 'code' as const }]; + expect(p.review_exclude.kept_files).toEqual(kept); + expect(p.review_exclude).toMatchObject({ excluded: 1, content: { binary: 1 } }); + const head = repo.git('rev-parse', 'HEAD'); + const baseSha = repo.git('rev-parse', 'origin/main'); + const c = collectCommit(repo.work, baseSha, head); + const x = reviewExclude({ root: repo.work, baseSha, ref: 'origin/main', mergeBase: c.mergeBase!, head, perPath: c.perPath, added: c.added, classRoot: c.root ?? [], gateTagged: new Set(), context: 'test' }); + expect(x.state === 'active' && x.kept).toEqual(kept); + expect(x.state === 'active' && x.record.digest).toBe(p.review_exclude.digest); + }, 120_000); + + // Value: protects=a NUL byte cannot turn code or a key file into excludable content; fails_when=a NUL-bearing file with a + // source extension outside the old CODE_EXT list (.gradle, .sql, .tf, .groovy), a key store (.p12) or an extensionless key + // is excluded as binary; why_new=GST-972 item 6 (#3852 targeted gate reader at 61803d9); seam=bin/gstack-review-plan; + // no_claim=the list of binary content extensions is complete (an unknown one is kept, never excluded) + test('a NUL-bearing file is binary content only under a known content extension; code and key files stay kept', () => { + const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['assets/**']) } }); repos.push(repo); + fs.mkdirSync(path.join(repo.work, 'assets'), { recursive: true }); + const nul = (code: string) => Buffer.from(`/* \0 */ ${code}\n`); + const kept = { 'assets/Build.groovy': nul('println "x"'), 'assets/build.gradle': nul('apply plugin: "java"'), 'assets/id_ed25519': nul('openssh-key-v1'), 'assets/main.tf': nul('resource "x" "y" {}'), 'assets/query.sql': nul('DROP TABLE users;'), 'assets/server.p12': Buffer.from([0x30, 0x82, 0, 1, 2, 3]) }; + const content = { 'assets/report.pdf': GZ, 'assets/shot.png': GZ }; + for (const [rel, bytes] of Object.entries({ ...kept, ...content })) fs.writeFileSync(path.join(repo.work, rel), bytes); + commit(repo, {}); + const p = JSON.parse(plan(repo, ['--json']).out); + expect(p.review_exclude.kept_files).toEqual(Object.keys(kept).map(f => ({ path: f, reason: 'code' }))); + expect(p.review_exclude).toMatchObject({ excluded: 2, content: { binary: 2 } }); + }, 120_000); + + // Value: protects=a branch's .gitattributes cannot get a matched file with a credential line excluded by making git call it + // binary, at the plan (live tree) and at the receipt (head blobs); fails_when=the secret-line rule reads only the + // collector's added lines, which git leaves empty for an attribute-binary file, so the file is excluded; why_new=GST-972 + // item 5 (#3852 round 3, outside-voice#2); seam=bin/gstack-review-plan and reviewExclude in head mode; no_claim=pregate's gitleaks scan, what readers' own diffs show (they still follow the attributes) + test('a branch .gitattributes marking receipts binary cannot get a credential-line file excluded: the keep rule reads the text', () => { + const repo = makeFixtureRepo({ prefix: 'gstack-exclude-', baseFiles: { '.gstack/ship-policy.json': policyText(['receipts/**']) } }); repos.push(repo); + const secret = '{"cmd": "curl -H $OPENAI_API_KEY"}\n'; + commit(repo, { '.gitattributes': 'receipts/** binary\n', 'receipts/s.json': secret, 'receipts/a.b/c.json': secret, 'receipts/a.json': '{"ok": true}\n', 'receipts/a/t.json': '{"ok": true}\n' }); + const p = JSON.parse(plan(repo, ['--json']).out); + const kept = [{ path: 'receipts/a.b/c.json', reason: 'secret-line' as const }, { path: 'receipts/s.json', reason: 'secret-line' as const }]; + expect(p.review_exclude.kept_files).toEqual(kept); + expect(p.review_exclude).toMatchObject({ excluded: 2, content: { data: 2 } }); + const head = repo.git('rev-parse', 'HEAD'); + const baseSha = repo.git('rev-parse', 'origin/main'); + const c = collectCommit(repo.work, baseSha, head); + const x = reviewExclude({ root: repo.work, baseSha, ref: 'origin/main', mergeBase: c.mergeBase!, head, perPath: c.perPath, added: c.added, classRoot: c.root ?? [], gateTagged: new Set(), context: 'test' }); + expect(x.state === 'active' && x.kept).toEqual(kept); + expect(x.state === 'active' && x.record.digest).toBe(p.review_exclude.digest); + }, 120_000); + test('an unavailable gate scan keeps every candidate instead of excluding it', () => { const repo = census(); const head = repo.git('rev-parse', 'HEAD'); diff --git a/test/review-log-findings.test.ts b/test/review-log-findings.test.ts index c463521c3..2732b09ab 100644 --- a/test/review-log-findings.test.ts +++ b/test/review-log-findings.test.ts @@ -191,6 +191,23 @@ describe('lib: deriveFromFindings, bindReview order and reviewFreshness', () => expect(reviewFreshness(bound, 'd'.repeat(64))?.status).toBe('STALE'); }); + // Value: protects=a fix-check row is written with the fields review-read verifies (GEVA-218: the row lacked them, so it read + // UNVERIFIED and finish could not count it); fails_when=the logger accepts a review-fix-check row without completed and + // converged, or consumes the start token while refusing it; seam=bindReview + test('a review-fix-check row without completed and converged is refused and keeps its start token (GST-1130)', () => { + const dir = path.join(home, 'starts', '.review-starts'); + fs.mkdirSync(dir, { recursive: true }); + const token = '77777777-2222-3333-4444-555555555555'; + const w = 'e'.repeat(64); + fs.writeFileSync(path.join(dir, `${token}.json`), JSON.stringify({ skill: 'review-fix-check', repo: '/r', branch: 'b', wtree: w, started_at: 't' })); + const env = { GSTACK_REVIEW_DIR: path.join(home, 'starts'), GSTACK_REVIEW_REPO: '/r', GSTACK_REVIEW_BRANCH: 'b', GSTACK_STAMP_WTREE: w }; + for (const row of [{}, { completed: true }, { converged: true }, { completed: 'yes', converged: true }]) { + expect(() => bindReview({ skill: 'review-fix-check', status: 'clean', ...row }, token, env)).toThrow('review-log: a review-fix-check row must carry completed and converged as true or false'); + } + expect(fs.existsSync(path.join(dir, `${token}.json`))).toBe(true); + expect(bindReview({ skill: 'review-fix-check', status: 'clean', completed: true, converged: true }, token, env).review_binding.state).toBe('verified'); + }); + test('reviewFreshness reads findings_open: resolved criticals are CURRENT, claimed issues_found stays conservative', () => { const w = 'b'.repeat(64); const base = { skill: 'review', completed: true, converged: true, wtree: w, review_binding: { state: 'verified', start_wtree: w, end_wtree: w } }; diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts index 8275412cc..c1cf4fc10 100644 --- a/test/review-packets-removed.test.ts +++ b/test/review-packets-removed.test.ts @@ -74,6 +74,7 @@ const NO_LOSS_REPLAYS = { 'GST-967': "GST-967 dispatch-superset no-loss check (test/review-depth.test.ts, 'GST-967 dispatch-superset no-loss check'): over 1,440 planReview cases the branch plans exactly what main planned at 1022f4f except the 71 gate-class stops before Step 11, which add only Step 11's adversarial, outside and targeted voices, so no reviewer main dispatches is dropped or re-efforted; the brief bodies lib/review-brief.ts cuts from the ship sections hash to main's; only the lead's repeat-decision and Step 11 instructions changed; reviewer text is unchanged", 'GST-508+GST-464+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: ship/sections/review-army.md and adversarial.md are main's GST-967 bytes plus exactly the GST-508+GST-464 lines (the merged file minus main equals the wave minus fc292e22f, line for line), so GST-967's dispatch-superset no-loss check and GST-508's no-loss replay both hold; merged-head checks in GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md", 'GST-508+R51+GST-911+GST-967': "wave review-53cfb5bfe011 merge of main 68410828f into #3852: lib/review-depth.ts is main's GST-967 bytes plus exactly the GST-508+R51+GST-911 lines (the merged file minus main equals the wave minus fc292e22f, line for line); GST-967's dispatch-superset no-loss check (test/review-depth.test.ts) passes on the merged file, and the GST-508+R51+GST-911 checks are rerun at the merged head (GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md)", + 'GST-940': "GST-940 verdict no-loss check: svgDrawing reads a style element's opening tag as quoted pairs with ASCII whitespace, walks its body over the raw text, refuses any & in it, reads comments as CSS and refuses a non-ASCII element or attribute name; on 62,805 unique SVG files (21,658 with a style element) it gives the same verdict as main at 4bbad2422 on every file, and the planner over the last 100 first-parent main commits to 4bbad2422 changes no commit's depth, class or risk classes; the only verdicts it changes are the GST-940 cases in test/review-planner-misreads.test.ts; rerun on the merge with main at 8f08cb76a (GST-967 merged), with the same results; rerun on the merge with main at 5f705f367 (#3852 and #3864 merged), with the same results", } as const; type Replay = keyof typeof NO_LOSS_REPLAYS; const REVIEWER_BRIEFS: Record = { @@ -95,7 +96,7 @@ 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: 'd76a9afcfadfe0a95b15428c04e6f7a88c6b0fa75591201c45f023fec2bb90db', replay: 'GST-508+R51+GST-911+GST-967' }, + 'lib/review-depth.ts': { sha: '25050a7a61f81df649d1df0d0237dbf961671356395317c6a527741f5bb56877', replay: 'GST-940' }, 'lib/review-risk.ts': { sha: '08464b78a723ee463ca3a28d0abbc4eb8f4f512192061c703743707b66b5a311', replay: 'GST-942' }, }; diff --git a/test/review-planner-misreads.test.ts b/test/review-planner-misreads.test.ts index 0c34036a0..2bbaea298 100644 --- a/test/review-planner-misreads.test.ts +++ b/test/review-planner-misreads.test.ts @@ -125,7 +125,9 @@ describe('GST-911 misread 1: a chart SVG is data, an SVG that can run code is se // 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()); + // pairs with a later one (review pass 2 finding), or any CSS beyond declarations and url(#id) (Step 11 finding: image-set()), + // including CSS behind a character-reference or comment, an HTML-only comment end, a quoted > on the style tag, a + // non-ASCII space before > in , or a tag or attribute under a non-ASCII namespace prefix (GST-940); // seam=collectCommit + depthOfCommitInput test.each([ ['an external image', ' '], @@ -153,11 +155,29 @@ describe('GST-911 misread 1: a chart SVG is data, an SVG that can run code is se ['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)} ]]>'], + ['a character-reference in a style element', ' '], + ['a hex character-reference in a style element', ' '], + ['a character-reference comment in a style element', ' '], + ['CSS after an HTML empty comment in a style element', ' '], + ['CSS after an HTML --!> comment end in a style element', ' '], + ['a inside a quoted style attribute', ' '], + ['an escaped ampersand in a style element', ' '], + ['a no-break space before > in ', ' '], + ['a vertical tab before > in ', ' '], + ['a script under a non-ASCII prefix', ' fetch(1)'], + ['a style under a non-ASCII prefix', ' rect{fill:image-set("https://x.test/p.png" 1x)}'], + ['an external href under a non-ASCII prefix', ' '], ])('%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=the GST-940 style walker still reads a plain chart style as a drawing: a comment, CDATA and a quoted attribute + // without markup; fails_when=the walker over-refuses them, so chart SVGs lose the light review; seam=svgDrawing + test('a style comment, CDATA and quoted attribute keep a chart a drawing (GST-940)', () => { + expect(svgDrawing(chart(5, ' '))).toBe(true); + }); + // 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', () => { diff --git a/test/review-risk-linear.test.ts b/test/review-risk-linear.test.ts index 7caf71633..ef19c7f19 100644 --- a/test/review-risk-linear.test.ts +++ b/test/review-risk-linear.test.ts @@ -7,6 +7,7 @@ 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 { bunIsolationArgs } from '../lib/bun-isolation'; import { DATA_LINE, MONEY_LINE, SECRET_LINE, SECURITY_LINE, matchRisk } from '../lib/review-risk'; const ROOT = path.join(import.meta.dir, '..'); @@ -51,7 +52,7 @@ describe('the rules hashes cover every new pattern', () => { fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts')); fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n"); const hash = () => { - const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 }); + const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 }); expect(r.status, r.stderr).toBe(0); return r.stdout.trim(); }; diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 63bc87850..f83c6755e 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -757,3 +757,83 @@ describe('finish with an owner-accepted edit after a fix-check (GST-965, GBRA-27 }); }); + +// Value: protects=a granted fix-check's clean read clears the paths it reviewed at Step 16, a gate-edited test included, so +// finish does not send them back through Steps 5–11.5 (GEVA-218, gstack-evals#168); fails_when=finish routes a path inside +// the fix-check's range as behavior, counts a path edited after the fix-check, or counts a row that is not a completed clean +// fix-check with a range base; seam=finish's route reading the review log; no_claim=Step 11.5's binding +describe('finish after a completed fix-check (GST-1130, GEVA-218)', () => { + const TEST_V1 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); });\n"; + const TEST_V2 = "import { expect, test } from 'bun:test';\ntest('a', () => { expect(2).toBe(2); }, 1_000);\ntest('b finishes fast', () => { expect(1 + 1).toBe(2); }, 1_000);\n"; + const logFixCheck = (f: ShipFx, row: Record) => { + const start = f.run(path.join(BIN, 'gstack-review-log'), ['--start', 'review-fix-check']); + expect(start.status).toBe(0); + return f.run(path.join(BIN, 'gstack-review-log'), [JSON.stringify({ skill: 'review-fix-check', timestamp: '2026-11-02T00:00:00Z', via: 'ship', review_scope: 'frozen', status: 'clean', issues_found: 0, critical: 0, ...row }), '--finish', start.stdout.trim()]); + }; + const grant = (f: ShipFx, stopHead: string) => { + const a = f.run(path.join(BIN, 'gstack-approvals'), ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${stopHead.slice(0, 12)})`, '--words', 'one scoped fix-check after round 2', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { GSTACK_APPROVALS_MIRROR: '0' }); + expect(a.status).toBe(0); + return JSON.parse(a.stdout).added[0] as string; + }; + const afterFixCheck = () => { + const f = fx({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': TEST_V1 }); + const passHead = f.git('rev-parse', 'HEAD').stdout.trim(); + const reviewed = wtree(f); + f.write({ 'src/x.ts': 'export const x = 3;\n', 'test/x.test.ts': TEST_V2 }); f.commit('fix the round-2 blocker and bound its case'); + return { f, passHead, fixHead: f.git('rev-parse', 'HEAD').stdout.trim(), reviewed, post: snapshot(f) }; + }; + + test('paths inside a completed clean fix-check range count as reviewed, a gate-edited test included; a later edit still stops', () => { + const { f, passHead, fixHead, reviewed, post } = afterFixCheck(); + const plain = finish(f, reviewed, post); + expect([plain.status, plain.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]); + const id = grant(f, passHead); + expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0); + const r = finish(f, reviewed, post); + expect(lineOf(r.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: reviewed src/x.ts,test/x.test.ts (fix-check at ${fixHead.slice(0, 12)}, range from ${passHead.slice(0, 12)}, grant ${id})`); + expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=0 docs=0 metadata=0 tests=0 base=unknown context=0 fix_checked=2$/); + expect([r.status, lineOf(r.stdout, 'EVIDENCE')]).toEqual([0, expect.stringMatching(/^EVIDENCE: FRESH label=tests /)]); + f.write({ 'src/x.ts': 'export const x = 4;\n' }); f.commit('an edit after the fix-check'); + const late = finish(f, reviewed, post); + expect(lineOf(late.stdout, 'FIX_CHECK')).toStartWith('FIX_CHECK: reviewed test/x.test.ts ('); + expect([late.status, late.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts)')]).toEqual([3, true]); + }); + + test('a clean fix-check row with no recorded review_loop_grant for a stop head in its range is not counted', () => { + const { f, passHead, reviewed, post } = afterFixCheck(); + expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0); + const ungranted = finish(f, reviewed, post); + expect(lineOf(ungranted.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (no approved review_loop_grant names a stop head inside its range_base..commit)'); + expect([ungranted.status, ungranted.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]); + grant(f, mergeBase(f)); + const elsewhere = finish(f, reviewed, post); + expect([elsewhere.status, lineOf(elsewhere.stdout, 'FIX_CHECK')]).toEqual([3, 'FIX_CHECK: not counted (no approved review_loop_grant names a stop head inside its range_base..commit)']); + }); + + test('a fix-check row that did not finish clean, or names no range base, is not counted', () => { + const { f, passHead, reviewed, post } = afterFixCheck(); + expect(logFixCheck(f, { completed: false, converged: false, range_base: passHead }).status).toBe(0); + const incomplete = finish(f, reviewed, post); + expect(lineOf(incomplete.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (missing start capture or incomplete/nonconverged pass)'); + expect([incomplete.status, incomplete.stdout.includes('STOP: behavior, tests or build inputs changed since review (src/x.ts,test/x.test.ts)')]).toEqual([3, true]); + expect(logFixCheck(f, { completed: true, converged: true }).status).toBe(0); + const unranged = finish(f, reviewed, post); + expect(lineOf(unranged.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (the row names no range_base commit on this branch)'); + expect(unranged.status).toBe(3); + const fork = mergeBase(f); + expect(logFixCheck(f, { completed: true, converged: true, range_base: fork }).status).toBe(0); + const early = finish(f, reviewed, post); + expect(lineOf(early.stdout, 'FIX_CHECK')).toBe(`FIX_CHECK: not counted (range_base ${fork.slice(0, 12)} does not hold the reviewed tree)`); + expect(early.status).toBe(3); + }); + + test('a fix-check logged on uncommitted content is not counted', () => { + const { f, passHead, reviewed, post } = afterFixCheck(); + f.write({ 'src/x.ts': 'export const x = 9;\n' }); + expect(logFixCheck(f, { completed: true, converged: true, range_base: passHead }).status).toBe(0); + f.git('checkout', '--', 'src/x.ts'); + const r = finish(f, reviewed, post); + expect(lineOf(r.stdout, 'FIX_CHECK')).toBe('FIX_CHECK: not counted (the row was logged on content no branch commit holds)'); + expect(r.status).toBe(3); + }); +}); diff --git a/test/stale-local-base.test.ts b/test/stale-local-base.test.ts new file mode 100644 index 000000000..5c2c749d6 --- /dev/null +++ b/test/stale-local-base.test.ts @@ -0,0 +1,112 @@ +/** + * A bare --base reads origin's tracking branch, not a stale local branch (GST-1036). Capy clones and linked + * worktrees never move the local base branch, so `--base main` there merged against an old local main: review and ship + * bins then counted upstream commits the branch only inherited (a docs diff planned at gate depth, banned terms and + * prose findings in lines the branch never wrote). Every such bin resolves the base through resolveBase + * (lib/gate-diff/git.ts) and names the ref it used. + */ +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 { BANNED_TERMS_SCHEMA } from '../lib/banned-terms'; +import { resolveCommit } from '../lib/pr-shots'; + +const BIN = path.join(import.meta.dir, '..', 'bin'); +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(), 'stale-base-'))); + 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'); + fs.mkdirSync(path.join(work, '.gstack')); + fs.writeFileSync(path.join(work, '.gstack', 'banned-terms.json'), JSON.stringify({ $schema: BANNED_TERMS_SCHEMA, terms: ['frobnicate'] }) + '\n'); + fs.writeFileSync(path.join(work, 'README.md'), '# t\n'); + sh(work, 'git add -A && git commit -qm base && git push -q origin main'); + sh(root, "git clone -q origin.git other && cd other && mkdir docs && printf 'Upstream notes frobnicate the parser.\\n\\nShowcasing vibrant features.\\n' > docs/up.md && git add -A && git commit -qm upstream && git push -q origin main"); + sh(work, "git fetch -q origin && git checkout -qb feat origin/main && mkdir src && echo 'export const v = 1;' > src/v.ts && git add -A && git commit -qm feat"); + const origin = sh(work, 'git rev-parse refs/remotes/origin/main'); + expect(sh(work, 'git rev-parse refs/heads/main')).not.toBe(origin); + const run = (bin: string, args: string[]) => { const r = spawnSync(path.join(BIN, bin), args, { cwd: work, env, encoding: 'utf8', timeout: 180_000 }); return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' }; }; + return { root, work, sh, run, origin }; +} + +const NOTE = 'local main is behind origin/main; base is origin/main'; + +describe('a bare --base reads origin/ over a stale local branch (GST-1036)', () => { + // Value: protects=review-plan names the base ref its plan was computed against, so a stale-base plan shows in its output; + // fails_when=review-plan prints no REVIEW_BASE line, or names the local main while origin/main is ahead, or re-qualifies origin/main; + // why_new=GST-1036 (#3882 planned a docs diff at full depth against local main 319ef500); seam=bin/gstack-review-plan, lib/review-collect.ts collectLive + test('gstack-review-plan prints the ref it used and why', () => { + const { run, origin } = fixture(); + const bare = run('gstack-review-plan', ['--base', 'main']); + expect(bare.status, bare.out + bare.err).toBe(0); + expect(bare.out).toContain(`REVIEW_BASE: origin/main ${origin.slice(0, 12)}\n`); + expect(bare.err).toContain(`gstack-review-plan: ${NOTE}`); + const json = run('gstack-review-plan', ['--base', 'main', '--json']); + expect(JSON.parse(json.out)).toMatchObject({ paths: ['src/v.ts'], base: { ref: 'origin/main', sha: origin, local: 'behind' } }); + const qualified = run('gstack-review-plan', ['--base', 'origin/main']); + expect(qualified.out).toContain(`REVIEW_BASE: origin/main ${origin.slice(0, 12)}\n`); + expect(qualified.err).not.toContain('base is'); + }, 240_000); + + // Value: protects=banned-terms scans only the lines the branch adds; fails_when=it merges against the stale local main and reports a term an upstream commit added; + // why_new=GST-1036 sibling bin (/review passes a bare ); seam=bin/gstack-banned-terms + test('gstack-banned-terms check ignores inherited upstream lines', () => { + const { run } = fixture(); + for (const base of ['main', 'origin/main']) { + const r = run('gstack-banned-terms', ['check', '--base', base]); + expect(r.status, r.out + r.err).toBe(0); + expect(r.out).toContain('BANNED_TERMS: ok'); + expect(r.err).toContain(`gstack-banned-terms: ${NOTE}`); + } + }, 120_000); + + // Value: protects=without a remote-tracking twin the local base still stands, so the origin preference never hides lines; + // fails_when=the fix drops the local fallback; why_new=negative control for GST-1036; seam=bin/gstack-banned-terms + test('no origin: gstack-banned-terms keeps the local main and reports what the branch carries over it', () => { + const { sh, work, run } = fixture(); + sh(work, 'git remote remove origin'); + const r = run('gstack-banned-terms', ['check', '--base', 'main']); + expect(r.status, r.out + r.err).toBe(1); + expect(r.out).toContain('frobnicate'); + expect(r.err).not.toContain('base is'); + }, 120_000); + + // Value: protects=prose-lint --base lints only the lines the branch adds; fails_when=a bare base merges against the stale local main; + // why_new=GST-1036 sibling bin; seam=lib/prose-lint.ts addedOnly + test('gstack-prose-lint --base main skips a file only upstream changed; origin/main is read as given', () => { + const { run } = fixture(); + for (const base of ['main', 'origin/main']) { + const r = run('gstack-prose-lint', ['docs/up.md', '--base', base, '--gate']); + expect(r.status, r.out + r.err).toBe(0); + expect(r.out).not.toContain('PROSE:'); + } + }, 120_000); + + // Value: protects=the docs-audit snapshot selects only the branch's files; fails_when=--base main resolves to the stale local main and selects upstream docs; + // why_new=GST-1036 sibling bin; seam=bin/gstack-docs-candidate + test('gstack-docs-candidate snapshot --base main records origin/main and selects only the branch', () => { + const { root, run, origin } = fixture(); + const r = run('gstack-docs-candidate', ['snapshot', '--out', path.join(root, 'c.json'), '--audit-id', 'a1', '--mode', 'read-only', '--base', 'main']); + expect(r.status, r.out + r.err).toBe(0); + expect(r.out).toContain(`base=${origin} `); + expect(r.out).toContain('committed: "src/v.ts"\n'); + expect(r.err).toContain(`gstack-docs-candidate: ${NOTE}`); + }, 120_000); + + // Value: protects=the PR screenshot's before side is origin's base, not a stale local copy; fails_when=resolveCommit reads refs/heads/main first; + // why_new=GST-1036 sibling bin; seam=lib/pr-shots.ts resolveCommit + test('gstack-pr-shots --base main reads origin/main; a sha resolves as given', () => { + const { work, sh, origin } = fixture(); + expect(resolveCommit(work, 'main')).toBe(origin); + const local = sh(work, 'git rev-parse refs/heads/main'); + expect(resolveCommit(work, local)).toBe(local); + }, 120_000); +}); diff --git a/test/windows-native-workflows.test.ts b/test/windows-native-workflows.test.ts index a111dc605..ed41250e5 100644 --- a/test/windows-native-workflows.test.ts +++ b/test/windows-native-workflows.test.ts @@ -148,7 +148,7 @@ test('every windows-smoke test is Windows-safe, seeded fast, and says why it is test('no Windows job restores or saves the bun install cache', () => { // GSTA-203 PR 1: a cold install (16-17 s) beat restoring the ~450 MB // windows-bun entry (33-42 s), and PR merge-ref saves filled the 10 GB quota. - for (const file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml']) { + for (const file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml', 'windows-scoped-tests.yml']) { for (const [name, job] of Object.entries(load(file).jobs)) { const caches = (job.steps ?? []).filter((step: any) => step.uses?.startsWith('actions/cache')); expect({ file, job: name, caches }).toEqual({ file, job: name, caches: [] }); diff --git a/test/windows-path-scope-workflow.test.ts b/test/windows-path-scope-workflow.test.ts new file mode 100644 index 000000000..ce9fd74bd --- /dev/null +++ b/test/windows-path-scope-workflow.test.ts @@ -0,0 +1,311 @@ +/** + * Windows checks by path (ship-90-90 plan D, GST-1135). Pins three things: + * + * 1. windows-smoke.yml stays the unfiltered Windows check on every pull + * request. + * 2. windows-setup-e2e.yml starts only on install paths, and its + * windows-scope job keeps the two Windows jobs off a pull request whose + * only install change is package.json's version line (the merge-train + * stamp). + * 3. windows-scoped-tests.yml runs the changed Windows-safe test files when + * a pull request touches path, file-name or process code. Its filter must + * catch #3758 (/freeze's check-freeze.sh, a hook path) and #3829 (a test + * that spawned Bun with --config=/dev/null), and pass over a docs-only + * and a pure-logic change. Its aggregate passes when nothing is in scope, + * so a skipped run still reports success. + * + * The filter and scope steps run here as the runner would run them: bash in a + * two-commit git repository, with HEAD^1 as the pull request's base. + */ +import { afterAll, 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 { parseWorkflow } from '../lib/pregate/workflows'; +import { collectFreeTestFiles, curateWindowsSafe } from '../scripts/test-free-shards'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +const SCOPED_FILE = 'windows-scoped-tests.yml'; +const SETUP_FILE = 'windows-setup-e2e.yml'; +const scopedText = read(`.github/workflows/${SCOPED_FILE}`); +const setupText = read(`.github/workflows/${SETUP_FILE}`); +const scoped = Bun.YAML.parse(scopedText) as any; +const setupE2e = Bun.YAML.parse(setupText) as any; +const smoke = Bun.YAML.parse(read('.github/workflows/windows-smoke.yml')) as any; +const toShell = (p: string) => p.split(path.sep).join('/'); +const CAN_RUN = !!Bun.which('bash') && !!Bun.which('git'); + +const PKG = (version: string, deps = '"a": "1.0.0"') => `{\n "name": "gstack",\n "version": "${version}",\n "dependencies": {\n ${deps}\n }\n}\n`; + +function git(cwd: string, args: string[]): string { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout; +} + +/** A repository with a base commit and one change on top: HEAD^1 plays the pull request's base, as on the merge ref. */ +function repo(base: Record, change: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-')); + const write = (files: Record) => { + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), text); + } + }; + git(dir, ['init', '-q']); + write(base); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', 'base']); + write(change); + git(dir, ['add', '-A']); + git(dir, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@gstack.test', 'commit', '-q', '-m', 'change']); + return dir; +} + +/** GITHUB_OUTPUT lines, including name< { + const out: Record = {}; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const block = /^([\w-]+)<<(.+)$/.exec(lines[i]!); + if (block) { + const end = lines.indexOf(block[2]!, i + 1); + out[block[1]!] = lines.slice(i + 1, end).join('\n'); + i = end; + } else if (lines[i]!.includes('=')) { + const at = lines[i]!.indexOf('='); + out[lines[i]!.slice(0, at)] = lines[i]!.slice(at + 1); + } + } + return out; +} + +interface StepRun { status: number | null; stderr: string; outputs: Record; summary: string; temp: string } + +const TEMPS: string[] = []; +afterAll(() => { for (const dir of TEMPS) fs.rmSync(dir, { recursive: true, force: true }); }); +const newTemp = () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-temp-')); TEMPS.push(dir); return dir; }; + +/** Run one workflow step's script under bash with a fresh GITHUB_OUTPUT and summary; temp is RUNNER_TEMP, kept for a later step. */ +function runStep(script: string, cwd: string, env: Record = {}, temp = newTemp()): StepRun { + const output = path.join(temp, 'github-output'); + const summary = path.join(temp, 'github-summary'); + fs.writeFileSync(output, ''); + fs.writeFileSync(summary, ''); + const r = spawnSync('bash', ['-c', script], { + cwd, encoding: 'utf8', timeout: 30_000, + env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? temp, GITHUB_EVENT_NAME: 'pull_request', RUNNER_TEMP: toShell(temp), GITHUB_OUTPUT: toShell(output), GITHUB_STEP_SUMMARY: toShell(summary), ...env }, + }); + return { status: r.status, stderr: r.stderr, outputs: parseOutputs(fs.readFileSync(output, 'utf8')), summary: fs.readFileSync(summary, 'utf8'), temp }; +} + +const step = (job: any, id: string) => job.steps.find((s: any) => s.id === id); +const filterScript: string = step(scoped.jobs['windows-scope'], 'filter').run; +const selectScript: string = step(scoped.jobs['windows-scope'], 'select').run; +const setupScopeScript: string = step(setupE2e.jobs['windows-scope'], 'scope').run; + +// #3758 (v1.92.9.0, /freeze checks where an edit really lands): its changed files. Its Windows failure was in +// test/hook-scripts.test.ts, from check-freeze.sh's path handling (run 41243040943). +const PR_3758 = ['CHANGELOG.md', 'VERSION', 'agents-digest/gstack-AGENTS.md', 'careful/bin/hook-extract.sh', 'freeze/SKILL.md', 'freeze/SKILL.md.tmpl', + 'freeze/bin/check-freeze.sh', 'package.json', 'test/hook-scripts.test.ts']; +// #3829 (v1.92.61.0, risk classes cover child_process): its changed files, none named for paths or processes. Its +// Windows failure (nightly run 41420490350) was test/review-risk-linear.test.ts, a file it added, spawning Bun with +// --config=/dev/null: EINVAL on native Windows. +const PR_3829 = ['CHANGELOG.md', 'VERSION', 'agents-digest/gstack-AGENTS.md', 'docs/review-policy.md', 'lib/review-collect.ts', 'lib/review-depth.ts', + 'lib/review-risk.ts', 'lib/review-targeted.ts', 'package.json', 'test/fixtures/review-risk/gbrain-evals-289.json', 'test/helpers/free-fixtures-data.ts', + 'test/review-packets-removed.test.ts', 'test/review-risk-linear.test.ts', 'test/review-risk-routing.test.ts']; +const LINE_3829 = " const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 });"; + +/** Every file of a pull request with a neutral one-line edit, package.json as a version bump, plus the given lines. */ +function prRepo(files: string[], lines: Record = {}) { + const base: Record = {}; + const change: Record = {}; + for (const file of files) { + base[file] = file === 'package.json' ? PKG('1.0.0') : 'unchanged\n'; + change[file] = file === 'package.json' ? PKG('1.0.1') : `unchanged\nedited\n${lines[file] ?? ''}\n`; + } + return repo(base, change); +} + +describe('windows-smoke stays on every pull request', () => { + test('no paths filter, no job condition, one job', () => { + expect(smoke.on.pull_request).toEqual({ branches: ['main'] }); + expect(smoke.on.push).toEqual({ branches: ['main'] }); + expect(Object.keys(smoke.jobs)).toEqual(['windows-smoke']); + expect(smoke.jobs['windows-smoke'].if).toBeUndefined(); + expect(smoke.jobs['windows-smoke'].needs).toBeUndefined(); + }); + + test('no Windows job that can skip is a required check, and the scoped aggregate always reports', () => { + const required = JSON.parse(read('.gstack/ship-policy.json')).changelog_fragments.required_checks as string[]; + expect(required).toContain('windows-smoke'); + for (const name of ['windows-scope', 'windows-setup', 'held-install-journey', 'windows-scoped-shard', 'Windows Setup E2E']) expect(required).not.toContain(name); + expect(scoped.jobs['windows-scoped-tests'].if).toBe('always()'); + }); +}); + +describe('Windows Setup E2E runs by install path', () => { + const paths: string[] = setupE2e.on.pull_request.paths; + + test('pull requests on install paths and dispatch only; every Windows job waits on windows-scope', () => { + expect(Object.keys(setupE2e.on).sort()).toEqual(['pull_request', 'workflow_dispatch']); + expect(setupE2e.on.pull_request.branches).toEqual(['main']); + for (const p of ['setup', 'setup.ps1', 'package.json', 'bun.lock', 'gstack-upgrade/**', 'bin/gstack-config', 'bin/gstack-patch-names', + 'bin/gstack-state-root.sh', 'bin/gstack-migrate-claude-code', 'hosts/claude/hooks/**', 'docs/troubleshooting.md', `.github/workflows/${SETUP_FILE}`]) expect(paths).toContain(p); + expect(new Set(paths).size).toBe(paths.length); + for (const p of paths.filter(p => !p.includes('*'))) expect([p, fs.existsSync(path.join(ROOT, p))]).toEqual([p, true]); + const scope = setupE2e.jobs['windows-scope']; + expect(scope['runs-on']).toBe('ubuntu-24.04'); + expect(scope.steps[0].with).toEqual({ 'fetch-depth': 2, 'persist-credentials': false }); + const windowsJobs = Object.keys(setupE2e.jobs).filter(name => name !== 'windows-scope'); + expect(windowsJobs).toEqual(['windows-setup', 'held-install-journey']); + for (const name of windowsJobs) { + expect([name, setupE2e.jobs[name]['runs-on'], setupE2e.jobs[name].needs, setupE2e.jobs[name].if]) + .toEqual([name, 'windows-latest', 'windows-scope', "needs.windows-scope.outputs.install == 'true'"]); + } + }); + + test("windows-scope's install list is the paths list", () => { + const list = setupScopeScript.slice(setupScopeScript.indexOf("<<'PATHS'\n") + 10, setupScopeScript.indexOf('\nPATHS\n')).split('\n'); + expect(list).toEqual(paths); + }); + + test('pregate still reads windows-setup as a Windows lane obligation (no condition keys on a dispatch input)', () => { + const lanes = parseWorkflow(SETUP_FILE, setupText, 'linux').filter(l => l.runsTests); + expect(lanes.map(l => [l.job, l.manual, l.tier])).toContainEqual(['windows-setup', false, 'platform']); + }); + + test.skipIf(!CAN_RUN)('a version-only package.json change starts no Windows job; any other install change does', () => { + const scope = (base: Record, change: Record) => { + const dir = repo(base, change); + try { + const r = runStep(setupScopeScript, dir); + expect(r.status, r.stderr).toBe(0); + return r.outputs.install; + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }; + const base = { 'package.json': PKG('1.0.0'), setup: '#!/bin/bash\n', 'lib/x.ts': 'export const x = 1;\n', 'hosts/claude/hooks/a/b.ts': 'a\n' }; + expect(scope(base, { 'package.json': PKG('1.0.1'), 'lib/x.ts': 'export const x = 2;\n' })).toBe('false'); + expect(scope(base, { 'package.json': PKG('1.0.1', '"a": "1.0.1"') })).toBe('true'); + expect(scope(base, { 'package.json': PKG('1.0.1'), setup: '#!/bin/bash\necho changed\n' })).toBe('true'); + expect(scope(base, { 'hosts/claude/hooks/a/b.ts': 'b\n' })).toBe('true'); + expect(scope({ 'package.json': PKG('1.0.0', '"version": "1.0.0"') }, { 'package.json': PKG('1.0.0', '"version": "1.0.1"') })).toBe('true'); + const dispatched = runStep(setupScopeScript, ROOT, { GITHUB_EVENT_NAME: 'workflow_dispatch' }); + expect([dispatched.status, dispatched.outputs.install]).toEqual([0, 'true']); + }); +}); + +describe('the path-scoped Windows shard on pull requests', () => { + test('every pull request runs the cheap scope job; a dispatch names its files; read-only and secretless', () => { + expect(Object.keys(scoped.on).sort()).toEqual(['pull_request', 'workflow_dispatch']); + expect(scoped.on.pull_request).toEqual({ branches: ['main'] }); + expect(scoped.on.workflow_dispatch.inputs.files).toMatchObject({ type: 'string', required: true }); + expect(scoped.permissions).toEqual({ contents: 'read' }); + expect(scopedText).not.toContain('secrets.'); + expect(scopedText).not.toContain('pull_request_target'); + expect(scoped.concurrency.group).toBe('windows-scoped-tests-${{ github.event.pull_request.number || github.run_id }}'); + const scope = scoped.jobs['windows-scope']; + expect(scope['runs-on']).toBe('ubuntu-24.04'); + expect(scope.steps[0].with).toEqual({ 'fetch-depth': 2, 'persist-credentials': false }); + const shard = scoped.jobs['windows-scoped-shard']; + expect(shard['runs-on']).toBe('windows-latest'); + expect(shard.needs).toBe('windows-scope'); + expect(shard.steps[0].with).toEqual({ 'persist-credentials': false }); + const run = shard.steps.find((s: any) => s.name === 'Run the scoped Windows-safe test files'); + expect(run.env.FILES).toBe('${{ inputs.files || needs.windows-scope.outputs.files }}'); + expect(run.env.GSTACK_FREE_RETRY_FLAKY).toBe('1'); + expect(run.run).toContain('bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-scoped-files.txt"'); + expect(scoped.jobs['windows-scoped-tests'].needs).toEqual(['windows-scope', 'windows-scoped-shard']); + }); + + test('pregate reads the scoped shard as manual, so it adds no remote-clear obligation', () => { + const lanes = parseWorkflow(SCOPED_FILE, scopedText, 'linux').filter(l => l.runsTests); + expect(lanes.map(l => [l.job, l.manual])).toEqual([['windows-scoped-shard', true]]); + }); + + test("#3758's and #3829's failing test files are in the curated Windows-safe subset", () => { + const { safe } = curateWindowsSafe(collectFreeTestFiles(ROOT), ROOT); + expect(safe).toContain('test/hook-scripts.test.ts'); + expect(safe).toContain('test/review-risk-linear.test.ts'); + }); + + const CAN_SELECT = CAN_RUN && !!Bun.which('jq'); + /** Filter, then select with a stub bun whose curated plan holds the given files. */ + function scopeJob(dir: string, curated: string[]) { + const filter = runStep(filterScript, dir); + expect(filter.status, filter.stderr).toBe(0); + const scope = fs.readFileSync(path.join(filter.temp, 'windows-scope'), 'utf8'); + if (filter.outputs.windows !== 'true') return { windows: filter.outputs.windows, scope, files: undefined as string | undefined }; + const bin = path.join(filter.temp, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nwhile [ $# -gt 0 ]; do [ "$1" = --ci-plan ] && printf \'%s\' "$PLAN_JSON" > "$2"; shift; done\necho \'{"shard":[1]}\'\n', { mode: 0o755 }); + const plan = JSON.stringify({ version: 1, revision: 'r', shards: [{ shard: 1, predictedMs: 1, files: curated }], id: 'x' }); + const select = runStep(selectScript, dir, { PATH: `${toShell(bin)}:${process.env.PATH}`, PLAN_JSON: plan }, filter.temp); + expect(select.status, select.stderr).toBe(0); + return { windows: filter.outputs.windows, scope, files: select.outputs.files }; + } + const CURATED = ['test/hook-scripts.test.ts', 'test/review-risk-linear.test.ts', 'test/review-risk-routing.test.ts']; + + test.skipIf(!CAN_SELECT)('the select step reads the real curated plan: a changed Windows-safe test is picked, a POSIX-only one is not', () => { + const temp = newTemp(); + fs.writeFileSync(path.join(temp, 'changed-files'), 'freeze/bin/check-freeze.sh\ntest/hook-scripts.test.ts\ntest/windows-path-scope-workflow.test.ts\n'); + const select = runStep(selectScript, ROOT, { PATH: `${toShell(path.dirname(process.execPath))}:${process.env.PATH}` }, temp); + expect(select.status, select.stderr).toBe(0); + expect(select.outputs.files).toBe('test/hook-scripts.test.ts'); + }, 60_000); + + test.skipIf(!CAN_SELECT)('#3758: the hook paths put it in scope and the Windows job runs test/hook-scripts.test.ts', () => { + const dir = prRepo(PR_3758); + try { + const job = scopeJob(dir, CURATED); + expect(job.windows).toBe('true'); + expect(job.scope).toContain('path: careful/bin/hook-extract.sh'); + expect(job.scope).toContain('path: freeze/bin/check-freeze.sh'); + expect(job.files).toBe('test/hook-scripts.test.ts'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test.skipIf(!CAN_SELECT)('#3829: no path names it; the spawn line it added puts it in scope and the job runs its changed tests', () => { + const dir = prRepo(PR_3829, { 'test/review-risk-linear.test.ts': LINE_3829 }); + try { + const job = scopeJob(dir, CURATED); + expect(job.windows).toBe('true'); + expect(job.scope.trim().split('\n')).toEqual([`content: test/review-risk-linear.test.ts: +${LINE_3829}`]); + expect(job.files).toBe('test/review-risk-linear.test.ts\ntest/review-risk-routing.test.ts'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test.skipIf(!CAN_SELECT)('negative controls: a docs-and-stamp change and a pure-logic change start no Windows job', () => { + for (const [files, lines] of [ + [['CHANGELOG.md', 'VERSION', 'package.json', 'docs/support-tiers.md', 'changelog.d/x.md'], {}], + [['lib/review-depth.ts', 'test/review-depth.test.ts'], { 'lib/review-depth.ts': 'export const LIMIT = 3;', 'test/review-depth.test.ts': ' expect(LIMIT).toBe(3);' }], + ] as Array<[string[], Record]>) { + const dir = prRepo(files, lines); + try { + const job = scopeJob(dir, [...CURATED, 'test/review-depth.test.ts']); + expect([files, job.windows, job.scope]).toEqual([files, 'false', '']); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } + }); + + test.skipIf(!CAN_SELECT)('in scope but no changed Windows-safe test file: no files, so no Windows job', () => { + const dir = prRepo(['bin/gstack-example', 'test/posix-only.test.ts']); + try { + const job = scopeJob(dir, CURATED); + expect([job.windows, job.files]).toEqual(['true', undefined]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test.skipIf(!Bun.which('bash'))('the aggregate passes when nothing is in scope and otherwise requires the Windows job', () => { + const aggregate = scoped.jobs['windows-scoped-tests'].steps[0]; + expect(aggregate.env).toEqual({ SCOPE_RESULT: '${{ needs.windows-scope.result }}', SHARD_RESULT: '${{ needs.windows-scoped-shard.result }}', FILES: '${{ inputs.files || needs.windows-scope.outputs.files }}' }); + const status = (env: Record) => runStep(aggregate.run, ROOT, env).status; + expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'skipped', FILES: '' })).toBe(0); + expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'success', FILES: 'test/a.test.ts' })).toBe(0); + expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'failure', FILES: 'test/a.test.ts' })).not.toBe(0); + expect(status({ SCOPE_RESULT: 'success', SHARD_RESULT: 'cancelled', FILES: 'test/a.test.ts' })).not.toBe(0); + expect(status({ SCOPE_RESULT: 'failure', SHARD_RESULT: 'skipped', FILES: '' })).not.toBe(0); + }); +});