diff --git a/.github/workflows/windows-scoped-tests.yml b/.github/workflows/windows-scoped-tests.yml new file mode 100644 index 000000000..1675d15e7 --- /dev/null +++ b/.github/workflows/windows-scoped-tests.yml @@ -0,0 +1,206 @@ +name: Windows Scoped Tests + +# The path-scoped Windows free shard (ship-90-90 plan D). windows-smoke.yml is +# still the required Windows check on every pull request, and the full +# curated suite (windows-free-tests.yml) still runs nightly on main. This +# workflow sits between them: when a pull request touches path, file-name or +# process code, one windows-latest job runs the test files the pull request +# changed that are in the curated Windows-safe subset. +# +# "Path, file-name or process code" (the windows-scope filter step): +# - file names: anything under a bin/ directory (shell entry points and +# skill hooks such as freeze/bin/check-freeze.sh, #3758), shell and +# PowerShell scripts, hosts/*/hooks, setup, bun.lock, the Windows +# curation (scripts/test-free-shards.ts, scripts/lib/windows-curation.ts, +# the Windows duration seed, windows-free-tests.yml), and code files whose +# names say path, spawn, exec, process, shell, launch, win32, windows, +# tmp, temp, symlink, state-root, slug, claude-bin, bun-isolation or +# script-invocation; +# - added or removed code lines that spawn processes or spell paths: +# child_process, spawn/exec calls, Bun.spawn, Bun.$, path.win32 and the +# other separator-sensitive path calls, process.platform/execPath, win32, +# tmpdir(), /dev/null, /tmp/, symlink, realpath, chmod, cygpath, +# USERPROFILE, homedir(), fileURLToPath and .exe. #3829's Windows break was +# one such line in a test it added: spawnSync(process.execPath, [..., +# '--config=/dev/null']), which native Windows Bun cannot open. +# test/windows-path-scope-workflow.test.ts proves the filter catches #3758 +# and #3829 and passes over a docs-only and a pure-logic change. +# +# The windows-scoped-tests job is the check to read: it passes when nothing +# is in scope (no Windows job ran) and otherwise requires the Windows job, so +# a skipped run reports success. It is not one of the required checks, but +# gstack-ship-queue ready and land refuse a head with any failed check, so a +# red Windows job blocks the pull request it runs on: in a pull request that +# touches path, file-name or process code, a changed curated test file has to +# pass on Windows. A change to path or process code with no changed curated +# test selects nothing; the nightly suite covers it. +# gstack-pregate reads a job keyed on a dispatch input as manual, never a PR +# obligation: this check runs on the pull request itself. A workflow_dispatch +# run with the files input reruns chosen Windows-safe files on a branch. + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + files: + description: Space-separated Windows-safe free test files to run (from bun run test:windows --list) + type: string + required: true + +concurrency: + group: windows-scoped-tests-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +# Test-only lane — no token writes. +permissions: + contents: read + +jobs: + windows-scope: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + files: ${{ steps.select.outputs.files }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: github.event_name == 'pull_request' + with: + fetch-depth: 2 + persist-credentials: false + - id: filter + name: Does the diff touch path, file-name or process code? + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + git diff --name-only --no-renames HEAD^1 HEAD > "$RUNNER_TEMP/changed-files" + code_re='\.(ts|tsx|js|mjs|cjs|sh|bash|ps1|cmd|bat)$|(^|/)bin/[^/.]+$|(^|/)setup$' + path_re='(^|/)bin/|\.(sh|bash|ps1|cmd|bat)$|(^|/)hosts/[^/]+/hooks/|(^|/)setup$|^bun\.lock$|^scripts/test-free-shards\.ts$|^scripts/lib/windows-curation\.ts$|^scripts/free-test-durations-windows\.json$|^\.github/workflows/windows-free-tests\.yml$' + name_re='(path|spawn|exec|process|shell|launch|win32|windows|tmp|temp|symlink|state-root|slug|claude-bin|bun-isolation|script-invocation)[^/]*$' + content_re='child_process|\b(spawn|spawnSync|execSync|execFile|execFileSync)[[:space:]]*\(|Bun\.(spawn|spawnSync|\$)|\bpath\.(win32|posix|sep|delimiter|relative|isAbsolute|normalize)\b|process\.(platform|execPath)|\bwin32\b|tmpdir\(|/dev/null|/tmp/|symlink|realpath|chmod|cygpath|USERPROFILE|homedir\(|fileURLToPath|\.exe\b' + { + grep -E "$path_re" "$RUNNER_TEMP/changed-files" | sed 's/^/path: /' || true + grep -E "$code_re" "$RUNNER_TEMP/changed-files" | grep -E "$name_re" | sed 's/^/name: /' || true + { grep -E "$code_re" "$RUNNER_TEMP/changed-files" || true; } | while IFS= read -r file; do + git diff -U0 --no-renames HEAD^1 HEAD -- "$file" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' | grep -m1 -E "$content_re" | awk -v f="$file" '{ print "content: " f ": " $0 }' || true + done + } > "$RUNNER_TEMP/windows-scope" + if [ -s "$RUNNER_TEMP/windows-scope" ]; then + echo 'windows=true' >> "$GITHUB_OUTPUT" + { echo 'This pull request touches path, file-name or process code:'; echo '~~~'; head -n 40 "$RUNNER_TEMP/windows-scope"; echo '~~~'; } >> "$GITHUB_STEP_SUMMARY" + else + echo 'windows=false' >> "$GITHUB_OUTPUT" + echo 'This pull request touches no path, file-name or process code, so no Windows job runs. windows-smoke still runs on every pull request.' >> "$GITHUB_STEP_SUMMARY" + fi + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.filter.outputs.windows == 'true' + with: + bun-version: 1.4.2 + - id: select + name: Pick the changed test files in the curated Windows-safe subset + if: steps.filter.outputs.windows == 'true' + run: | + set -euo pipefail + GITHUB_STEP_SUMMARY=/dev/null bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/curated.json" --shards 1 > /dev/null + jq -r '.shards[].files[]' "$RUNNER_TEMP/curated.json" | LC_ALL=C sort -u > "$RUNNER_TEMP/curated" + LC_ALL=C sort -u "$RUNNER_TEMP/changed-files" | LC_ALL=C comm -12 - "$RUNNER_TEMP/curated" > "$RUNNER_TEMP/selected" + if [ ! -s "$RUNNER_TEMP/selected" ]; then + echo 'No changed test file is in the curated Windows-safe subset, so no Windows job runs.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + { echo 'files<> "$GITHUB_OUTPUT" + { echo "The Windows job runs $(wc -l < "$RUNNER_TEMP/selected" | tr -d ' ') changed Windows-safe test file(s):"; sed 's/^/- /' "$RUNNER_TEMP/selected"; } >> "$GITHUB_STEP_SUMMARY" + + windows-scoped-shard: + needs: windows-scope + if: ${{ (github.event_name == 'workflow_dispatch' && inputs.files != '') || needs.windows-scope.outputs.files != '' }} + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24.18.0 + + # The same inputs as a windows-free-tests.yml shard, with no bun install + # cache (a cold install beats restoring it on windows-latest). + - name: Configure git identity (required by tests that init temp repos) + run: | + git config --global user.email "windows-ci@gstack.test" + git config --global user.name "Windows CI" + git config --global init.defaultBranch main + shell: bash + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build server-node.mjs (required by Windows browse path) + run: bash browse/scripts/build-node-server.sh + shell: bash + + - name: Generate host SKILL.md outputs (.agents, .factory) + run: bun run gen:skill-docs --host all + shell: bash + + - name: Install Chromium for the Node worker smoke + run: bunx playwright install chromium + + - name: Run the scoped Windows-safe test files + env: + FILES: ${{ inputs.files || needs.windows-scope.outputs.files }} + GSTACK_FREE_RETRY_FLAKY: '1' + GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl + TEMP: ${{ runner.temp }} + TMP: ${{ runner.temp }} + run: | + set -euo pipefail + printf '%s\n' "$FILES" | tr -d '\r' | tr ' ' '\n' | sed '/^$/d' > "$RUNNER_TEMP/windows-scoped-files.txt" + bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-scoped-files.txt" + shell: bash + + - name: Upload full test logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: windows-scoped-test-logs + path: | + .context/free-test-logs/gstack-free-test-*.log + ${{ runner.temp }}/gstack-free-test-*.log + include-hidden-files: true + if-no-files-found: ignore + + - name: Upload flake ledger + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: flake-ledger-windows-scoped + path: ${{ runner.temp }}/flake-ledger.jsonl + if-no-files-found: ignore + retention-days: 90 + + windows-scoped-tests: + needs: [windows-scope, windows-scoped-shard] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Pass when nothing is in scope, else require the Windows job + env: + SCOPE_RESULT: ${{ needs.windows-scope.result }} + SHARD_RESULT: ${{ needs.windows-scoped-shard.result }} + FILES: ${{ inputs.files || needs.windows-scope.outputs.files }} + run: | + set -eu + test "$SCOPE_RESULT" = success + if [ -z "$FILES" ]; then + echo 'Nothing in scope (no changed Windows-safe test file in a pull request that touches path, file-name or process code); passing without a Windows run.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + test "$SHARD_RESULT" = success diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 2ca74e7b4..37cee929a 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -32,6 +32,13 @@ name: Windows Setup E2E # Separate from windows-free-tests.yml because that one runs a curated # unit-test subset; this one exercises the install path itself. # +# Runs by path (ship-90-90 plan D): the paths below are the install files +# this workflow runs or ./setup sources or runs. The windows-scope job then +# drops a pull request whose only install change is package.json's version +# line, which is what the merge-train stamp commit puts on every PR head. +# windows-scope's list must match the paths list (pinned by +# test/windows-path-scope-workflow.test.ts). +# # Runner: GitHub-hosted free windows-latest, two parallel jobs. ~10-25 min. on: @@ -39,6 +46,7 @@ on: branches: [main] paths: - 'package.json' + - 'bun.lock' - 'scripts/build.sh' - 'scripts/build-cso.sh' - 'scripts/build-cso-windows.ps1' @@ -64,6 +72,18 @@ on: - 'bin/gstack-claude-root.sh' - 'bin/gstack-browser-ensure' - 'bin/gstack-path-spelling.sh' + - 'bin/gstack-config' + - 'bin/gstack-patch-names' + - 'bin/gstack-state-root.sh' + - 'bin/gstack-skill-name.sh' + - 'bin/gstack-install-registry.sh' + - 'bin/gstack-render-claude.sh' + - 'bin/gstack-host-renders.sh' + - 'bin/gstack-migrate-claude-code' + - 'bin/gstack-settings-hook' + - 'bin/gstack-relink' + - 'bin/gstack-harness' + - 'scripts/preflight-codex-overlap.ts' - 'lib/path-spelling.ts' - 'hosts/claude/hooks/**' - 'gstack-upgrade/**' @@ -81,7 +101,94 @@ permissions: contents: read jobs: + windows-scope: + # The paths filter above starts this workflow; this job decides whether + # the Windows jobs run. A workflow_dispatch run always runs them. + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + install: ${{ steps.scope.outputs.install }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: github.event_name == 'pull_request' + with: + fetch-depth: 2 + persist-credentials: false + - id: scope + name: Run unless the only install change is package.json's version line + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo 'install=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + pkg_version_only=true + if git diff -U0 --no-renames HEAD^1 HEAD -- package.json | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' \ + | grep -vE '^[+-] "version": "[^"]*",?$' > /dev/null; then + pkg_version_only=false + fi + git diff --name-only --no-renames HEAD^1 HEAD | while IFS= read -r file; do + if [ "$file" = package.json ] && [ "$pkg_version_only" = true ]; then continue; fi + while IFS= read -r glob; do + # shellcheck disable=SC2254 + case "$file" in ${glob//\*\*/*}) echo "$file"; break ;; esac + done <<'PATHS' + package.json + bun.lock + scripts/build.sh + scripts/build-cso.sh + scripts/build-cso-windows.ps1 + scripts/write-version-files.sh + setup + setup.ps1 + test/setup-ps1.test.ts + docs/troubleshooting.md + browse/src/cli.ts + browse/src/find-browse.ts + browse/src/server.ts + browse/src/meta-commands.ts + browse/scripts/build-node-server.sh + design/src/cli.ts + make-pdf/src/cli.ts + bin/gstack-global-discover.ts + bin/gstack-paths + bin/gstack-doctor + bin/gstack-launch-probe.sh + bin/gstack-hook-check + bin/gstack-session-update + bin/gstack-ff-update + bin/gstack-claude-root.sh + bin/gstack-browser-ensure + bin/gstack-path-spelling.sh + bin/gstack-config + bin/gstack-patch-names + bin/gstack-state-root.sh + bin/gstack-skill-name.sh + bin/gstack-install-registry.sh + bin/gstack-render-claude.sh + bin/gstack-host-renders.sh + bin/gstack-migrate-claude-code + bin/gstack-settings-hook + bin/gstack-relink + bin/gstack-harness + scripts/preflight-codex-overlap.ts + lib/path-spelling.ts + hosts/claude/hooks/** + gstack-upgrade/** + .github/workflows/windows-setup-e2e.yml + PATHS + done > "$RUNNER_TEMP/install-files" + if [ -s "$RUNNER_TEMP/install-files" ]; then + echo 'install=true' >> "$GITHUB_OUTPUT" + { echo 'Install files changed, so the Windows jobs run:'; sed 's/^/- /' "$RUNNER_TEMP/install-files"; } >> "$GITHUB_STEP_SUMMARY" + else + echo 'install=false' >> "$GITHUB_OUTPUT" + echo "The only install change is package.json's version line (the merge-train stamp), so the Windows jobs do not run." >> "$GITHUB_STEP_SUMMARY" + fi + windows-setup: + needs: windows-scope + if: needs.windows-scope.outputs.install == 'true' runs-on: windows-latest timeout-minutes: 35 @@ -377,6 +484,8 @@ jobs: # docs/troubleshooting.md, against the runner's real ~/.claude install # path ($HOME in PowerShell). GSTACK_E2E_POWERSHELL and # GSTACK_E2E_UPDATER_PATH_PREFIX exist only to run these steps off Windows. + needs: windows-scope + if: needs.windows-scope.outputs.install == 'true' runs-on: windows-latest timeout-minutes: 50 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7fd0818..6f411c10c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,173 @@ # Changelog +## [1.94.24.0] - 2026-11-03 + +### Fixed + +- **The OpenClaw and Hermes start hook no longer breaks a fresh harness.** When the harness has no config yet, gstack runs none of its commands: `openclaw config set` and `hermes plugins enable` would have created a config holding only gstack's plugin setting, with no model, provider or terminal settings. The hook reports `written` with the setup to run first (`openclaw onboard`, `hermes setup`), and the next `./setup` or `gstack-harness refresh` turns it on, changing only the plugin entry. Turning the hook off or unregistering in such a profile also leaves the config alone. If v1.92.23.0 or a later release before this fix already created a config holding only the plugin entry, move it aside, run the harness's own setup, then `gstack-harness refresh`. +- **`OPENCLAW_CONFIG_PATH` naming the profile's own `openclaw.json` keeps the start hook on.** gstack compares the path after resolving symlinks, so only a config outside the profile turns the hook off. When `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` asked for the hook and it ends up off, `gstack-harness register` and `./setup --host` now exit 4 with the reason and the fix instead of exiting 0. +- **`gstack-harness status` reads the hook from the harness's config when the harness CLI is not on `PATH`**, as inside Hermes's or OpenClaw's own terminal tool. Hermes's `config.yaml` settles it (`active`, `disabled` or `written`); otherwise status says `unknown` instead of a wrong `written`. + +## [1.94.23.0] - 2026-11-03 + +**Two `gstack-pregate` preflight false positives fixed (GST-1211).** Both stopped /ship on real work in gbrain-evals. + +### Fixed +- **`strays`:** the scratch-name check matched three prefixes the docs never listed (`temp`, `debug-` and `probe`), so real files like `probe.py` and `temporal-graph-shipped.test.ts` failed preflight. It now matches only the documented names: `zz-*`, `tmp*`, `scratch*`, plus editor leftovers (`*.orig`, `*.rej`, `*.swp`, `*.swo`, `*.bak`, `*.tmp`, `*~`, `.DS_Store`, `Thumbs.db`), which docs/pregate.md and docs/troubleshooting.md now list too. +- **`literals`:** a named literal the branch only adds, such as `limit: 300` in a new object, was read as an existing `limit: 10` elsewhere in the file changing to 300, and every test that mentioned 10 was flagged. The check now compares every value under a name, so an added literal isn't a change. Editing an existing value still fails, and so do two cases the old last-value check could miss: changing one of two equal values under the same name, and deleting one site while editing another. + +### Tests +- `test/pregate-selection.test.ts`: `probe.py` and `temporal-*.test.ts` pass `strays` while `zz-notes.md`, `tmp-x.ts`, `scratch.py` and `util.js.orig` still fail; a new object literal beside `limit: 10` passes `literals` while editing that value still fails; the pure `changedLiterals` case keeps an exported constant change. + +## [1.94.22.0] - 2026-11-03 + +**/investigate, /review, /qa and /verify now say when to use them, and /investigate and /review declare what "done" means (GST-1088 lane B).** Untold, Opus 5.5 never opened /investigate in 252 trials (Run D, gstack-evals#140). A when-to-use description raised GPT-6.1 Sol's open rate from 9.5% to 27.0% in the same run. Claude Code's catalog shows only a description's first sentence, so each of the four now opens with a "Use when ..." sentence. The routing hook and the contract check that read the new frontmatter landed in v1.94.6.0 (lane A, off by default; see docs/skill-routing-and-contracts.md), so the "Done means" list now also names `gstack-skill-contract check` and `waive`. + +### Changed +- **Descriptions**: the first sentence of each is now a when-clause, for example /investigate's "Use when a test fails, code errors or output is wrong: find the root cause, then fix it." Hosts that show the full description also get the longer list of situations. The catalog stays within its existing budget. +- **Triggers**: /investigate, /review and /verify list the prompts people actually type, such as "test fails", "look over my diff" and "make it green". /qa keeps its three triggers because its size cap had 9 bytes of room. + +### Added +- **`contract:` frontmatter** (frozen v1 schema) on /investigate (the DEBUG REPORT labels, and a test or repro run after the last edit) and /review (one Agent dispatch per selected specialist, the adversarial pass, the Pre-Landing Review report). `{{CONTRACT_DONE_MEANS}}` (`scripts/resolvers/contract-done-means.ts`) renders each block as a short "Done means" list at the top of the skill, so the list and the block can't drift. + +### Tests +- `test/skill-descriptions.test.ts`: each of the four opens with a when-clause within the catalog, lead and host caps; triggers are short and don't overlap; ten sample prompts match exactly the intended skill; every template's contract block passes the v1 schema and names markers the skill text actually prints; the renderer covers each item kind; and malformed blocks fail with the file and the fix. + +## [1.94.21.0] - 2026-11-03 + +- **A stacked PR's /ship receipt now carries even when the child merged main before its receipt was written (GST-1214).** After the parent squash-landed and main was merged in again, `gstack-ship-receipt carry` compared the range from the parent's head, which still held every file the child's first main merge brought in, and refused with `RECEIPT_CARRY_PRODUCT_CHANGED` naming those files. The carry now leaves out paths whose content at the reviewed head is main's, unless HEAD changed them. A real edit to one of those files still refuses. + +## [1.94.20.0] - 2026-11-03 + +### Changed + +- **The review pins and the harness pins are one file per pin, so PRs that repin different files no longer conflict (GST-1084).** The reviewer-brief pins that lived in one table in `test/review-packets-removed.test.ts` now live under `test/fixtures/review-pins/`: `files/.json` holds a file's sha and the replay that admits it, `replays/.json` holds one replay's written no-loss justification, and `brief-bodies/.json` holds one brief body's sha. The harness pins in `scripts/harness-version.json` moved to `scripts/harness-pins/.json`; the manifest keeps only the version and the patterns, so `HARNESS_VERSION` and its readers are unchanged. Before, two PRs that repinned adjacent entries conflicted on the shared table (at least five main-merge conflicts on 2026-11-02, two of them on #3852). Every entry is ported unchanged, including the combined GST-508 entries from #3852. + +### For contributors + +- `/ship`'s post-review route still counts a non-behavioral harness re-pin as release metadata (GST-1031): it now reads the pin files under `scripts/harness-pins/` and refuses a manifest that regains a `files` table. +- The checks are at least as strict as before. Every pin still needs its sha and a replay whose written justification names a no-loss replay or re-score, and the brief-body check still guards the bodies. A malformed or misnamed pin file, a stray file in a pin directory, or a `files` block back in `scripts/harness-version.json` now fails too. New tests prove that a stale pin still fails, and that two branches repinning different, adjacent pins merge cleanly with a real `git merge-tree`, while two repins of one pin still conflict. +- `bun run scripts/bump-harness-version.ts` writes and deletes pin files itself. Repin a review file by editing its `files/` pin and adding the replay file it names. + +## [1.94.19.0] - 2026-11-03 + +### Fixed + +- **A finished /ship no longer blocks `gstack-upgrade` for two hours (GST-1205).** Each `gstack-skill-start` writes a workflow lease that `gstack-upgrade` checks before moving a shared checkout. Nothing released it when the workflow ended, and on Capy every session's lease names the machine's one `capy-server`. So after a /ship finished, its own lease kept refusing the upgrade with UPGRADE_BUSY until it was 120 minutes old, unless you set `GSTACK_UPGRADE_FORCE=1`. Now `gstack-skill-start` records its `SESSION_ID` as an open run on the lease, and `gstack-skill-end` closes it with the new `gstack-lease release`. Once every run on a lease is closed, the lease is idle and blocks nothing (`gstack-lease list` shows `idle`). A run another session still has open keeps blocking. So does a holder that records no run (an older gstack, or `gstack-lease touch` without `--run`, which now leaves a `hold` entry that no release removes). Each run counts for 120 minutes from its own start, so one that never reaches `gstack-skill-end` stops blocking on time even while other sessions keep starting. A nested skill's own run covers it while it runs, but other sessions' starts no longer extend a run past its 120 minutes. + +## [1.94.18.0] - 2026-11-03 + +**The Windows-safe test subset is green again on current main.** `test/qa-report-dir.test.ts` expected `ENOTDIR` when `.gstack` is a file, but Windows reports `ENOENT` for the same mistake, so the test failed on every Windows run (windows-free-tests run 41607809007 at f6d6215). The test now accepts either code on Windows and still requires `ENOTDIR` everywhere else. With Windows checks now running by path, a red subset test would block any pull request that touches it. + +## [1.94.17.0] - 2026-11-03 + +- A pregate guard whose exact command already passed at this tree no longer runs again (GST-1186). When the base branch's `ship_lanes_from` maps the command, it is carried from the verified CI run through the same `gstack-evidence record-ci` path /ship lanes use (`GUARD: carried runs=…`); otherwise a counted local ledger pass of that command at the same working tree stands (CI rows count only through a live `record-ci` check) (`GUARD: reused …`, and the new read-only `gstack-evidence passed -- `). Repos whose suites are guards, like founderstar, stop rerunning about six minutes of suites per /ship. See docs/pregate.md#guard-carry. +- Known limit: `ship_lanes_from` is still read from `origin/` only, so the PR that adds a mapping cannot carry from it. +- A `ship_lanes_from` entry can name the CI jobs that run a lane instead of an inventory command: `{ "profiles": [...], "jobs": ["verify", "typecheck"] }`, exactly one of the two (GST-1189). It carries only when every named job succeeded in the newest attempt of the verified run at HEAD's tree, through the same `verifyRun` job check. A missing job refuses as `job_missing` and a skipped one as the new `job_skipped`. Lanes with no test files, like gbrain's verify and typecheck (run 41564913688) or harness-evals' `bunx tsc --noEmit`, can now carry, and they record `tests_ran=n/a`. Fake `check:` entries in `tree.json` can move to this form. A repo declaring `jobs` should raise its `min_gstack_version` to this release first, since older gstack rejects the key. See docs/pregate.md#ship-lanes-jobs. + +- A `ship_lanes_from` entry can declare `aliases`, other exact spellings of the same lane such as `env -u ANTHROPIC_API_KEY bun test tests/` (GST-1190). gstack never strips env prefixes on its own, since an `env -i` run is a different environment. +- A carried lane no longer reports the whole run's test count. An inventory lane reports how many of its files the run covered, labeled as a file count (alphaclaw's 46-test guards lane recorded 9677 before). +- `gstack-ship-preflight --tests-only --policy-from head --simulate` proves a new mapping before it lands: every line starts `SIMULATION:`, no lane runs, no evidence row is written and nothing clears. An inventory-mapped lane runs the branch's inventory command, so the dry run needs `--allow-repo-commands`. A branch's own map never carries, so the first pull request that adopts a mapping pays its full lanes. +- `gstack-evidence carried`, which the ship receipt reads, lists only rows the receipt base's policy mapped, so a run against another base never lands in a receipt. +- An unknown `.gstack/pregate.json` key under a `min_gstack_version` above the running gstack now says `needs gstack >= ; upgrade` (`PREGATE_GSTACK_TOO_OLD`). It still refuses. +- docs/pregate.md#prove-a-mapping documents the proof route, the version floors, and that `tree.json` writers using Bun's JUnit must take the root `testsuite` count. + +- A test file whose every test skipped on the CI runner no longer counts as carried (GST-1190, harness-evals: 75 tests). A `tree.json` that lists such files in `skipped_files` makes the pregate tests stage run them here. A /ship lane or guard whose inventory names one carries the covered files and runs only the skipped ones here, through the `per_file` runner its `ship_lanes_from` entry declares. The CI row is written only after that run passes with a test count above zero and the carried runs still stand (`skipped_failed`, `skipped_zero_run` or `run_changed` otherwise), and an entry without `per_file` refuses as `inventory_skipped`, naming the files. A partly skipped file still counts. An older `tree.json` without the list is read as before. gstack itself changes nothing yet: its own writer and a `per_file` entry for its `bun run test` lane switch on together in a follow-up pull request, after the pull requests now queued on older gstack have landed, because an older gstack rejects the new key in main's policy. + +- A run profile can declare `skippable: { jobs, gate, scripts }` for jobs a path filter skips (GST-1190, truther #1169). A skipped job counts only in a `pull_request` run whose gate job succeeded, with the workflow, the declared `scripts` (every file the gate reads), the paths the gate job names (also under its `working-directory`) and `package.json` when it runs a package script matching `origin/` byte for byte; the lane then prints `not owed: CI skipped at this tree (gate job , workflow unchanged)`. If the branch changed any of them, it controlled the skip, so the lane runs here. +- `gstack-ci-carry find --policy-from head --simulate` is the same dry run for one lane command, every line starting `SIMULATION:`. + +### Tests +- `test/ship-ci-carry.test.ts`: in founderstar's shape (guards only, no lanes), `check-quick` carries from CI, `play-through` reuses a same-tree ledger pass, `assets` runs, and a dirty tree carries nothing. It fails without the guard carry. +- `test/ship-ci-carry.test.ts`: a lane mapped by `jobs` carries when every named job passed at the same tree with no test files, and refuses for a missing job, a skipped job, a rerun whose newest attempt skipped the job, and another tree's run. Those five fail without the change. +- `test/ship-ci-carry.test.ts` (GST-1190): an alias carries and an undeclared env prefix does not; a narrow lane reports its covered file count, not 9677; a jobs-mapped `bunx tsc --noEmit` guard carries with `n/a`; the newer-gstack message; `--simulate` writes and clears nothing and the branch's own map never carries; `carried` drops a row another base mapped. Six of these fail without the change. +- `test/ship-ci-carry.test.ts` (GST-1190 skipped files): a fully skipped inventory file stops the lane carry, an empty list or older `tree.json` carries, and the tests stage runs only the skipped selected file. Both fail without the change. +- `test/ship-ci-carry.test.ts` (path-skipped jobs and `find --simulate`): a skip under an unchanged, green gate is not owed; a changed workflow, a changed gate script or a failed gate job runs the lane here; `find --policy-from head --simulate` prints only SIMULATION lines and writes nothing. All five fail without the change. +- `test/ship-ci-carry.test.ts` (review fixes): a gate script behind `${{ github.workspace }}`, under `working-directory` or behind a package script is compared; the gate is the job, not a same-named step; only a `pull_request` run's skip counts; a skippable profile must declare `scripts`; `passed` never counts a CI row; a path-skipped guard says not owed; `--simulate` runs a branch inventory only with `--allow-repo-commands`. Eight fail without the fixes; a newer-failure veto test covers existing behavior. +- `test/ship-ci-carry.test.ts` (per-file lane carry): the covered file carries and only the skipped file runs through `per_file`, a failing or zero-test remainder, or a run that moved to a new attempt meanwhile, records no CI row and runs the lane whole, and `per_file` needs `{files}` and an inventory entry. All five fail without the change. + +## [1.94.16.0] - 2026-11-03 + +**A declared placeholder restamped to the new version is a stamp, and a carried receipt names the head's version (GST-1026, GST-1030).** gbrain writes a PR's own BEHAVIOR_CHANGES row as `since: 'TBD'` and its `release:restamp` fills in the version. Finish refused that receipt ("behavior, tests or build inputs changed since review"), and the receipt carry and the tree receipt would have refused the same row (gbrain #8444). Separately, a receipt carried after a main merge and a restamp kept the version of the head it was written at (GGST-1). + +### Added +- **`stamp_placeholders` policy key** (default `[]`, so nothing changes for a repo that does not set it): literal placeholders such as `["TBD"]`, at most 16, one line and 32 characters each, never `{version}` and never a version. It needs `min_gstack_version` at the release that introduced it. + +### Fixed +- **`lib/stamp-only.ts`:** `stampOnlyLine` takes the policy's placeholders. On a line the branch added itself (named by its position), a placeholder inside a `stamp_patterns` template may become that template at the new version, alongside any stamp-form version move on the same line. The built-in forms, a bare `{version}` template, base lines (including one whose text duplicates a branch line) and undeclared placeholders are still refused. Finish's `stampedFiles`, the receipt carry and the tree receipt share it. +- **`bin/gstack-ship-receipt` carry:** the carried receipt's `version` is HEAD's, read the way `write` reads it, instead of the version copied from the old receipt. A head with no version source keeps the version `write --version` recorded. +- **`bin/gstack-ship-receipt` carry, pin-bump PRs:** a `package.json` the branch changed as product (a dependency pin) that both main and a restamp moved since review is compared with only its `version` field masked, at all four sides. The receipt carries when nothing else in the file moved, and still refuses when the pin or any other field changed (GBEV-3, gbrain-evals #306). + +### Tests +- `test/tree-receipt-stamp-sensitive.test.ts`, `test/gstack-ship-receipt-docs.test.ts`: the shared predicate and the carry classifier accept gbrain's `since: 'TBD'` → `since: '0.60.553.0'` row and refuse an undeclared placeholder, a base row, a duplicated base copy, a built-in form and a line no template matches. +- `test/ship-rereview-triggers.test.ts`: finish routes the declared row as metadata and stops on the same controls. +- `test/restamp-mentions.test.ts`: the tree receipt and the carry accept the declared row and refuse it undeclared or on a base row; the carried receipt records the head's version after a main merge and a restamp; a version-only restamp of an EVIDENCE.md declared in `stamp_files` carries, and an undeclared one stays product; a pin-bump PR's receipt carries after a main merge and a restamp, and refuses when the pin moved since review. +- `test/gstack-ship-policy.test.ts`: the key's default, floor and validation. + +The EVIDENCE.md refusal (GST-1030's second point) reproduces on main and is already fixed by the GST-980 own-row carry this branch builds on, so this change adds only the test for it. + +## [1.94.15.0] - 2026-11-03 + +- **A stacked PR keeps its /ship receipt after its parent squash-merges, even when the receipt was written against main (GST-1199).** `gstack-ship-receipt carry` used to refuse with `RECEIPT_CARRY_PRODUCT_CHANGED` once the parent's hunks moved into main, forcing a full re-review of unchanged code. The carry now finds the newest reviewed commit that main already holds (`git merge-tree` into main changes no product path) and compares from there: an unchanged child carries as `stack parent landed`, base edits next to the child's hunks go to `RECEIPT_CARRY_SCOPED_REVIEW`, and any other change still refuses. A parent that never landed never counts, even if the child's merge dropped its change. + +## [1.94.14.0] - 2026-11-03 + +**`review_exclude_paths` keeps code it used to exclude, and the docs say exactly what the receipt check stops (GST-972).** Three ways to get code or a credential out of review under a base pattern are closed, each with a failing-first test in `test/review-exclude.test.ts`, and the receipt docs no longer over-claim. + +### Fixed +- **Native binaries are code.** A `.node`, `.so`, `.dylib` or `.dll` file under a base pattern was excluded as binary content, and so was a compiled executable with a data extension. A binary file that starts with ELF, Mach-O (universal binaries included), PE, WebAssembly or `#!` magic is now kept as `code` whatever its extension. Binary here is git's own verdict on the bytes, read with no attributes from the tree as well as with the branch's, so a branch's `.gitattributes` (`*.png diff -binary`) cannot make an ELF read as text and skip the check (#3910). +- **A NUL byte no longer makes code excludable.** A binary file was content unless its extension was on a short source list, so `.gradle`, `.sql`, `.tf`, `.groovy`, a `.p12` key store or an extensionless private key with a NUL byte was excluded. A binary file is now content only under a known content extension (images, fonts, media, documents, compressed archives, `.bin`, `.dat` and data blobs); every other binary file is kept as `code`. +- **A branch's `.gitattributes` cannot get a file with a credential line excluded.** Marking `receipts/**` binary left the collector's added lines empty, so the secret-line keep rule saw nothing and the file was excluded. For a file git calls binary only because of its attributes, the rule now reads `git diff --text --no-textconv --no-ext-diff`, so the file stays in review and the plan lists it as `REVIEW_EXCLUDE_KEPT: secret-line`. Readers' own diffs still follow the branch's attributes and show that file as `Binary files differ`; reading such files as text in every reader's diff is a separate follow-up. +- **`gstack-ship-receipt write --review-exclude` docs are exact.** The help, `docs/troubleshooting.md`, `docs/ship-policy.md` and the queue's refusal line said a receipt write alone cannot clear `field=review_exclude`. Write checks the digest against the base policy and does not read the review ledger, so a write given the refusal's `expected=` digest passes without a review. A mismatch proves only that the `--review-exclude` argument (or the digest the receipt recorded from it) differs from the head's selection, not what the readers read. The text now says that, and that the review rerun is the fix. + +## [1.94.13.0] - 2026-11-03 + +- **A bare `--base main` reads origin/main in every review and ship bin (GST-1036).** `gstack-review-plan` now prints `REVIEW_BASE: ` and names a lagging local base on stderr. `gstack-banned-terms`, `gstack-prose-lint --base`, `gstack-docs-candidate`, `gstack-design-detect.ts scan --changed` and `gstack-pr-shots --base` no longer merge against a stale local main and report lines the branch only inherited. They all use the one resolver gstack-gate-diff already used. +- **`gstack-diff-scope` stops treating authorship files as auth (GST-1065).** `author-spec.md`, `AUTHORS`, `authority.ts` and `coauthors.ts` no longer raise a docs diff to gate class. Every other name with auth in it still does, including `auth.ts`, `/auth/`, `oauth`, `nextauthmiddleware.ts` and camelCase names like `useAuth.ts`. + +## [1.94.12.0] - 2026-11-03 + +### Fixed + +- **Six markup tricks no longer pass a remote-loading or scripted SVG off as a chart (GST-940).** `</style>` or `<!--` inside a `` let CSS that loads a remote image read as a plain drawing, and a non-ASCII namespace prefix such as `` hid a script. Any of these could get the SVG a light review. The style walker now reads the ``, comment that only an HTML parser ends early, quoted `>` or Unicode space can hide CSS. A non-ASCII character in an element or attribute name, such as a `gé:` prefix, keeps the SVG code too (GST-940); anything else, a script or `foreignObject` included, keeps it code. More than 2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed` and fails the depth closed. The scan first lists the files that contain a candidate's @@ -384,7 +387,7 @@ and matching is token-bounded: `costume` never reads as cost. |---|---| | money | a path segment naming cost, spend, budget, ledger, billing, price, fee or invoice; or a changed line with an identifier ending in `_usd` or `Usd`, any `spend_cap` spelling, or a call to a paid provider `lib/pricing.ts` prices | | gate | a hunk the gate scan tagged: a relaxed threshold, a skipped test, a loosened check | -| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name; a data file named for sessions (`recovered-sessions.jsonl`) outside a config location does not set `SCOPE_AUTH` | +| auth | `SCOPE_AUTH` from `gstack-diff-scope`, on a code path with an auth, session, jwt, permission or role token in its name; a data file named for sessions (`recovered-sessions.jsonl`) outside a config location does not set `SCOPE_AUTH`, and `SCOPE_AUTH` fails safe: any name token containing auth sets it (`auth.ts`, `/auth/`, `oauth`, `useAuth.ts`, `nextauthmiddleware.ts`) except the authorship words `author`, `authors`, `authored`, `authoring`, `authorship`, `authority`, `authorities`, `coauthor` and `coauthors` | | secret | a secrets, credentials, keychain or token path, or a changed line that reads a key-named environment variable (`OPENAI_API_KEY`, `GH_TOKEN`) or a credential store | An input the classifier cannot read (the diff, `gstack-diff-scope`, the gate @@ -493,6 +496,21 @@ through the docs stage. A path outside that scope and a gate-edited test still route as behavior, with `--reviewed-base` (as /ship passes it) so does a file too large for the scoped review, and without the record nothing changes. +A completed fix-check is a review, not an owner acceptance (GST-1130). Finish +reads the newest `review-fix-check` row from `gstack-review-read`. When it is a +completed clean read on committed content (freshness `CURRENT` against its own +tree) whose commit is on the branch, whose `range_base` is a branch commit +holding the reviewed tree, and for which an approved `review_loop_grant` in +`gstack-approvals` names a stop head inside `range_base..commit`, each code or test path inside `range_base..commit` +that is unchanged since routes as `fix_checked=` in `ROUTE:` and is listed on a +`FIX_CHECK: reviewed` line, a gate-edited test included. Authored docs keep the +stage 2.2 recheck, and a path edited after the fix-check still routes by the +rules above. Any other row prints +`FIX_CHECK: not counted ()` and changes nothing. `gstack-review-log` +refuses a `review-fix-check` row without boolean `completed` and `converged`, +before it consumes the start token, because without them the row reads +`UNVERIFIED` and finish cannot count it. + The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A receipt carried across a base merge or a test-only fix diff --git a/docs/ship-policy.md b/docs/ship-policy.md index a6fcabd18..f611346eb 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -53,12 +53,13 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. | | `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. | | `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, ``); matched literally at a version boundary, never as a regex. The restamp writer, its mention scanner and the tree receipt use them beside the built-in forms. | +| `stamp_placeholders` | literal placeholders | `[]` | At most 16 one-line literals of at most 32 characters, never `{version}` and never a version (`["TBD"]`). On a line the branch added itself, a placeholder inside a `stamp_patterns` template (`since: 'TBD'` under `since: '{version}'`) may become that template at the new version, and finish's route, the receipt carry and the tree receipt read the change as a stamp. The built-in forms never take a placeholder, and neither does a base line, even one whose text duplicates a branch line. The writer does not fill placeholders: the repo's `restamp_command` does. A newer key: set `min_gstack_version` to the release `gstack-ship-policy validate` names. Empty accepts no placeholder. | | `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). | | `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. | | `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. | | `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log ` from its run on the gated head. Needs `min_gstack_version`. | | `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. | -| `review_exclude_paths` | git glob pathspecs | `[]` | Content files (data, docs, binary, `.log`) `/ship`'s reviewers leave out; read from the base only, with code, gate, config, executable and named-by-code files kept. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. | +| `review_exclude_paths` | git glob pathspecs | `[]` | Content files `/ship`'s reviewers leave out: data, docs, `.log`, and binaries under a content extension (images, fonts, archives and the like) that do not start with executable magic. Read from the base only. Code (native libraries and every other binary outside the content extensions included), gate paths, agent prose, config, executables, links, `.gstack/` and `.github/` files, gate-tagged, secret-line and named-by-code files stay in review. See [review_exclude_paths](#review-exclude-paths). Needs `min_gstack_version`. | | `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | | `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. | | `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. | @@ -1287,8 +1288,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 +1301,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 +1352,15 @@ paths and three hashes). The `digest` hashes the declared patterns and the excluded and kept lists; `source` is provenance only. Write, carry and the queue each recompute the digest at the current base and compare it, so a base merge or a moved base that leaves the patterns and the lists unchanged still carries -and binds. A different digest means the reviewers read another selection: +and binds. A different digest means the digest write was given (or the receipt recorded) names another selection than the head has now: write refuses with `RECEIPT_INVALID field=review_exclude`, carry with `RECEIPT_CARRY_REFUSED review_exclude changed`, and the queue with `RECEIPT_INVALID field=review_exclude expected= recorded=`. The -fix is to rerun the review, never a receipt write alone. +fix is to rerun the review and write with the digest its `verify` prints. Write +checks the digest against the base policy, so a stale selection cannot be +recorded by accident, but it does not read the review ledger: a write given the +`expected=` digest passes without a review, and only the review run shows that +the reviewers read that selection. **Adopting it.** (1) Open a PR on the base that adds the patterns and `min_gstack_version` at least `REVIEW_EXCLUDE_KEY_VERSION`, and ship it like any @@ -1503,6 +1508,7 @@ receipt when the base moves: | a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | | any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | | a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) | +| a stacked PR whose receipt was written against the base, after its parent landed and the base was merged in, HEAD's product diff exactly the reviewed range from the parent's head | carries (reason `stack parent landed`); base context next to the child's hunks is `RECEIPT_CARRY_SCOPED_REVIEW` | | a stamp on the branch's own rows in a stamp file the branch also changed as product: every changed line at a branch-added position, the file's mode unchanged, the base not touching it, the rest of the product diff unchanged | carries (reason `a stamp on the branch's own rows ()`) | **Stacked PRs (#3570).** A child PR's receipt is written against its parent @@ -1520,6 +1526,24 @@ since, or a parent that landed with changes the child never merged all leave a different diff and refuse with `RECEIPT_CARRY_PRODUCT_CHANGED`. An older receipt whose parent branch is gone refuses with `RECEIPT_CARRY_REFUSED`. +**Stacked PRs reviewed against the base (GST-1199).** A child PR that targets +the base while stacked has a receipt whose `base` is the base and whose +reviewed range holds the parent's hunks too, and the receipt records no stack +parent. Once the parent squash-merges and the base is merged into the child, +the two heads' product diffs differ by exactly the parent's hunks, which now +sit in the base. When they differ, the carry looks for the newest commit inside +the reviewed range (the reviewed head excluded, at most 100 commits) whose +product change the current base already holds: `git merge-tree` of that commit +into the base changes no product path. A conflicted product path never counts, +so a parent that never landed is never treated as one, even when the child's +merge dropped its change. The comparison then runs from that commit, leaving out +every path whose content at the reviewed head is the base's (a base merge the +child made before its receipt brought it in) unless HEAD changed it against the +base (GST-1214): an equal +patch-id carries with reason `stack parent landed`, base context next +to the child's unchanged hunks is `RECEIPT_CARRY_SCOPED_REVIEW`, and any other +difference refuses with `RECEIPT_CARRY_PRODUCT_CHANGED`. + Since the release that added `review_scope`. A `/ship` receipt names the head it reviewed, so a conflict-only merge of the base or a test-only fix used to invalidate it and cost a full re-run on identical product code. diff --git a/docs/support-tiers.md b/docs/support-tiers.md index 05b01b4aa..b69b507a9 100644 --- a/docs/support-tiers.md +++ b/docs/support-tiers.md @@ -18,7 +18,7 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native ## What we stop doing - No Windows-only or Gemini-only features, parity work, tuning or polish. -- No new Windows or Gemini CI jobs or eval cells. Windows CI tuning stops. +- No new Windows or Gemini CI jobs or eval cells, apart from the path-scoped Windows checks under Windows CI below (ship-90-90 plan D). Other Windows CI tuning stops. - A Windows-only or Gemini-only report that doesn't break install, startup or a core workflow closes as won't fix ([triage rules](triage.md#closing-an-issue)), with a link to this page. A report that does break one of them gets a small fix. ## Windows CI @@ -26,6 +26,7 @@ That is the whole promise. Other skills, optional parts such as `/cso`'s native - `windows-smoke` is the required Windows check on pull requests. - The six-shard Windows free suite (the `Windows Free Tests` workflow, `windows-free-tests`) runs nightly on main, or when someone starts it by hand, and never blocks a pull request. - A red nightly run gets a fix only when it shows an install, startup or core-workflow break. +- Windows checks run by path (ship-90-90 plan D, approved 2026-11-03): `Windows Scoped Tests` runs a pull request's changed Windows-safe test files on one Windows job only when it touches path, file-name or process code (a red run blocks that pull request, like any failed check), and `Windows Setup E2E` runs only when install files change (a version-only `package.json` change doesn't count). ## What doesn't change diff --git a/docs/test-audit-2026-10.md b/docs/test-audit-2026-10.md index f1a4ba51e..fe8825438 100644 --- a/docs/test-audit-2026-10.md +++ b/docs/test-audit-2026-10.md @@ -50,14 +50,14 @@ per-failure-class breakdown from `test:health`. full consumed-input fingerprint (all touchfiles, globals included) as `series_fingerprint`, plus `harness_version`, as provenance. (`caseSeriesIdentitiesV2` in `scripts/eval-trial-series.ts`.) -- **Harness guard.** `scripts/harness-version.json` pins the blob of every - shared execution-harness file: `GLOBAL_TOUCHFILES` plus the session runners, - the PTY harness, the e2e gate and the LLM judge (36 files). A free test +- **Harness guard.** `scripts/harness-pins/.json` pins the blob of every + shared execution-harness file (`scripts/harness-version.json` keeps the version and patterns): `GLOBAL_TOUCHFILES` plus the session runners, + the PTY harness, the e2e gate and the LLM judge (42 files). A free test (`test/harness-version.test.ts`) fails when one changes without a decision. `bun run scripts/bump-harness-version.ts --bump ""` raises `HARNESS_VERSION` and starts a new series for every case; `--non-behavioral ""` records the new blobs as compatible with no - reset. Entries are one line per file so concurrent PRs that touch different + reset. Each pin is its own file so concurrent PRs that touch different files merge cleanly; on a conflict, take the higher version, then rerun. - **Weekly history (W4b).** Weeks for quarantine expiry and drift are `evals-periodic.yml` runs on `main`: scheduled runs and main dispatches. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7ae72bf86..c7854d943 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1705,10 +1705,12 @@ session holds a workflow lease on this installation: pid

