mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
v1.94.15.0 stamp: merge main 8d276f9e31 into #3928 (land)
This commit is contained in:
commit
0513f47068
52 files changed
+2293
-112
No files matched your search
@@ -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<<GSTACK_SCOPED_FILES'; cat "$RUNNER_TEMP/selected"; echo 'GSTACK_SCOPED_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
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <sha12> 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: <ref> <sha>` 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 `<style>` element, a comment that only an HTML parser ends early, a `>` inside a quoted attribute on the `<style>` tag, or a Unicode space before the `>` of `</style>` let CSS that loads a remote image read as a plain drawing, and a non-ASCII namespace prefix such as `<gé:script>` hid a script. Any of these could get the SVG a light review. The style walker now reads the `<style>` tag as quoted pairs with ASCII whitespace and its text as written, refuses any `&` in it, and reads its comments as CSS; a non-ASCII character in an element or attribute name keeps the SVG code. Over 62,805 unique SVG files, no other verdict changed.
|
||||
|
||||
## [1.94.11.0] - 2026-11-03
|
||||
|
||||
**A granted fix-check's clean read now counts at Step 16.** On gstack-evals#168 the owner granted one scoped fix-check, the reader read `9b0c56c..7ea1907` and found nothing, and `gstack-ship-release finish` still stopped with `STOP: behavior, tests or build inputs changed since review (tests/panel-kit-v3-1.test.ts)`, sending the test file the fix-check had read back through Steps 5 to 11.5. The review-loop exception could not help, because it is the owner accepting unreviewed edits and it never accepts a gate-edited test. The fix-check row also read `UNVERIFIED`, because it was logged without its `completed` and `converged` fields (GST-1130, GEVA-218).
|
||||
|
||||
### Fixed
|
||||
- **`gstack-ship-release finish`** reads the newest `review-fix-check` row. When the row is a completed clean read on committed content, its `range_base` is a branch commit holding the reviewed tree, and the owner's recorded `review_loop_grant` names a stop head inside that range, every code or test path inside that range that is unchanged since routes as `fix_checked=` on the `ROUTE:` line, with a `FIX_CHECK: reviewed <paths>` line. That includes a gate-edited test. Authored docs still get the plan-item recheck, and a path edited after the fix-check still stops. A row that does not qualify prints `FIX_CHECK: not counted (<reason>)` and changes nothing.
|
||||
- **`gstack-review-log`** refuses a `review-fix-check` row that does not carry `completed` and `converged` as true or false, and keeps the start token for the retry. The refusal names the missing fields, so the row is fixed when it is written instead of reading `UNVERIFIED` at Step 11.5.
|
||||
|
||||
### Tests
|
||||
- `test/ship-rereview-triggers.test.ts`: a fixture shaped like gstack-evals#168 (a round-2 pass, then a fix commit that edits the source and a gate-edited test) stops on main and passes finish once a clean fix-check row with its range base is logged through the real `gstack-review-log`. Negative controls: an edit after the fix-check still stops, and an ungranted row, a grant for another stop head, an incomplete row, a row with no range base, a range base that does not hold the reviewed tree, or a row logged on uncommitted content is not counted.
|
||||
- `test/review-log-findings.test.ts`: a `review-fix-check` row missing either field, or with a non-boolean value, is refused and keeps its start token; the complete row binds as verified.
|
||||
|
||||
## [1.94.10.0] - 2026-11-03
|
||||
|
||||
**Windows checks now run by path.** Windows Setup E2E, the 10-to-25-minute install test, no longer starts its Windows jobs on a pull request whose only install change is `package.json`'s version line, which the merge-train stamp puts on every pull request before it merges. Of the 61 pull requests in its last 100 runs (as of 2026-11-03), 37 changed no install file besides `package.json`. Its path list also gains `bun.lock` and the `bin/` scripts that the test or `./setup` runs. A new `Windows Scoped Tests` workflow runs the Windows-safe test files a pull request changed, on one Windows job, when the pull request touches path, file-name or process code, such as a hook script or a new `spawnSync` call. On the last 120 commits to main (through 923fc0a) it would have run on 50, with a median of one test file. It catches both Windows breaks the plan names: /freeze's path check (#3758) and a test that started Bun with `--config=/dev/null` (#3829). A red run blocks its pull request like any failed check, so this release also fixes #3829's test, which has failed every nightly Windows run since it landed. `windows-smoke` still runs on every pull request, and the full Windows suite still runs nightly.
|
||||
|
||||
### CI
|
||||
- `.github/workflows/windows-setup-e2e.yml`: a Linux `windows-scope` job runs first and skips both Windows jobs when the only install change is `package.json`'s `"version"` line. Paths add `bun.lock`, `bin/gstack-config`, `bin/gstack-patch-names` and the scripts `./setup` sources or runs.
|
||||
- `.github/workflows/windows-scoped-tests.yml` (new): a Linux scope job reads the diff's file names and changed code lines, picks the changed files in the curated Windows-safe subset, and a `windows-latest` job runs them. The `windows-scoped-tests` check passes when nothing is in scope, so a skipped run still reports success. A `workflow_dispatch` with a `files` input reruns chosen Windows-safe files on a branch.
|
||||
|
||||
### Tests
|
||||
- `test/review-risk-linear.test.ts`: the rules-hash case starts Bun with `bunIsolationArgs()` (`--config=NUL` on native Windows) instead of `--config=/dev/null`, which native Windows Bun cannot open (nightly Windows Free Tests run 41420490350, EINVAL).
|
||||
- `scripts/test-pr-profile.ts`: the new workflow is listed as free-lane only beside the other Windows workflows (test/free-fixtures.test.ts requires every workflow to be classified).
|
||||
- `test/windows-path-scope-workflow.test.ts`: runs both scope steps in a two-commit git repository. The filter catches #3758's and #3829's changed files and passes over a docs-and-stamp change and a pure-logic change. The Setup E2E scope skips a version-only `package.json` bump and runs on a dependency change. `windows-smoke` stays unfiltered, and no job that can skip is a required check.
|
||||
|
||||
## [1.94.9.0] - 2026-11-03
|
||||
|
||||
**A pull request can clear its remote gate obligation from a run with named flaky failures, once the owner grants it (GST-1096).** A timing flake in a test file the pull request never touched used to keep the pregate red with no way through but another paid run. `gstack-pregate clear --remote <lane> --run <id|url> --flaky-exception "<file>::<case>" --pr <n> --grant <approval id>` now accepts such a run when every failure is named from the run's own shard results and logs, at most three cases, each file outside the pull request's diff and its import closure, none paid and none a security scan test, and a gstack-approvals row in the owner's words binds the repository, PR, head, tree, lane, run attempt and cases. It covers the pregate only, and a red required CI check still needs its own green run.
|
||||
|
||||
### Added
|
||||
- **`--flaky-exception`, `--pr` and `--grant` on `gstack-pregate clear`** (`lib/pregate/flaky-exception.ts`). Every job of the run attempt is reconciled (only test-shard jobs and their aggregate may fail), every planned shard needs a complete result, and each failed shard's log must name no failure its result leaves out. Without `--grant` the clear checks everything and prints the exact `gstack-approvals add` command, whose scope is a digest of everything the grant binds, so a grant can never be moved to another head or case list.
|
||||
- **`pass-with-exception`.** pregate.json records `exceptions`; `gstack-pregate verify` re-checks the binding offline and prints `PREGATE: pass-with-exception (<cases>; grant <id>)`; `gstack-ship-queue ready` accepts it and prints a `FLAKY_EXCEPTION:` line; the ship receipt records `flaky_exception`.
|
||||
- **docs/pregate.md#flaky-exception**, including why `base_red` is not used: it proves a failure by reproducing it on the base, and a timing flake passes there.
|
||||
|
||||
### Tests
|
||||
- `test/pregate-flaky-exception.test.ts`: a red run with one named flake clears once the bound grant exists, and verify, ready and the receipt report it; refusals for an unnamed or extra failure, a case that did not fail, more than three cases, an unattributed failure, a failed non-shard job, a missing result or log, a timed-out shard, a log failure the result omits, a file in the diff or its import closure, a paid or security test, another head, another PR head, a newer run, a run from another repository or fork, a stale or unfinished run, a moved attempt (also after the inventory runs), and a grant with another scope, outcome or speaker; ready still refuses red CI, and refuses an exception granted for another PR or head. The printed grant command quotes case names for the shell. These tests failed before the change.
|
||||
|
||||
## [1.94.8.0] - 2026-11-03
|
||||
|
||||
**`gstack-approvals render` no longer puts the owner's exact words, autoplan run ids or plan hashes into PR bodies on public repositories (GST-1160).** On a public repo, /ship pasted the full `## Approvals` table into the PR description. That table carries the owner's verbatim quote, the run id and the plan sha256, and GitHub keeps every earlier version of a description in its edit history, which no API can delete.
|
||||
|
||||
+2
-1
@@ -193,7 +193,8 @@ the new defaults.
|
||||
| Run the full free suite from a small machine | `bun run test:ubicloud` | Free suite on a billed 16-vCPU VM, about 5 minutes | `UBICLOUD_API_KEY`, `UBI_OWNER` |
|
||||
| Clear pregate's test tier on a machine under 8 cores from CI, the default on gstack ([CI carry](docs/pregate.md#ci-carry)) | push, `gstack-ci-carry wait --base main`, then `gstack-pregate --allow-repo-commands` (a branch behind main first runs `gh workflow run free-tests.yml --ref <branch>`) | Free; reuses the green Free Tests run at your pushed tree | Pushed branch, `gh` |
|
||||
| Clear pregate's test tier on a machine under 8 cores when no CI run covers your tree ([remote gate](docs/pregate.md#remote-gate), opt-in) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`, `UBI_OWNER`; keyless: nothing |
|
||||
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check) | Free | Windows, Git Bash |
|
||||
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check, and a PR that touches path, file-name or process code also runs its changed Windows-safe test files in `Windows Scoped Tests`) | Free | Windows, Git Bash |
|
||||
| Rerun chosen Windows-safe test files on Windows CI | `gh workflow run windows-scoped-tests.yml --ref <branch> -f files='<file> <file>'` (pick files from `bun run test:windows --list`) | Free CI runners | Pushed branch, `gh` with workflow rights |
|
||||
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref <branch> -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights |
|
||||
| Run a native Windows qualification campaign | `gh workflow run native-qualification.yml --ref <branch> -f mode=<mode>` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights |
|
||||
| Preview which paid cases my diff selects | `bun run eval:select` (PR profile; `--profile full` for the plain touchfile selection) | Free | — |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.94.8.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.94.15.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { BANNED_TERMS_PATH, addedLines, exampleBannedTermsJson, loadBannedTerms, renderBannedTerms, scanBannedTerms, validateBannedTerms, wholeFiles } from '../lib/banned-terms';
|
||||
import { baseArg } from '../lib/gate-diff/git';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { resolveBaseBranch } from '../lib/ship-policy';
|
||||
@@ -75,7 +76,7 @@ function main(argv: string[]): number {
|
||||
let files;
|
||||
if (filesFlag) files = wholeFiles(repoRoot, filesFlag.split(',').map(s => s.trim()).filter(Boolean));
|
||||
else {
|
||||
const diffBase = flag(rest, '--diff-base') ?? (() => { const mb = spawnSync('git', ['merge-base', base, 'HEAD'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); return mb.status === 0 ? mb.stdout.trim() : undefined; })();
|
||||
const diffBase = flag(rest, '--diff-base') ?? (() => { const mb = spawnSync('git', ['merge-base', baseArg('gstack-banned-terms', base, repoRoot), 'HEAD'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); return mb.status === 0 ? mb.stdout.trim() : undefined; })();
|
||||
if (!diffBase) { process.stderr.write(`gstack-banned-terms: no merge base with ${base}; pass --diff-base <sha> or --files <csv>\n`); return EXIT.usage; }
|
||||
const diff = spawnSync('git', ['diff', '--unified=0', '--no-color', diffBase], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
if (diff.status !== 0) { process.stderr.write(`gstack-banned-terms: git diff ${diffBase} failed\n`); return EXIT.fail; }
|
||||
|
||||
@@ -97,6 +97,7 @@ import { writeReceipt, writeOutcome } from '../lib/egress-receipt';
|
||||
import { readConfigKey, resolveStateRoot } from '../lib/state-root';
|
||||
import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog';
|
||||
import { isFrontendPath } from '../lib/frontend-scope';
|
||||
import { baseArg } from '../lib/gate-diff/git';
|
||||
|
||||
// ── Environment ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -821,7 +822,7 @@ function resolveTargets(args: ScanArgs, p: Probe): { targets: string[]; refusedB
|
||||
for (const rel of r.stdout.toString('utf-8').split('\0')) if (rel) files.add(rel);
|
||||
return true;
|
||||
};
|
||||
if (!runZ(['diff', '-z', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`])) {
|
||||
if (!runZ(['diff', '-z', '--name-only', '--diff-filter=ACMR', `${baseArg('gstack-design-detect', base, top)}...HEAD`])) {
|
||||
// A base that does not resolve must not read as "no frontend changes".
|
||||
refuse(base, 'git diff against this base failed (unknown ref or unfetched base)');
|
||||
return { targets: out, refusedBase: true };
|
||||
|
||||
+22
-1
@@ -128,6 +128,26 @@ _session_data() {
|
||||
return "$r"
|
||||
}
|
||||
|
||||
# An auth path holds a token, split the way lib/review-risk.ts tokens() splits
|
||||
# paths (camelCase, then every non-alphanumeric run), that contains auth
|
||||
# (GST-1065). It fails safe: every such token raises the class except the
|
||||
# authorship words below, so author-spec.md, AUTHORS, authority.ts and
|
||||
# coauthors.ts do not, while auth.ts, /auth/, nextauthmiddleware.ts,
|
||||
# noauthrequired.ts, unauthenticated.ts and HTTPAuthorization.ts do.
|
||||
_auth_path() {
|
||||
local s="$1" t r=1
|
||||
while [[ "$s" =~ ^(.*[a-z0-9])([A-Z].*)$ ]]; do s="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"; done
|
||||
shopt -s nocasematch
|
||||
for t in ${s//[^a-zA-Z0-9]/ }; do
|
||||
case "$t" in
|
||||
author|authors|authored|authoring|authorship|authority|authorities|coauthor|coauthors) ;;
|
||||
*auth*) r=0; break ;;
|
||||
esac
|
||||
done
|
||||
shopt -u nocasematch
|
||||
return "$r"
|
||||
}
|
||||
|
||||
_print_flags() {
|
||||
echo "SCOPE_FRONTEND=$FRONTEND"
|
||||
echo "SCOPE_BACKEND=$BACKEND"
|
||||
@@ -293,9 +313,10 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
|
||||
# named for sessions (recovered-sessions.jsonl, GST-911) holds rows, not
|
||||
# session handling, so it is not auth; session config still is.
|
||||
case "$f" in
|
||||
*auth*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
|
||||
*jwt*|*permission*|*role*) m_auth=true ;;
|
||||
*session*) _session_data "$f" || m_auth=true ;;
|
||||
esac
|
||||
_auth_path "$f" && m_auth=true
|
||||
|
||||
# Backend: code that isn't a frontend component/view file. Includes ESM/CJS
|
||||
# (.mjs/.cjs) and explicit-module TS (.mts/.cts) — #1810: these matched no
|
||||
|
||||
@@ -38,6 +38,7 @@ import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { baseContainsHead } from '../lib/default-base';
|
||||
import { baseArg } from '../lib/gate-diff/git';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
|
||||
|
||||
@@ -138,7 +139,7 @@ function main(argv: string[]): void {
|
||||
if (out === top || out.startsWith(top + path.sep)) throw new Error('--out must be outside the repository');
|
||||
const mode = one(args, '--mode');
|
||||
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
|
||||
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
|
||||
const base = git(['rev-parse', '--verify', baseArg('gstack-docs-candidate', one(args, '--base'), process.cwd()) + '^{commit}']).trim();
|
||||
const self = baseContainsHead(top, base);
|
||||
if (self) throw new Error(resultLine('snapshot', 'BASE_CONTAINS_HEAD', self));
|
||||
const exceptionFile = flag(args, '--exception')[0];
|
||||
|
||||
@@ -32,7 +32,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { scanCandidate, parseGateIntegrityBlock, specDirectoryHoldsSpecs, type FileDiff, type CommitInfo } from '../lib/gate-diff/index';
|
||||
import {
|
||||
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
|
||||
baseNote, commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
|
||||
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
|
||||
} from '../lib/gate-diff/git';
|
||||
import { baseContainsHead, noDefaultBaseLine } from '../lib/default-base';
|
||||
@@ -162,8 +162,8 @@ function main(argv: string[]): number {
|
||||
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
|
||||
return 2;
|
||||
}
|
||||
if (base.local === 'fallback') console.error(`gstack-gate-diff: ${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${baseRef}`}; base is the local ${baseRef}, which may lag its remote`);
|
||||
else if (base.local) console.error(`gstack-gate-diff: ${{ unrelated: `local ${baseRef} shares no history with HEAD`, missing: `no local branch ${baseRef}`, behind: `local ${baseRef} is behind ${base.ref}`, ahead: `local ${baseRef} is ahead of ${base.ref}`, diverged: `local ${baseRef} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`);
|
||||
const note = baseNote(baseRef, base);
|
||||
if (note) console.error(`gstack-gate-diff: ${note}`);
|
||||
const mb = mergeBase(base.sha, head, root);
|
||||
if (!mb) {
|
||||
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
|
||||
|
||||
+26
-6
@@ -7,6 +7,7 @@
|
||||
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
|
||||
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
|
||||
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
|
||||
* [--flaky-exception "<test file>::<case>" … --pr <n> [--grant <approval id>]] a run with failures, under the owner's grant
|
||||
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
|
||||
* gstack-pregate validate | init [--explain] [--force] the working-tree .gstack/pregate.json
|
||||
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip] [--json]
|
||||
@@ -36,6 +37,7 @@ import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clear
|
||||
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
|
||||
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
|
||||
import { describeSubstitution } from '../lib/pregate/remote-receipt';
|
||||
import { approvalGrants, describeExceptions, type FlakyInput } from '../lib/pregate/flaky-exception';
|
||||
import { ensureTmpExcluded } from '../lib/restamp';
|
||||
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
|
||||
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
|
||||
@@ -58,7 +60,10 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
verifies GitHub Actions runs against the base branch's run_profiles instead (docs/pregate.md#run-evidence):
|
||||
one run of the profile named after the lane, or for pregate.json/remote_gate one run per
|
||||
remote_gate_runs profile covering the gate's inventory (needs --allow-repo-commands); where a lane has a
|
||||
profile, --run-url is verified the same way
|
||||
profile, --run-url is verified the same way. --flaky-exception "<test file>::<case>" (at most 3) --pr <n>
|
||||
--grant <approval id> takes such runs with failures when every failure is named from the run's shard
|
||||
results and logs, outside the PR's diff and import closure, and an owner's gstack-approvals row binds
|
||||
them; without --grant it prints the row to record (pregate only; docs/pregate.md#flaky-exception)
|
||||
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
|
||||
validate | init the working-tree ${PREGATE_CONFIG_PATH}
|
||||
sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip]
|
||||
@@ -117,7 +122,7 @@ function flag(args: string[], name: string): string | undefined { const i = args
|
||||
const VERB_FLAGS: Record<string, [string[], string[]]> = {
|
||||
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run', '--from-run'], ['--allow-repo-commands', '--explain', '--json', '--remote', '--no-ci-carry']],
|
||||
verify: [['--out'], ['--json']],
|
||||
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out'], ['--allow-repo-commands']],
|
||||
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out', '--flaky-exception', '--pr', '--grant'], ['--allow-repo-commands']],
|
||||
lanes: [[], ['--explain', '--json']],
|
||||
validate: [[], ['--json']],
|
||||
init: [[], ['--explain', '--force']],
|
||||
@@ -223,7 +228,12 @@ function cmdVerify(root: string, rest: string[], json: boolean): number {
|
||||
if (!read.file) { for (const e of read.errors) process.stderr.write(resultLine('gstack-pregate verify', e.code, `${e.message} at ${e.path}`) + '\n'); return EXIT.fail; }
|
||||
const v = verifyPregate(root, read.file);
|
||||
if (json) { process.stdout.write(JSON.stringify({ ...v, verdict: read.file.verdict, tree: read.file.tree, head: read.file.head, out }) + '\n'); return v.ok ? EXIT.ok : EXIT.fail; }
|
||||
if (v.ok) { process.stdout.write(`PREGATE_VERIFY: ok ${v.message}\n`); return EXIT.ok; }
|
||||
if (v.ok) {
|
||||
const excepted = describeExceptions(read.file);
|
||||
if (excepted) process.stdout.write(`PREGATE: ${excepted}\n`);
|
||||
process.stdout.write(`PREGATE_VERIFY: ok ${v.message}\n`);
|
||||
return EXIT.ok;
|
||||
}
|
||||
process.stderr.write(resultLine('gstack-pregate verify', v.code!, v.message) + '\n');
|
||||
for (const r of v.uncleared) process.stdout.write(`requires-remote: ${r.lane} (${r.platform}) ${r.tests.length} test(s) uncleared\n`);
|
||||
process.stdout.write(`PREGATE_VERIFY: refused ${v.code}\n`);
|
||||
@@ -233,6 +243,14 @@ function cmdVerify(root: string, rest: string[], json: boolean): number {
|
||||
function cmdClear(root: string, rest: string[]): number {
|
||||
const lane = flag(rest, '--remote');
|
||||
if (!lane) { process.stderr.write(`gstack-pregate clear: --remote <workflow.yml/job> with --run-url <url>, --bundle-lane <label> or (for ${REMOTE_GATE_LANE}) --receipt <file> is required\n`); return EXIT.usage; }
|
||||
const cases = rest.flatMap((a, i) => a === '--flaky-exception' ? [rest[i + 1]!] : []);
|
||||
let flaky: FlakyInput | undefined;
|
||||
if (cases.length) {
|
||||
if (!rest.includes('--run') && !rest.includes('--run-url')) { process.stderr.write('gstack-pregate clear: --flaky-exception needs --run <id|url>: an exception reads GitHub Actions runs\n'); return EXIT.usage; }
|
||||
const pr = Number(flag(rest, '--pr'));
|
||||
if (!Number.isInteger(pr) || pr <= 0) { process.stderr.write('gstack-pregate clear: --flaky-exception needs --pr <n>, the pull request the grant binds\n'); return EXIT.usage; }
|
||||
flaky = { cases, pr, grant: flag(rest, '--grant'), grantRow: approvalGrants(root) };
|
||||
} else if (flag(rest, '--pr') !== undefined || flag(rest, '--grant') !== undefined) { process.stderr.write('gstack-pregate clear: --pr and --grant apply only with --flaky-exception\n'); return EXIT.usage; }
|
||||
const out = outPath(root, rest);
|
||||
const read = fs.existsSync(out) ? readPregateFile(out) : { file: null, errors: [] };
|
||||
if (!read.file) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} is missing or invalid; run gstack-pregate first`) + '\n'); return EXIT.fail; }
|
||||
@@ -242,7 +260,7 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
const checked = runs.length || runUrl || bundleLane ? buildRunCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
|
||||
if (checked && 'error' in checked && (runs.length || checked.profiled)) { process.stderr.write(`gstack-pregate clear: ${checked.error}${checked.profiled ? `; ${lane} may have a run profile on the base branch, so it clears only with a verified --run` : ''}\n`); return EXIT.fail; }
|
||||
const evidence = checked && !('error' in checked) ? checked : undefined;
|
||||
const decision = clearDecision(read.file, lane, { runUrl, runs, bundleLane, receiptPath: flag(rest, '--receipt'), evidence }, root);
|
||||
const decision = clearDecision(read.file, lane, { runUrl, runs, bundleLane, receiptPath: flag(rest, '--receipt'), evidence, flaky }, root);
|
||||
if ('refused' in decision) {
|
||||
if (decision.refused === 'usage') { process.stderr.write(`gstack-pregate clear: ${decision.message}\n`); return EXIT.usage; }
|
||||
process.stderr.write(`gstack-pregate clear: receipt refused — ${decision.message}; fix: ${decision.fix ?? RESULT_CODES.PREGATE_RECEIPT_INVALID.fix} ${resultCodeSuffix('PREGATE_RECEIPT_INVALID')}\n`);
|
||||
@@ -250,9 +268,11 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
}
|
||||
const current = readPregateFile(out).file;
|
||||
if (!current || current.tree !== read.file.tree || current.generated_at !== read.file.generated_at) { process.stderr.write(resultLine('gstack-pregate clear', 'PREGATE_STALE', `${out} changed while ${lane} was being verified; rerun the clear against the new file`) + '\n'); return EXIT.fail; }
|
||||
const file = clearObligation(current, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`, decision.substitution);
|
||||
const file = clearObligation(current, lane, decision.receipt, `gstack-pregate clear ${new Date().toISOString()}`, decision.substitution, decision.exception);
|
||||
writePregateFile(root, out, file);
|
||||
if (/ runs=\S+ profiles=/.test(decision.receipt)) process.stdout.write(`REUSE: accepted ${lane} ${decision.receipt}\n`);
|
||||
const e = decision.exception;
|
||||
if (e) process.stdout.write(`FLAKY_EXCEPTION: accepted ${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)\n`);
|
||||
else if (/ runs=\S+ profiles=/.test(decision.receipt)) process.stdout.write(`REUSE: accepted ${lane} ${decision.receipt}\n`);
|
||||
if (decision.substitution) process.stdout.write(`PREGATE_RECEIPT_SUBSTITUTED: ${describeSubstitution(decision.substitution)}\n`);
|
||||
process.stdout.write(`PREGATE_CLEARED: ${lane} receipt=${decision.receipt} verdict=${file.verdict}${decision.attested ? ` (attested, not validated: ${lane} has no machine-checkable receipt)` : ''}\n`);
|
||||
if (checked && 'error' in checked) process.stderr.write(`gstack-pregate clear: run profiles unavailable (${checked.error}); ${lane} was recorded without run verification\n`);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { baseArg } from '../lib/gate-diff/git';
|
||||
import { lintFiles, renderProseLint } from '../lib/prose-lint';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
|
||||
@@ -54,7 +55,7 @@ function main(argv: string[]): number {
|
||||
if ([base, sections, flags.has('--unreleased') || undefined].filter(Boolean).length > 1) { process.stderr.write('gstack-prose-lint: --unreleased, --base and --sections are different selections; pass one\n'); return EXIT.usage; }
|
||||
for (const f of files) if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) { process.stderr.write(`gstack-prose-lint: cannot read ${f}\n`); return EXIT.usage; }
|
||||
let result;
|
||||
try { result = lintFiles(files, { unreleased: flags.has('--unreleased'), base, sections }); }
|
||||
try { result = lintFiles(files, { unreleased: flags.has('--unreleased'), base: base && baseArg('gstack-prose-lint', base, process.cwd()), sections }); }
|
||||
catch (e: any) { process.stderr.write(`gstack-prose-lint: ${e.message}\n`); return EXIT.usage; }
|
||||
if (sections && flags.has('--gate') && result.empty.length) {
|
||||
process.stderr.write(`gstack-prose-lint: no "## <section>" heading from --sections in ${result.empty.join(', ')}; nothing would be gated\n`);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { locate, readRecord } from '../lib/approval-record';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
import { baseNote } from '../lib/gate-diff/git';
|
||||
import { collectCommit, collectLive } from '../lib/review-collect';
|
||||
import { excludeLines, reviewExclude, type Exclusion } from '../lib/review-exclude';
|
||||
import { isUnavailable } from '../lib/ship-policy';
|
||||
@@ -126,8 +127,11 @@ function main(): number {
|
||||
for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`);
|
||||
return EXIT.fail;
|
||||
}
|
||||
const note = collected.base && baseNote(base, collected.base);
|
||||
if (note) process.stderr.write(`gstack-review-plan: ${note}\n`);
|
||||
const ref = collected.base?.ref ?? base;
|
||||
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
|
||||
const self = collected.mergeBase === head ? baseContainsHead(root, git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) ? `origin/${base}` : base) : null;
|
||||
const self = collected.mergeBase === head ? baseContainsHead(root, ref) : null;
|
||||
if (self) { process.stderr.write(`${resultLine('gstack-review-plan', 'BASE_CONTAINS_HEAD', self)}; no plan is safer than a plan for a diff compared with itself\n`); return EXIT.fail; }
|
||||
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
|
||||
const scopeOf = (args: string[]): Record<string, string> => {
|
||||
@@ -145,7 +149,6 @@ function main(): number {
|
||||
const gateTagsOf = (args: string[]): number | null => { const g = runGateDiff(args); return tagCount(g.stdout, g.status); };
|
||||
const diffBase = collected.mergeBase ?? base;
|
||||
const scope = scopeOf([diffBase]);
|
||||
const ref = git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) !== null ? `origin/${base}` : base;
|
||||
const baseSha = collected.registry.sha ?? git(['rev-parse', '--verify', '-q', `${ref}^{commit}`])?.trim() ?? '';
|
||||
const policy = baseSha ? loadPolicyAt(root, baseSha, ref) : null;
|
||||
const declares = !!policy && !isUnavailable(policy) && policy.policy.review_exclude_paths.length > 0;
|
||||
@@ -193,11 +196,11 @@ function main(): number {
|
||||
const gateScan = { tagged: gateTagged, head };
|
||||
if (has('--json')) {
|
||||
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
|
||||
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
|
||||
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, base: collected.base, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed, drawings: collected.drawings,
|
||||
...(exclusion.state === 'head-only' ? { review_exclude: exclusion } : active ? { review_exclude: { state: 'active', ...active.record, specs: active.specs, keep: active.keep, kept_files: active.kept, content: active.content, references: active.references } } : {}) }) + '\n');
|
||||
return EXIT.ok;
|
||||
}
|
||||
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`,
|
||||
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, `REVIEW_BASE: ${ref} ${baseSha.slice(0, 12)}`,
|
||||
`TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`];
|
||||
out.push(...excludeLines(exclusion, base));
|
||||
if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`);
|
||||
|
||||
+11
-5
@@ -40,6 +40,7 @@ import { versionAtRef } from '../lib/restamp';
|
||||
import { branchSides, stampOnlyLine, type BranchSide } from '../lib/stamp-only';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
|
||||
import { exceptionConsumerProblem } from '../lib/pregate/flaky-exception';
|
||||
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
|
||||
import { approvalForReceipt } from '../lib/approval-citations';
|
||||
import {
|
||||
@@ -98,7 +99,9 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
|
||||
unreadable input records full).
|
||||
--review-exclude <digest|none> (the digest= on gstack-review-dispatch verify's REVIEW_EXCLUDE_RECORD: line, which the reviewers' briefs
|
||||
name; none without that line): must equal the digest HEAD has under the base policy's review_exclude_paths, else
|
||||
RECEIPT_INVALID field=review_exclude and nothing is written (rerun the review; write alone cannot clear it); recorded as review_exclude. Carry and the queue compare that digest.
|
||||
RECEIPT_INVALID field=review_exclude and nothing is written; recorded as review_exclude. Carry and the queue compare that digest.
|
||||
The check binds the digest to the base policy, not to a review: write does not read the review ledger, so the expected=
|
||||
digest a refusal prints passes too. After a refusal, rerun the review and pass the digest its verify prints.
|
||||
--review-targeted <json|@file> ({classes, rules, brief_sha, status, findings, raised_by, head}: gstack-review-dispatch verify
|
||||
--targeted-out); recorded as review_targeted bound to the receipt's product_patch_id, and refused (RECEIPT_INVALID) when the
|
||||
commit the reader read (head) is not an ancestor or a change since it carries a targeted class (gate scan included). write and carry
|
||||
@@ -235,7 +238,7 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
const excludeNow = review.exclude.state === 'active' ? review.exclude.record : null;
|
||||
const excludeGiven = flag(rest, '--review-exclude') ?? 'none';
|
||||
if ((excludeNow?.digest ?? 'none') !== excludeGiven) {
|
||||
process.stderr.write(`gstack-ship-receipt write: field=review_exclude expected=${excludeNow?.digest ?? 'none'} recorded=${excludeGiven} source=${excludeNow?.source ?? `origin/${base} (no review_exclude_paths)`}: the review --review-exclude names read another selection than ${base}'s review_exclude_paths gives ${head.slice(0, 12)}; fix: rerun /ship's review on ${head.slice(0, 12)} (gstack-review-dispatch pass --base ${base}), run gstack-review-dispatch verify --ledger <ledger> and write with the --review-exclude <d> its REVIEW_EXCLUDE_RECORD: line prints (none when it prints no such line); nothing written (RECEIPT_INVALID)\n`);
|
||||
process.stderr.write(`gstack-ship-receipt write: field=review_exclude expected=${excludeNow?.digest ?? 'none'} recorded=${excludeGiven} source=${excludeNow?.source ?? `origin/${base} (no review_exclude_paths)`}: the --review-exclude argument names another selection than ${base}'s review_exclude_paths gives ${head.slice(0, 12)} (write compares the argument, not the review ledger); fix: rerun /ship's review on ${head.slice(0, 12)} (gstack-review-dispatch pass --base ${base}), run gstack-review-dispatch verify --ledger <ledger> and write with the --review-exclude <d> its REVIEW_EXCLUDE_RECORD: line prints (none when it prints no such line); nothing written (RECEIPT_INVALID)\n`);
|
||||
return EXIT.fail;
|
||||
}
|
||||
const product = reviewedProduct(repoRoot, base, head, pregateFile);
|
||||
@@ -472,15 +475,18 @@ function gitVersionBelow(repoRoot: string, minimum: string): string | null {
|
||||
return major > minMajor || (major === minMajor && minor >= minMinor) ? null : m[0];
|
||||
}
|
||||
|
||||
type PregateField = { pregate: string; requires_remote?: string[] };
|
||||
type PregateField = { pregate: string; requires_remote?: string[]; flaky_exception?: ShipReceipt['flaky_exception'] };
|
||||
/** A pregate result file → the receipt's pregate field, verified current for this checkout's tree (shared by write and carry). */
|
||||
function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: true; field: PregateField } | { ok: false; errors: Array<{ code: string; message: string }> } {
|
||||
const read = readPregateFile(file);
|
||||
if (!read.file) return { ok: false, errors: read.errors.map(e => ({ code: e.code, message: `${e.message} at ${e.path}` })) };
|
||||
const v = verifyPregate(repoRoot, read.file);
|
||||
if (!v.ok) return { ok: false, errors: [{ code: v.code!, message: v.message ?? 'pregate is not current' }] };
|
||||
const foreign = exceptionConsumerProblem(read.file, head);
|
||||
if (foreign) return { ok: false, errors: [{ code: 'PREGATE_REMOTE_UNCLEARED', message: foreign }] };
|
||||
const remote = read.file.requires_remote ?? [];
|
||||
return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined } };
|
||||
const exceptions = read.file.exceptions ?? [];
|
||||
return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined, flaky_exception: exceptions.length ? exceptions.map(e => ({ lane: e.lane, runs: e.runs, cases: e.cases, grant: e.grant, by: e.by })) : undefined } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -784,7 +790,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
const now = review.depth;
|
||||
if (review.exclude.state === 'refused') return refused('RECEIPT_CARRY_REFUSED', `${review.exclude.lines.join('; ')}; run /ship on ${head.slice(0, 12)} once the base policy reads`);
|
||||
const digest = review.exclude.state === 'active' ? review.exclude.record.digest : 'none';
|
||||
if (digest !== (receipt.review_exclude?.digest ?? 'none')) return refused('RECEIPT_CARRY_REFUSED', `review_exclude changed: recorded=${receipt.review_exclude?.digest ?? 'none'} now=${digest} source=${review.exclude.state === 'active' ? review.exclude.record.source : `${base} (no review_exclude_paths)`}; the reviewers read another selection than HEAD ${head.slice(0, 12)} has under the base policy, so carry does not apply; run /ship on ${head.slice(0, 12)}`);
|
||||
if (digest !== (receipt.review_exclude?.digest ?? 'none')) return refused('RECEIPT_CARRY_REFUSED', `review_exclude changed: recorded=${receipt.review_exclude?.digest ?? 'none'} now=${digest} source=${review.exclude.state === 'active' ? review.exclude.record.source : `${base} (no review_exclude_paths)`}; the receipt's digest is the --review-exclude argument write was given, and it names another selection than HEAD ${head.slice(0, 12)} has under the base policy (carry compares that digest, not the review ledger), so carry does not apply; run /ship on ${head.slice(0, 12)}`);
|
||||
if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`);
|
||||
depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) }, review_exclude: receiptExclude(review.exclude) };
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **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 <sha12> 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.
|
||||
@@ -1696,7 +1696,17 @@ the core skills, drives `/browse` on gstack's own browser, and runs the short
|
||||
test list in its last step, where each file's reason sits beside it.
|
||||
`test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast.
|
||||
|
||||
The curated suite below runs nightly on main and on dispatch, never on a PR.
|
||||
Windows checks run by path. `windows-scoped-tests.yml` (Windows Scoped Tests)
|
||||
runs on every PR, and when the diff touches path, file-name or process code (the
|
||||
filter is described at the top of the workflow), one `windows-latest` job runs
|
||||
the PR's changed test files that are in the curated subset below. Its
|
||||
`windows-scoped-tests` check passes when nothing is in scope, and a red run
|
||||
blocks its PR. `windows-setup-e2e.yml` skips its Windows jobs when the only
|
||||
install change is `package.json`'s version line. Both filters are pinned by
|
||||
`test/windows-path-scope-workflow.test.ts`. To rerun chosen Windows-safe files on
|
||||
a branch, dispatch `windows-scoped-tests.yml` with `-f files='<file> <file>'`.
|
||||
|
||||
The full curated suite below runs nightly on main and on dispatch, never on a PR.
|
||||
`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6
|
||||
--shard-jobs $WINDOWS_FREE_JOBS`), runs one strict serial shard per
|
||||
`windows-latest` job
|
||||
|
||||
@@ -1059,6 +1059,91 @@ and without it every push falls back to the full run. Windows Free Tests now
|
||||
runs nightly and by hand only (docs/support-tiers.md), never on a push, so its
|
||||
`reuse` job is always skipped and every nightly run takes the strict path.
|
||||
|
||||
<a id="flaky-exception"></a>
|
||||
## Owner-granted flaky exception
|
||||
|
||||
A timing flake in a test file the pull request never touched can keep a remote
|
||||
gate obligation red: the rerun hits the same 5 s timeout on a loaded runner,
|
||||
and nothing in the diff can fix it. `clear --flaky-exception` lets the owner
|
||||
accept named cases for one head, on the run's own records:
|
||||
|
||||
```bash
|
||||
gstack-pregate clear --remote pregate.json/remote_gate --run <id|url> \
|
||||
--flaky-exception "test/docsync-fault-interface-legacy.test.ts::rejects legacy-third" \
|
||||
--pr <n> --allow-repo-commands
|
||||
```
|
||||
|
||||
The first call checks everything below and then refuses with
|
||||
`reason=grant_needed`, printing the exact `gstack-approvals add` command to run
|
||||
once the owner says yes: its `--scope` is `flaky-exception:<sha256>`, a digest of
|
||||
the repository, PR, head, tree, lane, every run with its attempt, and the sorted
|
||||
cases. Record the owner's exact words in that row, then rerun the same clear with
|
||||
`--grant <the row id>`. The obligation clears only when all of this holds:
|
||||
|
||||
- **Every failure is named from the run's own records.** A red run uploads no
|
||||
`tree.json`, so the clear reads the strict shard runner's artifacts instead
|
||||
(`free-plan`, `free-result-<n>` and `free-test-shard-logs-<n>` in
|
||||
`free-tests.yml`; the `windows-` names in `windows-free-tests.yml`; any other
|
||||
workflow refuses with `no_shard_results`). Every job of the attempt is
|
||||
reconciled: only the shard jobs (`free-suite (<n>)`) and the aggregate that
|
||||
needs them may fail, every job the profile requires must pass, and any other
|
||||
failed or cancelled job refuses (`unnamed_failure`). Every planned shard needs
|
||||
its result; a missing result, a timed-out shard or a missing summary is
|
||||
`shard_incomplete`. A failure the runner could not attribute to a test file,
|
||||
an unhandled error between tests, or a `(fail)` line in a failed shard's log
|
||||
that its result does not record refuses. A retry that passed is a recovered
|
||||
flake; a retry that failed again keeps its own failures.
|
||||
- **The cases are exactly the failures.** At most three, written as
|
||||
`<test file>::<test name>` the way the run printed them. A failure no
|
||||
`--flaky-exception` names refuses (`unnamed_failure`), and so does a named
|
||||
case that did not fail (`case_not_failed`). The refusal lists the run's
|
||||
failures in that form.
|
||||
- **The files are not the PR's business.** A named file in the PR's diff
|
||||
(`case_in_diff`), or one the import graph reaches from any changed path
|
||||
(`case_in_closure`, lib/pregate/import-graph.ts with the base branch's
|
||||
`dependencies`), refuses. The closure follows imports only: a test that reads a
|
||||
changed doc, template or workflow through the file system is not reached, so
|
||||
required CI stays the check on it. A paid test file (`case_paid`) and a
|
||||
security test (`case_security`: a file name with cso, secrets, gitleaks, sealed,
|
||||
redact or redaction, osv, security, sanitize or inject) are never excepted. Security scans themselves are pregate checks and CI jobs,
|
||||
not test cases, so no exception can reach them.
|
||||
- **One head, one grant.** The runs must have tested pregate.json's own head and
|
||||
tree, with no stamp-only substitution (`head_mismatch`); the PR must be open with
|
||||
that head (`pr_mismatch`); no newer run of the workflow may exist on the head
|
||||
(`newer_run`), and the run may not move to another attempt during the check
|
||||
(`run_changed`). The grant row must carry this exception's scope
|
||||
(`grant_mismatch` otherwise), record `approved` (`grant_not_approved`) and be
|
||||
the owner's words, not an agent's (`grant_not_owner`). That last check reads
|
||||
the row's `by` field, which `gstack-approvals add --by` takes as written, so it
|
||||
only catches a row honestly labeled `agent:`. The record is as trustworthy as
|
||||
whoever writes it, the same as every gstack approval. A new head needs a new
|
||||
run and a new grant; a grant is never rebound: `ready` and the ship receipt
|
||||
refuse an exception granted for another PR or head, even when the tree matches.
|
||||
|
||||
pregate.json records the grant under `exceptions` (lane, repository, PR, head,
|
||||
tree, runs, cases, grant id, who said it, their words and the binding), and the
|
||||
obligation's receipt adds `exception=<grant> cases=<n>` to the usual run fields.
|
||||
The verdict becomes `pass-with-exception`. `verify` re-checks offline that each
|
||||
exception receipt has its entry, bound to this head and tree, with a binding that
|
||||
still matches its fields, and prints
|
||||
`PREGATE: pass-with-exception (<cases>; grant <id>)`. `gstack-ship-queue ready`
|
||||
accepts it and prints one `FLAKY_EXCEPTION:` line per exception in the READY
|
||||
block, and `gstack-ship-receipt write` records `flaky_exception` (lane, runs,
|
||||
cases, grant, who).
|
||||
|
||||
**Pregate only.** The exception clears the pregate obligation and nothing else.
|
||||
It never waives a red required CI check: `ready` still needs every required check
|
||||
green at the exact head, so a red Free Tests check on the pull request needs its
|
||||
own rerun to pass. Changing that contract is a separate decision for the owner.
|
||||
|
||||
**Why not `base_red`.** `base_red` (docs/ship-policy.md#base-red) proves a
|
||||
failure belongs to the base by reproducing it there, at the base head and at the
|
||||
PR head. A timing flake usually passes on the base (the 2026-11-02 docsync case
|
||||
passed in 3.5 s on main and failed at 5 s on the PR's tree), so `base_red`
|
||||
returns `IN_BRANCH` and refuses. The exception path asks a different question:
|
||||
not "is the base broken?" but "is every failure named, outside this PR, and
|
||||
accepted by the owner?"
|
||||
|
||||
<a id="guards"></a>
|
||||
## Guards and the lane-done rule
|
||||
|
||||
|
||||
+22
-4
@@ -174,9 +174,12 @@ applies no edits is its exit too.
|
||||
`gstack-review-plan --base <base>` classifies the diff, picks a **depth** and prints
|
||||
the reviewers the next step dispatches: `REVIEW_CLASS:`, `REVIEW_DEPTH: <d>
|
||||
runtime_lines=<n> rules=<hash> reasons=<…> codes=<…>`, `GATE_SCAN: tagged=<n>|unavailable
|
||||
head=<sha>`, one `DEPTH_ERROR:` per input that failed closed, one `DISPATCH:` per reviewer
|
||||
head=<sha>`, `REVIEW_BASE: <ref> <sha12>` (the base the diff was taken against: a bare
|
||||
`--base main` reads `origin/main` when it shares history with HEAD, and a local `main` that lags
|
||||
it is named on stderr), one `DEPTH_ERROR:` per input that failed closed, one `DISPATCH:` per reviewer
|
||||
with its effort and a shell-quoted `DIFF_CMD:` (plus `DIFF_EXCLUDE: <name> none` when it
|
||||
reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields.
|
||||
reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields (the
|
||||
base as `base`).
|
||||
A plan that fails closed to full exits 0; a diff git cannot list exits 1.
|
||||
|
||||
**Classes** are unchanged. A mixed diff takes its most restrictive class: **docs**
|
||||
@@ -204,7 +207,7 @@ elements and RDF metadata, CSS made of declarations only (no `@` rule and no
|
||||
function but a same-file `url(#id)`, which closes `@import`, `image-set()`,
|
||||
`image()`, `element()` and every other CSS load), no backslash escape, every
|
||||
`href` a same-file `#` reference or an inline raster image, no event handler and no
|
||||
entity declaration, and no DOCTYPE but a bare `<!DOCTYPE svg>` or W3C's own SVG DTD, read after character references are decoded; anything else, a
|
||||
entity declaration, and no DOCTYPE but a bare `<!DOCTYPE svg>` or W3C's own SVG DTD, read after character references are decoded. A `<style>` element's text is read as written. Any `&` in it keeps the SVG code, its comments are read as CSS, an opening tag it cannot read as quoted `name="value"` pairs keeps the SVG code, and only ASCII whitespace may come before the `>` of either tag, so no character-reference `</style>`, 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 (<reason>)` 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=<n> blocking_found=<k> deferred=<m>`,
|
||||
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
|
||||
|
||||
+11
-7
@@ -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 <file>` 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 <base>` (the caps come from `origin/<base>`'s policy, never the branch's copy; `--policy <file>` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: <path> <bytes>/<max> 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=<digest> recorded=<digest>`. 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <digest>` 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 <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 <base>`),
|
||||
run `gstack-review-dispatch verify --ledger <ledger>`, which prints
|
||||
`REVIEW_EXCLUDE_RECORD: digest=<d>` from the briefs the readers got, then
|
||||
`gstack-ship-receipt write --pr <n> --review-exclude <d> --body <body>`.
|
||||
|
||||
@@ -137,6 +137,27 @@ export function resolveBase(ref: string, cwd: string, head?: string): { ref: str
|
||||
return sha ? { ref, sha } : null;
|
||||
}
|
||||
|
||||
export type ResolvedBase = NonNullable<ReturnType<typeof resolveBase>>;
|
||||
|
||||
/** 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 `<bin>: <note>` 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, "'\\''")}'`;
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
+4
-1
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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 <lane> --run <run>
|
||||
* --flaky-exception "<test file>::<case>" … --pr <n> --grant <approval id>` (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<PregateException, 'lane' | 'repo' | 'pr' | 'head' | 'tree' | 'runs' | 'cases'>;
|
||||
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<string, ShardLayout> = {
|
||||
'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 `<file>::<case>` 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 "<test file>::<case>", 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 (<cases>; grant <id>)`. */
|
||||
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<string, RunProfile>; 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<number>();
|
||||
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<T> = { 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<T>(art: FailureDeps, id: number, name: string): T | { error: string } {
|
||||
let files: Record<string, string>;
|
||||
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 `<file>::<test name>`, 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<FreeCiPlan>(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<FreeCiResult>(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<number, ApiJob> | { reason: string; detail: string } {
|
||||
const shardRe = new RegExp(`^${escapeRe(layout.shardJob)} \\((\\d+)\\)$`);
|
||||
const shards = new Map<number, ApiJob>();
|
||||
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 (`<file>::<test>`) 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<string> } | { 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<string>): { reason: string; detail: string } | null {
|
||||
let files: Record<string, string>;
|
||||
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/<base>, and each test the diff reaches through the import graph. */
|
||||
function prScope(repoRoot: string, file: PregateFile): { diff: Set<string>; closure: Map<string, string[]> } | { 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<string>();
|
||||
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<string, string[]>();
|
||||
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 "<the owner's exact words>" --by "<owner>" --scope "${binding}" --source pregate-flaky-exception, then rerun this clear with --grant <the row id it prints>`);
|
||||
}
|
||||
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 } };
|
||||
}
|
||||
+35
-11
@@ -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<string, RunProfile>; 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<string, RunProfile>; 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 <id|url>: an exception reads GitHub Actions runs' };
|
||||
if (!input.flaky.pr) return { refused: 'usage', message: '--flaky-exception needs --pr <n>, 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 <id|url>, 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;
|
||||
}
|
||||
@@ -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; };
|
||||
|
||||
+12
-5
@@ -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/<base> (or <base>; a remote-qualified base reads refs/remotes/<base> 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/<base> when it shares history with HEAD, else the local branch; a remote-qualified base reads
|
||||
* refs/remotes/<base> 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 };
|
||||
}
|
||||
+32
-21
@@ -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 = /(?<![\w.:\-\u0080-\uffff])([A-Za-z_\u0080-\uffff][\w.:\-\u0080-\uffff]*)\s*=\s*(?=(?:"([^"]*)"|'([^']*)'|([^\s"'<>=]+)))/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)) {
|
||||
i = t.indexOf('-->', lt + 4) + 3;
|
||||
if (i === 2) return false;
|
||||
} else if (t.startsWith('<![CDATA[', lt)) {
|
||||
const end = t.indexOf(']]>', lt + 9);
|
||||
css += text.slice(i, lt);
|
||||
const [start, close] = text.startsWith('<!--', lt) ? [lt + 4, '-->'] : text.startsWith('<![CDATA[', lt) ? [lt + 9, ']]>'] : [-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(/(?<![\w.:-])([A-Za-z_][\w.:-]*)\s*=\s*(?=(?:"([^"]*)"|'([^']*)'|([^\s"'<>=]+)))/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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -272,6 +272,9 @@ export function bindReview(rec: Record<string, any>, 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.
|
||||
|
||||
+67
-14
@@ -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<string, Matched>, claimed: ReadonlySet<string>, unread: Set<string>): Set<string> {
|
||||
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<string, Matched>, claimed: ReadonlySet<string>, unread: Set<string>, native: Set<string>): Set<string> {
|
||||
const out = new Set<string>();
|
||||
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<string, string[]>();
|
||||
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<string, Matched>, 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<string>): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {};
|
||||
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<string>): 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<string>): 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<string>, native: ReadonlySet<string>): 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<string>();
|
||||
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<string>();
|
||||
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<string, KeptReason | null>();
|
||||
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) {
|
||||
|
||||
@@ -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<string, string>;
|
||||
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/<id>[/attempts/<n>][/job/<j>]`). */
|
||||
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 = <T>(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<string, string> = {};
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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 <n> --review-exclude <d> --body <body> with the digest gstack-review-dispatch verify --ledger <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 <n> --review-exclude <d> --body <body> with the digest gstack-review-dispatch verify --ledger <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 => {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
|
||||
'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. */
|
||||
|
||||
+36
-4
@@ -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();
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <base>); 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 = () => <p />;\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', () => {
|
||||
|
||||
@@ -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<ReturnType<typeof makeQueueRepo>> = [];
|
||||
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<PregateFile> {
|
||||
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<ApiRun> }
|
||||
|
||||
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<string, Record<string, string>> = { '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<GrantRow> = {}): (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 "<the owner's exact words>" --by "<owner>" --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<NonNullable<PregateFile['exceptions']>[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 "<test file>::<case>"');
|
||||
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 <id|url>')]);
|
||||
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 <n>')]);
|
||||
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);
|
||||
});
|
||||
+104
-1
@@ -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');
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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<string, { sha: string; replay: Replay }> = {
|
||||
@@ -95,7 +96,7 @@ const REVIEWER_BRIEFS: Record<string, { sha: string; replay: Replay }> = {
|
||||
'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' },
|
||||
};
|
||||
|
||||
|
||||
@@ -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 </style> or comment, an HTML-only comment end, a quoted > on the style tag, a
|
||||
// non-ASCII space before > in </style>, or a tag or attribute under a non-ASCII namespace prefix (GST-940);
|
||||
// seam=collectCommit + depthOfCommitInput
|
||||
test.each([
|
||||
['an external image', ' <image href="https://x.test/pixel.png" width="1" height="1"/>'],
|
||||
@@ -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()', ' <rect style="fill:cross-fade(url(https://x.test/a.png), url(#m0), 50%)"/>'],
|
||||
['a style element whose </style> hides in a comment', ' <style>svg{fill:<!-- </style> -->image-set("https://x.test/p.png" 1x)}</style>'],
|
||||
['a style element whose </style> hides in CDATA', ' <style><![CDATA[ /* </style> */ svg{fill:image-set("https://x.test/p.png" 1x)} ]]></style>'],
|
||||
['a character-reference </style> in a style element', ' <style></style>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
|
||||
['a hex character-reference </style> in a style element', ' <style></style>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
|
||||
['a character-reference comment in a style element', ' <style><!-- rect{fill:image-set("https://x.test/p.png" 1x)} --></style>'],
|
||||
['CSS after an HTML empty comment in a style element', ' <style><!-->rect{fill:image-set("https://x.test/p.png" 1x)}--></style>'],
|
||||
['CSS after an HTML --!> comment end in a style element', ' <style><!-- --!>rect{fill:image-set("https://x.test/p.png" 1x)} --></style>'],
|
||||
['a </style> inside a quoted style attribute', ' <style title="></style>">rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
|
||||
['an escaped ampersand in a style element', ' <style>text{font-family:"A & B"}</style>'],
|
||||
['a no-break space before > in </style>', ' <style>a{}</style\u00a0>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
|
||||
['a vertical tab before > in </style>', ' <style>a{}</style\u000b>rect{fill:image-set("https://x.test/p.png" 1x)}</style>'],
|
||||
['a script under a non-ASCII prefix', ' <g\u00e9:script xmlns:g\u00e9="http://www.w3.org/2000/svg">fetch(1)</g\u00e9:script>'],
|
||||
['a style under a non-ASCII prefix', ' <g\u00e9:style xmlns:g\u00e9="http://www.w3.org/2000/svg">rect{fill:image-set("https://x.test/p.png" 1x)}</g\u00e9:style>'],
|
||||
['an external href under a non-ASCII prefix', ' <image x\u00e9:href="https://x.test/p.png" xmlns:x\u00e9="http://www.w3.org/1999/xlink"/>'],
|
||||
])('%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, ' <style type="text/css" media="all"><!-- plot --><![CDATA[ rect{fill:#000} ]]></style>'))).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', () => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* A bare --base <branch> 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/<base> 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 <base>); 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);
|
||||
});
|
||||
@@ -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<any>(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: [] });
|
||||
|
||||
@@ -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<string, string>, change: Record<string, string>): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'windows-scope-'));
|
||||
const write = (files: Record<string, string>) => {
|
||||
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<<DELIMITER blocks. */
|
||||
function parseOutputs(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string>; 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<string, string> = {}, 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<string, string> = {}) {
|
||||
const base: Record<string, string> = {};
|
||||
const change: Record<string, string> = {};
|
||||
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<string, string>, change: Record<string, string>) => {
|
||||
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<string, string>]>) {
|
||||
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<string, string>) => 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user