(m /bin/gstack-upgrade once the lease is 120 minutes old, or GSTACK_UPGRADE_FORCE=1 /bin/gstack-upgrade when no other session uses this checkout (UPGRADE_BUSY))` and leaves the -checkout where the run started. Each `gstack-skill-start` renews the lease. +checkout where the run started. Each `gstack-skill-start` renews the lease +and opens a run that its `gstack-skill-end` closes; a lease whose runs are all +closed is idle and never counts (GST-1205). **Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows -every lease with its age and `alive|dead|stale`; a holder is named as +every lease with its age and `alive|idle|dead|stale`; a holder is named as `pid

(m )`), then re-run `bin/gstack-upgrade`. For the session's own run, or a shared-harness lease, re-run once the lease is 120 minutes old. When you know no other session uses the checkout, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade` @@ -3340,10 +3342,13 @@ targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verif use this code when the receipt's review_exclude digest (or the `--review-exclude ` write was given) is not the digest HEAD has under the base policy's [`review_exclude_paths`](ship-policy.md#review-exclude-paths). The line -names `expected=` (now), `recorded=` (what the review read, `none` when it read -without exclusion) and `source=` (the base commit). The reviewers read another -selection than the one the head now has, so a receipt write alone cannot clear -it: rerun `/ship`'s review on the head (`gstack-review-dispatch pass --base `), +names `expected=` (now), `recorded=` (the `--review-exclude` argument, or the digest +the receipt recorded from it; `none` without one) and `source=` (the base commit). +A mismatch proves only that this argument differs from the selection the head has +now. Write compares the argument and does not read the review ledger, so the line +says nothing about what the readers read, and a write that passes the `expected=` digest clears +the field without a review. Do not do that. Rerun +`/ship`'s review on the head (`gstack-review-dispatch pass --base `), run `gstack-review-dispatch verify --ledger `, which prints `REVIEW_EXCLUDE_RECORD: digest=` from the briefs the readers got, then `gstack-ship-receipt write --pr --review-exclude --body `. @@ -3730,6 +3735,23 @@ the entry is reported as unverified, not `REGEN_STALE`. **Fix.** Run the printed install command (`bun install`, or the one matching the repository's lockfile), then rerun `gstack-regen check` or the pre-gate. + +### `PREGATE_GSTACK_TOO_OLD` + +**Meaning.** `.gstack/pregate.json` uses a key this gstack does not know, and +the same tree's `.gstack/ship-policy.json` sets `min_gstack_version` above this +gstack. The key belongs to a newer gstack (one a later release adds), so the +line reads `needs gstack >= ; upgrade`. The policy still refuses, +exactly as `PREGATE_CONFIG_INVALID` would. Only a gstack that has this check +prints it; one released before it (1.94.0.0 included) reports the same key as +`PREGATE_CONFIG_INVALID`. + +**Fix.** Upgrade gstack on this machine to at least the version named +(`bin/gstack-upgrade`, or the session install with `--for `), then +rerun. A repo adopting a new key raises `min_gstack_version` first, so an older +gstack that has this check prints this instead of a generic invalid-config +error. + ### `PREGATE_CONFIG_INVALID` @@ -3767,7 +3789,9 @@ anchored pattern and a reason ([docs/pregate.md](pregate.md#allowlist)); ### `PREGATE_STRAYS` **Meaning.** A file was added at the repository root, in a new top-level -directory, or under a scratch name (`zz-*`, `tmp*`, `scratch*`). +directory, or under a scratch name (`zz-*`, `tmp*`, `scratch*`) or an editor +leftover name (`*.orig`, `*.rej`, `*.swp`, `*.swo`, `*.bak`, `*.tmp`, `*~`, +`.DS_Store`, `Thumbs.db`). **Fix.** Delete or move it. An intentional root file goes in `strays.allow`. @@ -5981,7 +6005,10 @@ the reviewed tree but the base moved under it, so the diff a reviewer would see is not the reviewed one. For a stacked PR retargeted to the base after its parent landed, it means HEAD's product diff against the base is not the range reviewed against the parent branch: the parent has not fully landed, or the -branch changed since. The reviewed verdict does not cover HEAD, so the +branch changed since. A stacked PR whose receipt was written against the base +carries from the newest reviewed commit the base already holds +([ship-policy.md](ship-policy.md#receipt-carry)); it refuses here only when no +such commit exists or the branch changed since. The reviewed verdict does not cover HEAD, so the receipt is not carried. The refusal names the changed product paths, or for a retargeted stacked PR the parent branch and the base. @@ -6445,7 +6472,7 @@ There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `g ### `gstack-harness: has no start hook` / `the start hook goes into the profile

, and this registration uses another skills folder` (HARNESS_HOOK_UNSUPPORTED) -The [start hook](hosts.md#start-hook) exists for OpenClaw and Hermes only, and it goes into the harness profile's own plugin folder, the one the harness scans. A registration with `--skills-dir` somewhere else has no profile gstack can trust, and with `OPENCLAW_CONFIG_PATH` set OpenClaw reads a config outside the profile, where gstack's settings change would not land, so `--start-hook` is refused and nothing is written. Register without `--skills-dir` (set `OPENCLAW_STATE_DIR` or `HERMES_HOME` to pick the profile), or drop `--start-hook`. When the choice comes from `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` instead of the flag, such a registration just gets no hook, and its receipt says so. +The [start hook](hosts.md#start-hook) exists for OpenClaw and Hermes only, and it goes into the harness profile's own plugin folder, the one the harness scans. A registration with `--skills-dir` somewhere else has no profile gstack can trust, and with `OPENCLAW_CONFIG_PATH` naming another config OpenClaw reads settings outside the profile, where gstack's settings change would not land, so `--start-hook` is refused and nothing is written. `OPENCLAW_CONFIG_PATH` naming the profile's own `openclaw.json` (compared after resolving symlinks) is not refused. Register without `--skills-dir` (set `OPENCLAW_STATE_DIR` or `HERMES_HOME` to pick the profile), unset `OPENCLAW_CONFIG_PATH` or point it at the profile's own `openclaw.json`, or drop `--start-hook`. When the choice comes from `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` instead of the flag, the registration gets no hook, and `gstack-harness register` and `./setup --host` exit 4 with the reason (`HARNESS_HOOK_INACTIVE`, `start hook is off`); refreshes only print it. ### `gstack-harness: GSTACK_HARNESS_START_HOOK="" is not on or off` (HARNESS_HOOK_CHOICE_INVALID) @@ -6460,7 +6487,9 @@ Each harness profile has one start hook, owned by the gstack registration that w ### `gstack-harness: start hook is written: . Turn it on: ` (HARNESS_HOOK_INACTIVE, exit 4) -The skills are registered and the start hook's files are in place, but the hook is not active in the harness. gstack turns the hook on through the harness's own CLI and then reads it back, so this means the CLI was not on `PATH`, one of its commands failed or timed out (the message quotes its last output), or the harness's settings block the plugin: OpenClaw's `plugins.allow` set without `gstack-start`, `gstack-start` in `plugins.deny`, or `hooks.allowPromptInjection: false`. gstack never edits your allow or deny lists. Run the printed command, then `gstack-harness status`; `--start-hook` again retries the whole step. `./setup --host` exits 4 for the same reason. Refreshes and upgrades retry on their own without failing, and stop once you disable the plugin in the harness. +The skills are registered and the start hook's files are in place, but the hook is not active in the harness. gstack turns the hook on through the harness's own CLI and then reads it back, so this means the harness has no config yet (`no OpenClaw config at yet` or `no Hermes config at yet`: run `openclaw onboard` or `hermes setup` first, then `gstack-harness refresh`; gstack runs no harness command until then, because the CLI would create a config holding only gstack's plugin setting), the CLI was not on `PATH`, one of its commands failed or timed out (the message quotes its last output), or the harness's settings block the plugin: OpenClaw's `plugins.allow` set without `gstack-start`, `gstack-start` in `plugins.deny`, or `hooks.allowPromptInjection: false`. gstack never edits your allow or deny lists. Run the printed command, then `gstack-harness status`; `--start-hook` again retries the whole step. `./setup --host` exits 4 for the same reason. Refreshes and upgrades retry on their own without failing, and stop once you disable the plugin in the harness. + +The same code with `start hook is off: ` means you asked for the hook through `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` but this registration cannot take it (a custom `--skills-dir`, `OPENCLAW_CONFIG_PATH` naming another config, or an unsafe plugin folder); the message names the fix. ### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS) diff --git a/gstack/llms.txt b/gstack/llms.txt index 3dbe42205..ec8a7721d 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -37,7 +37,7 @@ Conventions: - [/gstack-upgrade](gstack-upgrade/SKILL.md): Upgrade gstack to the latest version. - [/guard](guard/SKILL.md): Full safety mode: destructive command warnings + directory-scoped edits. - [/health](health/SKILL.md): Code quality dashboard. -- [/investigate](investigate/SKILL.md): Systematic debugging with root cause investigation. +- [/investigate](investigate/SKILL.md): Use when a test fails, code errors or output is wrong: find the root cause, then fix it. - [/ios-clean](ios-clean/SKILL.md): Remove the DebugBridge SPM package and all #if DEBUG wiring from an iOS app. - [/ios-design-review](ios-design-review/SKILL.md): Visual design audit for iOS apps on real hardware. - [/ios-fix](ios-fix/SKILL.md): Autonomous iOS bug fixer. @@ -56,10 +56,10 @@ Conventions: - [/plan-devex-review](plan-devex-review/SKILL.md): Interactive developer experience plan review. - [/plan-eng-review](plan-eng-review/SKILL.md): Eng manager-mode plan review. - [/plan-tune](plan-tune/SKILL.md): Self-tuning question sensitivity + developer psychographic for gstack (v1: observational). -- [/qa](qa/SKILL.md): Test the site, API or CLI and fix the bugs it finds. +- [/qa](qa/SKILL.md): Use when you test the site, API or CLI and fix bugs. - [/qa-only](qa-only/SKILL.md): Report browser/API/CLI/job/worker/webhook bugs. - [/retro](retro/SKILL.md): Weekly engineering retrospective. -- [/review](review/SKILL.md): Review this PR's diff. +- [/review](review/SKILL.md): Use when you review this PR's diff. - [/scrape](scrape/SKILL.md): Pull data from a web page through the Aside browser — your real, already signed-in sessions. - [/setup-browser-cookies](setup-browser-cookies/SKILL.md): Import cookies from your real Chromium browser into the headless browse session. - [/setup-deploy](setup-deploy/SKILL.md): Configure deployment settings for /land-and-deploy. @@ -71,7 +71,7 @@ Conventions: - [/test-audit](test-audit/SKILL.md): Find low-value or duplicate tests and the test-only code they keep alive. - [/ubicloud](ubicloud/SKILL.md): Run a heavy suite, build or eval on an ephemeral 16-vCPU Ubicloud VM: TTL-named VMs, a reaper, quota preflight and a hang watchdog. - [/unfreeze](unfreeze/SKILL.md): Clear the freeze boundary set by /freeze, allowing edits to all directories again. -- [/verify](verify/SKILL.md): Build loop while coding: run the repo's checks and the tests /ship would pick, fix, repeat until green. +- [/verify](verify/SKILL.md): Use when coding: run the checks and tests /ship picks until green. - [/write-skill](write-skill/SKILL.md): Write a new agent skill or improve one without bloat: the edit ladder, levels, drift and size checks, full diets. ## Browse Commands diff --git a/investigate/SKILL.md b/investigate/SKILL.md index cb6a50df2..191fe7e05 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -2,7 +2,7 @@ name: investigate preamble-tier: 2 version: 1.0.0 -description: Systematic debugging with root cause investigation. (gstack) +description: "Use when a test fails, code errors or output is wrong: find the root cause, then fix it. (gstack)" allowed-tools: - Bash - Read @@ -15,9 +15,29 @@ allowed-tools: triggers: - debug this - fix this bug + - test fails + - failing test - why is this broken - - root cause analysis + - this doesn't work + - build is broken + - stack trace - investigate this error + - root cause analysis +contract: + version: 1 + items: + - id: debug-report + kind: report_labels + heading: DEBUG REPORT + labels: + - Symptom + - Root cause + - Fix + - Evidence + - Regression test + - Status + - id: check-after-last-edit + kind: check_after_last_edit hooks: PreToolUse: - matcher: "Edit" @@ -61,6 +81,15 @@ gbrain: +## Done means + +Finish only when each of these exists (contract v1): + +- `debug-report`: a `DEBUG REPORT` with Symptom, Root cause, Fix, Evidence, Regression test and Status filled in (`none: ` where the skill's own stop rule leaves one empty). +- `check-after-last-edit`: a test or repro command run after your last edit. + +`gstack-skill-contract check` reads them from the transcript (docs/skill-routing-and-contracts.md). For an item that truly does not apply, run `gstack-skill-contract waive --reason ""` (this session only; it counts as a miss). + ## Shared preamble (load first) This skill follows gstack shared preamble `4e82fa4c839f` (AskUserQuestion format, voice, writing, completeness and completion rules). If your Read of that file, headed by this id, is still in context, do not read it again; otherwise Read `~/.claude/skills/gstack/investigate/sections/preamble.md` in full before the command below. A section or subsection here with a shared heading replaces the shared one. diff --git a/investigate/SKILL.md.tmpl b/investigate/SKILL.md.tmpl index c7469537d..a9f7b2ecb 100644 --- a/investigate/SKILL.md.tmpl +++ b/investigate/SKILL.md.tmpl @@ -3,10 +3,10 @@ name: investigate preamble-tier: 2 version: 1.0.0 description: | - Systematic debugging with root cause investigation. Iron Law: no fixes - without root cause. - Use when asked to "debug this", "fix this bug", "why is this broken", - "investigate this error", or "root cause analysis". (gstack) + Use when a test fails, code errors or output is wrong: find the root cause, then fix it. + Systematic debugging; Iron Law: no fixes without root cause. Fits a failing + build, test, script, service or config, an error message or stack trace, + wrong or missing output, or broken data, before any edit that fixes it. (gstack) allowed-tools: - Bash - Read @@ -19,9 +19,29 @@ allowed-tools: triggers: - debug this - fix this bug + - test fails + - failing test - why is this broken - - root cause analysis + - this doesn't work + - build is broken + - stack trace - investigate this error + - root cause analysis +contract: + version: 1 + items: + - id: debug-report + kind: report_labels + heading: DEBUG REPORT + labels: + - Symptom + - Root cause + - Fix + - Evidence + - Regression test + - Status + - id: check-after-last-edit + kind: check_after_last_edit hooks: PreToolUse: - matcher: "Edit" @@ -63,6 +83,8 @@ gbrain: render_as: "## Recent eureka moments (cross-project)" --- +{{CONTRACT_DONE_MEANS}} + {{PREAMBLE}} # Systematic Debugging diff --git a/lib/ci-carry.ts b/lib/ci-carry.ts index 16aec8629..c258f5ec4 100644 --- a/lib/ci-carry.ts +++ b/lib/ci-carry.ts @@ -14,7 +14,9 @@ * max age, the run's own tree.json naming HEAD's tree); * every run executed tests; * with an inventory command, the runs' tree.json files cover every file it - * prints (a lane carries only what CI provably ran); + * prints (a lane carries only what CI provably ran); with named jobs instead + * (a lane with no test files, GST-1189), each of those jobs succeeded in the + * run's newest attempt, read by verifyRun like the profile's own jobs; * every run still stands after the inventory ran (runChanged). * * Anything else returns a refusal with a reason code, and the caller runs the @@ -25,12 +27,18 @@ * bounded by a timeout, and returns green, red with the failing jobs, still * running, or none. */ -import { runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RunDeps, type RunEvidence, type RunProfile, type RunSource } from './run-evidence'; +import { normalPath, runChanged, runInventory, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RunDeps, type RunEvidence, type RunProfile, type RunSource } from './run-evidence'; export type CarryRefusal = 'dirty_tree' | 'no_profile' | 'no_run' | 'no_verified_run' | 'tree_mismatch' | 'no_tests' - | 'inventory_failed' | 'inventory_empty' | 'inventory_uncovered' | 'run_changed' | 'tree_changed' | 'api_error'; + | 'inventory_failed' | 'inventory_empty' | 'inventory_uncovered' | 'inventory_skipped' | 'run_changed' | 'tree_changed' | 'api_error'; export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string }; -export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number; receipt: string } | CarryRefused; +/** + * `tests` never claims a whole run's count for a narrow lane: with an inventory it is the number of the lane's inventory files + * the runs' tree.json covers (`unit: 'files'`; tree.json has no per-file test counts yet), with named jobs `n/a`, and only + * without either (the whole run carried) the runs' test count. `remainder`: inventory files every run skipped entirely + * (tree.json `skipped_files`), which the caller must run here with the lane's per_file runner before the lane counts. + */ +export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; remainder?: string[]; receipt: string } | CarryRefused; /** How many of HEAD's runs per workflow are tried, newest first. */ const CANDIDATES = 10; @@ -38,12 +46,13 @@ const no = (reason: CarryRefusal, detail: string): CarryRefused => ({ ok: false, const newestFirst = (a: ApiRun, b: ApiRun) => (b.created_at ?? '').localeCompare(a.created_at ?? '') || b.id - a.id; /** The newest run of `profile` on `head` that verifies; a refusal lists each candidate's reason, newest first. */ -function newestVerified(src: RunSource, name: string, profile: RunProfile, head: string): { ok: true; evidence: RunEvidence } | CarryRefused { +function newestVerified(src: RunSource, name: string, profile: RunProfile, head: string, jobs: string[]): { ok: true; evidence: RunEvidence } | CarryRefused { + if (jobs.length) profile = { ...profile, jobs: [...new Set([...profile.jobs, ...jobs])] }; const listed = src.deps.runsForHead(profile.workflow, head).filter(r => profile.events.includes(r.event)).sort(newestFirst).slice(0, CANDIDATES); if (!listed.length) return no('no_run', `no ${profile.workflow} run (${profile.events.join(', ')}) on ${head.slice(0, 12)} yet`); const refusals: string[] = []; for (const run of listed) { - const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now }); + const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now, unchanged: src.unchanged }); if (v.ok) return v; refusals.push(`run ${run.id}: ${v.reason}`); } @@ -52,9 +61,13 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head: /** * The CI evidence for HEAD's tree under `profiles` (one verified run each), or why there is none. `wtree` reads the - * working-tree fingerprint (before the lookup and after it); `inventory` lists the files the runs must cover. + * working-tree fingerprint (before the lookup and after it); `inventory` lists the files the runs must cover, or `jobs` + * names the jobs that must have succeeded in each run's newest attempt (a lane with no test files; no test count needed). */ -export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string } }): CarryVerdict { +export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean }): CarryVerdict { + const jobs = o.jobs ?? []; + let covered: number | undefined; + let remainder: string[] = []; const before = o.wtree(); if (before !== o.tree) return no('dirty_tree', `the working tree (${before ? before.slice(0, 12) : 'fingerprint unavailable'}) is not HEAD's committed tree ${o.tree.slice(0, 12)}; only a clean, committed tree carries`); if (!o.profiles.length) return no('no_profile', 'no run profile is named'); @@ -63,20 +76,24 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string; for (const name of o.profiles) { const profile = src.profiles[name]; if (!profile) return no('no_profile', `${name} is not a run_profiles name on the base branch`); - const v = newestVerified(src, name, profile, o.head); + const v = newestVerified(src, name, profile, o.head, jobs); if (!v.ok) return v; runs.push(v.evidence); } const other = runs.find(e => e.tree !== o.tree); if (other) return no('tree_mismatch', `run ${other.run_id} tested tree ${other.tree.slice(0, 12)}, not ${o.tree.slice(0, 12)}`); - const empty = runs.find(e => !e.files.length || e.tests === 0); + const empty = jobs.length ? undefined : runs.find(e => !e.files.length || e.tests === 0); if (empty) return no('no_tests', `run ${empty.run_id}'s tree.json ${empty.files.length ? 'records 0 tests' : 'lists no test files'}`); if (o.inventory) { const listed = o.inventory(); if ('error' in listed) return no('inventory_failed', listed.error); if (!listed.length) return no('inventory_empty', 'the inventory command printed no files'); - const missing = uncoveredFiles(listed, runs); - if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`); + const absent = uncoveredFiles(listed, runs, 'covered'); + const some = (fs: string[]) => `${fs.slice(0, 5).join(', ')}${fs.length > 5 ? ', …' : ''}`; + if (absent.length) return no('inventory_uncovered', `${absent.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${some(absent)}`); + remainder = uncoveredFiles(listed, runs, 'uncovered'); + if (remainder.length && !o.perFile) return no('inventory_skipped', `${remainder.length} of ${listed.length} inventory file(s) skipped every test in CI and the lane declares no per_file runner: ${some(remainder)}`); + covered = new Set(listed.map(normalPath)).size - remainder.length; } for (const e of runs) { const changed = runChanged(src.deps, e.run_id, e.run_attempt); @@ -87,6 +104,13 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string; } const after = o.wtree(); if (after !== o.tree) return no('tree_changed', `the working tree changed while the runs were verified (now ${after ? after.slice(0, 12) : 'unreadable'})`); + if (jobs.length) { + const gates = [...new Set(o.profiles.map(n => src.profiles[n]?.skippable?.gate).filter(Boolean))]; + const allSkipped = runs.every(e => jobs.every(j => e.skipped_jobs?.includes(j))); + const notOwed = allSkipped ? `CI skipped at this tree (gate job ${gates.join(', ')}, workflow unchanged)` : undefined; + return { ok: true, runs, tests: 'n/a', ...(notOwed ? { notOwed } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} jobs=${jobs.join(',')}${notOwed ? ` not_owed=${gates.join(',')}` : ''} ${runReceiptFields(runs, src.now)}` }; + } + if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', ...(remainder.length ? { remainder } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered}${remainder.length ? ` skipped_here=${remainder.length}` : ''} ${runReceiptFields(runs, src.now)}` }; const tests = runs.reduce((n, e) => n + (e.tests ?? e.files.length), 0); return { ok: true, runs, tests, receipt: `ci-carry tree=${o.tree.slice(0, 12)} tests=${tests} ${runReceiptFields(runs, src.now)}` }; } @@ -109,24 +133,75 @@ export async function waitForRun(deps: RunDeps, o: { workflow: string; events: s } } -/** `ship_lanes_from` (base branch only): exact lane command → the profiles whose runs carry it and the command listing its test files. */ -export interface ShipLaneMapping { profiles: string[]; inventory: string } +/** + * `ship_lanes_from` (base branch only): exact lane command → the profiles whose runs carry it and either the command listing + * its test files (`inventory`) or the CI jobs that run it (`jobs`, for a lane with no test files such as a typecheck; GST-1189). + */ +export interface ShipLaneMapping { profiles: string[]; inventory?: string; jobs?: string[]; aliases?: string[]; per_file?: string } + +/** + * The `ship_lanes_from` entry for an exact lane command: the entry it keys, else the entry that declares it in `aliases` + * (GST-1190: the same lane run under another exact spelling, such as an env prefix). Never normalized: an alias is a + * second exact command the base's owner declared, not a pattern. + */ +export function laneMapping(from: Record, command: string): ShipLaneMapping | undefined { + if (Object.prototype.hasOwnProperty.call(from, command)) return from[command]; + return Object.values(from).find(m => m.aliases?.includes(command)); +} + +/** findCarry's coverage rule for one mapping: its inventory (a pin-file command run in `cwd`, with a per_file runner or not) or its named jobs. */ +export const mappingCoverage = (m: ShipLaneMapping, cwd: string): { inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean } => + m.jobs ? { jobs: m.jobs } : { inventory: () => runInventory(m.inventory!, cwd), perFile: !!m.per_file }; + +/** + * What `gstack-evidence record-ci` reported, read from its own last record line onward (the per_file remainder's output comes + * first, so a test printing look-alike lines cannot change the reading): the carried runs, count, unit and remainder, any + * not-owed reason, or the last CARRY_NONE reason. + */ +export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; none?: string } { + const lines = stderr.split('\n'); + const at = lines.findLastIndex(x => x.startsWith('gstack-evidence: recorded label=') && x.includes(' log=- source=ci runs=')); + const none = lines.findLast(x => x.startsWith('CARRY_NONE: '))?.slice('CARRY_NONE: '.length); + if (at < 0) return none ? { none } : {}; + const got = / tests_ran=(\d+|n\/a)( unit=files)? log=- source=ci runs=(\S+)/.exec(lines[at]!); + const here = / skipped_here=(\d+) tests_ran_here=(\S+)$/.exec(lines[at]!); + const notOwed = lines.slice(at + 1).find(x => x.startsWith('gstack-evidence: not owed: '))?.slice('gstack-evidence: not owed: '.length); + return { runs: got?.[3], tests: got?.[1], files: !!got?.[2], ...(notOwed ? { notOwed } : {}), ...(here ? { here: `skipped_here=${here[1]} file(s) tests_ran_here=${here[2]}` } : {}) }; +} + +/** The per_file runner command for `files`: `{files}` replaced by the paths, each single-quoted for bash. */ +export const perFileCommand = (template: string, files: string[]): string => + template.replaceAll('{files}', files.map(f => `'${f.replace(/'/g, `'\\''`)}'`).join(' ')); /** `: ` errors for `ship_lanes_from`; empty when valid. Shared with lib/pregate/config.ts. */ export function shipLanesErrors(raw: unknown, profileNames: readonly string[]): Array<{ path: string; message: string }> { if (raw === undefined) return []; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'ship_lanes_from', message: 'must be an object of exact lane command → { profiles: [run_profiles names], inventory: command }' }]; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'ship_lanes_from', message: 'must be an object of exact lane command → { profiles: [run_profiles names], inventory: command } or { profiles, jobs: [job names] }' }]; const errors: Array<{ path: string; message: string }> = []; const oneLine = (s: unknown) => typeof s === 'string' && !!s.trim() && s.length <= 512 && !/[\0\r\n]/.test(s); + const declared = new Map(Object.keys(raw as object).map(k => [k, 'a key'])); for (const [command, m] of Object.entries(raw as Record)) { const at = `ship_lanes_from[${JSON.stringify(command)}]`; if (!oneLine(command)) errors.push({ path: at, message: 'the key must be the exact lane command: one line under 512 characters' }); - if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push({ path: at, message: 'must be { profiles, inventory }' }); continue; } + if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push({ path: at, message: 'must be { profiles, inventory } or { profiles, jobs }' }); continue; } const o = m as Record; - for (const k of Object.keys(o)) if (k !== 'profiles' && k !== 'inventory') errors.push({ path: `${at}.${k}`, message: 'unknown key; valid keys: profiles, inventory' }); + for (const k of Object.keys(o)) if (!['profiles', 'inventory', 'jobs', 'aliases', 'per_file'].includes(k)) errors.push({ path: `${at}.${k}`, message: 'unknown key; valid keys: profiles, inventory, jobs, aliases, per_file' }); + if ('per_file' in o && (!oneLine(o.per_file) || !(o.per_file as string).includes('{files}') || !('inventory' in o))) errors.push({ path: `${at}.per_file`, message: 'must be one command line under 512 characters containing {files}, on an entry with an inventory (it runs the inventory files CI skipped entirely)' }); + if ('aliases' in o) { + const aliases = o.aliases; + if (!Array.isArray(aliases) || !aliases.length || aliases.some(a => !oneLine(a)) || new Set(aliases).size !== aliases.length) errors.push({ path: `${at}.aliases`, message: 'must be a non-empty array of distinct exact lane commands, each one line under 512 characters' }); + else for (const a of aliases as string[]) { + if (declared.has(a)) errors.push({ path: `${at}.aliases`, message: `${JSON.stringify(a)} is already ${declared.get(a)}; each exact command maps to one entry` }); + else declared.set(a, `an alias of ${JSON.stringify(command)}`); + } + } if (!Array.isArray(o.profiles) || !o.profiles.length || o.profiles.some(p => typeof p !== 'string')) errors.push({ path: `${at}.profiles`, message: 'must be a non-empty array of run_profiles names' }); else for (const p of o.profiles as string[]) if (!profileNames.includes(p)) errors.push({ path: `${at}.profiles`, message: `${JSON.stringify(p)} is not a run_profiles name` }); - if (!oneLine(o.inventory)) errors.push({ path: `${at}.inventory`, message: 'must be one command line under 512 characters that prints the lane\'s test files, one per line' }); + if (('inventory' in o) === ('jobs' in o)) errors.push({ path: at, message: 'declare exactly one of inventory (a command listing the lane\'s test files) or jobs (the CI jobs that run it)' }); + else if ('jobs' in o) { + const jobs = o.jobs; + if (!Array.isArray(jobs) || !jobs.length || jobs.some(j => typeof j !== 'string' || !oneLine(j) || j.length > 128) || new Set(jobs).size !== jobs.length) errors.push({ path: `${at}.jobs`, message: 'must be a non-empty array of distinct CI job names, each one line under 128 characters' }); + } else if (!oneLine(o.inventory)) errors.push({ path: `${at}.inventory`, message: 'must be one command line under 512 characters that prints the lane\'s test files, one per line' }); } return errors; } diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts index adc46fbbf..daebe14d4 100644 --- a/lib/gate-diff/git.ts +++ b/lib/gate-diff/git.ts @@ -137,6 +137,27 @@ export function resolveBase(ref: string, cwd: string, head?: string): { ref: str return sha ? { ref, sha } : null; } +export type ResolvedBase = NonNullable>; + +/** Why resolveBase read another ref than the name given (`local main is behind origin/main; base is origin/main`); null when the name stood as given. */ +export function baseNote(name: string, base: ResolvedBase): string | null { + if (base.local === 'fallback') return `${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${name}`}; base is the local ${name}, which may lag its remote`; + if (!base.local) return null; + return `${{ unrelated: `local ${name} shares no history with HEAD`, missing: `no local branch ${name}`, behind: `local ${name} is behind ${base.ref}`, ahead: `local ${name} is ahead of ${base.ref}`, diverged: `local ${name} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`; +} + +/** + * A --base argument as the commit a review or ship bin reads (GST-1036): resolveBase against HEAD, so a bare `main` + * reads origin/main over a stale local main, with the note on stderr as `: ` when it read another ref than + * the name. An unresolvable name comes back as given, so the caller's own git error still names it. + */ +export function baseArg(bin: string, name: string, cwd: string): string { + const base = resolveBase(name, cwd, 'HEAD'); + const note = base && baseNote(name, base); + if (note) process.stderr.write(`${bin}: ${note}\n`); + return base?.sha ?? name; +} + /** A word for a printed shell line: as is when it is plain, single-quoted otherwise (ref names may hold `;`, `$`, `(`, `{1,2}`). */ function shellWord(s: string): string { return /^[A-Za-z0-9._\/@^~+:-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`; diff --git a/lib/harness-register.ts b/lib/harness-register.ts index 9b00b665f..8744f0977 100644 --- a/lib/harness-register.ts +++ b/lib/harness-register.ts @@ -22,7 +22,7 @@ import { spawnSync } from 'node:child_process'; import { bunIsolationArgs } from './bun-isolation'; import { runBashSync } from './script-invocation'; import { frontmatterList, routerLine } from './skill-triggers'; -import { activateHook, assertHookRoot, hookChoice, hookFiles, hookProfile, hookRoot, inspectHook, isHookTarget, profileSkills, type HookResult } from './harness-start-hook'; +import { activateHook, assertHookRoot, foreignOpenclawConfig, hookChoice, hookConfig, hookFiles, hookProfile, hookRoot, inspectHook, isHookTarget, profileSkills, type HookResult } from './harness-start-hook'; export type SkillsMode = 'router' | 'hybrid' | 'all'; export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok'; @@ -135,7 +135,7 @@ export const HARNESS_CODES = { HARNESS_RENDER_FAILED: { anchor: 'harness-render-failed', fix: 'run bun install in the gstack checkout, then register again; the render output above names the failing skill' }, HARNESS_BUDGET_EXCEEDED: { anchor: 'harness-budget-exceeded', fix: 'this is a gstack bug: report it with the byte count printed above' }, HARNESS_NOT_REGISTERED: { anchor: 'harness-not-registered', fix: 'run gstack-harness status to see the registrations on this machine' }, - HARNESS_HOOK_UNSUPPORTED: { anchor: 'harness-hook-unsupported', fix: 'the start hook is for OpenClaw and Hermes registered in their own profile: drop --skills-dir (set OPENCLAW_STATE_DIR or HERMES_HOME to pick the profile), or drop --start-hook' }, + HARNESS_HOOK_UNSUPPORTED: { anchor: 'harness-hook-unsupported', fix: 'the start hook is for OpenClaw and Hermes registered in their own profile: drop --skills-dir (set OPENCLAW_STATE_DIR or HERMES_HOME to pick the profile), unset OPENCLAW_CONFIG_PATH or point it at the profile\'s own openclaw.json, or drop --start-hook' }, HARNESS_HOOK_CHOICE_INVALID: { anchor: 'harness-hook-choice-invalid', fix: 'set it to on or off (GSTACK_HARNESS_START_HOOK also takes 1 or 0), or unset it' }, HARNESS_HOOK_OWNED_ELSEWHERE: { anchor: 'harness-hook-owned-elsewhere', fix: 'if another gstack install wrote it, run that install\'s gstack-harness register --no-start-hook; otherwise move the folder aside, then register again' }, HARNESS_HOOK_INACTIVE: { anchor: 'harness-hook-inactive', fix: 'run the command printed above, then gstack-harness status; the skills themselves are registered' }, @@ -525,7 +525,7 @@ function shellRegistry(opts: RegisterOptions, fn: string, args: string[]): boole return sourcedBash(opts.checkout, 'gstack-install-registry.sh', fn, args, opts.stateRoot, opts.env).status === 0; } -interface HookPlan { on: boolean; prevOn: boolean; explicit: boolean; retry: boolean; profile: string; root: string; files: Map; note?: string } +interface HookPlan { on: boolean; prevOn: boolean; explicit: boolean; retry: boolean; profile: string; root: string; files: Map; note?: string; fix?: string; requested?: boolean } /** The start hook for this run: whether it is on, where it goes, and its files (null for targets without a hook). */ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): HookPlan | null { @@ -544,17 +544,19 @@ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): Hoo const name = t.name; const off = (p: HookPlan) => { if (saved) assertHookRoot(name, saved.profile, saved.root, saved.root, (code, msg) => { throw new HarnessError(code, msg); }); return p; }; if (!choice.on) return off(plan); - const why = dest !== profileSkills(t.name, profile) ? 'this registration uses another skills folder (' + dest + ')' - : t.name === 'openclaw' && opts.env.OPENCLAW_CONFIG_PATH ? 'OPENCLAW_CONFIG_PATH points OpenClaw at a config outside that profile' : ''; + const requested = !opts.inherit; + const foreign = t.name === 'openclaw' ? foreignOpenclawConfig(opts.env, profile) : ''; + const [why, fix] = dest !== profileSkills(t.name, profile) ? ['this registration uses another skills folder (' + dest + ')', 'register without --skills-dir to get the hook, or pass --no-start-hook to keep this registration without it'] + : foreign ? ['OPENCLAW_CONFIG_PATH points OpenClaw at ' + foreign + ', outside that profile', 'unset OPENCLAW_CONFIG_PATH or set it to ' + hookConfig('openclaw', profile) + ', then register again'] : ['', '']; if (why && (plan.explicit || !prevOn)) { if (plan.explicit) throw new HarnessError('HARNESS_HOOK_UNSUPPORTED', 'the start hook goes into the ' + t.displayName + ' profile ' + profile + ', and ' + why); - return off({ ...plan, on: false, note: 'off: ' + why + ' (' + choice.source + ' asked for the hook)' }); + return off({ ...plan, on: false, requested, fix, note: 'off: ' + why + ' (' + choice.source + ' asked for the hook)' }); } try { assertHookRoot(t.name, profile, root, prevOn ? prev!.meta.hook_root : undefined, (code, msg) => { throw new HarnessError(code, msg); }); } catch (e) { if (plan.explicit || prevOn || !(e instanceof HarnessError)) throw e; - return off({ ...plan, on: false, note: 'off: ' + e.message + ' (' + choice.source + ' asked for the hook; ' + HARNESS_CODES[e.code].fix + ')' }); + return off({ ...plan, on: false, requested, fix: HARNESS_CODES[e.code].fix, note: 'off: ' + e.message + ' (' + choice.source + ' asked for the hook)' }); } return { ...plan, files: hookFiles(t.name, root) }; } @@ -562,7 +564,7 @@ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): Hoo /** Turns the hook on after its files are written (when this run turns it on, was asked to, or the last try did not end active), and reads it back. */ function finishHook(opts: RegisterOptions, hook: HookPlan | null, released: string): RegisterResult['hook'] { if (!hook) return undefined; - if (!hook.on) return { state: 'off', ...(hook.note ? { detail: hook.note } : {}), ...(released ? { fix: released } : {}) }; + if (!hook.on) return { state: 'off', ...(hook.note ? { detail: hook.note } : {}), ...(released || hook.fix ? { fix: released || hook.fix } : {}), ...(hook.requested ? { required: true } : {}) }; const t = opts.target.name as 'openclaw' | 'hermes'; const required = !hook.prevOn || hook.explicit; if (!required && !hook.retry) return { state: 'unchecked', profile: hook.profile, root: hook.root, fix: 'gstack-harness status shows whether it is active' }; @@ -678,7 +680,7 @@ export function register(opts: RegisterOptions): RegisterResult { if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']); if (same && hook?.on && hook.explicit && prev.meta.hook_active !== 'no') writeLedger(lfile, { ...prev, meta: { ...prev.meta, hook_active: 'no' } }); const hookResult = finishHook(opts, hook, released); - const active = hookResult?.required || hookResult?.retried ? ({ active: 'yes', disabled: 'disabled' } as Record)[hookResult.state] ?? 'no' : ''; + const active = hook?.on && (hookResult?.required || hookResult?.retried) ? ({ active: 'yes', disabled: 'disabled' } as Record)[hookResult!.state] ?? 'no' : ''; const saved = active ? readLedger(lfile) : null; if (saved && saved.meta.hook_active !== active) writeLedger(lfile, { ...saved, meta: { ...saved.meta, hook_active: active } }); return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept, ...(hookResult ? { hook: hookResult } : {}) }; diff --git a/lib/harness-start-hook.ts b/lib/harness-start-hook.ts index 7594d6865..7529d0fd2 100644 --- a/lib/harness-start-hook.ts +++ b/lib/harness-start-hook.ts @@ -22,14 +22,14 @@ export const START_NOTE_MAX = 300; export const START_NOTE = 'gstack: if you use a gstack skill this turn, first run the gstack-skill-start command in its Preamble section exactly as written, with no pipe, head, || true or ; echo. Read its output, then follow the skill.'; export type HookTarget = 'openclaw' | 'hermes'; -export type HookState = 'active' | 'written' | 'disabled' | 'unchecked' | 'off'; +export type HookState = 'active' | 'written' | 'disabled' | 'unknown' | 'unchecked' | 'off'; export interface HookResult { state: HookState; fix?: string; detail?: string; profile?: string; root?: string } type Env = Record; const MANAGED = "Managed by gstack-harness: it rewrites this file. Turn the hook off: gstack-harness register %s --no-start-hook"; const SPECS = { openclaw: { - profileVar: 'OPENCLAW_STATE_DIR', home: '.openclaw', sub: 'extensions', skills: ['skills'], + profileVar: 'OPENCLAW_STATE_DIR', home: '.openclaw', sub: 'extensions', skills: ['skills'], name: 'OpenClaw', config: 'openclaw.json', setup: 'openclaw onboard', activate: [['config', 'set', 'plugins.entries.' + HOOK_ID + '.hooks.allowConversationAccess', 'true'], ['config', 'set', 'plugins.entries.' + HOOK_ID + '.enabled', 'true']], files: (): Record => ({ 'index.js': [ @@ -55,7 +55,7 @@ const SPECS = { }), }, hermes: { - profileVar: 'HERMES_HOME', home: '.hermes', sub: 'plugins', skills: ['skills', 'gstack'], + profileVar: 'HERMES_HOME', home: '.hermes', sub: 'plugins', skills: ['skills', 'gstack'], name: 'Hermes', config: 'config.yaml', setup: 'hermes setup', activate: [['plugins', 'enable', HOOK_ID]], files: (): Record => ({ 'plugin.yaml': ['# ' + MANAGED.replace('%s', 'hermes'), 'name: ' + HOOK_ID, 'version: 1.0.0', "description: Puts gstack's start-command note on each user turn (managed by gstack-harness).", ''].join('\n'), @@ -91,6 +91,51 @@ export const hookRoot = (t: HookTarget, profile: string) => path.join(profile, S /** The skills folder a registration must use for the hook to go into that profile. */ export const profileSkills = (t: HookTarget, profile: string) => path.join(profile, ...SPECS[t].skills); const activateCommands = (t: HookTarget, profile: string) => SPECS[t].activate.map(a => SPECS[t].profileVar + '=' + JSON.stringify(profile) + ' ' + t + ' ' + a.join(' ')).join(' && '); +/** The harness's own config file in a profile: the file activation edits through the harness CLI. */ +export const hookConfig = (t: HookTarget, profile: string) => path.join(profile, SPECS[t].config); + +function canonical(p: string): string { + try { return fs.realpathSync(p); } catch {} + const parent = path.dirname(p); + return parent === p ? p : path.join(canonical(parent), path.basename(p)); +} +/** The config OPENCLAW_CONFIG_PATH names when it is not the profile's own openclaw.json (compared after realpath), else ''. */ +export function foreignOpenclawConfig(env: Env, profile: string): string { + const v = env.OPENCLAW_CONFIG_PATH; + if (!v) return ''; + const named = path.resolve(v); + return canonical(named) === canonical(hookConfig('openclaw', profile)) ? '' : named; +} + +/** A profile with no harness config yet: turning the plugin on through the harness CLI would create a config holding only that setting, so gstack waits for the harness's own setup. */ +function noConfig(t: HookTarget, profile: string): HookResult | null { + const cfg = hookConfig(t, profile); + if (fs.existsSync(cfg)) return null; + const s = SPECS[t]; + return { state: 'written', detail: 'no ' + s.name + ' config at ' + cfg + ' yet, and turning the plugin on would create one holding only that setting', fix: 'run ' + s.name + "'s own setup first (" + s.profileVar + '=' + JSON.stringify(profile) + ' ' + s.setup + '), then gstack-harness refresh' }; +} + +/** Reads the hook from the harness's config file when its CLI is not on PATH (a harness's own terminal tool often lacks it). */ +function inspectConfig(t: HookTarget, profile: string, fix: string): HookResult { + const why = t + ' is not on PATH'; + const written: HookResult = { state: 'written', detail: why + ', so gstack could not turn the plugin on or check it', fix }; + const cfg = hookConfig(t, profile); + const missing = noConfig(t, profile); + if (missing) return { ...missing, detail: why + ' and ' + missing.detail }; + let plugins: any; + try { plugins = ((t === 'hermes' ? Bun.YAML.parse(fs.readFileSync(cfg, 'utf8')) : JSON.parse(fs.readFileSync(cfg, 'utf8'))) as any)?.plugins ?? {}; } + catch { return { state: 'unknown', detail: why + ' and ' + cfg + ' could not be parsed, so gstack could not check the plugin', fix: 'gstack-harness status from a shell where ' + t + ' is on PATH' }; } + if (t === 'hermes') { + const listed = (k: string) => Array.isArray(plugins[k]) && plugins[k].includes(HOOK_ID); + if (listed('disabled')) return { state: 'disabled', detail: 'turned off in Hermes (plugins.disabled in ' + cfg + '); gstack leaves it off', fix }; + if (listed('enabled') && fs.existsSync(path.join(hookRoot(t, profile), 'plugin.yaml'))) return { state: 'active', detail: 'read from plugins.enabled in ' + cfg + ' (' + why + ')' }; + return written; + } + const entry = plugins.entries?.[HOOK_ID] ?? {}; + if (entry.enabled === false) return { state: 'disabled', detail: 'turned off in openclaw.json (plugins.entries.' + HOOK_ID + '.enabled false); gstack leaves it off', fix }; + if (entry.enabled === true && entry.hooks?.allowConversationAccess === true) return { state: 'unknown', detail: cfg + ' turns it on, but ' + why + ', so gstack could not check that OpenClaw loads it', fix: 'gstack-harness status from a shell where openclaw is on PATH' }; + return written; +} /** The plugin's files (absolute path -> bytes): fixed code plus note.txt, which the code reads at each turn. */ export function hookFiles(t: HookTarget, root: string): Map { @@ -124,6 +169,8 @@ const onPath = (bin: string, env: Env) => (env.PATH ?? '').split(path.delimiter) /** Turns the plugin on in the harness. Returns the first failure, or ''. */ export function activateHook(t: HookTarget, profile: string, env: Env, home: string): string { if (!onPath(t, env)) return t + ' is not on PATH'; + const missing = noConfig(t, profile); + if (missing) return missing.detail!; for (const a of SPECS[t].activate) { const r = runHarness(t, profile, a, env, home, 60_000); if (!r.ok) return t + ' ' + a.join(' ') + ' failed (' + r.why + ')'; @@ -136,12 +183,14 @@ function parseJson(s: string): any { return null; } -/** Reads the hook back from the harness: active (configured for new sessions), disabled by the user, or written (with the fix). */ +/** Reads the hook back from the harness: active (configured for new sessions), disabled by the user, written (with the fix), or unknown (no CLI and a config that cannot settle it). Runs no harness command while the profile has no harness config. */ const READ_TIMEOUT = 15_000; export function inspectHook(t: HookTarget, profile: string, env: Env, home: string): HookResult { const fix = activateCommands(t, profile); - if (!onPath(t, env)) return { state: 'written', detail: t + ' is not on PATH, so gstack could not turn the plugin on or check it', fix }; + if (!onPath(t, env)) return inspectConfig(t, profile, fix); + const missing = noConfig(t, profile); + if (missing) return missing; if (t === 'hermes') { const r = runHarness(t, profile, ['plugins', 'list', '--json'], env, home, READ_TIMEOUT); const row = (parseJson(r.stdout) ?? []).find?.((p: any) => p?.name === HOOK_ID); diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 1749944fc..faf23822f 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -83,6 +83,8 @@ export interface PregateCheck { /** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */ export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> } export interface PregateRemote { lane: string; platform: string; workflow: string; job: string; tests: string[]; receipt: string | null; cleared_by?: string; command?: string; substitution?: PregateReceiptSubstitution } +/** An owner-granted flaky exception (lib/pregate/flaky-exception.ts): the runs, cases and grant that cleared one obligation, bound to one head and tree. */ +export interface PregateException { lane: string; repo: string; pr: number; head: string; tree: string; runs: string[]; cases: string[]; grant: string; by: string; words: string; binding: string } export interface PregateFile { schema_version: 1; tree: string; checks: PregateCheck[]; run?: string; stage1_ms?: number; stage2_ms?: number; // C-pregate (C8): head/base identity, the policy sources, persisted requires-remote obligations and the one-line verdict. @@ -93,6 +95,8 @@ export interface PregateFile { policy_user_path?: string; /** This run's scratch directory under the state root (logs, scan files). */ scratch?: string; + /** Obligations cleared from runs with failures under an owner's grant (`clear --flaky-exception`). */ + exceptions?: PregateException[]; } export interface ShipReceipt { schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string; @@ -123,6 +127,8 @@ export interface ShipReceipt { review_targeted?: { classes: string[]; rules: string; brief_sha: string; product_patch_id: string; status: 'done' | 'failed' | 'not-run'; findings: number; raised_by: string }; /** Step 5 failures gstack-base-red proved are the base's own (lib/base-red.ts): a conditional receipt the queue refuses until /ship --resume rewrites it without this block. */ base_red?: { cases: string[]; files: string[]; base: string; lane: string; expires_at: string }; + /** The pregate's owner-granted flaky exceptions (gstack-pregate clear --flaky-exception): pregate only, never a required CI check. */ + flaky_exception?: Array<{ lane: string; runs: string[]; cases: string[]; grant: string; by: string }>; /** The base policy's review_exclude_paths at the reviewed head (lib/review-exclude.ts); absent when the base declares none. Carry and the queue compare `digest`. */ review_exclude?: { source: string; patterns: string[]; applied: string[]; excluded: number; excluded_bytes: number; kept: number; gate_tagged: string[]; patterns_sha256: string; list_sha256: string; digest: string }; } @@ -234,6 +240,7 @@ export const ARTIFACT_SCHEMAS = { head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str, requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str, substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } }, + exceptions: { type: 'array', items: { type: 'object', required: ['lane', 'repo', 'pr', 'head', 'tree', 'runs', 'cases', 'grant', 'by', 'words', 'binding'], properties: { lane: str, repo: str, pr: { type: 'integer' }, head: str, tree: str, runs: strList, cases: strList, grant: str, by: str, words: str, binding: str } } }, }, }, 'ship-receipt': { @@ -250,6 +257,7 @@ export const ARTIFACT_SCHEMAS = { carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, head_tree: str, product_patch_id: str, gstack_version: str, base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } }, + flaky_exception: { type: 'array', items: { type: 'object', required: ['lane', 'runs', 'cases', 'grant', 'by'], properties: { lane: str, runs: strList, cases: strList, grant: str, by: str } } }, review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList, risk: { type: 'array', items: { enum: ['money', 'auth', 'secret', 'security', 'data'] } } } }, review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } }, review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } }, diff --git a/lib/pr-shots.ts b/lib/pr-shots.ts index 821569dc8..0e8c4659a 100644 --- a/lib/pr-shots.ts +++ b/lib/pr-shots.ts @@ -17,6 +17,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { resolveBase } from './gate-diff/git'; import { findSection } from './markdown-sections'; import { scan } from './redact-engine'; import type { ResultCodeName } from './result-codes'; @@ -55,9 +56,11 @@ export function readCapture(file: string, roots: string[], what: string): string return cleanText(fs.readFileSync(real), `${what} file`); } -/** Resolve `ref` to a commit sha without letting it be read as an option. */ +/** Resolve `ref` to a commit sha without letting it be read as an option; a bare branch name reads origin's tracking branch first (resolveBase, GST-1036). */ export function resolveCommit(cwd: string, ref: string): string | ShotError { if (!ref || ref.startsWith('-') || /\s/.test(ref)) return { code: 'PR_SHOTS_INPUT_REFUSED', message: `ref ${JSON.stringify(ref)} is not a plain ref` }; + const base = resolveBase(ref, cwd, 'HEAD'); + if (base) return base.sha; const r = git(cwd, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]); return r.status === 0 ? r.stdout.trim() : { code: 'PR_SHOTS_INPUT_NOT_FOUND', message: `base ref ${ref}` }; } diff --git a/lib/pregate/checks/guards.ts b/lib/pregate/checks/guards.ts index 1a67ff05a..c8d40feb0 100644 --- a/lib/pregate/checks/guards.ts +++ b/lib/pregate/checks/guards.ts @@ -11,9 +11,17 @@ * incident: a gbrain serial test landed without its manifest row and * passed both the isolation check and pregate; `test/cli-goldens.test.ts` * spawns the CLI as a subprocess, so no import graph selects it. + * + * GST-1186: a guard whose exact command already passed at this tree is not run + * again. #3901's carry (gstack-evidence record-ci) carries it from a verified + * CI run when the base's ship_lanes_from maps the command, else a counted + * ledger pass of the same command at the same working tree stands + * (gstack-evidence passed). ship_lanes_from is read from origin/ only, so + * the PR that adds a mapping cannot carry from it yet. */ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { laneMapping, recordCiResult } from '../../ci-carry'; import { matchesAny } from '../../ship-policy'; import type { Guard } from '../config'; import { baseInputs, sha256 } from '../context'; @@ -23,8 +31,29 @@ import { checkScratch } from '../run-scratch'; import { readRange, STALL_IDLE, supervise } from '../supervise'; import type { EnvironmentCause } from '../types'; import type { CheckDef, CheckResult, PregateContext } from '../types'; +import { runScriptSync } from '../../script-invocation'; -interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null } +interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null; prior?: string; carryNone?: string } + +const EVIDENCE = path.resolve(import.meta.dir, '..', '..', '..', 'bin', 'gstack-evidence'); + +/** + * The pass that already covers this guard's exact command at this tree (GST-1186): a CI carry (or a CI skip the unchanged gate + * decided, printed as not owed), else a same-tree local ledger pass under the runtime the guard would launch with. + */ +function priorPass(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv): { prior?: string; carryNone?: string } { + const evidence = (args: string[]) => runScriptSync(EVIDENCE, args, { cwd: ctx.repoRoot, env, encoding: 'utf8', timeout: 600_000 }); + let carryNone: string | undefined; + if (laneMapping(ctx.config.ship_lanes_from, guard.command)) { + const ci = evidence(['record-ci', '--label', `guard-${guard.id}`, '--base', ctx.base, '--', guard.command]); + const got = recordCiResult(String(ci.stderr ?? '')); + if (ci.status === 0 && got.runs) return { prior: got.notOwed ? `not owed: ${got.notOwed} runs=${got.runs}` : `carried runs=${got.runs}` }; + carryNone = got.none ?? `record-ci exit ${ci.status}`; + } + const local = evidence(['passed', '--', guard.command]); + const hit = /^PASSED: (.+)$/m.exec(String(local.stdout ?? ''))?.[1]; + return local.status === 0 && hit ? { prior: `reused ${hit}`, carryNone } : { carryNone }; +} async function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv, dir: string): Promise { const timeoutMs = guard.timeout_s ? guard.timeout_s * 1000 : ctx.timeoutMs; @@ -53,9 +82,14 @@ async function run(ctx: PregateContext): Promise { if (runtime.refusal) return { ...base, ...runtime.refusal, cause: 'environment', environment: launchCause('runtime', runtime.refusal.detail), lines: [`runtime: ${runtime.label}`, environmentLine(launchCause('runtime', runtime.refusal.detail))] }; const dir = checkScratch(ctx.scratch, 'guards'); const runs: GuardRun[] = []; - for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir)); + for (const g of selected) { + const p = priorPass(ctx, g, runtime.env); + runs.push(p.prior ? { guard: g, exit: 0, timedOut: false, stalled: false, tail: [], log: '-', environment: null, ...p } : { ...(await runGuard(ctx, g, runtime.env, dir)), carryNone: p.carryNone }); + } const lines = [`runtime: ${runtime.label}`]; for (const r of runs) { + if (r.carryNone) lines.push(`CI_CARRY: none guard=${r.guard.id} (${r.carryNone})`); + if (r.prior) { lines.push(`GUARD: ${r.guard.id} ${r.prior}`); continue; } lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`); if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`)); if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`); @@ -68,7 +102,8 @@ async function run(ctx: PregateContext): Promise { if (failed.length) return { ...withRuns, status: 'fail', cause: 'diff', code: 'PREGATE_GUARD_FAILED', detail: `${failed.length} guard(s) failed: ${failed.map(r => r.guard.id).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}${blocked.length ? `; blocked by the environment: ${blocked.map(r => r.guard.id).join(', ')}` : ''}`, fix: fixOf(failed) }; if (blocked.length) return environmentResult(withRuns, blocked[0]!.environment!, `${blocked.length} guard(s) could not run on this machine: ${blocked.map(r => `${r.guard.id} (${r.environment!.kind})`).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}`); if (timedOut.length) return { ...withRuns, status: 'incomplete', code: timedOut.some(r => r.stalled) ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${timedOut.length} guard(s) did not finish: ${timedOut.map(r => `${r.guard.id} (${r.stalled ? 'stalled' : 'timed out'})`).join(', ')}`, fix: 'fix the hang, or raise the guard\'s timeout_s, --timeout or --stall-timeout; an unfinished guard never passes' }; - return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}` }; + const prior = runs.filter(r => r.prior).length; + return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}${prior ? ` (${prior} from an earlier pass at this tree)` : ''}` }; } export const guardsCheck: CheckDef = { id: 'guards', stage: 'preflight', tier: 2, downgradable: false, needsRepoCommands: true, run }; diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index a16f4b0ed..a0ff1f85a 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -40,7 +40,7 @@ import { selectLanes, type Selection } from '../lane-selection'; import { launchRuntime } from '../runtime'; import { cargoModel, type CargoModel } from '../rust'; import { adapterFor, readRange, STALL_IDLE, supervise, type Counter, type Supervised } from '../supervise'; -import { verifyFromRun, type RunProfile } from '../../run-evidence'; +import { ranFiles, verifyFromRun, type RunProfile } from '../../run-evidence'; import { matchesAny } from '../../ship-policy'; import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types'; @@ -643,9 +643,10 @@ function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: s const names = 'error' in check ? [] : fromRunProfiles(ctx, check.profiles); const v = 'error' in check ? { ok: false as const, reason: 'profiles_unavailable', detail: check.error } : verifyFromRun(check, ref, names, ctx.wtree, ctx.head); if (!v.ok) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every selected test runs here`, passed: [] }; - const ran = new Set(v.evidence.files); + const ran = new Set(ranFiles(v.evidence)); const passed = sel.localTests.filter(t => ran.has(t)); - return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run`, passed, receipt: v.receipt }; + const skipped = sel.localTests.filter(t => v.evidence.skipped?.includes(t)).length; + return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run${skipped ? ` (${skipped} skipped every test in the run)` : ''}`, passed, receipt: v.receipt }; } /** diff --git a/lib/pregate/checks/literals.ts b/lib/pregate/checks/literals.ts index b34311e09..a1db40bb7 100644 --- a/lib/pregate/checks/literals.ts +++ b/lib/pregate/checks/literals.ts @@ -57,10 +57,10 @@ export function constantsIn(text: string): Map { return out; } -/** Named numeric call sites (`timeout: 5000`) keyed by name; the last value per name wins, which is enough to spot a change. */ -export function callLiteralsIn(text: string): Map { - const out = new Map(); - for (const m of text.matchAll(CALL_PATTERN)) out.set(m[1]!, m[2]!); +/** Named numeric call sites (`timeout: 5000`) keyed by name, every value in source order. */ +export function callLiteralsIn(text: string): Map { + const out = new Map(); + for (const m of text.matchAll(CALL_PATTERN)) out.set(m[1]!, [...(out.get(m[1]!) ?? []), m[2]!]); return out; } @@ -69,7 +69,14 @@ export function changedLiterals(file: string, oldText: string, newText: string): const oldC = constantsIn(oldText); const newC = constantsIn(newText); for (const [name, old] of oldC) { const now = newC.get(name); if (now !== undefined && now !== old) out.push({ name, old, new: now, file }); } const oldCall = callLiteralsIn(oldText); const newCall = callLiteralsIn(newText); - for (const [name, old] of oldCall) { const now = newCall.get(name); if (now !== undefined && now !== old && !oldC.has(name)) out.push({ name, old, new: now, file }); } + for (const [name, olds] of oldCall) { + if (oldC.has(name)) continue; + const added = [...(newCall.get(name) ?? [])]; + const removed = olds.filter(v => { const i = added.indexOf(v); if (i >= 0) added.splice(i, 1); return i < 0; }); + if (!added.length) continue; + const seen = new Set(); + removed.forEach((old, i) => { if (!seen.has(old)) { seen.add(old); out.push({ name, old, new: added[Math.min(i, added.length - 1)]!, file }); } }); + } return out; } diff --git a/lib/pregate/checks/strays.ts b/lib/pregate/checks/strays.ts index 4b5d5b572..fed27ae1d 100644 --- a/lib/pregate/checks/strays.ts +++ b/lib/pregate/checks/strays.ts @@ -12,7 +12,7 @@ import type { CheckDef, CheckResult, PregateContext } from '../types'; /** Conventional root files a repo adds without a declaration. */ export const ROOT_OK = /^(?:README|LICENSE|LICENCE|NOTICE|CHANGELOG|CONTRIBUTING|CODE_OF_CONDUCT|SECURITY|AUTHORS|VERSION|Makefile|Dockerfile|Procfile|Gemfile|Rakefile|Cargo\.(?:toml|lock)|go\.(?:mod|sum)|package(?:-lock)?\.json|bun\.lockb?|pnpm-lock\.yaml|yarn\.lock|tsconfig[\w.-]*\.json|bunfig\.toml|pyproject\.toml|pubspec\.(?:yaml|lock)|analysis_options\.yaml|dart_test\.yaml|requirements[\w.-]*\.txt|setup\.(?:py|cfg)|\.[\w.-]+|[\w.-]+\.config\.[cm]?[jt]s|[\w.-]+\.md)$/i; -export const STRAY_NAME = /^(?:zz-|tmp|temp|scratch|debug-|probe)|\.(?:orig|rej|swp|swo|bak|tmp)$|~$|^\.DS_Store$|^Thumbs\.db$/i; +export const STRAY_NAME = /^(?:zz-|tmp|scratch)|\.(?:orig|rej|swp|swo|bak|tmp)$|~$|^\.DS_Store$|^Thumbs\.db$/i; function run(ctx: PregateContext): CheckResult { const inputs = baseInputs(ctx); diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index 9212f6acf..1797338a8 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -46,9 +46,13 @@ * tree, the command listing the gate's test files they must * cover, and the command naming the selected files the gate * itself never runs (gstack: paid files) - * ship_lanes_from exact /ship lane command → `{ profiles, inventory }`: the - * run profiles whose verified runs at HEAD's tree carry that - * lane, and the command listing its test files (lib/ci-carry.ts) + * ship_lanes_from exact /ship lane command → `{ profiles, inventory }` or + * `{ profiles, jobs }`: the run profiles whose verified runs + * at HEAD's tree carry that lane, and either the command + * listing its test files or the CI jobs that run it (a lane + * with no test files, GST-1189), plus optional `aliases`: + * other exact commands for the same lane (GST-1190) + * (lib/ci-carry.ts) * allowlist `{ finding, paths?, pattern?, reason }` entries the `secrets` * check accepts as benign: a gstack-redact finding id or a * non-secret category, scoped by path globs and/or a regex @@ -74,7 +78,8 @@ import { shipLanesErrors, type ShipLaneMapping } from '../ci-carry'; import type { ResultCodeName } from '../result-codes'; import { runProfileErrors, type RemoteGateRuns, type RunProfile } from '../run-evidence'; import { sealedErrors } from '../sealed-guard'; -import { containedRelative, globToRegExp } from '../ship-policy'; +import { containedRelative, globToRegExp, runningGstackVersion, SHIP_POLICY_PATH } from '../ship-policy'; +import { cmpVersion, parseVersion } from '../version-source'; export const PREGATE_CONFIG_PATH = '.gstack/pregate.json'; export const PREGATE_CONFIG_SCHEMA = 'https://github.com/garrytan/gstack/docs/pregate.md#pregate-v1'; @@ -291,6 +296,22 @@ function git(cwd: string, args: string[]): { ok: boolean; out: string } { return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim() }; } +/** + * GST-1190: an unknown key in a policy whose project floor (min_gstack_version in the same tree's ship-policy.json) is + * above this gstack belongs to a newer gstack. It still refuses, but as an upgrade, not as a typo. + */ +function newerGstackErrors(parsed: { config: PregateConfig; errors: ConfigError[]; declaresLanes: boolean }, policyText: string | null): typeof parsed { + const unknown = (e: ConfigError) => e.message.startsWith('unknown key'); + if (!policyText || !parsed.errors.some(unknown)) return parsed; + let min: unknown; + try { min = (JSON.parse(policyText) as Record | null)?.min_gstack_version; } catch { return parsed; } + const running = runningGstackVersion(); + const want = typeof min === 'string' ? parseVersion(min) : null; + const have = parseVersion(running); + if (!want || !have || cmpVersion(want, have) <= 0) return parsed; + return { ...parsed, errors: parsed.errors.map(e => unknown(e) ? { ...e, code: 'PREGATE_GSTACK_TOO_OLD' as const, message: `needs gstack >= ${min as string}; upgrade (this gstack is ${running} and does not know this key)` } : e) }; +} + export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: readonly string[] = []): ConfigLoad { const ref = `origin/${base.replace(/^origin\//, '')}`; const sha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); @@ -302,13 +323,15 @@ export function loadConfigFromBase(repoRoot: string, base: string, knownChecks: if (!entry.ok || entry.out) return { unavailable: true, base: base.replace(/^origin\//, ''), detail: `${ref}:${PREGATE_CONFIG_PATH} could not be read and git cannot confirm it is absent` }; return { config: DEFAULT_CONFIG, errors: [], present: false, label: `${ref}@${sha.out.slice(0, 12)} (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; } - return { ...parseConfig(show.out, knownChecks), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' }; + const policy = git(repoRoot, ['show', `${sha.out}:${SHIP_POLICY_PATH}`]); + return { ...newerGstackErrors(parseConfig(show.out, knownChecks), policy.ok ? policy.out : null), present: true, label: `${ref}@${sha.out.slice(0, 12)}:${PREGATE_CONFIG_PATH}`, kind: 'base' }; } export function loadConfigFromHead(repoRoot: string, knownChecks: readonly string[] = []): ConfigLoad { const file = path.join(repoRoot, PREGATE_CONFIG_PATH); if (!fs.existsSync(file)) return { config: DEFAULT_CONFIG, errors: [], present: false, label: `head (no ${PREGATE_CONFIG_PATH})`, kind: 'none' }; - return { ...parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' }; + const policy = path.join(repoRoot, SHIP_POLICY_PATH); + return { ...newerGstackErrors(parseConfig(fs.readFileSync(file, 'utf8'), knownChecks), fs.existsSync(policy) ? fs.readFileSync(policy, 'utf8') : null), present: true, label: `head (unreviewed) ${PREGATE_CONFIG_PATH}`, kind: 'head' }; } /** A symlink to nothing on the way to `file` (a dotfiles link not yet cloned): a policy the user meant to have, not an absent one. */ @@ -365,7 +388,7 @@ export function explainConfig(): string { 'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree= tests= failed=) (docs/pregate.md#remote-gate)', 'run_profiles: name → { workflow, events, jobs, artifact, max_age_hours?, os? }; the GitHub Actions runs gstack-pregate clear --run verifies as evidence on the identical tree (docs/pregate.md#run-evidence)', 'remote_gate_runs: { profiles, inventory, skipped? }; the run profiles that together clear the remote gate obligation, the command listing the gate\'s test files they must cover, and an optional command printing which selected files (PREGATE_FILES_FILE) the gate itself does not run, which then need no run (docs/pregate.md#run-evidence)', - 'ship_lanes_from: exact /ship lane command → { profiles, inventory }; a verified run of each profile at HEAD\'s clean tree whose tree.json covers the inventory carries the lane instead of running it again (docs/pregate.md#ci-carry)', + 'ship_lanes_from: exact /ship lane command → { profiles, inventory } or { profiles, jobs }, with optional aliases (other exact commands for the same lane); a verified run of each profile at HEAD\'s clean tree whose tree.json covers the inventory, or whose newest attempt ran every named job to success, carries the lane instead of running it again (docs/pregate.md#ci-carry)', `allowlist: [{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted. finding is a gstack-redact id (env.kv, google.api_key, pii.phone.e164, …) or a category (${ALLOWLISTABLE_CATEGORIES.join(', ')}); paths are globs; pattern is a regex matched at the finding's position in the added line; one of the two is required, reason always. Never ${NON_ALLOWLISTABLE.join(' or ')} or engine.* (docs/pregate.md#allowlist)`, 'no .gstack/pregate.json on the base: /projects//pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)', ].join('\n') + '\n'; diff --git a/lib/pregate/flaky-exception.ts b/lib/pregate/flaky-exception.ts new file mode 100644 index 000000000..107e4f125 --- /dev/null +++ b/lib/pregate/flaky-exception.ts @@ -0,0 +1,403 @@ +/** + * pregate flaky exception (GST-1096, docs/pregate.md#flaky-exception): an owner-granted exception that lets GitHub + * Actions runs with failures clear one requires-remote obligation. `gstack-pregate clear --remote --run + * --flaky-exception "::" … --pr --grant ` (lib/pregate/index.ts clearByRuns) takes + * the runs only when: + * + * - every failure is named from the run's own records: each job of the attempt reconciles (only test-shard jobs, and + * the aggregate that needs them, may fail), the plan and every shard's strict result are present and complete (a + * timed-out or unattributed shard is incomplete evidence), and each failed shard's log names no failure its result + * omits; + * - the named cases are exactly the failures, at most three, none in a paid test file or a test of a security scan, + * and each file is outside the PR's diff and the diff's import closure (lib/pregate/import-graph.ts); + * - the runs tested pregate.json's own head and tree (no stamp-only substitution), the PR's head is that head, and an + * approved gstack-approvals row in the owner's words carries the scope `exceptionBinding` computes over repository, + * PR, head, tree, lane, runs with their attempts and cases, so it cannot be rebound to another head or case list. + * + * It is pregate only: nothing here touches a required CI check, which still needs its own green run at the head. + * base_red is not used: it proves a failure by reproducing it on the base, and a timing flake passes there. + */ +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; +import { locate, readRecord } from '../approval-record'; +import type { PregateException, PregateFile } from '../headless-artifacts'; +import { parseRunRef, verifyRun, type ApiJob, type ApiRun, type RunArtifactDeps, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence'; +import { wtreeFingerprint } from '../script-invocation'; +import { resolveBaseBranch } from '../ship-policy'; +import type { FreeCiPlan, FreeCiResult, FreeShardOutcome } from '../../scripts/test-free-shards'; +import { isConfigUnavailable, loadConfigFromBase } from './config'; +import { buildGraph, testsFor } from './import-graph'; + +export type FailureDeps = RunArtifactDeps; +export interface GrantRow { id: string; outcome: string; scope: string; words: string; by: string } +export interface FlakyInput { cases: string[]; pr?: number; grant?: string; grantRow?: (id: string) => GrantRow | null } +export type FlakyRefusal = { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string }; +/** What a flaky exception is granted for; `exceptionBinding` hashes exactly these fields. */ +export type ExceptionSubject = Pick; +export interface FlakyRuns { evidence: RunEvidence[]; red: Array<{ evidence: RunEvidence; failures: string[] }> } + +export const MAX_FLAKY_CASES = 3; +export const FLAKY_SCOPE_PREFIX = 'flaky-exception:'; +/** The receipt field an exception clear adds; verify and verdictOf read it back. */ +export const EXCEPTION_RECEIPT = / exception=(\S+)/; + +/** The strict shard runner's artifacts and job names (scripts/test-free-shards.ts --ci-run) in the workflows that upload them. */ +interface ShardLayout { plan: string; result: string; logs: string; shardJob: string; aggregate: string } +const SHARD_LAYOUTS: Record = { + 'free-tests.yml': { plan: 'free-plan', result: 'free-result-', logs: 'free-test-shard-logs-', shardJob: 'free-suite', aggregate: 'free-tests' }, + 'windows-free-tests.yml': { plan: 'windows-plan', result: 'windows-result-', logs: 'windows-free-test-shard-logs-', shardJob: 'windows-free-shard', aggregate: 'windows-free-tests' }, +}; +/** A test of a security scanner (/cso, secrets, gitleaks, the sealed guard, redaction, OSV): never excepted. */ +const SECURITY_TEST = /(?:^|[-_.])(?:cso|secrets?|gitleaks|sealed|redact\w*|osv|security|sanitize\w*|injection|inject)(?=[-_.])/; +const SHA = /^[0-9a-f]{40}$/; +const LOG_FILE_HEADER = /^(\S.*\.test\.(?:[cm]?[jt]s|tsx|jsx)):$/; + +const sha256 = (text: string) => createHash('sha256').update(text).digest('hex'); +const short = (s: string | undefined) => (s ?? '').slice(0, 12); +const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +/** The gstack-approvals scope that grants one exception: a digest of every field it binds, cases in sorted order. */ +export function exceptionBinding(e: ExceptionSubject): string { + const lines = [`lane=${e.lane}`, `repo=${e.repo}`, `pr=${e.pr}`, `head=${e.head}`, `tree=${e.tree}`, ...e.runs.map(r => `run=${r}`), ...[...e.cases].sort().map(c => `case=${c}`)]; + return FLAKY_SCOPE_PREFIX + sha256(lines.join('\n')); +} + +/** The approvals rows of this checkout's branch record (lib/approval-record.ts), looked up by id. */ +export function approvalGrants(repoRoot: string, env: NodeJS.ProcessEnv = process.env): (id: string) => GrantRow | null { + return id => { + const loc = locate({ cwd: repoRoot, env }); + return 'code' in loc ? null : readRecord(loc).rows.find(r => r.id === id) ?? null; + }; +} + +/** The `--flaky-exception` values as `::` strings, or the usage refusal. */ +export function parseCases(cases: string[]): string[] | FlakyRefusal { + if (cases.length > MAX_FLAKY_CASES) return { refused: 'usage', message: `--flaky-exception names at most ${MAX_FLAKY_CASES} cases; got ${cases.length}` }; + const bad = cases.find(c => { const i = c.indexOf('::'); return i <= 0 || i + 2 >= c.length; }); + if (bad !== undefined) return { refused: 'usage', message: `--flaky-exception takes "::", the file and the test name as the run printed them; got ${JSON.stringify(bad)}` }; + if (new Set(cases).size !== cases.length) return { refused: 'usage', message: '--flaky-exception names each case once' }; + return cases; +} + +/** One-line summary for verify's PREGATE line: `pass-with-exception (; grant )`. */ +export function describeExceptions(file: PregateFile): string | null { + const list = file.exceptions ?? []; + if (!list.length) return null; + return `pass-with-exception (${list.flatMap(e => e.cases).join(', ')}; grant ${list.map(e => e.grant).join(', ')})`; +} + +/** READY's line for each exception: it waives the pregate obligation, never a required CI check. */ +export function flakyExceptionLines(file: PregateFile): string[] { + return (file.exceptions ?? []).map(e => `FLAKY_EXCEPTION: ${e.lane} cases=${e.cases.join(' | ')} run=${e.runs.join(',')} grant=${e.grant} by=${e.by} (pregate only; every required CI check still needs its own green run at this head)`); +} + +/** verify's offline check: every exception receipt has its entry, bound to this head and tree, with an intact binding; null when all hold. */ +export function exceptionProblem(file: PregateFile): string | null { + const list = file.exceptions ?? []; + const remote = file.requires_remote ?? []; + for (const r of remote) { + const grant = EXCEPTION_RECEIPT.exec(r.receipt ?? '')?.[1]; + if (!grant) continue; + const e = list.find(x => x.lane === r.lane && x.grant === grant); + if (!e) return `${r.lane}'s receipt names flaky exception grant ${grant}, but pregate.json has no exceptions entry for it; clear again`; + if (e.head !== file.head || e.tree !== file.tree) return `${r.lane}'s flaky exception (grant ${grant}) is bound to head ${short(e.head)} tree ${short(e.tree)}, not pregate.json's head ${short(file.head)} tree ${short(file.tree)}; a new head needs a new run and a new grant`; + if (exceptionBinding(e) !== e.binding) return `${r.lane}'s flaky exception (grant ${grant}): the binding does not match its recorded fields (granted scope ${e.binding}); clear again`; + const runs = / runs=(\S+)/.exec(r.receipt ?? '')?.[1]; + if (runs !== e.runs.join(',')) return `${r.lane}'s receipt names runs ${runs ?? 'none'}, but its flaky exception (grant ${grant}) was granted for ${e.runs.join(',')}; clear again`; + } + const orphan = list.find(e => !remote.some(r => r.lane === e.lane && EXCEPTION_RECEIPT.exec(r.receipt ?? '')?.[1] === e.grant)); + return orphan ? `pregate.json records a flaky exception for ${orphan.lane} (grant ${orphan.grant}) that no receipt uses; clear again` : null; +} + +/** Why `file`'s exceptions cannot stand for this PR head: each binds the PR and head it was granted for, and a same-tree commit or another PR reusing the record never inherits it. */ +export function exceptionConsumerProblem(file: PregateFile, head: string, pr?: string): string | null { + const e = (file.exceptions ?? []).find(x => x.head !== head || (pr !== undefined && String(x.pr) !== pr)); + return e ? `flaky exception grant ${e.grant} is bound to PR #${e.pr} at ${short(e.head)}, not ${pr ? `PR #${pr} at ` : ''}${short(head)}; a new head needs a new run and a new grant` : null; +} + +/** Why red run `id` no longer stands as completed, failed attempt `attempt`; read again once the whole clear is assembled. */ +export function redRunChanged(deps: RunDeps, id: number, attempt: number): string | null { + const again = deps.run(id); + return again.run_attempt === attempt && again.status === 'completed' && again.conclusion === 'failure' ? null : `run ${id} moved to attempt ${again.run_attempt} (${again.status === 'completed' ? again.conclusion : again.status}) while it was being verified`; +} + +/** verify's ok-line suffix naming each exception. */ +export function exceptionSummary(file: PregateFile): string { + return (file.exceptions ?? []).map(e => `flaky exception ${e.cases.join(', ')} (grant ${e.grant}, by ${e.by}, run ${e.runs.join(',')})`).join('; '); +} + +const invalid = (lane: string, reason: string, detail: string, fix?: string): FlakyRefusal => ({ refused: 'PREGATE_RECEIPT_INVALID', message: `FLAKY_EXCEPTION: refused reason=${reason} — ${detail}; ${lane} stays uncleared`, ...(fix ? { fix } : {}) }); +const errText = (e: unknown) => String((e as Error)?.message ?? e).slice(0, 300); + +/** + * The runs for `names` (one per profile, matched by workflow): a green run verifies as `clear --run` verifies it, a red + * run through `readRunFailures`. At least one must be red, or no exception is needed. + */ +export function flakyRuns(lane: string, check: { repo: string; deps: RunDeps; profiles: Record; now: Date; failures?: FailureDeps }, names: string[], refs: string[], vetoHead: string | undefined): FlakyRuns | FlakyRefusal { + if (!check.failures) return { refused: 'usage', message: 'the run artifacts cannot be read here (no GitHub origin), so failures cannot be named' }; + const runs: Array<{ ref: string; id: number; run: ApiRun }> = []; + for (const ref of refs) { + const id = parseRunRef(ref, check.repo); + if (id === null) return invalid(lane, 'run_ref_invalid', `${JSON.stringify(ref)} is not a run id or a run URL on ${check.repo}`); + try { runs.push({ ref, id, run: check.deps.run(id) }); } + catch (e) { return invalid(lane, 'api_error', `reading run ${id} failed: ${errText(e)}`, `check gh auth status and that gh can read ${check.repo}'s Actions runs, then rerun the same clear`); } + } + if (runs.every(r => r.run.status === 'completed' && r.run.conclusion === 'success')) return invalid(lane, 'run_green', `${runs.map(r => `run ${r.id} attempt ${r.run.run_attempt}`).join(', ')} passed; clear with --run alone, no exception is needed`); + const out: FlakyRuns = { evidence: [], red: [] }; + const used = new Set(); + for (const name of names) { + const profile = check.profiles[name]!; + const pick = runs.find(r => !used.has(r.id) && r.run.path === `.github/workflows/${profile.workflow}`) ?? runs.find(r => !used.has(r.id)); + if (!pick) return { refused: 'usage', message: `${lane} needs one run per profile (${names.join(', ')})` }; + used.add(pick.id); + if (pick.run.conclusion === 'success') { + const v = verifyRun(check.deps, { repo: check.repo, runRef: pick.ref, profileName: name, profile, vetoHeads: vetoHead ? [vetoHead] : [], now: check.now }); + if (!v.ok) return invalid(lane, v.reason, `${name}: ${v.detail}`); + out.evidence.push(v.evidence); + continue; + } + const r = readRunFailures(check.deps, check.failures, { repo: check.repo, id: pick.id, run: pick.run, profileName: name, profile, now: check.now }); + if (!r.ok) return invalid(lane, r.reason, r.detail); + out.evidence.push(r.evidence); + out.red.push({ evidence: r.evidence, failures: r.failures }); + } + return out; +} + +type Read = { ok: true } & T | { ok: false; reason: string; detail: string }; +const fail = (reason: string, detail: string) => ({ ok: false as const, reason, detail }); + +/** The one JSON file of artifact `name`, parsed; a message when it is missing, ambiguous or unreadable. */ +function jsonArtifact(art: FailureDeps, id: number, name: string): T | { error: string } { + let files: Record; + try { files = art.artifactFiles(id, name); } catch (e) { return { error: errText(e) }; } + const json = Object.entries(files).filter(([f]) => f.endsWith('.json')); + if (json.length !== 1) return { error: `${name} holds ${json.length} JSON files, not one` }; + try { return JSON.parse(json[0]![1]) as T; } catch (e) { return { error: `${json[0]![0]} is not JSON: ${errText(e)}` }; } +} + +const isError = (v: unknown): v is { error: string } => !!v && typeof v === 'object' && 'error' in v && typeof (v as { error: unknown }).error === 'string'; +const escapeRe = (s: string) => s.replace(/[.*+?^${{}()|[\]\\]/g, '\\$&'); + +/** + * A red run under `profile` read from its own records (B8: a red run uploads no tree.json): every job of the attempt, the + * plan, every shard's strict result and each failed shard's logs. Returns the evidence (the plan's files, the tests the + * shards ran) and the failed cases as `::`, or the refusal naming what could not be reconciled. + */ +function readRunFailures(deps: RunDeps, art: FailureDeps, o: { repo: string; id: number; run: ApiRun; profileName: string; profile: RunProfile; now: Date }): Read<{ evidence: RunEvidence; failures: string[] }> { + const { run, profile: p, id } = o; + const layout = SHARD_LAYOUTS[p.workflow]; + if (!layout) return fail('no_shard_results', `${p.workflow} uploads no strict shard results gstack can read (only ${Object.keys(SHARD_LAYOUTS).join(' and ')} do), so its failures cannot be named`); + const at = `run ${id} attempt ${run.run_attempt}`; + try { + if ((run.repository?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return fail('other_repository', `${at} belongs to ${run.repository?.full_name ?? 'an unknown repository'}, not ${o.repo}`); + if ((run.head_repository?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return fail('fork_run', `${at} ran for ${run.head_repository?.full_name ?? 'an unknown repository'}, not ${o.repo}`); + if (run.path !== `.github/workflows/${p.workflow}`) return fail('workflow_mismatch', `${at} ran ${run.path ?? 'an unknown workflow'}, not .github/workflows/${p.workflow}`); + if (!p.events.includes(run.event)) return fail('event_mismatch', `${at} is a ${run.event} run; ${o.profileName} accepts ${p.events.join(', ')}`); + if (run.status !== 'completed') return fail('run_in_progress', `${at} is ${run.status}`); + if (run.conclusion !== 'failure') return fail('run_not_failure', `${at} concluded ${run.conclusion}; only a run that finished with test failures can carry an exception (a cancelled or timed-out run is incomplete evidence)`); + if (!SHA.test(run.head_sha)) return fail('api_error', `${at} has no head commit`); + const jobs = deps.jobs(id, run.run_attempt); + const finishedAt = jobs.map(j => j.completed_at).filter((t): t is string => !!t).sort().pop() ?? run.updated_at; + const finished = Date.parse(finishedAt ?? ''); + const maxAge = p.max_age_hours ?? 24; + if (!Number.isFinite(finished)) return fail('api_error', `${at} has no completion time`); + if (o.now.getTime() - finished > maxAge * 3600_000) return fail('run_stale', `${at} finished ${finishedAt}, more than ${maxAge} h before now`); + const newer = deps.runsForHead(p.workflow, run.head_sha).find(r => r.id !== id && (r.run_started_at ?? r.created_at ?? '') >= (run.created_at ?? '')); + if (newer) return fail('newer_run', `run ${newer.id} of ${p.workflow} on ${short(run.head_sha)} started after ${at}; clear with the newest run`); + const shardJobs = reconcileJobs(jobs, layout, p, at); + if ('reason' in shardJobs) return fail(shardJobs.reason, shardJobs.detail); + const plan = jsonArtifact(art, id, layout.plan); + if (isError(plan)) return fail('shard_incomplete', `${at} has no readable ${layout.plan} (${plan.error})`); + if (plan.version !== 1 || typeof plan.id !== 'string' || typeof plan.revision !== 'string' || !Array.isArray(plan.shards) || !plan.shards.length + || plan.shards.some((s, i) => s.shard !== i + 1 || !Array.isArray(s.files) || s.files.some(f => typeof f !== 'string'))) return fail('result_mismatch', `${at}'s ${layout.plan} is not a strict shard plan`); + const headTree = deps.commitTree(run.head_sha); + if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`); + if (plan.revision !== run.head_sha && (!SHA.test(plan.revision) || deps.commitTree(plan.revision) !== headTree)) return fail('tree_mismatch', `${at} tested ${short(plan.revision)}, whose tree is not its head ${short(run.head_sha)}'s tree ${short(headTree)}`); + if (shardJobs.size !== plan.shards.length || plan.shards.some(s => !shardJobs.has(s.shard))) return fail('shard_mismatch', `${at} has ${layout.shardJob} job(s) for shard(s) ${[...shardJobs.keys()].sort((a, b) => a - b).join(', ') || 'none'}, but its plan has ${plan.shards.length} shard(s)`); + const failures: string[] = []; + let tests = 0; + for (const s of plan.shards) { + const res = jsonArtifact(art, id, `${layout.result}${s.shard}`); + if (isError(res)) return fail('shard_incomplete', `shard ${s.shard} has no readable ${layout.result}${s.shard} (${res.error})`); + if (res.planId !== plan.id || res.revision !== plan.revision || res.outcome?.shard !== s.shard || JSON.stringify(res.outcome.files) !== JSON.stringify(s.files)) return fail('result_mismatch', `shard ${s.shard}'s result is not shard ${s.shard} of plan ${plan.id}`); + const named = shardFailures(res, s.shard); + if ('reason' in named) return fail(named.reason, named.detail); + const job = shardJobs.get(s.shard)!; + if ((job.conclusion === 'failure') !== (named.final.length > 0)) return fail('shard_mismatch', `shard ${s.shard}'s job concluded ${job.conclusion}, but its result names ${named.final.length} final failure(s)`); + if (job.conclusion === 'failure') { + const problem = logProblem(art, id, `${layout.logs}${s.shard}`, s.shard, s.files, named.all); + if (problem) return fail(problem.reason, problem.detail); + } + failures.push(...named.final); + tests += res.outcome.summary?.testsRan ?? 0; + } + const again = deps.run(id); + if (again.run_attempt !== run.run_attempt || again.status !== 'completed' || again.conclusion !== run.conclusion) return fail('run_changed', `run ${id} moved to attempt ${again.run_attempt} (${again.status === 'completed' ? again.conclusion : again.status}) while it was being verified`); + return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files: plan.shards.flatMap(s => s.files), tests }, failures: [...new Set(failures)] }; + } catch (e) { + return fail('api_error', `reading run ${id} failed: ${errText(e)}`); + } +} + +/** Shard number → job, once every job of the attempt is accounted for: only shard jobs and the aggregate may fail. */ +function reconcileJobs(jobs: ApiJob[], layout: ShardLayout, p: RunProfile, at: string): Map | { reason: string; detail: string } { + const shardRe = new RegExp(`^${escapeRe(layout.shardJob)} \\((\\d+)\\)$`); + const shards = new Map(); + for (const j of jobs) { + if (j.conclusion === null) return { reason: 'shard_incomplete', detail: `${at}'s ${j.name} job has not finished` }; + const m = shardRe.exec(j.name); + if (m) { + if (shards.has(Number(m[1]))) return { reason: 'shard_mismatch', detail: `${at} has two jobs named ${j.name}` }; + if (j.conclusion !== 'success' && j.conclusion !== 'failure') return { reason: 'shard_incomplete', detail: `${at}'s ${j.name} job concluded ${j.conclusion}` }; + shards.set(Number(m[1]), j); + continue; + } + if (j.name === layout.aggregate) continue; + const allowed = p.jobs.includes(j.name) ? ['success'] : ['success', 'skipped', 'neutral']; + if (!allowed.includes(j.conclusion)) return { reason: 'unnamed_failure', detail: `${at}'s ${j.name} job concluded ${j.conclusion}; only test-shard failures can be named` }; + } + const missing = p.jobs.find(n => !jobs.some(j => j.name === n)); + if (missing) return { reason: 'job_missing', detail: `${at} has no job named ${missing}` }; + const aggregate = jobs.filter(j => j.name === layout.aggregate); + if (aggregate.length !== 1) return { reason: 'job_missing', detail: `${at} has ${aggregate.length} jobs named ${layout.aggregate}` }; + if (![...shards.values()].some(j => j.conclusion === 'failure')) return { reason: 'unnamed_failure', detail: `${at} failed, but no ${layout.shardJob} job did, so the failure is not a test case` }; + if (aggregate[0]!.conclusion !== 'failure') return { reason: 'shard_mismatch', detail: `${at}'s ${layout.aggregate} job concluded ${aggregate[0]!.conclusion} although a shard failed` }; + return shards; +} + +function completeSummary(o: FreeShardOutcome): boolean { + const s = o.summary; + return !!s && Number.isInteger(s.testsRan) && s.testsRan! >= 0 && s.filesRan === o.files.length && (o.files.length === 0 || s.sawTerminalSummary === true); +} + +/** + * A shard's final failures (`::`) and every failure it recorded. A retry that passed recovered its files; a + * retry that failed replaces the first run's failures for the files it ran. Anything unattributed or incomplete refuses. + */ +function shardFailures(r: FreeCiResult, n: number): { final: string[]; all: Set } | { reason: string; detail: string } { + const o = r.outcome; + const key = (f: { file: string | null; testName: string }) => `${f.file}::${f.testName}`; + if (o.status === 'timed-out') return { reason: 'shard_incomplete', detail: `shard ${n} timed out, so the tests after the hang never ran` }; + if (!completeSummary(o)) return { reason: 'shard_incomplete', detail: `shard ${n}'s result has no complete run summary` }; + if (o.status === 'passed') { + if (o.exitCode !== 0 || o.failingFiles.length || o.unattributedFailures || r.retry) return { reason: 'result_mismatch', detail: `shard ${n} passed with failures recorded` }; + return { final: [], all: new Set() }; + } + if (o.status !== 'failed') return { reason: 'result_mismatch', detail: `shard ${n} has status ${String(o.status)}` }; + if (o.unattributedFailures) return { reason: 'unnamed_failure', detail: `shard ${n}: ${o.unattributedFailures} failure(s) the runner could not attribute to a test file` }; + const first = o.failures ?? []; + const unnamed = first.filter(f => !f.file || !o.files.includes(f.file) || !f.testName); + if (!first.length || unnamed.length) return { reason: 'unnamed_failure', detail: `shard ${n} failed ${first.length ? `with ${unnamed.length} failure(s) not attributed to one of its files` : 'without naming a failure'}` }; + if (JSON.stringify([...new Set(first.map(f => f.file))].sort()) !== JSON.stringify([...o.failingFiles].sort())) return { reason: 'result_mismatch', detail: `shard ${n}'s named failures and failing files disagree` }; + const all = new Set(first.map(key)); + const t = r.retry; + if (!t) return { final: [...all], all }; + if (t.status === 'timed-out' || !completeSummary(t)) return { reason: 'shard_incomplete', detail: `shard ${n}'s retry did not complete` }; + if (t.files.some(f => !o.failingFiles.includes(f))) return { reason: 'result_mismatch', detail: `shard ${n}'s retry ran files that had not failed` }; + const again = t.failures ?? []; + if (t.unattributedFailures || (t.status === 'failed' && (!again.length || again.some(f => !f.file || !t.files.includes(f.file))))) return { reason: 'unnamed_failure', detail: `shard ${n}'s retry failed without naming each failure` }; + if (t.status === 'passed' && (again.length || t.exitCode !== 0)) return { reason: 'result_mismatch', detail: `shard ${n}'s retry passed with failures recorded` }; + for (const f of again) all.add(key(f)); + const final = [...first.filter(f => !t.files.includes(f.file!)), ...again].map(key); + return { final: [...new Set(final)], all }; +} + +/** Why a failed shard's logs do not reconcile with its result: missing, an unhandled error between tests, or a failure the result omits. */ +function logProblem(art: FailureDeps, id: number, name: string, n: number, planned: string[], recorded: Set): { reason: string; detail: string } | null { + let files: Record; + try { files = art.artifactFiles(id, name); } catch (e) { return { reason: 'logs_missing', detail: `shard ${n} failed and has no readable ${name} (${errText(e)})` }; } + const logs = Object.entries(files).filter(([f]) => f.endsWith('.log')); + if (!logs.length) return { reason: 'logs_missing', detail: `shard ${n} failed and its ${name} holds no .log file` }; + const canonical = (printed: string) => { + const stripped = printed.replace(/\\/g, '/').replace(/^(?:\.{1,2}\/)+/, ''); + return planned.includes(stripped) ? stripped : planned.find(p => stripped.endsWith(`/${p}`) || p.endsWith(`/${stripped}`)) ?? stripped; + }; + const { classifyBunTestOutputLine, parseBunFailureResult, stripAnsiLine } = require('../../scripts/lib/shard-engine') as typeof import('../../scripts/lib/shard-engine'); + for (const [, text] of logs) { + let file: string | null = null; + let recap = false; + let unhandled = 0; + for (const raw of text.split('\n')) { + const line = stripAnsiLine(raw).replace(/^::group::/, '').replace(/\r$/, ''); + if (/^\d+ tests? failed:$/.test(line)) { recap = true; file = null; continue; } + if (classifyBunTestOutputLine(line) === 'unhandled-between-tests') { unhandled++; continue; } + const header = LOG_FILE_HEADER.exec(line); + if (header) { if (!recap) file = canonical(header[1]!); continue; } + const failed = recap ? null : parseBunFailureResult(line); + if (failed !== null && !recorded.has(`${file}::${failed}`)) return { reason: 'unnamed_failure', detail: `shard ${n}'s log names ${file}::${failed}, which its result does not` }; + } + if (unhandled) return { reason: 'unnamed_failure', detail: `shard ${n}'s log shows ${unhandled} unhandled error(s) between tests` }; + } + return null; +} + +/** The PR's changed paths against the merge base with origin/, and each test the diff reaches through the import graph. */ +function prScope(repoRoot: string, file: PregateFile): { diff: Set; closure: Map } | { error: string } { + const git = (args: string[]) => { const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; + const base = resolveBaseBranch(repoRoot); + const mb = git(['merge-base', `refs/remotes/origin/${base}`, '--end-of-options', file.head!])?.trim(); + if (!mb) return { error: `git cannot find the merge base of origin/${base} and ${short(file.head)}` }; + const listed = git(['diff', '--name-status', '-z', '--no-renames', '--end-of-options', mb, file.head!]); + if (listed === null) return { error: `git diff ${short(mb)} ${short(file.head)} failed` }; + const parts = listed.split('\0').filter(Boolean); + const diff = new Set(); + const deleted: string[] = []; + for (let i = 0; i + 1 < parts.length; i += 2) { diff.add(parts[i + 1]!); if (parts[i] === 'D') deleted.push(parts[i + 1]!); } + const loaded = loadConfigFromBase(repoRoot, base); + if (isConfigUnavailable(loaded) || loaded.errors.length) return { error: `the base branch's .gstack/pregate.json cannot be read, so its dependency declarations are unknown` }; + const at = () => { const fp = wtreeFingerprint({ cwd: repoRoot }); return 'tree' in fp ? fp.tree : null; }; + if (at() !== file.tree) return { error: `the working tree is not pregate.json's tree ${short(file.tree)}; the import closure must be read from that tree` }; + const declarations = loaded.config.dependencies; + const graph = buildGraph(repoRoot, { declarations, extraFiles: deleted }); + const closure = new Map(); + for (const changed of diff) for (const t of testsFor(graph, changed, declarations).tests) closure.set(t, [...(closure.get(t) ?? []), changed]); + if (at() !== file.tree) return { error: 'the working tree changed while the import closure was read' }; + return { diff, closure }; +} + +/** + * After the runs verified and covered the obligation: the named cases must be exactly the failures, in no paid or + * security test, outside the PR's diff and import closure, the PR's head must be pregate.json's, and the grant row must + * carry this exception's binding in the owner's words. Returns the exception to record. + */ +export function flakyDecision(file: PregateFile, lane: string, check: { repo: string; failures?: FailureDeps }, runs: FlakyRuns, input: FlakyInput & { cases: string[] }, repoRoot: string): { exception: PregateException } | FlakyRefusal { + const label = runs.red.map(r => `run ${r.evidence.run_id} attempt ${r.evidence.run_attempt}`).join(', '); + const failures = [...new Set(runs.red.flatMap(r => r.failures))]; + const extra = failures.filter(f => !input.cases.includes(f)); + if (extra.length) return invalid(lane, 'unnamed_failure', `${label} failed ${extra.slice(0, 5).join(', ')}${extra.length > 5 ? ` and ${extra.length - 5} more` : ''}, which no --flaky-exception names`); + const notFailed = input.cases.find(c => !failures.includes(c)); + if (notFailed) return invalid(lane, 'case_not_failed', `${notFailed} did not fail in ${label}; its failures are ${failures.join(', ')}`); + const files = [...new Set(input.cases.map(c => c.slice(0, c.indexOf('::'))))]; + const { isPaidTestFile } = require('../../test/helpers/paid-test-set') as typeof import('../../test/helpers/paid-test-set'); + const paid = files.find(f => isPaidTestFile(f)); + if (paid) return invalid(lane, 'case_paid', `${paid} is a paid test file; the paid gate covers it, never an exception`); + const security = files.find(f => SECURITY_TEST.test(path.posix.basename(f))); + if (security) return invalid(lane, 'case_security', `${security} tests a security scan, which an exception never covers`); + let pr: { head_sha: string; state: string; head_repo: string | null }; + try { pr = check.failures!.pull(input.pr!); } + catch (e) { return invalid(lane, 'api_error', `reading PR #${input.pr} failed: ${errText(e)}`); } + if (pr.state !== 'open') return invalid(lane, 'pr_mismatch', `PR #${input.pr} is ${pr.state}`); + if ((pr.head_repo ?? '').toLowerCase() !== check.repo.toLowerCase()) return invalid(lane, 'pr_mismatch', `PR #${input.pr}'s head is on ${pr.head_repo ?? 'an unknown repository'}, not ${check.repo}`); + if (pr.head_sha !== file.head) return invalid(lane, 'pr_mismatch', `PR #${input.pr}'s head is ${short(pr.head_sha)}, not pregate.json's head ${short(file.head)}`); + const scope = prScope(repoRoot, file); + if ('error' in scope) return invalid(lane, 'closure_failed', scope.error); + const touched = files.find(f => scope.diff.has(f)); + if (touched) return invalid(lane, 'case_in_diff', `${touched} is in the PR's diff`); + const reached = files.find(f => scope.closure.has(f)); + if (reached) return invalid(lane, 'case_in_closure', `${reached} depends on a file the PR changes (${scope.closure.get(reached)!.slice(0, 5).join(', ')})`); + const subject: ExceptionSubject = { lane, repo: check.repo, pr: input.pr!, head: file.head!, tree: file.tree, runs: runs.evidence.map(e => `${e.run_id}/${e.run_attempt}`), cases: [...input.cases].sort() }; + const binding = exceptionBinding(subject); + if (!input.grant) { + const ask = `Accept flaky ${subject.cases.join(', ')} in run ${subject.runs.join(',')} for PR #${subject.pr} at ${short(subject.head)}? Pregate only; required CI still needs a green run.`.slice(0, 300); + return invalid(lane, 'grant_needed', `every failure in ${label} is named (${subject.cases.join(', ')}) and outside the PR's diff and its import closure; the owner must grant the exception`, + `ask the owner, record their words with gstack-approvals add --ask ${shellQuote(ask)} --words "" --by "" --scope "${binding}" --source pregate-flaky-exception, then rerun this clear with --grant `); + } + const row = input.grantRow?.(input.grant) ?? null; + if (!row) return invalid(lane, 'grant_missing', `no gstack-approvals row ${input.grant} in this branch's record`); + if (row.scope !== binding) return invalid(lane, 'grant_mismatch', `row ${row.id}'s scope ${JSON.stringify(row.scope.slice(0, 80))} is not this exception's binding ${binding}; a grant covers one repository, PR, head, tree, lane, run attempt and case list, so a new head needs a new grant`); + if (row.outcome !== 'approved') return invalid(lane, 'grant_not_approved', `row ${row.id} records outcome ${row.outcome}, not approved`); + if (/^agent:/i.test(row.by)) return invalid(lane, 'grant_not_owner', `row ${row.id} records an agent's words (${row.by}); only the owner grants an exception`); + return { exception: { ...subject, grant: row.id, by: row.by, words: row.words, binding } }; +} diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 70e614cec..73ab1ba2f 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts'; +import { ARTIFACT_SCHEMAS, checkSchema, type PregateException, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts'; import { wtreeFingerprint, type WtreeResult } from '../script-invocation'; import { resolveStateRoot } from '../state-root'; import { guardsCheck } from './checks/guards'; @@ -28,6 +28,7 @@ import { timingCheck } from './checks/timing'; import { NON_DOWNGRADABLE, PREGATE_CONFIG_PATH, userPolicyAbsent } from './config'; import { sha256, withFingerprint } from './context'; import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt'; +import { EXCEPTION_RECEIPT, exceptionProblem, exceptionSummary, flakyDecision, flakyRuns, parseCases, redRunChanged, type FailureDeps, type FlakyInput } from './flaky-exception'; import { buildRunCheck } from './run-check'; import { resolveBaseBranch } from '../ship-policy'; import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiJob, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence'; @@ -134,6 +135,7 @@ export function verdictOf(file: PregateFile): string { if (statuses.has('incomplete') || statuses.has('not_installed')) return 'incomplete'; const uncleared = (file.requires_remote ?? []).filter(r => !r.receipt); if (uncleared.length) return `requires-remote (${uncleared.length} uncleared)`; + if ((file.requires_remote ?? []).some(r => EXCEPTION_RECEIPT.test(r.receipt ?? ''))) return 'pass-with-exception'; if (statuses.has('requires-remote')) return 'requires-remote cleared'; if (statuses.has('warn')) return 'warn'; return 'pass'; @@ -234,8 +236,12 @@ export function verifyPregate(repoRoot: string, file: PregateFile, treeNow: Wtre // A run receipt was verified online at clear for its tree; offline it must still name this tree (or carry its substitution). const foreignRuns = (file.requires_remote ?? []).filter(r => { const run = parseRunReceipt(r.receipt ?? ''); return run && !r.substitution && run.tree !== file.tree.slice(0, 12); }); if (foreignRuns.length) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: `${foreignRuns.length} run receipt(s) verified for another tree: ${foreignRuns.map(r => `${r.lane} (${r.receipt})`).join(', ')}; clear again with runs on tree ${file.tree.slice(0, 12)}`, current, uncleared: foreignRuns }; + // A flaky exception was granted for one head, tree, run attempt and case list; recorded for another, or edited, it clears nothing. + const exception = exceptionProblem(file); + if (exception) return { ok: false, code: 'PREGATE_REMOTE_UNCLEARED', message: exception, current, uncleared: [] }; const substituted = (file.requires_remote ?? []).filter(r => r.substitution).map(r => `${r.lane}: ${describeSubstitution(r.substitution!)}`); - return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}`, current, uncleared }; + const excepted = exceptionSummary(file); + return { ok: true, message: `pregate ${file.verdict} is current for tree ${file.tree.slice(0, 12)}${substituted.length ? `; ${substituted.join('; ')}` : ''}${excepted ? `; ${excepted}` : ''}`, current, uncleared }; } /** @@ -259,10 +265,10 @@ function userPolicyChange(file: PregateFile): string | null { return `${absent} now holds a user-owned policy, and pregate.json was recorded under the defaults; rerun gstack-pregate`; } -export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck } +export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck; flaky?: FlakyInput } /** What a run clear needs: the base branch's run profiles, the verifier's dependencies, the clock, and the remote gate's inventory. */ -export interface RunCheck { repo: string; deps: RunDeps; profiles: Record; gateRuns: RemoteGateRuns | null; now: Date; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string } } -export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string }; +export interface RunCheck { repo: string; deps: RunDeps; profiles: Record; gateRuns: RemoteGateRuns | null; now: Date; unchanged?: (p: RunProfile) => string | null; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string }; failures?: FailureDeps } +export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution; exception?: PregateException } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string }; /** * What `clear` may record for one obligation. A `pregate.json/remote_gate` @@ -284,12 +290,21 @@ export type ClearDecision = { receipt: string; attested: boolean; substitution?: * `--run` (and `--run-url` on a lane the base branch profiles) goes to * clearByRuns, which accepts only verified Actions runs; a profiled lane * refuses `--bundle-lane`, and the remote gate clears by runs only when the - * base declares remote_gate_runs. + * base declares remote_gate_runs. `flaky` (`--flaky-exception`) takes runs with failures the same way under an owner's + * grant (lib/pregate/flaky-exception.ts). */ export function clearDecision(file: PregateFile, lane: string, input: ClearInput, repoRoot: string): ClearDecision { const list = file.requires_remote ?? []; const target = list.find(r => r.lane === lane); if (!target) return { refused: 'usage', message: `no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}` }; + if (input.flaky) { + const cases = parseCases(input.flaky.cases); + if (!Array.isArray(cases)) return cases; + const refs = [...(input.runs ?? []), ...(input.runUrl ? [input.runUrl] : [])]; + if (!refs.length) return { refused: 'usage', message: '--flaky-exception needs --run : an exception reads GitHub Actions runs' }; + if (!input.flaky.pr) return { refused: 'usage', message: '--flaky-exception needs --pr , the pull request the grant binds' }; + return clearByRuns(file, lane, refs, input.evidence, repoRoot, { ...input.flaky, cases }); + } const profiled = input.evidence && (lane === REMOTE_GATE_LANE ? input.evidence.gateRuns : input.evidence.profiles[lane]); if (input.runs?.length || (input.runUrl && profiled)) return clearByRuns(file, lane, [...(input.runs ?? []), ...(input.runUrl ? [input.runUrl] : [])], input.evidence, repoRoot); if (input.bundleLane && profiled && lane !== REMOTE_GATE_LANE) return { refused: 'usage', message: `${lane} has a run profile on the base branch; clear it with --run , which verifies the run` }; @@ -372,7 +387,7 @@ function treeSubstitution(file: PregateFile, gatedTree: string, lane: string, re * does not run (never an inventory file); any other lane needs one verified run of the profile named after it. The run * tree must be pregate.json's tree or a stamp-only predecessor of it, exactly as a gate report's tree must. */ -function clearByRuns(file: PregateFile, lane: string, refs: string[], check: RunCheck | undefined, repoRoot: string): ClearDecision { +function clearByRuns(file: PregateFile, lane: string, refs: string[], check: RunCheck | undefined, repoRoot: string, flaky?: FlakyInput & { cases: string[] }): ClearDecision { if (!check) return { refused: 'usage', message: `--run needs the base branch's run_profiles; none could be read` }; const isGate = lane === REMOTE_GATE_LANE; const names = isGate ? check.gateRuns?.profiles ?? [] : check.profiles[lane] ? [lane] : []; @@ -404,13 +419,17 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run } return null; }; - const evidence = assign(0, new Set()); + const red = flaky ? flakyRuns(lane, check, names, refs, file.head) : null; + if (red && 'refused' in red) return red; + const evidence = red ? red.evidence : assign(0, new Set()); if (!evidence) { const failed = names.map(name => `${name}: ${refs.map(ref => { const v = verdict(name, ref); return v.ok ? `${ref} ok` : `${ref} reason=${v.reason} ${v.detail}`; }).join('; ')}`); return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=no_verified_run — no run per profile verifies (${failed.join(' | ')}); ${lane} stays uncleared` }; } const trees = [...new Set(evidence.map(e => e.tree))]; if (trees.length > 1) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=tree_mismatch — the runs tested different trees (${trees.map(t => t.slice(0, 12)).join(', ')}); ${lane} stays uncleared` }; + const off = red && evidence.find(e => e.head_sha !== file.head || e.tree !== file.tree); + if (off) return { refused: 'PREGATE_RECEIPT_INVALID', message: `FLAKY_EXCEPTION: refused reason=head_mismatch — run ${off.run_id} attempt ${off.run_attempt} tested head ${off.head_sha.slice(0, 12)} tree ${off.tree.slice(0, 12)}, not pregate.json's head ${(file.head ?? '').slice(0, 12)} tree ${file.tree.slice(0, 12)}; an exception binds the exact head and tree, never a stamp-only predecessor; ${lane} stays uncleared` }; const sub = treeSubstitution(file, trees[0]!, lane, repoRoot); if ('refused' in sub) return { ...sub, message: `REUSE: refused reason=tree_not_stamp_only — ${sub.message}` }; const idle = evidence.filter(e => (e.tests ?? e.files.length) === 0); @@ -433,24 +452,29 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run } const missing = uncoveredFiles([...required], evidence); if (missing.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${isGate ? 'inventory_uncovered' : 'tests_uncovered'} — ${missing.length} of the ${required.size} test file(s) ${lane} must cover appear in no run's tree.json (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}); ${lane} stays uncleared` }; + const granted = red ? flakyDecision(file, lane, check, red, flaky!, repoRoot) : null; + if (granted && 'refused' in granted) return granted; for (const e of evidence) { let changed: string | null; - try { changed = runChanged(check.deps, e.run_id, e.run_attempt); } + try { changed = red?.red.some(r => r.evidence === e) ? redRunChanged(check.deps, e.run_id, e.run_attempt) : runChanged(check.deps, e.run_id, e.run_attempt); } catch (err: any) { changed = `reading run ${e.run_id} again failed: ${String(err?.message ?? err).slice(0, 200)}`; } if (changed) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=run_changed — ${changed}; ${lane} stays uncleared` }; } const tests = evidence.reduce((n, e) => n + (e.tests ?? e.files.length), 0); const accepted = sub.substitution ? ` accepted-for=${file.tree.slice(0, 12)} stamp-only=${sub.substitution.files.length}` : ''; - return { receipt: `${isGate ? 'remote-gate' : 'run'} tree=${trees[0]!.slice(0, 12)} tests=${tests}${skippedCount ? ` skipped=${skippedCount}` : ''}${accepted} ${runReceiptFields(evidence, check.now)}`, attested: false, substitution: sub.substitution }; + const exception = granted?.exception; + return { receipt: `${isGate ? 'remote-gate' : 'run'} tree=${trees[0]!.slice(0, 12)} tests=${tests}${skippedCount ? ` skipped=${skippedCount}` : ''}${exception ? ` exception=${exception.grant} cases=${exception.cases.length}` : ''}${accepted} ${runReceiptFields(evidence, check.now)}`, attested: false, substitution: sub.substitution, ...(exception ? { exception } : {}) }; } /** Record the receipt that clears one obligation (a run URL, an evidence-bundle lane label, or a remote gate report, with the stamp-only substitution when one was accepted). */ -export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: PregateReceiptSubstitution): PregateFile { +export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: PregateReceiptSubstitution, exception?: PregateException): PregateFile { const list = file.requires_remote ?? []; const target = list.find(r => r.lane === lane); if (!target) throw new Error(`no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}`); target.receipt = receipt; target.cleared_by = by; if (substitution) target.substitution = substitution; else delete target.substitution; + const exceptions = [...(file.exceptions ?? []).filter(e => e.lane !== lane), ...(exception ? [exception] : [])]; + if (exceptions.length) file.exceptions = exceptions; else delete file.exceptions; file.verdict = verdictOf(file); return file; } diff --git a/lib/pregate/run-check.ts b/lib/pregate/run-check.ts index cd527c499..b3c795fda 100644 --- a/lib/pregate/run-check.ts +++ b/lib/pregate/run-check.ts @@ -11,27 +11,66 @@ 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, type RunProfile } from '../run-evidence'; import { wtreeFingerprint } from '../script-invocation'; import { resolveBaseBranch } from '../ship-policy'; import { normalizeRemoteUrl } from '../ship-queue-train-record'; import { REMOTE_GATE_LANE } from './checks/lanes'; -import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase } from './config'; +import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from './config'; import type { RunCheck } from './index'; /** - * `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is - * recorded unverified only once the base shows it has none. + * Null when a skippable profile's workflow, its declared `scripts` and every repo path its gate job names are byte-identical + * at HEAD and origin/ (GST-1190); else the first that differs. Named paths are read from the gate job's block in the base + * workflow (after `${{ … }}` prefixes, and also under each working-directory), plus package.json when the job runs a package + * script. Files the gate reaches indirectly (imports, a package script's own files) count only when `scripts` declares them. */ -export function buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root)): RunCheck | { error: string; profiled?: boolean } { - const loaded = loadConfigFromBase(root, base); +export function gateInputsChanged(root: string, base: string, p: RunProfile): string | null { + const ref = `origin/${base.replace(/^origin\//, '')}`; + const wf = `.github/workflows/${p.workflow}`; + const gate = p.skippable?.gate; + if (!gate) return 'the profile names no gate job'; + const blob = (rev: string, f: string) => { const r = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${rev}:${f}`], { cwd: root, encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : null; }; + const show = spawnSync('git', ['show', `${ref}:${wf}`], { cwd: root, encoding: 'utf8', timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }); + if (show.status !== 0) return `${wf} is not on ${ref}`; + const lines = show.stdout.split('\n'); + const jobsAt = lines.findIndex(l => /^jobs:\s*$/.test(l)); + const blocks: string[][] = []; + for (let i = jobsAt + 1; jobsAt >= 0 && i < lines.length && !/^\S/.test(lines[i]!); i++) { + if (/^ [\w-]+:\s*$/.test(lines[i]!)) blocks.push([lines[i]!]); + else blocks.at(-1)?.push(lines[i]!); + } + const block = blocks.find(b => b[0]!.trim() === `${gate}:` || b.some(l => /^ name:/.test(l) && l.trim() === `name: ${gate}`)); + if (!block) return `${wf} on ${ref} has no job ${gate}`; + const text = block.join('\n'); + const dirs = ['', ...[...text.matchAll(/working-directory:\s*['"]?([\w.\/-]+)/g)].map(m => `${m[1]!.replace(/^\.?\/+|\/+$/g, '')}/`)]; + const tokens = [...text.matchAll(/[\w.\/-]+/g)].map(m => m[0]).filter(t => t.includes('/') || /\.(sh|ts|js|mjs|cjs|py|rb)$/.test(t)); + const named = [...new Set(tokens.flatMap(t => dirs.map(d => path.posix.normalize(d + t.replace(/^\.?\/+/, ''))).filter(n => !n.startsWith('..'))))]; + const pkg = /\b(bun|npm|pnpm|yarn)\s+(run\s+)?[\w:-]+/.test(text) ? dirs.map(d => `${d}package.json`) : []; + for (const f of [wf, ...(p.skippable!.scripts ?? []), ...named, ...pkg]) { + const a = blob(ref, f); + const b = blob('HEAD', f); + if (a === null && b === null && f !== wf && !p.skippable!.scripts?.includes(f)) continue; + if (a === null || a !== b) return `${f} differs from ${ref}`; + } + return null; +} + +/** + * `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is + * recorded unverified only once the base shows it has none. `policy: 'head'` reads the branch's profiles instead, only + * for gstack-ship-preflight --simulate, a dry run that records and clears nothing. + */ +export function buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root), policy: 'base' | 'head' = 'base'): RunCheck | { error: string; profiled?: boolean } { + const loaded = policy === 'head' ? loadConfigFromHead(root) : loadConfigFromBase(root, base); if (isConfigUnavailable(loaded)) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is unreadable (${loaded.detail}); run profiles come only from the base`, profiled: true }; if (loaded.errors.length) return { error: `the base branch's ${PREGATE_CONFIG_PATH} is invalid: ${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, profiled: true }; const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', timeout: 30_000 }); 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(), + unchanged: p => gateInputsChanged(root, base, p), inventory: (command, tree, selected) => { if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' }; const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; }; diff --git a/lib/restamp.ts b/lib/restamp.ts index be2c6fc23..9eba5f88e 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -313,7 +313,7 @@ export function branchAddedLines(cwd: string, since: string, files: string[], to return out; } -const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export const escapeRegExp = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const VERSION_RE = '(?\\d+(?:\\.\\d+){1,3})(?![0-9.])'; /** * The built-in stamp forms as scanners, the version in the `v` group: `(was X)`, `since: X` and `vX`. diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 2d0a0dddb..6c209131a 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -16,6 +16,11 @@ export const RELEASE_RESULT_CODES = { summary: 'the ship policy sets min_gstack_version above this gstack, so this gstack refuses to read it', fix: 'upgrade gstack (bin/gstack-upgrade, or the session install with --for ) to the version named, then rerun', }, + PREGATE_GSTACK_TOO_OLD: { + anchor: 'pregate-gstack-too-old', + summary: 'the pregate policy uses a key this gstack does not know, and the project floor (min_gstack_version in .gstack/ship-policy.json) is above this gstack, so the key belongs to a newer gstack; the policy stays refused', + fix: 'upgrade gstack (bin/gstack-upgrade, or the session install with --for ) to at least the version named, then rerun', + }, QUEUE_NOT_READY: { anchor: 'queue-not-ready', summary: 'the lane is not ready to report: the working tree, HEAD or CI state does not match the pushed PR head', diff --git a/lib/review-collect.ts b/lib/review-collect.ts index ac16ba0d2..f5e10709f 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -10,6 +10,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { resolveBase, type ResolvedBase } from './gate-diff/git'; import { GENERATED_PATH, parseRegistry } from './regen'; import { RESULT_CODES } from './result-codes'; import { classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth'; @@ -31,6 +32,8 @@ export interface Collected extends ClassInput { untracked: string[]; consumed: string[] | 'all'; drawings: string[]; + /** collectLive only: the ref resolveBase read for the base name (GST-1036), null when nothing resolved. */ + base?: ResolvedBase | null; } function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } { @@ -255,16 +258,20 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings }); } -/** The working tree against the merge base with origin/ (or ; a remote-qualified base reads refs/remotes/ only), untracked files counted per path; the registry is the base ref's commit. */ +/** + * The working tree against the merge base with the base resolveBase reads (lib/gate-diff/git.ts: a bare name is + * origin/ when it shares history with HEAD, else the local branch; a remote-qualified base reads + * refs/remotes/ only), untracked files counted per path; the registry is the base ref's commit. + */ export function collectLive(root: string, base: string): Collected { const errors: DepthError[] = []; - const qualified = base.startsWith('origin/') || git(root, ['remote']).out.split('\n').some(r => r && base.startsWith(`${r}/`)); - const ref = (qualified ? [`refs/remotes/${base}`] : [`refs/remotes/origin/${base}`, `refs/heads/${base}`]).find(r => git(root, ['rev-parse', '--verify', '-q', `${r}^{commit}`]).ok) ?? base; + const resolved = resolveBase(base, root, 'HEAD'); + const ref = resolved?.sha ?? base; const registry = loadRegistryAt(root, ref); const mb = git(root, ['merge-base', ref, 'HEAD']); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` }); - return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); + return { ...result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }), base: resolved }; } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase], errors); @@ -284,5 +291,5 @@ export function collectLive(root: string, base: string): Collected { const drawings = drawingsAmong(diff.perPath, root, p => { try { return fs.statSync(path.join(root, p)).size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; } }); - return result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings }); + return { ...result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings }), base: resolved }; } diff --git a/lib/review-depth.ts b/lib/review-depth.ts index e8751cc8e..c337ebd98 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -93,7 +93,16 @@ const SVG_MARKUP = /^<(?:!--|!\[CDATA\[|\?xml\s|!DOCTYPE\s+svg(?:\s+PUBLIC\s+([" export const SVG_REFUSED = [/&(?!(?:amp|lt|gt|quot|apos);)/, /\\/, /@import/i, /url\((?!\s*#|['"]\s*#)/i, /expression\s*\(/i, /-moz-binding|\bbehavior\s*:/i]; /** A drawing's CSS, a style element's text or a style attribute's value, holds declarations only: no @ rule and no function but url(#id). */ const SVG_STYLE_OPEN = /<(?:[A-Za-z_][\w.-]*:)?style\b/gi; -const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style\s*>/i; +const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style[ \t\n\r]*>/i; +/** + * A style element's opening tag, read as quoted name="value" pairs separated by the ASCII whitespace XML and HTML share: any + * other shape (a value holding < or >, an unquoted or valueless attribute, />, another space) refuses, so it cannot hide a closing tag. + */ +const SVG_STYLE_TAG = /^<(?:[A-Za-z_][\w.-]*:)?style(?:[ \t\n\r]+[\w.:-]+[ \t\n\r]*=[ \t\n\r]*(?:"[^"<>]*"|'[^'<>]*'))*[ \t\n\r]*>/i; +/** An element name, ended by ASCII whitespace, / or >: a non-ASCII character in the name (a prefix such as gé:) refuses rather than reading as its ASCII start. */ +const SVG_TAG_NAME = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)(?=[ \t\n\r/>]|$)/; +/** Every name= in the text, non-ASCII letters included so a prefix such as xé:href is read whole and refused. */ +const SVG_ATTR = /(?=]+)))/g; const SVG_CSS_REF = /url\(\s*(['"]?)#[\w.:-]*\1\s*\)/gi; const SVG_CSS_REFUSED = /[@(]/; /** An href or src value a drawing may hold: a same-document reference, or a raster image inline. */ @@ -102,10 +111,12 @@ const SVG_HREF = /^\s*(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+ /** * Whether an SVG's whole text is a drawing (GST-911): every element in SVG_ELEMENTS, no event handler, every href or src a * same-document reference or an inline raster image, CSS of declarations only, and nothing SVG_REFUSED names, read after character references are - * decoded. Every name= in the text is read, so a stray quote cannot hide a later attribute inside its value; an unquoted value - * stops at the next =, which keeps the scan linear. A style element's CSS is walked with indexOf to its closing tag, dropping - * comments and reading CDATA as CSS, so neither can hide a closing tag; any other markup inside it, or a style element that never - * closes, refuses. Anything else, an SVG that can run code included, stays code. + * decoded. Every name= in the text is read (SVG_ATTR), so a stray quote cannot hide a later attribute inside its value, and a name + * holding a non-ASCII character refuses, like an element name (SVG_TAG_NAME); an unquoted value + * stops at the next =, which keeps the scan linear. A style element's opening tag is read quote by quote (SVG_STYLE_TAG), and its CSS is walked with indexOf to its closing tag over the raw + * text, so a character reference cannot fake a closing tag or a comment, and any & in it refuses (GST-940). Comments and CDATA are + * read as CSS, so neither can hide a closing tag and an HTML-only comment end ( or --!>) exposes nothing unread; any other + * markup inside it, or a style element that never closes, refuses. Anything else, an SVG that can run code included, stays code. */ export function svgDrawing(text: string): boolean { const t = decodeCharRefs(text); @@ -113,36 +124,36 @@ export function svgDrawing(text: string): boolean { const cssDrawing = (css: string) => !SVG_CSS_REFUSED.test(css.replace(SVG_CSS_REF, '')); for (let at = 0; ;) { SVG_STYLE_OPEN.lastIndex = at; - const open = SVG_STYLE_OPEN.exec(t); + const open = SVG_STYLE_OPEN.exec(text); if (!open) break; - let i = t.indexOf('>', open.index) + 1; - if (!i) return false; + const tag = SVG_STYLE_TAG.exec(text.slice(open.index, open.index + 4096)); + if (!tag) return false; + let i = open.index + tag[0].length; let css = ''; for (;;) { - const lt = t.indexOf('<', i); + const lt = text.indexOf('<', i); if (lt === -1) return false; - css += t.slice(i, lt); - if (t.startsWith('', lt + 4) + 3; - if (i === 2) return false; - } else if (t.startsWith('', lt + 9); + css += text.slice(i, lt); + const [start, close] = text.startsWith(''] : text.startsWith(''] : [-1, '']; + if (start !== -1) { + const end = text.indexOf(close, start); if (end === -1) return false; - css += t.slice(lt + 9, end); + css += ' ' + text.slice(start, end) + ' '; i = end + 3; - } else if (SVG_STYLE_CLOSE.test(t.slice(lt, lt + 256))) { + } else if (SVG_STYLE_CLOSE.test(text.slice(lt, lt + 256))) { at = lt + 1; break; } else return false; } - if (!cssDrawing(css)) return false; + if (css.includes('&') || !cssDrawing(css)) return false; } for (let at = t.indexOf('<'); at !== -1; at = t.indexOf('<', at + 1)) { if (SVG_MARKUP.test(t.slice(at, at + 512))) continue; - const tag = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/.exec(t.slice(at, at + 256)); + const tag = SVG_TAG_NAME.exec(t.slice(at, at + 256)); if (!tag || !SVG_ELEMENTS.has(tag[1]!.toLowerCase())) return false; } - for (const m of t.matchAll(/(?=]+)))/g)) { + for (const m of t.matchAll(SVG_ATTR)) { + if (/[^\x00-\x7f]/.test(m[1]!)) return false; const local = m[1]!.slice(m[1]!.lastIndexOf(':') + 1).toLowerCase(); const value = m[2] ?? m[3] ?? m[4] ?? ''; if (local.startsWith('on') || ((local === 'href' || local === 'src') && !SVG_HREF.test(value)) || (local === 'style' && !cssDrawing(value))) return false; @@ -153,7 +164,7 @@ export function svgDrawing(text: string): boolean { /** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */ export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({ RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, SVG_ELEMENTS: [...SVG_ELEMENTS], - patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), + patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_STYLE_TAG: [SVG_STYLE_TAG], SVG_TAG_NAME: [SVG_TAG_NAME], SVG_ATTR: [SVG_ATTR], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), })).digest('hex').slice(0, 16); /** diff --git a/lib/review-evidence.ts b/lib/review-evidence.ts index 60dc4e669..f4cd47549 100644 --- a/lib/review-evidence.ts +++ b/lib/review-evidence.ts @@ -272,6 +272,9 @@ export function bindReview(rec: Record, token: string, env = proces if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE; if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true'; stampGateFindings(rec.findings, env.GSTACK_STAMP_SESSION_KIND); + if (rec.skill === 'review-fix-check' && (typeof rec.completed !== 'boolean' || typeof rec.converged !== 'boolean')) { + throw new Error('review-log: a review-fix-check row must carry completed and converged as true or false (review-army item 6 fields; without them gstack-review-read marks the row UNVERIFIED and finish cannot count it)'); + } // A /ship pass whose findings were all fixed, or deferred or noted below the floor, left no open defect (review-army // item 6), so a claimed issues_found is recorded clean with zero counts and Step 11.5 can bind it (GST-430). Only the // logger-stamped action counts (never a caller's status), and the findings must account for every claimed issue. diff --git a/lib/review-exclude.ts b/lib/review-exclude.ts index 80533bc7d..78d9131f6 100644 --- a/lib/review-exclude.ts +++ b/lib/review-exclude.ts @@ -122,20 +122,38 @@ function select(i: ExcludeInput, patterns: readonly string[]): { byPattern: Map< return { byPattern, files }; } +/** Executable image magic: ELF, Mach-O (thin, universal and 64-bit universal; 0xcafebabe is also a Java class), PE, WebAssembly and a script's #!. */ +const NATIVE_MAGIC = [[0x7f, 0x45, 0x4c, 0x46], [0xfe, 0xed, 0xfa, 0xce], [0xfe, 0xed, 0xfa, 0xcf], [0xce, 0xfa, 0xed, 0xfe], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xca, 0xfe, 0xba, 0xbf], [0x4d, 0x5a], [0x00, 0x61, 0x73, 0x6d], [0x23, 0x21]]; + /** - * The matched paths git calls binary whose bytes hold a NUL in their first 8,000 (git's own test), read from the head blob, - * the file on a live tree, or the old blob for a deletion. A branch's .gitattributes can make git call a text file binary; - * only real bytes make it binary content here. + * The matched paths git calls binary by their bytes alone: `git diff --numstat` with attributes read from the empty tree, so + * a branch's .gitattributes (`diff`, `-binary`, `text`) cannot make an executable image read as text and skip the + * magic check. null when git cannot run it (a git without --attr-source); the caller then reads every matched file's bytes. */ -function binaryContent(i: ExcludeInput, files: ReadonlyMap, claimed: ReadonlySet, unread: Set): Set { +function bytesBinary(i: ExcludeInput, patterns: readonly string[]): string[] | null { + const empty = git(i.root, ['hash-object', '-t', 'tree', '--stdin'], ''); + if (!empty.ok) return null; + const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase]; + const r = git(i.root, [`--attr-source=${empty.out.trim()}`, 'diff', '--numstat', '-z', '--no-renames', ...range, '--', ...patterns.map(p => `:(top,glob)${p}`)]); + return r.ok ? r.out.split('\0').filter(l => l.startsWith('-\t-\t')).map(l => l.slice(4)) : null; +} +const nativeImage = (b: Buffer) => NATIVE_MAGIC.some(m => m.every((x, k) => b[k] === x)); + +/** + * The claimed paths (git calls them binary under the branch's attributes or under none: bytesBinary) whose bytes hold a NUL + * in their first 8,000 (git's own test), read from the head blob, the file on a live tree, or the old blob for a deletion. + * A branch's .gitattributes can make git call a text file binary; only real bytes make it binary content here. A binary file that starts with executable image magic also goes in `native`. + */ +function binaryContent(i: ExcludeInput, files: ReadonlyMap, claimed: ReadonlySet, unread: Set, native: Set): Set { const out = new Set(); + const sniff = (p: string, b: Buffer) => { if (!b.includes(0)) return; out.add(p); if (nativeImage(b)) native.add(p); }; const real = (sha: string) => /^[0-9a-f]{40,64}$/.test(sha) && !/^0+$/.test(sha); const bySha = new Map(); for (const p of claimed) { const m = files.get(p); if (!m) continue; if (!i.head && m.newMode !== '000000') { - try { const fd = fs.openSync(path.join(i.root, p), 'r'); const buf = Buffer.alloc(8000); const n = fs.readSync(fd, buf, 0, 8000, 0); fs.closeSync(fd); if (buf.subarray(0, n).includes(0)) out.add(p); } catch { unread.add(p); } + try { const fd = fs.openSync(path.join(i.root, p), 'r'); const buf = Buffer.alloc(8000); const n = fs.readSync(fd, buf, 0, 8000, 0); fs.closeSync(fd); sniff(p, buf.subarray(0, n)); } catch { unread.add(p); } continue; } const sha = real(m.newSha) ? m.newSha : real(m.oldSha) ? m.oldSha : ''; @@ -144,8 +162,36 @@ function binaryContent(i: ExcludeInput, files: ReadonlyMap, cla for (const [sha, paths] of bySha) { const r = spawnSync('git', ['cat-file', 'blob', sha], { cwd: i.root, timeout: 30_000, maxBuffer: 8000 }); const read = r.status === 0 || (r.error as NodeJS.ErrnoException | undefined)?.code === 'ENOBUFS'; - const binary = read && (r.stdout ?? Buffer.alloc(0)).subarray(0, 8000).includes(0); - for (const p of paths) if (!read) unread.add(p); else if (binary) out.add(p); + const head = (r.stdout ?? Buffer.alloc(0)).subarray(0, 8000); + for (const p of paths) if (!read) unread.add(p); else sniff(p, head); + } + return out; +} + +/** + * The added lines of matched files git calls binary only because of their attributes (the bytes hold no NUL): the + * collector's added lines are empty for them, so the secret-line rule reads `git diff --text` instead, which no + * .gitattributes, textconv or external diff driver changes. One git diff per KEEP_ARG_MAX_BYTES of literal pathspecs; + * git prints one section per path in byte order, so a chunk whose sections do not line up with its sorted paths, or a + * git failure, puts every path of that chunk in `unread`. + */ +function attributeBinaryLines(i: ExcludeInput, paths: readonly string[], unread: Set): Record { + const out: Record = {}; + const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase]; + const chunks: string[][] = []; + let bytes = Infinity; + for (const p of [...paths].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) { + const n = argBytes([p]); + if (bytes + n > KEEP_ARG_MAX_BYTES) { chunks.push([]); bytes = 0; } + chunks[chunks.length - 1]!.push(p); + bytes += n; + } + for (const chunk of chunks) { + const r = git(i.root, ['diff', '--text', '--no-textconv', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '-U0', ...range, '--', ...chunk.map(literal)]); + const sections = r.ok ? r.out.split(/^(?=diff --git )/m).filter(s => s.startsWith('diff --git ')) : []; + const aligned = sections.length === chunk.length && chunk.every((p, k) => /["\\\x00-\x1f\x7f]/.test(p) || sections[k]!.startsWith(`diff --git a/${p} b/${p}\n`)); + if (!aligned) { for (const p of chunk) unread.add(p); continue; } + chunk.forEach((p, k) => { let hunk = false; out[p] = sections[k]!.split('\n').filter(l => (hunk ||= l.startsWith('@@')) && l.startsWith('+')).map(l => l.slice(1)); }); } return out; } @@ -159,17 +205,21 @@ function contentKind(i: ExcludeInput, p: string, binary: ReadonlySet): C return classifyPath(p, i.classRoot) === 'docs' ? 'docs' : null; } -/** Source and script extensions: a NUL in one does not make it excludable binary content. */ -const CODE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|go|rs|java|kts?|scala|swift|exs?|php|cs|c|h|cc|cpp|hpp|mm?|dart|pl|pm|lua|sh|bash|zsh|fish|ps1|bat|cmd|vue|svelte|html?|wasm)$/i; +/** + * The only extensions a binary file may be excludable content under: images, fonts, media, documents, compressed archives + * and data blobs. Any other binary (source with a NUL, a native library or executable, a key store, a pickle, no + * extension) is code; so is one that starts with executable image magic whatever its extension. + */ +const BINARY_CONTENT_EXT = /\.(?:png|jpe?g|gif|webp|bmp|ico|avif|tiff?|heic|heif|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|ogv|webm|mov|avi|mkv|flac|aac|opus|pdf|docx|xlsx|pptx|odt|ods|odp|gz|tgz|zip|tar|bz2|xz|zst|7z|bin|dat|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|sqlite3?|db|safetensors)$/i; -/** Why a matched file is kept by its path, mode and lines alone; null for content the policy may exclude. A binary file readers would only see as "Binary files differ" is content unless its directory, mode or gate tags keep it. */ -function pathReason(i: ExcludeInput, p: string, m: Matched, binary: ReadonlySet): KeptReason | null { +/** Why a matched file is kept by its path, mode and lines alone; null for content the policy may exclude. A binary file readers would only see as "Binary files differ" is content unless its directory, mode, gate tags, extension or magic keep it. */ +function pathReason(i: ExcludeInput, p: string, m: Matched, binary: ReadonlySet, native: ReadonlySet): KeptReason | null { if (/^\.(gstack|github)\//.test(p)) return 'policy-dir'; if (isGatePath(p)) return 'gate-path'; if (m.oldMode === '100755' || m.newMode === '100755') return 'executable'; if ([m.oldMode, m.newMode].some(x => x === '120000' || x === '160000')) return 'link'; if (i.gateTagged?.has(p)) return 'gate-tagged'; - if (binary.has(p)) return CODE_EXT.test(p) ? 'code' : null; + if (binary.has(p)) return BINARY_CONTENT_EXT.test(p) && !native.has(p) ? null : 'code'; if (isAgentProse(p)) return 'agent-prose'; if (isDataConfig(p)) return 'config'; if (contentKind(i, p, binary) === null) return 'code'; @@ -227,9 +277,12 @@ export function reviewExclude(i: ExcludeInput): Exclusion { const sel = select(i, patterns); if ('error' in sel) return { state: 'refused', code: 'REVIEW_DIFF_UNREADABLE', lines: [`${i.context}: git could not list the files review_exclude_paths matches (${sel.error}); fix: git fetch origin ${base}, then rerun once; nothing was planned (REVIEW_DIFF_UNREADABLE)`] }; const unread = new Set(); - const binary = binaryContent(i, sel.files, new Set(i.perPath.filter(e => e.binary && sel.files.has(e.path)).map(e => e.path)), unread); + const native = new Set(); + const claimed = i.perPath.filter(e => e.binary && sel.files.has(e.path)).map(e => e.path); + const binary = binaryContent(i, sel.files, new Set([...claimed, ...(bytesBinary(i, patterns) ?? sel.files.keys())]), unread, native); + const text = { ...i, added: { ...i.added, ...attributeBinaryLines(i, claimed.filter(p => !binary.has(p) && !unread.has(p)), unread) } }; const reasons = new Map(); - for (const [p, m] of sel.files) reasons.set(p, unread.has(p) ? 'consumer-scan-unavailable' : pathReason(i, p, m, binary)); + for (const [p, m] of sel.files) reasons.set(p, unread.has(p) ? 'consumer-scan-unavailable' : pathReason(text, p, m, binary, native)); const candidates = new Set([...reasons].filter(([, r]) => r === null).map(([p]) => p)); const named = grepFixed(i, [...candidates].filter(p => !p.includes('\n')), candidates, [i.mergeBase]); for (const p of candidates) { diff --git a/lib/run-evidence.ts b/lib/run-evidence.ts index 9a66a99d9..513d8fa2d 100644 --- a/lib/run-evidence.ts +++ b/lib/run-evidence.ts @@ -42,7 +42,11 @@ import * as path from 'node:path'; import { sha256Hex, writeReceipt } from './egress-receipt'; import { runBashSync } from './script-invocation'; -export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string } +/** + * `skippable` (GST-1190): named jobs the workflow may skip by path; a skip counts only in a pull_request run whose `gate` job + * succeeded, with the workflow, the declared `scripts` and any path the gate's steps name matching origin/ byte for byte. + */ +export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string; skippable?: { jobs: string[]; gate: string; scripts: string[] } } export interface RemoteGateRuns { profiles: string[]; inventory: string; skipped?: string } export interface ApiRun { id: number; run_attempt: number; status: string; conclusion: string | null; event: string; head_sha: string; @@ -50,7 +54,14 @@ export interface ApiRun { repository?: { full_name: string } | null; head_repository?: { full_name: string } | null; } 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 } +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; skipped_files?: unknown } + +/** What a red run's reader needs beyond RunDeps (lib/pregate/flaky-exception.ts): every file of one artifact, and a pull request's head. */ +export interface RunArtifactDeps { + /** Every file of the run's one unexpired artifact `name`, path → text; throws when it is missing, ambiguous or too large. */ + artifactFiles(id: number, name: string): Record; + pull(n: number): { head_sha: string; state: string; head_repo: string | null }; +} /** Everything verifyRun reads, injectable so every refusal has a fixture. */ export interface RunDeps { @@ -62,18 +73,21 @@ export interface RunDeps { } export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | 'workflow_mismatch' | 'event_mismatch' | 'run_in_progress' - | 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch' + | 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_skipped' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch' | 'artifact_reused' | 'tree_mismatch' | 'newer_run_vetoes' | 'run_changed' | 'run_stale' | 'os_mismatch' | 'api_error'; -export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null } +/** `skipped`: the files whose every test skipped on the runner (tree.json `skipped_files`, GST-1190); undefined when an older tree.json does not say. */ +export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[]; skipped_jobs?: string[] } export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string }; const SHA = /^[0-9a-f]{40}$/; const RUN_EVENTS = ['push', 'workflow_dispatch', 'pull_request']; -const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os']; +const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os', 'skippable']; const WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/; const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/; export const DEFAULT_MAX_AGE_HOURS = 24; const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024; +/** Shard logs are the whole test stream, so a red run's reader allows more than a tree.json. */ +const MAX_LOG_ARTIFACT_BYTES = 64 * 1024 * 1024; /** A numeric run id, or a run URL on this repository (`…/actions/runs/[/attempts/][/job/]`). */ export function parseRunRef(ref: string, repo: string): number | null { @@ -104,6 +118,14 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{ if (typeof p.artifact !== 'string' || !NAME.test(p.artifact)) errors.push({ path: `${at}.artifact`, message: 'must be the artifact name holding tree.json' }); if ('max_age_hours' in p && (!Number.isInteger(p.max_age_hours) || (p.max_age_hours as number) < 1 || (p.max_age_hours as number) > 168)) errors.push({ path: `${at}.max_age_hours`, message: 'must be an integer from 1 to 168' }); if ('os' in p && (typeof p.os !== 'string' || !/^(Linux|macOS|Windows)$/.test(p.os))) errors.push({ path: `${at}.os`, message: 'must be Linux, macOS or Windows' }); + if ('skippable' in p) { + const s = p.skippable as Record | null; + const names = (v: unknown) => Array.isArray(v) && v.length > 0 && v.length <= 32 && v.every(x => typeof x === 'string' && !!x.trim() && x.length <= 128); + const repoPath = (x: unknown) => typeof x === 'string' && !!x && !x.startsWith('/') && !x.split('/').includes('..') && !/[\0\r\n]/.test(x); + if (!s || typeof s !== 'object' || Array.isArray(s) || Object.keys(s).some(k => !['jobs', 'gate', 'scripts'].includes(k)) || !names(s.jobs) || typeof s.gate !== 'string' || !s.gate.trim() + || (s.jobs as string[]).includes(s.gate as string) || !Array.isArray(s.scripts) || !s.scripts.length || !s.scripts.every(repoPath)) + errors.push({ path: `${at}.skippable`, message: 'must be { jobs: [job names], gate: , scripts: [every repo file the gate reads] }, the gate not among the jobs' }); + } } } if (gateRuns !== undefined && gateRuns !== null) { @@ -126,13 +148,13 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{ const fail = (reason: RunRefusal, detail: string): RunVerdict => ({ ok: false, reason, detail }); /** When a run's newest attempt started. A run vetoes the candidate when it started at or after the second the candidate was created: a red re-run of an older run counts, and re-running the candidate does not hide a failure in between. */ const started = (r: ApiRun): string => r.run_started_at ?? r.created_at ?? ''; -const normalPath = (f: string): string => f.replace(/^(\.\/)+/, ''); +export const normalPath = (f: string): string => f.replace(/^(\.\/)+/, ''); /** * The verdict for run `runRef` under `profile`. `vetoHeads` are further heads * whose newer runs of the same workflow can veto (the consumer's own head). */ -export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; now: Date }): RunVerdict { +export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; now: Date; unchanged?: (p: RunProfile) => string | null }): RunVerdict { const id = parseRunRef(o.runRef, o.repo); if (id === null) return fail('run_ref_invalid', `${JSON.stringify(o.runRef)} is not a run id or a run URL on ${o.repo}`); const p = o.profile; @@ -153,10 +175,22 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof const maxAge = p.max_age_hours ?? DEFAULT_MAX_AGE_HOURS; 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 skippedJobs: string[] = []; for (const name of p.jobs) { const hits = jobs.filter(j => j.name === name); if (hits.length === 0) return fail('job_missing', `${at} has no job named ${name}`); if (hits.length > 1) return fail('job_ambiguous', `${at} has ${hits.length} jobs named ${name}`); + if (hits[0]!.conclusion === 'skipped') { + const skip = p.skippable; + if (!skip?.jobs.includes(name)) return fail('job_skipped', `${at}'s ${name} job was skipped`); + if (run.event !== 'pull_request') return fail('job_skipped', `${at}'s ${name} job was skipped in a ${run.event} run; only a pull_request run's gate diffs against the PR base, so only its skip counts`); + const gate = jobs.filter(j => j.name === skip.gate); + if (gate.length !== 1 || gate[0]!.conclusion !== 'success') return fail('job_skipped', `${at}'s ${name} job was skipped and its gate job ${skip.gate} ${gate.length === 1 ? `concluded ${gate[0]!.conclusion}` : gate.length ? 'is ambiguous' : 'is missing'}`); + const changed = o.unchanged ? o.unchanged(p) : 'no base copy of the workflow was compared'; + if (changed) return fail('job_skipped', `${at}'s ${name} job was skipped, but ${changed}, so this branch decided the skip`); + skippedJobs.push(name); + continue; + } if (hits[0]!.conclusion !== 'success') return fail('job_not_success', `${at}'s ${name} job concluded ${hits[0]!.conclusion}`); } const heads = [...new Set([run.head_sha, ...(o.vetoHeads ?? []).filter(h => SHA.test(h))])]; @@ -186,11 +220,13 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`); if (rec.files !== undefined && (!Array.isArray(rec.files) || rec.files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} files is not a list of paths`); if (rec.tests !== undefined && (!Number.isInteger(rec.tests) || (rec.tests as number) < 0)) return fail('artifact_mismatch', `${at}'s ${p.artifact} tests is not a non-negative integer`); + if (rec.skipped_files !== undefined && (!Array.isArray(rec.skipped_files) || rec.skipped_files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} skipped_files is not a list of paths`); const files = ((rec.files ?? []) as string[]).map(normalPath); const tests = rec.tests === undefined ? null : (rec.tests as number); const changed = runChanged(deps, id, run.run_attempt); if (changed) return fail('run_changed', changed); - 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, tests } }; + const skipped = rec.skipped_files === undefined ? {} : { skipped: (rec.skipped_files as string[]).map(normalPath) }; + 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, tests, ...skipped, ...(skippedJobs.length ? { skipped_jobs: skippedJobs } : {}) } }; } catch (e: any) { return fail('api_error', `reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`); } @@ -210,13 +246,21 @@ export function runInventory(command: string, cwd: string, env?: Record e.files.map(normalPath))); +/** A run's files minus the ones whose every test skipped there (GST-1190): only those ran anything. Older tree.json: all files. */ +export const ranFiles = (e: RunEvidence): string[] => { const skipped = new Set(e.skipped ?? []); return e.files.map(normalPath).filter(f => !skipped.has(f)); }; + +/** + * The inventory files no run covers. `skipped: 'uncovered'` (a carry, which drops tests on a machine where they might run) + * also counts a file every test of which skipped in the run; `'covered'` (the remote gate, a Linux runner like CI) does not. + */ +export function uncoveredFiles(inventory: string[], evidence: RunEvidence[], skipped: 'uncovered' | 'covered' = 'covered'): string[] { + const ran = new Set(evidence.flatMap(e => (skipped === 'uncovered' ? ranFiles(e) : e.files.map(normalPath)))); return inventory.filter(f => !ran.has(normalPath(f))); } /** What a run check reads: the repository, the verifier's dependencies, the base branch's profiles and the clock. */ -export interface RunSource { repo: string; deps: RunDeps; profiles: Record; now: Date } +/** `unchanged` (findCarry only): null when a profile's workflow and gate inputs match the base, else which file differs. */ +export interface RunSource { repo: string; deps: RunDeps; profiles: Record; now: Date; unchanged?: (p: RunProfile) => string | null } export type FromRunVerdict = { ok: true; evidence: RunEvidence; receipt: string } | { ok: false; reason: string; detail: string }; /** @@ -298,3 +342,37 @@ export function ghRunDeps(repo: string, repoRoot: string): RunDeps { }, }; } + +/** The gh-backed artifact and pull request reads for `repo` (lib/pregate/flaky-exception.ts): one unexpired artifact by name, every regular file in it under a byte cap. */ +export function ghArtifactDeps(repo: string): RunArtifactDeps { + const all = (p: string, key: string): T[] => gh(['api', '--paginate', p, '--jq', `.${key}[]`]).split('\n').filter(Boolean).map(l => JSON.parse(l) as T); + return { + artifactFiles: (id, name) => { + const listed = all<{ name: string; size_in_bytes: number; expired: boolean }>(`repos/${repo}/actions/runs/${id}/artifacts?name=${encodeURIComponent(name)}&per_page=100`, 'artifacts').filter(a => a.name === name && !a.expired); + if (listed.length !== 1) throw new Error(`run ${id} has ${listed.length} unexpired artifacts named ${name}`); + if (!(listed[0]!.size_in_bytes <= MAX_LOG_ARTIFACT_BYTES)) throw new Error(`artifact ${name} is ${listed[0]!.size_in_bytes} bytes, over ${MAX_LOG_ARTIFACT_BYTES}`); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-run-evidence-')); + try { + gh(['run', 'download', String(id), '--repo', repo, '--name', name, '--dir', dir], 300_000); + const out: Record = {}; + let bytes = 0; + const walk = (rel: string) => { + for (const entry of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) { + const child = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { walk(child); continue; } + if (!entry.isFile()) continue; + bytes += fs.statSync(path.join(dir, child)).size; + if (bytes > MAX_LOG_ARTIFACT_BYTES) throw new Error(`artifact ${name} unpacks to more than ${MAX_LOG_ARTIFACT_BYTES} bytes`); + out[child] = fs.readFileSync(path.join(dir, child), 'utf8'); + } + }; + walk(''); + return out; + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, + pull: n => { + const p = JSON.parse(gh(['api', `repos/${repo}/pulls/${n}`])) as { state: string; head: { sha: string; repo: { full_name: string } | null } }; + return { head_sha: p.head.sha, state: p.state, head_repo: p.head.repo?.full_name ?? null }; + }, + }; +} diff --git a/lib/ship-bin-common.ts b/lib/ship-bin-common.ts index 51bb7d167..c76e51887 100644 --- a/lib/ship-bin-common.ts +++ b/lib/ship-bin-common.ts @@ -10,6 +10,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fragmentSlug } from './changelog-fragments'; +import { laneMapping, recordCiResult, type ShipLaneMapping } from './ci-carry'; import { isConfigUnavailable, loadConfigFromBase } from './pregate/config'; import { parseUnifiedDiff, versionAtRef } from './restamp'; import { branchSides, stampOnlyLine } from './stamp-only'; @@ -113,7 +114,8 @@ export function releasePaths(policy: Record | null, cwd: string, re * added since the merge base with origin/, and not added by the base since): after a lost slot and a merge of the * base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead, * the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). A branch line is - * named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980). + * named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980); on it a declared + * `stamp_placeholders` value may also become the new version inside a `stamp_patterns` template (GST-1026). */ export function stampedFiles(policy: Record | null, cwd: string, ref: string, base: string): string[] { const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : []; @@ -123,11 +125,13 @@ export function stampedFiles(policy: Record | null, cwd: string, re let after = ''; try { after = extractVersion(fs.readFileSync(path.join(cwd, before.rel), 'utf8'), before.rel); } catch { return []; } if (!after) return []; - const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : []; + const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []); + const templates = strings(policy?.stamp_patterns); + const placeholders = strings(policy?.stamp_placeholders); const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]); if (diff.status !== 0) return []; const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref); - const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at); + const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at, placeholders); return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 && f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!, h.removedAt[i]!)))).map(f => f.path); } @@ -171,15 +175,15 @@ export function laneConcurrency(mode: string | undefined, cpus = os.availablePar return cpus <= 4 ? 1 : Infinity; } -export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string } +export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; notOwed?: string; here?: string } -/** The base branch's `ship_lanes_from` entry for each lane whose command is a key, byte for byte (the ledger binds its sha256). */ -export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map { +/** The base branch's `ship_lanes_from` entry for each lane whose command is a key or a declared alias, byte for byte (the ledger binds its sha256). */ +export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map { const loaded = loadConfigFromBase(cwd, base); - const map = new Map(); + const map = new Map(); if (isConfigUnavailable(loaded) || loaded.errors.length) return map; const from = loaded.config.ship_lanes_from; - for (const l of list) if (Object.prototype.hasOwnProperty.call(from, l.command)) map.set(l.label, from[l.command]!); + for (const l of list) { const m = laneMapping(from, l.command); if (m) map.set(l.label, m); } return map; } @@ -189,9 +193,9 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co */ export function carryLane(cwd: string, base: string, l: { label: string; command: string }): LaneResult { const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], 600_000); - const got = / tests_ran=(\d+) log=- source=ci runs=(\S+)/.exec(rec.stderr); - if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[1]!, log: '-', carried: got[2]! }; - return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: /^CARRY_NONE: (.+)$/m.exec(rec.stderr)?.[1] ?? `record_failed: ${firstLine(rec.stderr)}` }; + const got = recordCiResult(rec.stderr); + if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}) }; + return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}` }; } /** @@ -201,7 +205,7 @@ export function carryLane(cwd: string, base: string, l: { label: string; command */ export async function runLanes(cwd: string, list: { label: string; command: string }[], concurrency = Infinity, opts: { base?: string; reuse?: boolean } = {}): Promise { const out: LaneResult[] = []; - const mapped = opts.base ? mappedLanes(cwd, opts.base, list) : new Map(); + const mapped = opts.base ? mappedLanes(cwd, opts.base, list) : new Map(); let next = 0; const worker = async () => { while (next < list.length) { @@ -222,7 +226,8 @@ export async function runLanes(cwd: string, list: { label: string; command: stri /** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone; a carried lane names its CI runs. */ export function laneLine(l: LaneResult): { line: string; ok: boolean } { - if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}`, ok: true }; + if (l.notOwed) return { line: `LANE: ${l.label} not owed: ${l.notOwed} run=${l.carried}`, ok: true }; + if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}${l.here ? ` ${l.here}` : ''}`, ok: true }; const zero = l.exit === 0 && l.testsRan === '0'; const state = l.exit !== 0 ? 'FAIL' : zero ? 'ZERO-RUN' : l.testsRan === 'unknown' ? 'pass (count unavailable)' : 'pass'; return { line: `LANE: ${l.label} ${state} exit=${l.exit} tests_ran=${l.testsRan} log=${l.log}${l.reused ? ` reused=${l.reused}` : ''}`, ok: l.exit === 0 && !zero }; diff --git a/lib/ship-policy-stamps.ts b/lib/ship-policy-stamps.ts new file mode 100644 index 000000000..6e0b144da --- /dev/null +++ b/lib/ship-policy-stamps.ts @@ -0,0 +1,35 @@ +/** + * Validation of the stamp policy keys (stamp_patterns templates and stamp_placeholders), split out of ship-policy.ts + * to keep that module under the module-size ratchet. + */ +import type { PolicyError } from './ship-policy'; +import { parseVersion } from './version-source'; + +const MAX_TEMPLATES = 16; +const MAX_TEMPLATE = 128; +const MAX_PLACEHOLDERS = 16; +const MAX_PLACEHOLDER = 32; +export const VERSION_SLOT = '{version}'; + +export function templateErrors(value: unknown, out: PolicyError[]): void { + if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; } + if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` }); + value.forEach((t, i) => { + const at = `stamp_patterns[${i}]`; + if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } + if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` }); + else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` }); + }); +} + +export function placeholderErrors(value: unknown, out: PolicyError[]): void { + if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_placeholders', message: 'must be an array of literal placeholders' }); return; } + if (value.length > MAX_PLACEHOLDERS) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_placeholders', message: `more than ${MAX_PLACEHOLDERS} placeholders` }); + value.forEach((p, i) => { + const at = `stamp_placeholders[${i}]`; + if (typeof p !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } + if (!p.trim() || p.length > MAX_PLACEHOLDER || /[\0\r\n]/.test(p)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one non-empty line of at most ${MAX_PLACEHOLDER} characters` }); + else if (p.includes(VERSION_SLOT)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} must not contain ${VERSION_SLOT}` }); + else if (parseVersion(p)) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(p)} is a version, not a placeholder` }); + }); +} diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 0092b3be4..3fd85545c 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -18,6 +18,8 @@ import * as path from 'node:path'; import { defaultBaseBranch, noDefaultBaseLine } from './default-base'; import { RESULT_CODES, type ResultCodeName } from './result-codes'; import { cmpVersion, parseVersion } from './version-source'; +import { placeholderErrors, templateErrors } from './ship-policy-stamps'; +export { VERSION_SLOT } from './ship-policy-stamps'; export const SHIP_POLICY_PATH = '.gstack/ship-policy.json'; export const SHIP_POLICY_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1'; @@ -64,6 +66,8 @@ export interface ShipPolicy { stamp_files: string[]; /** Literal stamp templates with one `{version}` slot, beside the built-in `(was X)`, `since: X`, `vX`; writer and classifier share them. */ stamp_patterns: string[]; + /** Placeholders (`TBD`) a restamp may replace with the new version inside a `stamp_patterns` template, on the branch's own lines only. */ + stamp_placeholders: string[]; /** The repo's own restamp, run first in the staging worktree with the target version; gstack's version writer is skipped. */ restamp_command: string | null; /** Migration numbering: `{ dir, id }` where `id` is a regex with one capture group over the file name. */ @@ -161,6 +165,7 @@ export const DEFAULT_POLICY: ShipPolicy = { maintainers: [], stamp_files: [], stamp_patterns: [], + stamp_placeholders: [], restamp_command: null, migrations: null, gate_command: null, @@ -193,6 +198,7 @@ export const POLICY_EXPLANATIONS: Record = { maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.', stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.', stamp_patterns: 'Literal templates with exactly one {version} slot (at most 16, 128 characters each), for example `"version": "{version}"` or `since: \'{version}\'`; the restamp writer and the tree receipt use them beside (was X), since: X and vX.', + stamp_placeholders: 'Literal placeholders (at most 16, one line, 32 characters each, never `{version}` or a version), for example `["TBD"]`: a line the branch added that reads a placeholder in a stamp_patterns template (`since: \'TBD\'`) may become that template at the new version, and finish, the receipt carry and the tree receipt read it as a stamp. Empty (default) accepts no placeholder.', restamp_command: 'The repo’s own restamp, run first in gstack-restamp’s staging worktree (a clean checkout of HEAD) with the target version as its argument and in GSTACK_RESTAMP_VERSION; gstack’s version writer is skipped, output confinement still applies. Needs a clean caller tree and --allow-repo-commands.', migrations: '{ dir, id }: the migration directory and a regex with one capture group for the number in a file name; `gstack-pregate regate` reports MIGRATION_COLLISION against the base branch and every open PR.', gate_command: 'The repo’s full pre-merge gate command. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. null when the repo has none.', @@ -236,6 +242,7 @@ export const BUMP_NEVER_KEY_VERSION = '1.91.353.0'; export const BASE_RED_KEY_VERSION = '1.91.374.0'; /** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */ export const TRAIN_GATES_KEY_VERSION = '1.92.0.0'; +export const STAMP_PLACEHOLDERS_KEY_VERSION = '1.94.16.0'; export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.33.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', @@ -245,6 +252,7 @@ export const POLICY_KEY_INTRODUCED: Record = { train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION, changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION, base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION, + stamp_placeholders: STAMP_PLACEHOLDERS_KEY_VERSION, }; const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0; @@ -274,9 +282,6 @@ const MAX_PATTERN = 256; const MAX_REGEX = 512; const MAX_COMMAND = 512; const SHA_RE = /^[0-9a-f]{7,64}$/; -const MAX_TEMPLATES = 16; -const MAX_TEMPLATE = 128; -export const VERSION_SLOT = '{version}'; function hasDotDot(p: string): boolean { return p.split(/[\\/]+/).some(seg => seg === '..'); @@ -343,17 +348,6 @@ function excludeErrors(value: unknown, out: PolicyError[]): void { }); } -function templateErrors(value: unknown, out: PolicyError[]): void { - if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; } - if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` }); - value.forEach((t, i) => { - const at = `stamp_patterns[${i}]`; - if (typeof t !== 'string') { out.push({ code: 'POLICY_INVALID', path: at, message: 'must be a string' }); return; } - if (t.split(VERSION_SLOT).length !== 2) out.push({ code: 'POLICY_INVALID', path: at, message: `${JSON.stringify(t)} must contain exactly one ${VERSION_SLOT}` }); - else if (t.length > MAX_TEMPLATE || /[\0\r\n]/.test(t)) out.push({ code: 'POLICY_CONTAINMENT', path: at, message: `must be one line of at most ${MAX_TEMPLATE} characters` }); - }); -} - function migrationErrors(value: unknown, out: PolicyError[]): void { const m = value as Record | null; if (!m || typeof m !== 'object' || Array.isArray(m)) { out.push({ code: 'POLICY_INVALID', path: 'migrations', message: 'must be { dir, id } or null' }); return; } @@ -497,6 +491,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = if ('release_outputs' in obj) listErrors('release_outputs', obj.release_outputs, errors, globError); if ('stamp_files' in obj) listErrors('stamp_files', obj.stamp_files, errors, globError); if ('stamp_patterns' in obj) templateErrors(obj.stamp_patterns, errors); + if ('stamp_placeholders' in obj) placeholderErrors(obj.stamp_placeholders, errors); if ('restamp_command' in obj && obj.restamp_command !== null) { const t = obj.restamp_command; if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'restamp_command', message: 'must be a non-empty string or null' }); diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index ed6591ce5..2b483b308 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -8,8 +8,12 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { findCarry, laneMapping, mappingCoverage } from './ci-carry'; import { defaultBaseBranch } from './default-base'; +import { isConfigUnavailable, loadConfigFromHead } from './pregate/config'; +import { buildRunCheck } from './pregate/run-check'; import { classifyDiff } from './review-depth'; +import { wtreeFingerprint } from './script-invocation'; import { audit } from './ship-audit'; import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common'; @@ -20,6 +24,11 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste --lanes serial|parallel how Step 5 runs its lanes; unset, one at a time on 4 or fewer cores, all at once above --policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes (a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS + gstack-ship-preflight --tests-only --base --policy-from head --simulate [--allow-repo-commands] --lane