mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
v1.92.43.0 stamp: train gate.train-3de19fd7187c-5225e35cc603-a7fc12 S1 (#3812)
This commit is contained in:
commit
5cb19d6272
88 files changed
+3248
-339
No files matched your search
@@ -71,6 +71,9 @@ jobs:
|
||||
|
||||
windows:
|
||||
uses: ./.github/workflows/windows-free-tests.yml
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
with:
|
||||
record_durations: true
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ name: Free Tests
|
||||
# is permanent false comfort.
|
||||
#
|
||||
# Local `bun run test` still uses the existing bounded process pool.
|
||||
#
|
||||
# One full suite per tree (GSTA-156, docs/pregate.md#ci-reuse). The aggregate
|
||||
# uploads a `tested-tree` record (tree.json) only after strict verification
|
||||
# passes. A push to main first runs `reuse`: when the merged pull request's
|
||||
# head has the pushed tree and a green pull_request or workflow_dispatch run of
|
||||
# this workflow verifies (lib/run-evidence.ts), every suite job is skipped and
|
||||
# the aggregate links that run instead. Any other answer, a reuse failure
|
||||
# included, runs every job as before. Pull request and dispatch runs never
|
||||
# consume; reused runs never upload a record, so they are never reused.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -54,7 +63,40 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Main pushes only, and the one job with actions: read (it reads this
|
||||
# repository's runs and their tested-tree artifacts). Every suite job below
|
||||
# runs unless this job succeeded and reused a run; a skipped or failed reuse
|
||||
# job runs them all. Their conditions start with !cancelled() because a
|
||||
# skipped needed job would otherwise skip its dependents on pull_request and
|
||||
# dispatch runs, where reuse never runs.
|
||||
reuse:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
reused: ${{ steps.find.outputs.reused }}
|
||||
run_id: ${{ steps.find.outputs.run_id }}
|
||||
run_url: ${{ steps.find.outputs.run_url }}
|
||||
tree: ${{ steps.find.outputs.tree }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- id: find
|
||||
name: Find a green pull request or dispatch run of this exact tree
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bun run scripts/ci-tree-reuse.ts find --workflow free-tests.yml --profile ci-reuse/free-tests
|
||||
|
||||
free-plan:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -106,6 +148,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
cso-macos-launcher:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -144,6 +188,8 @@ jobs:
|
||||
# default, and the job fails if that layout is not symlinked. It also sources
|
||||
# the Codex probe from zsh, macOS's default shell (#3024; no paid call).
|
||||
macos-named-regressions:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -188,6 +234,8 @@ jobs:
|
||||
bun test "${files[@]}"
|
||||
|
||||
cso-windows-launcher:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -216,6 +264,8 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
cso-docker-integration:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -246,6 +296,8 @@ jobs:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
typecheck:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -269,6 +321,8 @@ jobs:
|
||||
# the quick tier also runs on it. Its pin equals GSTACK_BUN_DEFAULT;
|
||||
# test/bun-version-drift.test.ts checks this job exists and agrees.
|
||||
quick-bun-default:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -365,7 +419,8 @@ jobs:
|
||||
run: xvfb-run -a bun run test:quick
|
||||
|
||||
free-suite:
|
||||
needs: free-plan
|
||||
needs: [free-plan, reuse]
|
||||
if: ${{ !cancelled() && needs.free-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ${{ needs.free-plan.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -590,13 +645,38 @@ jobs:
|
||||
# Branch protection already requires the `free-tests` context. Keep that
|
||||
# stable name as an always-running aggregate so every platform-specific CSO
|
||||
# gate is merge-blocking without a separate branch-protection migration.
|
||||
# Its first step picks one of two exclusive paths before any download: a
|
||||
# verified reuse (main pushes only) links the reused run and passes; anything
|
||||
# else takes the strict path below unchanged, which alone uploads tested-tree.
|
||||
free-tests:
|
||||
if: always()
|
||||
needs: [free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration]
|
||||
needs: [reuse, free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Take the reuse path only for a verified main-push reuse
|
||||
id: route
|
||||
env:
|
||||
REUSE_RESULT: ${{ needs.reuse.result }}
|
||||
REUSED: ${{ needs.reuse.outputs.reused }}
|
||||
REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }}
|
||||
REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }}
|
||||
REUSE_TREE: ${{ needs.reuse.outputs.tree }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then
|
||||
echo 'reused=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
test "$GITHUB_EVENT_NAME" = push
|
||||
test "$GITHUB_REF" = refs/heads/main
|
||||
case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac
|
||||
case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac
|
||||
test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID"
|
||||
echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo 'reused=true' >> "$GITHUB_OUTPUT"
|
||||
- name: Require the free suite, typecheck, the quick tier on the default Bun, macOS regressions, and every CSO platform gate
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
env:
|
||||
FREE_SUITE_RESULT: ${{ needs.free-suite.result }}
|
||||
TYPECHECK_RESULT: ${{ needs.typecheck.result }}
|
||||
@@ -615,16 +695,20 @@ jobs:
|
||||
test "$CSO_WINDOWS_RESULT" = success
|
||||
test "$CSO_DOCKER_RESULT" = success
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
name: free-plan
|
||||
path: ${{ runner.temp }}
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
pattern: free-result-*
|
||||
merge-multiple: true
|
||||
@@ -633,10 +717,27 @@ jobs:
|
||||
# flake-ledger.jsonl). A green run is where a retried flake hides, so the
|
||||
# summary runs before strict verification.
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
pattern: flake-ledger-*
|
||||
path: ${{ runner.temp }}/flake-ledgers
|
||||
- name: Summarize flaky passes absorbed by the serial retry
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
run: bun run scripts/test-health-report.ts flake-summary "$RUNNER_TEMP/flake-ledgers" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Require exact coverage and complete strict results
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
run: bun run scripts/test-free-shards.ts --ci-verify "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results"
|
||||
# Only after strict verification passed, so a red or partial run never
|
||||
# leaves a record; the reuse path never reaches here.
|
||||
- name: Record the tested tree
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results" --os Linux,macOS,Windows --out "$RUNNER_TEMP/tested-tree/tree.json"
|
||||
- name: Upload the tested tree
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: tested-tree
|
||||
path: ${{ runner.temp }}/tested-tree/tree.json
|
||||
if-no-files-found: error
|
||||
@@ -19,6 +19,12 @@ name: Windows Free Tests
|
||||
#
|
||||
# Native browser qualification campaigns (Windows cookie extraction, launch
|
||||
# diagnostics) are dispatch-only in native-qualification.yml.
|
||||
#
|
||||
# One full suite per tree (GSTA-156, docs/pregate.md#ci-reuse), as in
|
||||
# free-tests.yml: the aggregate uploads `tested-tree` only after strict
|
||||
# verification passes, and a push to main whose merged pull request already
|
||||
# ran this workflow green on the same tree reuses that run. Pull request,
|
||||
# dispatch and duration-recording runs never consume.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -50,8 +56,36 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Main pushes only, and the one job with actions: read; see free-tests.yml.
|
||||
# A skipped or failed reuse job runs every job below as before.
|
||||
reuse:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
reused: ${{ steps.find.outputs.reused }}
|
||||
run_id: ${{ steps.find.outputs.run_id }}
|
||||
run_url: ${{ steps.find.outputs.run_url }}
|
||||
tree: ${{ steps.find.outputs.tree }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- id: find
|
||||
name: Find a green pull request or dispatch run of this exact tree
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bun run scripts/ci-tree-reuse.ts find --workflow windows-free-tests.yml --profile ci-reuse/windows-free-tests
|
||||
|
||||
windows-plan:
|
||||
if: ${{ !inputs.record_durations }}
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && !inputs.record_durations && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -79,7 +113,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
windows-free-shard:
|
||||
needs: windows-plan
|
||||
needs: [windows-plan, reuse]
|
||||
if: ${{ !cancelled() && needs.windows-plan.result == 'success' && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
@@ -184,11 +219,33 @@ jobs:
|
||||
|
||||
windows-free-tests:
|
||||
if: ${{ always() && !inputs.record_durations }}
|
||||
needs: [windows-plan, windows-free-shard]
|
||||
needs: [reuse, windows-plan, windows-free-shard]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Take the reuse path only for a verified main-push reuse
|
||||
id: route
|
||||
env:
|
||||
REUSE_RESULT: ${{ needs.reuse.result }}
|
||||
REUSED: ${{ needs.reuse.outputs.reused }}
|
||||
REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }}
|
||||
REUSE_RUN_URL: ${{ needs.reuse.outputs.run_url }}
|
||||
REUSE_TREE: ${{ needs.reuse.outputs.tree }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then
|
||||
echo 'reused=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
test "$GITHUB_EVENT_NAME" = push
|
||||
test "$GITHUB_REF" = refs/heads/main
|
||||
case "$REUSE_RUN_ID" in ''|*[!0-9]*) echo "::error::the reuse job named no run id"; exit 1 ;; esac
|
||||
case "$REUSE_TREE" in ''|*[!0-9a-f]*) echo "::error::the reuse job named no tree"; exit 1 ;; esac
|
||||
test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID"
|
||||
echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo 'reused=true' >> "$GITHUB_OUTPUT"
|
||||
- name: Require the plan and every Windows shard
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
env:
|
||||
PLAN_RESULT: ${{ needs.windows-plan.result }}
|
||||
SHARD_RESULT: ${{ needs.windows-free-shard.result }}
|
||||
@@ -197,22 +254,40 @@ jobs:
|
||||
test "$PLAN_RESULT" = success
|
||||
test "$SHARD_RESULT" = success
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
name: windows-plan
|
||||
path: ${{ runner.temp }}
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
with:
|
||||
pattern: windows-result-*
|
||||
merge-multiple: true
|
||||
path: ${{ runner.temp }}/windows-results
|
||||
- name: Require exact coverage and complete strict results
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
run: bun run scripts/test-free-shards.ts --windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results "$RUNNER_TEMP/windows-results"
|
||||
# Only after strict verification passed; the reuse path never reaches here.
|
||||
- name: Record the tested tree
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/windows-plan.json" --results "$RUNNER_TEMP/windows-results" --os Windows --out "$RUNNER_TEMP/tested-tree/tree.json"
|
||||
- name: Upload the tested tree
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: tested-tree
|
||||
path: ${{ runner.temp }}/tested-tree/tree.json
|
||||
if-no-files-found: error
|
||||
|
||||
windows-record-durations:
|
||||
if: ${{ inputs.record_durations }}
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
"free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list",
|
||||
"windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list"
|
||||
},
|
||||
"run_profiles": {
|
||||
"ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" },
|
||||
"ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" }
|
||||
},
|
||||
"warn": [],
|
||||
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
|
||||
}
|
||||
+115
@@ -1,5 +1,120 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.43.0] - 2026-11-01
|
||||
|
||||
**New page: [docs/support-tiers.md](docs/support-tiers.md) says what gstack supports fully and what it supports minimally.** macOS and Linux, with Claude and OpenAI models, get full support: the whole free suite and the paid evals. Windows (Git Bash or MSYS) and Gemini models get minimal support: install and the core skills (`/ship`, `/review`, `/qa-only`, `/investigate`, `/browse` on gstack's own browser) work without errors, checked on Windows by the `windows-smoke` job, which replaces the six-shard suite as the required Windows check when it lands, and by hand or one cheap smoke run on Gemini. Windows-only and Gemini-only reports that don't break install, startup or a core workflow close as won't fix with a link to the page.
|
||||
|
||||
### Documentation
|
||||
- `docs/support-tiers.md`: the two tiers, what we stop doing, the Windows CI shape today (the six-shard `windows-free-tests` suite required on every pull request) and once `windows-smoke` lands (`windows-smoke` required, the six-shard suite nightly on main and non-blocking) and what doesn't change (eval models, the cross-family review voice).
|
||||
- README's platform notes and AGENTS.md's platform support section link to it.
|
||||
|
||||
## [1.92.42.0] - 2026-11-01
|
||||
|
||||
**A /ship review pass that only left notes now binds at Step 11.5.** A pass whose findings were all fixed, deferred or noted has no open defect, but a driver could still log it as `issues_found`. Step 11.5 then refused it, and the only way out was an extra rebind pass (CAPY-2, qm#2837). When the tree changes during a pass, the refusal now names the start and end trees and the cheapest repair (GST-430).
|
||||
|
||||
**Receipt carry and write handle results PRs with thousands of files (GST-448).** A PR that adds about 8,300 run receipts (harness-evals#147) refused every carry with `git could not compute HEAD's product patch-id: git diff … failed`, and every base move cost it about 3 hours of /ship. The name-only listing ran past a 1 MB output buffer.
|
||||
|
||||
### Fixed
|
||||
- **`gstack-ship-receipt carry` and `write`** read git's path listings with a 256 MB buffer instead of the 1 MB default, so a large PR's `product_patch_id` is recorded and carried. The product patch-id streams `git diff` through a file under the git directory (`.git/gstack-patch-id`) into `git patch-id --verbatim` and no longer holds the diff in memory; a directory a killed run left is removed after an hour. The per-path walk compares only touched paths that changed at either head or either base, so a 6,000-path PR refuses or carries in seconds.
|
||||
- **/ship Step 1's carry check takes the scoped-review route (GST-442).** When the base edited context next to a reviewed hunk, `gstack-ship-preflight` now prints `NEXT: carry route`: fix what the base merge broke, commit and push, write the packet, one fix-check, then `carry --scoped-review`, instead of running the full loop. `carry --dry-run` writes no packet and offers this route only when none of carry's other checks would refuse.
|
||||
- **Step 16 names finish's scoped-review STOP and the `--docs-requested` fix (GST-444).** Stage 2 sends a STOP that names a scoped-review packet to its fix-check and `--context-reviewed` rerun, and stage 3 offers a fix the audit's result named before a re-audit or a named risk. The RECEIPT_CARRY_PRODUCT_CHANGED fix text points to the scoped-review refusal. Trims in Steps 3, 12 and 16 keep ship/SKILL.md under its cap.
|
||||
- **`gstack-tree-receipt`** fails closed (`EVIDENCE_IDENTITY_UNKNOWN`) when git cannot produce the diff between the gated and current heads, and names git's reason. Before, it read a failed diff as no changes.
|
||||
- **`gstack-review-log`** records a completed `review` or `review-fix-check` row claimed as `issues_found` as `clean` with zero counts when every finding's logger-stamped action is `fixed` or `auto-fixed`, or `deferred` or `noted` on a finding marked `blocking: false` that is not CRITICAL, and the findings account for the claimed issue and critical counts. A skipped finding, a gate finding left open, a CRITICAL or blocking note, a finding with no action, or an incomplete pass keeps the claimed status.
|
||||
- **Findings files** (`--findings`) count `auto-fixed` as resolved, the same as `fixed`, and `deferred` or `noted` only below the floor. Their gate rows are stamped like inline ones, so an unattended run's gate stays open whatever its status or disposition says.
|
||||
- **`review_freshness`** reads a claimed clean row as UNVERIFIED when a finding is a gate left `open`, a CRITICAL or blocking deferral or note, or any other unresolved CRITICAL finding.
|
||||
- **`review_freshness`** for a pass whose tree moved now reads `content changed during review: the tree moved from <start> to <end> while <skill> ran …`, followed by the repair: keep the checkout frozen until every reviewer returns, then rerun on the frozen tree (docs/troubleshooting.md#review-content-changed).
|
||||
|
||||
### Tests
|
||||
- `test/ship-rereview-triggers.test.ts`: preflight on a founderstar-style adjacent edit prints the scoped-review `NEXT: carry route`, and its dry run writes no packet. `test/ship-control-flow.test.ts`: Step 1.4 and Step 16 stages 2 and 3 name the routes.
|
||||
- `test/ship-receipt-carry-large-diff.test.ts`: a PR that adds 6,000 receipts under a long directory name writes a receipt with its product patch-id and carries across a base merge. Negative control: one rescored receipt still refuses with `RECEIPT_CARRY_PRODUCT_CHANGED`.
|
||||
- `test/review-notes-only-pass.test.ts`: a pass with three notes is recorded clean and reads CURRENT. Negative controls: a skipped finding, an open gate finding (also under a caller's resolved status), a CRITICAL or blocking note, a note with no `blocking` field, counts the findings do not cover, a finding with no action, and an incomplete pass keep `issues_found`. A claimed clean row with an open gate, a CRITICAL deferral, or a skipped or action-less CRITICAL finding is not CURRENT, while kept and restored gates, skipped advisories and an INFORMATIONAL deferral stay CURRENT. A `--findings` gate row stays open in an unattended run. Also checks the reason a pass gets when its tree moved.
|
||||
- `test/ship-receipt-carry-large-diff.test.ts` also: the patch-id file lives under the git directory (an unwritable TMPDIR still carries; an unwritable spot refuses by name), and a stale directory is swept. `test/train-gate.test.ts`: a diff git cannot produce refuses the tree receipt with git's reason. `test/ship-rereview-triggers.test.ts`: a dry run the receipt would refuse anyway names that refusal.
|
||||
|
||||
## [1.92.41.0] - 2026-11-01
|
||||
|
||||
**The Ubicloud runner works on macOS and on a project with no VMs (#3680, #3679, #3678).** On a Mac, `gstack-ubi pack` failed, so every `sync`, `run` and `verify` broke after the VM was already created, and `gstack-ubi usage` was a syntax error that also switched off the vCPU quota check. On any machine, an empty Ubicloud project made `reap` and every `up` die. All three were reported by Jared.
|
||||
|
||||
### Fixed
|
||||
- **`pack` on macOS.** macOS's tar read pack's options as file names and shipped an archive holding only `.git`. Pack now builds the archive in two steps that work with both macOS and GNU tar, and keeps macOS tar from adding hidden `._*` copies of files that carry extended attributes. (#3680)
|
||||
- **`usage` and the quota check on macOS.** One line of the usage report was a syntax error for macOS's awk. `usage` now also exits 1 instead of printing a partial count when Ubicloud can't list the VMs or read a size, and skips a VM destroyed between the two reads. When usage can't be read, the quota check retries while `UBI_QUOTA_WAIT` has time left, then says so in the log and leaves the decision to Ubicloud. (#3679)
|
||||
- **`reap` and `up` on an empty project.** An empty VM list was read as one VM named `-`, so they died with `invalid VM name '-'`. (#3678)
|
||||
|
||||
## [1.92.40.0] - 2026-11-01
|
||||
|
||||
**A subagent's /ship cost now says it is an upper bound.** A Capy subagent cannot read its own jam counter, so its /ship read `"this"`, the root thread, whose counter also holds every sibling lane. BUDGET_FINAL and the notes printed that total as if it were the run's own cost.
|
||||
|
||||
### Added
|
||||
- **`gstack-ship-budget start --meter-scope self|tree`.** A run started with `tree`, or with `--subagent` (or `GSTACK_SHIP_SUBAGENT=1`) and `--reading-from this`, is tree-scoped: every BUDGET, NOTE, BUDGET_FINAL and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`.
|
||||
- **`METER_SCOPE_PARENT`.** A subagent reading `"this"` that asks for `--meter-scope self` is refused with exit 2 and the fix `--meter-scope tree` (docs/troubleshooting.md#meter-scope-parent).
|
||||
|
||||
### Changed
|
||||
- The Capy skill and docs/ship-budget.md tell a subagent to start with `--subagent --reading-from this --meter-scope tree`, and say exact per-run cost comes from a root replay thread. The driver's Step 22 carries the `METER: tree` line into the report.
|
||||
|
||||
### Tests
|
||||
- `test/ship-budget-meter-scope.test.ts`: tree runs label every cost line, self runs print none, the refusal and its troubleshooting anchor, and the docs pointers.
|
||||
|
||||
## [1.92.39.0] - 2026-11-01
|
||||
|
||||
**A push to main no longer runs Free Tests and Windows Free Tests again on a tree its pull request already passed.** 22 of 22 merges in 30 hours (Oct 27–29) reran both suites on the identical tree, about 66 Linux shard-minutes and 44 Windows minutes each. Now a `reuse` job, on main pushes only, finds the merged pull request, requires its head's tree to equal the pushed tree, and verifies that head's newest green `pull_request` or `workflow_dispatch` run with the same checks `gstack-pregate clear --run` uses. When it verifies, every suite job is skipped and the required check links the reused run. Anything else runs every job as before. The same verifier backs a new `--from-run` flag for the pregate tests stage.
|
||||
|
||||
### Added
|
||||
- **Main-push reuse** in `free-tests.yml` and `windows-free-tests.yml` (`scripts/ci-tree-reuse.ts`, docs/pregate.md#ci-reuse). The `reuse` job is the only one with `actions: read`, and it reads at most 10 pull requests and 10 candidate runs. A newer red run on the head vetoes an older green one. A run that reused another run never counts. Pull request and dispatch runs never consume, and a skipped or failed `reuse` job runs every job exactly as before. The required `free-tests` and `windows-free-tests` aggregates pick their path before any download, and the strict path is unchanged.
|
||||
- **`tested-tree` records.** Both aggregates upload `tree.json` only after strict verification passes: run, attempt, event, sha, head, tree, workflow, OS list, every planned test file and the test count.
|
||||
- **`gstack-pregate --stage tests --from-run <run id|url>`** (docs/pregate.md#from-run). The selected local test files a verified Free Tests run on this exact tree lists count as passed and are recorded under `from_run` with the run receipt, and the rest still run. A refused run prints `REUSE: refused reason=<code>` and runs everything. It changes only the tests stage; `--stage all` still runs every preflight check, the secrets scan included.
|
||||
- **gstack's run profiles** in `.gstack/pregate.json`: `ci-reuse/free-tests` (the complete Free Tests run) and `ci-reuse/windows-free-tests`. Neither is named after a lane, so macOS and Windows lane clears and `gstack-ship-queue ready --dispatch-ci` keep recording the run URL. Lane profiles and dispatch-ci verification wait until open pull requests carry the `tested-tree` producer (GST-555).
|
||||
|
||||
### Changed
|
||||
- **Run profiles may accept `pull_request` runs.** Such a run counts only when its `tree.json` names the head and the merge commit, and the merge commit's tree equals the head's tree, which means the head already held its base. `tree.json` may list every OS its test jobs ran on, and the profile's OS must be one of them.
|
||||
- **`free-durations-record.yml` grants its Windows caller job `contents: read` and `actions: read`.** That job calls `windows-free-tests.yml`, whose new `reuse` job asks for `actions: read`, and a called workflow can't hold more than its caller grants. Without the grant, GitHub rejects the scheduled duration run before it starts.
|
||||
- **A run whose `tree.json` lists no test files or zero tests never counts** for `--from-run` or main-push reuse (`no_tests`).
|
||||
|
||||
### Tests
|
||||
- `test/ci-tree-reuse.test.ts`: candidate discovery with fake GitHub data (pull request found or missing, unmerged, fork head, tree mismatch, reused record, newer red run veto, dispatch and pull_request candidates, the 10-run bound, API errors), the `tree.json` producer, and the CLI refusing every event but a main push.
|
||||
- `test/ci-reuse-workflow-wiring.test.ts`: both workflows. It covers the push-only reuse job and its permissions, each suite job's condition evaluated for a skipped, failed and successful reuse, and status functions on every downstream job. It also covers pregate still requiring every test lane, the aggregate's route step run under bash for each input, and the profiles naming real jobs and never a lane.
|
||||
- `test/cso-distribution.test.ts`: the `free-tests` aggregate still requires `cso-docker-integration` on its strict path, and only a verified reuse skips that job.
|
||||
- `test/pregate-from-run.test.ts` failed on the old code. `test/run-evidence.test.ts` adds the `pull_request` and OS-list cases and `verifyFromRun`, including the zero-test refusal, which failed on the old code.
|
||||
- `test/free-durations-record-workflow.test.ts` pins the Windows caller grant, and `test/ci-reuse-workflow-wiring.test.ts` requires every caller of either workflow to grant `actions: read`; it failed without the grant.
|
||||
|
||||
## [1.92.38.0] - 2026-11-01
|
||||
|
||||
**brain-sync no longer gets stuck in `push_failed` when another machine changed a file you have edited locally.** After a rejected push, brain-sync fetches and merges the other machine's commits. Git refuses that merge when a file it would update has uncommitted local edits. brain-sync hid git's error and hit the same refusal on every run, so cross-machine sync stopped for good (#3704).
|
||||
|
||||
### Fixed
|
||||
- **The retry merge sets aside local edits to the files it changes and puts them back** (`bin/gstack-brain-sync`). Other local edits stay where they are, file names are never read as patterns, and only the stash entry this run made is put back. A failed merge is aborted, so it never leaves conflict markers or a half-finished merge in `~/.gstack`.
|
||||
- **When a local edit conflicts with the other machine's change, the other machine's version stays.** The local edit is kept in a git stash. The status names the stash and the exact `git -C ~/.gstack --literal-pathspecs restore --source=<stash> -- <path>` command that brings it back. A later sync never pushes the old local copy over the other machine's change. Files with a merge driver (`*.jsonl`, the union-merged plans) merge as before.
|
||||
- **`push_failed` now says why.** It carries git's merge reason (for example `CONFLICT (content): Merge conflict in developer-profile.json`) and the real push error line instead of git's `To <remote>` banner.
|
||||
|
||||
### Tests
|
||||
- `test/brain-sync.test.ts`: a dirty file the remote didn't touch, a dirty file the remote also changed (including a second sync and the restore command), local-delete/remote-edit, local-edit/remote-delete, a conflicting file whose name looks like a pattern, a file that never checks out clean, a stash push that saves nothing, and a committed conflict. Five of the eight fail on main.
|
||||
|
||||
## [1.92.37.0] - 2026-11-01
|
||||
|
||||
- **A Red Team finding that quotes `origin/<base>` no longer stops `/ship`'s review** (`lib/review-brief.ts`, GST-797). The renderer checked each finished brief for unfilled slots like `<base>`, `<diff-base>` and `<exclude>` after the reviewers' findings and past learnings had been pasted in, so a finding that quoted one failed the render with `BRIEF_RENDER_FAILED: red-team: unfilled placeholder <base>` (seen on #3795). A quoted `<diff-base>` was also quietly swapped for the commit sha. The check now runs on the brief before that text goes in, and the quoted text reaches the reader exactly as written. A slot the section really leaves open still fails the render. A brief whose findings and learnings quote no slot name renders the same bytes as before.
|
||||
|
||||
## [1.92.36.0] - 2026-11-01
|
||||
|
||||
**/ship's review collector keeps every severity label again, so a blocker named after "no CRITICAL issues except" is no longer lost.** v1.92.29.0 (GST-766) skipped any severity word that followed "no", "nothing", "none" and similar words, to stop a reviewer's closing line like "found nothing CRITICAL" from counting as a finding. That also threw away real blockers named in an exception clause: "No CRITICAL issues except the token leak in log.ts:3", "Nothing CRITICAL apart from the auth bypass (auth.ts:9)" and four more wordings collected nothing at all, so the gate never saw them. The collector now reads labels exactly as it did before v1.92.29.0. The reviewer's own Severity and Blocks fields are still recorded beside the label (`stated`), as in v1.92.29.0.
|
||||
|
||||
### Known
|
||||
- **A reviewer's closing summary collects at its label again.** Lines like "found nothing CRITICAL" or "Neither finding is CRITICAL." collect as CRITICAL, the same as before v1.92.29.0. They are known phantoms. Each stays CRITICAL in the judged set (lead judgment can fold it only into a finding at least as severe), so it counts toward what blocks. A lost blocker costs more than an extra one.
|
||||
|
||||
### Tests
|
||||
- `test/review-dispatch.test.ts`: the six exception-clause wordings collect CRITICAL for every reader. GST-766's test that a negated label collects nothing now checks that each of those lines keeps its label. GST-435's label-keeping probes (table rows, FIXABLE, negated subjects, closing summaries kept as known phantoms) are added with their HARN-15 and GEVA-77 fixtures under `test/fixtures/review-ledger/`.
|
||||
|
||||
### Docs
|
||||
- `docs/review-policy.md` (collect) says a negated severity word still collects at its label.
|
||||
|
||||
## [1.92.35.0] - 2026-11-01
|
||||
|
||||
- **A `.gstack/pregate.json` that only raises `timeout_s` no longer changes lane verdicts, `warn: ["lanes"]` no longer lets a failing test pass, and a `[]` declaration no longer skips a deleted test's suite** (GST-775, from gbrain-evals #272 and GBEV-22).
|
||||
- Before, any policy file, even `{"timeout_s": 1800}`, made a touched file with no test lane a hard `PREGATE_NO_LANE` that stopped before any test ran. Now only a policy that declares lanes (a `dependencies`, `empty_lanes`, `selection` or `local` key, even an empty one) does that. A policy with none of them keeps the no-policy verdict: `warn` `PREGATE_NO_POLICY` with a starter, the selected tests run, and a red test is still `PREGATE_LANE_FAILED`. A hand-written policy that sets only `runner`, `guards`, `strays`, `allowlist` or `remote_gate` moves from the hard failure to that warning; add `dependencies` to keep unmapped files failing. See docs/pregate.md#lane-requirements.
|
||||
- `warn: ["lanes"]` used to downgrade the missing-lane failure before the tests ran, so a red test beside an unmapped file passed. Now the selected tests run first. A red lane is `fail`, an unfinished one `incomplete`, a test only a platform lane runs stays `requires-remote`, and only a green run becomes `warn` `PREGATE_NO_LANE` naming the unmapped files.
|
||||
- A `[]` declaration that matches a deleted test (the `"**": []` catch-all) used to clear it before the removed-test handling, so its owning suite did not run. Now the deleted test still runs its owning suite, and a file naming it that a `[]` covers, such as a durations list, counts as covered.
|
||||
|
||||
## [1.92.34.0] - 2026-11-01
|
||||
|
||||
- `gstack-ship-queue wave plan` no longer packs the whole READY queue into one area. Shared files now link two PRs into one group only when `git merge-tree` finds a real conflict between them in a code file or skill template. PRs of the same area still pack together as before. Docs, other Markdown and JSON ledgers never link PRs, since most PRs add to them. Before, any shared file linked two PRs, so `docs/troubleshooting.md` and a few ledgers chained 55 READY PRs into eight waves all labeled `setup`. With this change, the 57-PR queue later that day gives five groups split by area. A READY head whose CI is red or still running now prints `NOT_GREEN: #<n> (...)` and stays out of the plan, as `ready` would refuse it. A head whose CI cannot be read fails the plan with `QUEUE_PROOF_MISSING`, the same as an unreadable PR. Renamed files are compared by their real paths (GST-770).
|
||||
|
||||
## [1.92.33.0] - 2026-11-01
|
||||
|
||||
**Eleven flaky tests from the 2026-10-31 flaky pass stop failing correct runs, and six more now name their cause when they fail.** Three paid detectors missed correct replies, one product script copied a file git was rewriting, and the free-test reds came from a binary's slow first launch on a fresh Windows runner, a fixture written in two steps and a cleanup that raced the runner's own scanner.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.33.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.43.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+59
-2
@@ -585,6 +585,60 @@ transcripts_consented() {
|
||||
transcript_policy --check 2>/dev/null
|
||||
}
|
||||
|
||||
# After a rejected push: merge origin/<branch> into HEAD (#3704). git refuses
|
||||
# a merge that would overwrite uncommitted edits to tracked files, and that
|
||||
# refusal used to repeat on every run, so local edits to the paths the merge
|
||||
# changes are set aside first and put back after; other local edits stay in
|
||||
# place, and only a stash entry this call created is put back. Paths are
|
||||
# literal, never patterns. A failed merge is aborted, so no half-finished merge
|
||||
# or conflict markers block the next commit; git's reason lands in MERGE_ERR.
|
||||
# When putting an edit back conflicts with what the merge brought in, the
|
||||
# merged version stays in the working tree, so a later sync never publishes
|
||||
# the stale local side over the other machine's change, and the local edit
|
||||
# stays in the kept stash entry, which the status names with the command
|
||||
# that restores it.
|
||||
MERGE_ERR=""
|
||||
merge_origin_keeping_local_edits() {
|
||||
local branch="$1" out rc=0 p stash_before="" stash_sha=""
|
||||
local -a lit=(git --literal-pathspecs -C "$GSTACK_HOME") blocking=()
|
||||
while IFS= read -r -d '' p; do
|
||||
"${lit[@]}" diff --quiet --no-renames HEAD "origin/$branch" -- "$p" 2>/dev/null || blocking+=("$p")
|
||||
done < <("${lit[@]}" diff -z --no-renames --name-only HEAD 2>/dev/null)
|
||||
if [ "${#blocking[@]}" -gt 0 ]; then
|
||||
stash_before=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null)
|
||||
if ! out=$("${lit[@]}" stash push -q -m "gstack-brain-sync: local edits before merging origin/$branch" -- "${blocking[@]}" 2>&1); then
|
||||
MERGE_ERR="could not set local edits aside: $(printf '%s\n' "$out" | head -1)"
|
||||
return 1
|
||||
fi
|
||||
stash_sha=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null)
|
||||
[ "$stash_sha" != "$stash_before" ] || stash_sha=""
|
||||
fi
|
||||
out=$("${lit[@]}" merge --no-edit "origin/$branch" 2>&1) || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
MERGE_ERR=$(printf '%s\n' "$out" | grep -m1 -E '^(CONFLICT|error|fatal)' || printf '%s\n' "$out" | head -1)
|
||||
"${lit[@]}" merge --abort >/dev/null 2>&1 || true
|
||||
fi
|
||||
[ -n "$stash_sha" ] || return "$rc"
|
||||
out=$("${lit[@]}" stash pop -q 2>&1) && return "$rc"
|
||||
local paths="" args="" failed=0
|
||||
while IFS= read -r -d '' p; do
|
||||
if "${lit[@]}" cat-file -e "HEAD:$p" 2>/dev/null; then
|
||||
"${lit[@]}" checkout -q HEAD -- "$p" >/dev/null 2>&1 || failed=1
|
||||
else
|
||||
{ "${lit[@]}" rm -q --cached -- "$p" >/dev/null 2>&1 && rm -f -- "$GSTACK_HOME/$p"; } || failed=1
|
||||
fi
|
||||
paths="$paths ${p}"
|
||||
args="$args $(printf '%q' "$p")"
|
||||
done < <("${lit[@]}" diff -z --name-only --diff-filter=U 2>/dev/null)
|
||||
"${lit[@]}" reset -q >/dev/null 2>&1 || failed=1
|
||||
if [ -z "$paths" ] || [ "$failed" = 1 ]; then
|
||||
MERGE_ERR="local edits could not be put back after merging origin/$branch ($(printf '%s\n' "$out" | head -1)); they are saved in git -C $GSTACK_HOME stash list"
|
||||
return 1
|
||||
fi
|
||||
ST_NOTE="$ST_NOTE; local edits to$paths conflicted with the merged remote version, which is kept; the local edits are saved in stash $stash_sha (get them back: git -C $GSTACK_HOME --literal-pathspecs restore --source=$stash_sha --$args)"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# Before every push: scan the unpublished range for transcript pages outside
|
||||
# the current consent; when some exist, the helper rewrites gstack-brain-sync's
|
||||
# own unpublished commits without them, or refuses (exit 3) when it cannot.
|
||||
@@ -1379,7 +1433,7 @@ subcmd_once() {
|
||||
bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then
|
||||
local branch
|
||||
branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)
|
||||
if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1 && guard_unpublished_range; then
|
||||
if merge_origin_keeping_local_edits "$branch" && guard_unpublished_range; then
|
||||
validate_outgoing_paths || exit 0
|
||||
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
|
||||
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
|
||||
@@ -1391,7 +1445,10 @@ subcmd_once() {
|
||||
fi
|
||||
fi
|
||||
# Commit exists locally; the run-start detector re-pushes it next time.
|
||||
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run"
|
||||
local push_reason
|
||||
push_reason=$(printf '%s\n' "$push_err" | grep -v -E '^(To |hint:)' | head -1)
|
||||
[ -n "$push_reason" ] || push_reason=$(printf '%s\n' "$push_err" | head -1)
|
||||
write_status "push_failed" "push failed: $push_reason${MERGE_ERR:+; merging origin failed: $MERGE_ERR}; commit retained locally, will retry next run"
|
||||
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ gstack_result_anchor() {
|
||||
UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;;
|
||||
BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;;
|
||||
BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;;
|
||||
METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;;
|
||||
BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;;
|
||||
BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;;
|
||||
BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;;
|
||||
|
||||
+12
-30
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
|
||||
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
|
||||
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
|
||||
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
|
||||
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
|
||||
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
|
||||
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
|
||||
@@ -27,17 +27,15 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
|
||||
import { PREGATE_CONFIG_PATH, explainConfig, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { PREGATE_CONFIG_PATH, explainConfig, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
|
||||
import { buildRunCheck } from '../lib/pregate/run-check';
|
||||
import { buildContext, defaultUserConfigPath, describeRefusal, loadedDotenvFiles } from '../lib/pregate/context';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile, type RunCheck } from '../lib/pregate/index';
|
||||
import { ghRunDeps, runInventory } from '../lib/run-evidence';
|
||||
import { wtreeFingerprint } from '../lib/script-invocation';
|
||||
import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearDecision, clearObligation, readPregateFile, renderTable, runPregate, verifyPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
|
||||
import { REMOTE_GATE_LANE } from '../lib/pregate/checks/lanes';
|
||||
import { describeSubstitution } from '../lib/pregate/remote-receipt';
|
||||
import { ensureTmpExcluded } from '../lib/restamp';
|
||||
import { normalizeRemoteUrl } from '../lib/ship-queue-train-record';
|
||||
import { RESULT_CODES, resultCodeSuffix, resultLine } from '../lib/result-codes';
|
||||
import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
|
||||
import { cancelActive } from '../lib/pregate/supervise';
|
||||
@@ -90,6 +88,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
spent, reserved and unknown
|
||||
--run <id> the run label recorded in pregate.json and the name of this run's scratch directory (a taken name gets a numbered sibling, <id>-2 … -9)
|
||||
(<state root>/projects/<slug>/pregate/runs/<id>: lane and guard logs, scan files; default a unique id)
|
||||
--from-run <id|url> a Free Tests run on this exact tree, verified against the base branch's run profile for this OS
|
||||
whose workflow holds a local lane (docs/pregate.md#from-run): the selected test files its tree.json lists count as passed and
|
||||
are recorded with its receipt; the rest still run. A refused run prints REUSE: refused and runs everything
|
||||
--explain print each selected test's provenance and each uncertain mapping
|
||||
--json one JSON object on stdout; a refusal before any check runs (unavailable base, invalid pin
|
||||
file, WTREE_UNAVAILABLE) is {ok:false,status:'refused',code,detail,fix,docs} with exit 3,
|
||||
@@ -108,7 +109,7 @@ function flag(args: string[], name: string): string | undefined { const i = args
|
||||
|
||||
/** Flags each verb accepts: `[flags that take a value, bare flags]`. Anything else is usage (exit 2), never a silent default run. */
|
||||
const VERB_FLAGS: Record<string, [string[], string[]]> = {
|
||||
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
|
||||
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run', '--from-run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
|
||||
verify: [['--out'], ['--json']],
|
||||
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out'], ['--allow-repo-commands']],
|
||||
lanes: [[], ['--explain', '--json']],
|
||||
@@ -141,6 +142,8 @@ function outPath(root: string, rest: string[]): string { const o = flag(rest, '-
|
||||
async function cmdRun(root: string, rest: string[], json: boolean): Promise<number> {
|
||||
const stage = (flag(rest, '--stage') ?? 'all') as 'preflight' | 'tests' | 'all';
|
||||
if (!['preflight', 'tests', 'all'].includes(stage)) { process.stderr.write('gstack-pregate: --stage needs preflight, tests or all\n'); return EXIT.usage; }
|
||||
const fromRun = flag(rest, '--from-run');
|
||||
if (fromRun !== undefined && stage === 'preflight') { process.stderr.write('gstack-pregate: --from-run applies to the tests stage (--stage tests or all)\n'); return EXIT.usage; }
|
||||
const only = flag(rest, '--only')?.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const unknown = (only ?? []).filter(id => !CHECK_IDS.includes(id));
|
||||
if (unknown.length) { process.stderr.write(`gstack-pregate: unknown check(s) ${unknown.join(', ')}${unknown.some(u => (PREGATE_TIER2 as readonly string[]).includes(u)) ? ' (tier 2, not in this gstack)' : ''}; known: ${CHECK_IDS.join(', ')}\n`); return EXIT.usage; }
|
||||
@@ -172,6 +175,7 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
|
||||
return EXIT.refused;
|
||||
}
|
||||
const ctx = loaded.ctx;
|
||||
if (fromRun !== undefined) ctx.fromRun = { ref: fromRun, check: buildRunCheck(root, null, allow, base) };
|
||||
ensureTmpExcluded(root);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => {
|
||||
@@ -228,7 +232,7 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
const runs = rest.flatMap((a, i) => a === '--run' ? [rest[i + 1]!] : []);
|
||||
const runUrl = flag(rest, '--run-url');
|
||||
const bundleLane = flag(rest, '--bundle-lane');
|
||||
const checked = runs.length || runUrl || bundleLane ? runCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
|
||||
const checked = runs.length || runUrl || bundleLane ? buildRunCheck(root, lane, rest.includes('--allow-repo-commands')) : undefined;
|
||||
if (checked && 'error' in checked && (runs.length || checked.profiled)) { process.stderr.write(`gstack-pregate clear: ${checked.error}${checked.profiled ? `; ${lane} may have a run profile on the base branch, so it clears only with a verified --run` : ''}\n`); return EXIT.fail; }
|
||||
const evidence = checked && !('error' in checked) ? checked : undefined;
|
||||
const decision = clearDecision(read.file, lane, { runUrl, runs, bundleLane, receiptPath: flag(rest, '--receipt'), evidence }, root);
|
||||
@@ -248,28 +252,6 @@ function cmdClear(root: string, rest: string[]): number {
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** The run-evidence inputs for `clear --run`: the base branch's run profiles, this repository on GitHub, and the gate's inventory command (a pin-file command, so only with --allow-repo-commands). */
|
||||
function runCheck(root: string, lane: string, allowRepoCommands: boolean): RunCheck | { error: string; profiled?: boolean } {
|
||||
const base = resolveBaseBranch(root);
|
||||
const loaded = loadConfigFromBase(root, base);
|
||||
// Unknown profiles are treated as present: a lane is cleared unverified only once the base shows it has no profile.
|
||||
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 repo = /^https:\/\/github\.com\/(.+)$/.exec(normalizeRemoteUrl(gitOut(root, ['config', '--get', 'remote.origin.url']) ?? ''))?.[1];
|
||||
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: 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(),
|
||||
inventory: (command, tree) => {
|
||||
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; };
|
||||
if (at() !== tree) return { error: `the working tree is not pregate.json's tree ${tree.slice(0, 12)}; the inventory must list that tree's gate files` };
|
||||
const listed = runInventory(command, root);
|
||||
if (at() !== tree) return { error: `the working tree changed while ${command} ran` };
|
||||
return listed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gitOut(root: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
return r.status === 0 ? r.stdout.trim() : null;
|
||||
|
||||
+50
-13
@@ -32,6 +32,7 @@
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
|
||||
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
|
||||
@@ -119,7 +120,7 @@ function jsonArg(raw: string | undefined): Record<string, unknown> | undefined {
|
||||
return parsed;
|
||||
}
|
||||
function git(args: string[], cwd?: string): string | undefined {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return r.status === 0 ? r.stdout.trim() : undefined;
|
||||
}
|
||||
function gh(args: string[]): { ok: boolean; out: string; err: string } {
|
||||
@@ -399,12 +400,37 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
|
||||
*/
|
||||
function patchId(repoRoot: string, from: string, to: string, pathspec: string[]): string | { error: string } {
|
||||
const firstLine = (s: string | null | undefined, fallback: string) => (s ?? '').trim().split('\n')[0]?.slice(0, 200) || fallback;
|
||||
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
|
||||
if (!diff.stdout.trim()) return '';
|
||||
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, input: diff.stdout, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
|
||||
return id.stdout.trim().split(/\s+/)[0] ?? '';
|
||||
const root = path.resolve(repoRoot, git(['rev-parse', '--git-path', 'gstack-patch-id'], repoRoot)?.trim() || '.git/gstack-patch-id');
|
||||
sweepStalePatchDirs(root);
|
||||
let dir: string;
|
||||
try { fs.mkdirSync(root, { recursive: true }); dir = fs.mkdtempSync(path.join(root, 'run-')); } catch (e) { return { error: `cannot create a temp file under ${root} (${firstLine((e as Error).message, 'mkdtemp failed')}); make that directory writable` }; }
|
||||
const file = path.join(dir, 'diff.patch');
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', `--output=${file}`, from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000 });
|
||||
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
|
||||
fd = fs.openSync(file, 'r');
|
||||
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, stdio: [fd, 'pipe', 'pipe'], encoding: 'utf8', timeout: 120_000 });
|
||||
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
|
||||
return id.stdout.trim().split(/\s+/)[0] ?? '';
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** A carry killed mid patch-id (preflight's timeout) skips patchId's `finally`; its product diff is removed by the next run once an hour old. */
|
||||
let swept = false;
|
||||
function sweepStalePatchDirs(root: string): void {
|
||||
if (swept) return;
|
||||
swept = true;
|
||||
const cutoff = Date.now() - 60 * 60 * 1000;
|
||||
let names: string[] = [];
|
||||
try { names = fs.readdirSync(root).filter(n => n.startsWith('run-')); } catch { return; }
|
||||
for (const n of names) {
|
||||
const p = path.join(root, n);
|
||||
try { if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { recursive: true, force: true }); } catch { /* another run removed it */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** `git patch-id --verbatim` exists from git 2.39 (docs/ship-policy.md#receipt-carry); an older git is a named refusal, never a silent fallback to the whitespace-stripping default. */
|
||||
@@ -581,6 +607,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
const reviewedBase = receipt.base.replace(/^origin\//, '');
|
||||
const retarget = reviewedBase !== base.replace(/^origin\//, '');
|
||||
let plan: ReturnType<typeof planCarry>;
|
||||
let scopedDryRun: string | undefined;
|
||||
if (!ancestor) {
|
||||
// Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded.
|
||||
const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' };
|
||||
@@ -620,10 +647,14 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
else if (productFrom !== productHead && !retarget) {
|
||||
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the walk stops there, and it reads only the paths the
|
||||
// branch itself touched against the base at either head: a base merge brings in every path the base changed, none of
|
||||
// which can differ between the two base diffs. A path whose only difference is base context next to unchanged hunks
|
||||
// which can differ between the two base diffs. A touched path with the same blob at both heads and at both bases has
|
||||
// the same patch, so only paths that moved on one side are compared (GST-448: 6000 touched paths). A path whose only difference is base context next to unchanged hunks
|
||||
// (lib/ship-patch.ts) needs a scoped review instead of the loop.
|
||||
const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head };
|
||||
for (const p of [...touched].sort()) {
|
||||
const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, fromBase, headBase)];
|
||||
if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
|
||||
const [movedHead, movedBase] = moved as Set<string>[];
|
||||
for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) {
|
||||
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
|
||||
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
|
||||
if (cls !== 'product') continue;
|
||||
@@ -644,7 +675,10 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`));
|
||||
if (packetBody && !changedProduct.length) {
|
||||
const record = flag(rest, '--scoped-review');
|
||||
if (!record) {
|
||||
if (!record && rest.includes('--dry-run')) {
|
||||
scopedDryRun = `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: carry without --dry-run writes the scoped-review packet for one fix-check reviewer, then carry --scoped-review <record.json>; nothing written`;
|
||||
scopedReview = `base context moved (scoped review pending: ${contextPaths.join(', ')})`;
|
||||
} else if (!record) {
|
||||
const gitPath = git(['rev-parse', '--git-path', `gstack/scoped-review-${head.slice(0, 12)}.md`], repoRoot);
|
||||
const packet = path.resolve(repoRoot, gitPath ?? `.git/gstack/scoped-review-${head.slice(0, 12)}.md`);
|
||||
try {
|
||||
@@ -655,9 +689,11 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
}
|
||||
return refused('RECEIPT_CARRY_SCOPED_REVIEW', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: one fix-check reviewer reads ${packet}, then rerun carry with --scoped-review <record.json> ({"head":"${head}","paths":[…],"reviewer":"<model>","verdict":"clean"}); a blocking finding means /ship on ${head.slice(0, 12)}`);
|
||||
}
|
||||
const review = readScopedReview(record, { head }, contextPaths);
|
||||
if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`);
|
||||
scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`;
|
||||
if (record) {
|
||||
const review = readScopedReview(record, { head }, contextPaths);
|
||||
if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`);
|
||||
scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`;
|
||||
}
|
||||
}
|
||||
const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
|
||||
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
|
||||
@@ -698,6 +734,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
|
||||
const newBody = placeReceiptFirst(body, carried);
|
||||
const line = receiptSummaryLine(carried);
|
||||
if (scopedDryRun) return refused('RECEIPT_CARRY_SCOPED_REVIEW', scopedDryRun);
|
||||
if (rest.includes('--dry-run')) {
|
||||
process.stderr.write(line + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'dry-run', head, carry: plan.carry }) + '\n' : `CARRY: would carry from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason}); nothing written\n`);
|
||||
|
||||
@@ -44,7 +44,7 @@ Run directories and scratch on Capy live under `~/.capy/work/<name>` (`--out ~/.
|
||||
|
||||
Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render <dir>/gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent:<id> --owner-reply "<their words>"`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out <new dir> --from <that dir>` restores the newest valid checkpoint.
|
||||
|
||||
Token caps on /ship: pass a brief's cap as `--token-cap <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam id>" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading <n> --credits <c>`, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`).
|
||||
Token caps on /ship: pass a brief's cap as `--token-cap <n>`. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "<your own jam id>" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading <n> --credits <c>`, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`).
|
||||
|
||||
/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '<launch file>' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "<launch file>", cwd: "<repo>", base: "<base>", out: "<run dir>/p<n>", pass: "<run>-p<n>", laneModel: "<your model id>", planFlags: [], logStart: ["review"], jam: "<jam id>", record: "<rec>", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: "<RANGE_BASE>"`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events <file>` on your jam's transcript since /ship began. A small waiting child saves nothing.
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
**New page: [docs/support-tiers.md](docs/support-tiers.md) says what gstack supports fully and what it supports minimally.** macOS and Linux, with Claude and OpenAI models, get full support: the whole free suite and the paid evals. Windows (Git Bash or MSYS) and Gemini models get minimal support: install and the core skills (`/ship`, `/review`, `/qa-only`, `/investigate`, `/browse` on gstack's own browser) work without errors, checked on Windows by the `windows-smoke` job, which replaces the six-shard suite as the required Windows check when it lands, and by hand or one cheap smoke run on Gemini. Windows-only and Gemini-only reports that don't break install, startup or a core workflow close as won't fix with a link to the page.
|
||||
|
||||
### Documentation
|
||||
- `docs/support-tiers.md`: the two tiers, what we stop doing, the Windows CI shape today (the six-shard `windows-free-tests` suite required on every pull request) and once `windows-smoke` lands (`windows-smoke` required, the six-shard suite nightly on main and non-blocking) and what doesn't change (eval models, the cross-family review voice).
|
||||
- README's platform notes and AGENTS.md's platform support section link to it.
|
||||
@@ -1020,7 +1020,9 @@ first (`UBI_REAP=0` skips it). Names outside the `ubirun-` prefix are never reap
|
||||
quota preflight adds the size's vCPUs to the project's running VMs and refuses
|
||||
before the create request when that exceeds the known ceiling
|
||||
(`UBI_QUOTA_VCPUS`, or the maximum the last refusal printed, remembered in the
|
||||
state root; `UBI_QUOTA_WAIT=<seconds>` waits for room instead). `run`, `verify`
|
||||
state root; `UBI_QUOTA_WAIT=<seconds>` waits for room instead). When the usage read fails (`usage` exits 1 on an
|
||||
unreadable VM list or size and skips a VM that 404s), the preflight retries
|
||||
within `UBI_QUOTA_WAIT`, then logs and leaves the decision to the API. `run`, `verify`
|
||||
and `ssh NAME COMMAND` stream the remote output through a watchdog: a heartbeat
|
||||
line every `UBI_HEARTBEAT` seconds (300), and a command silent for
|
||||
`UBI_IDLE_TIMEOUT` seconds (1800) or older than `UBI_RUN_TIMEOUT` (0, unlimited)
|
||||
|
||||
@@ -107,7 +107,11 @@ After fixing auth, run any skill to retry sync automatically.
|
||||
git's error appears after the colon.
|
||||
|
||||
**Cause.** Could be network issue, rejected push (remote ahead), server 500,
|
||||
or repo access revoked.
|
||||
or repo access revoked. The line is git's own error, not its `To <remote>`
|
||||
banner or `hint:` lines. After a rejected push, brain-sync fetches and merges
|
||||
the other machine's commits; when that merge fails, the status adds
|
||||
`; merging origin failed: <reason>` (for example `CONFLICT (content): Merge
|
||||
conflict in developer-profile.json`).
|
||||
|
||||
**Fix.** Look at `~/.gstack/.brain-sync-status.json` for more detail, or run:
|
||||
```bash
|
||||
|
||||
@@ -191,6 +191,15 @@ append commits. Git's default would conflict at the file tail, but the
|
||||
You shouldn't see conflict prompts. If you do (a real semantic conflict,
|
||||
like two machines editing the same plan), git will stop and prompt.
|
||||
|
||||
When a push is rejected, brain-sync fetches and merges the other machine's
|
||||
commits itself. Local edits to files that merge would change are set aside
|
||||
and put back afterward, and a failed merge is aborted, so `~/.gstack` never
|
||||
holds conflict markers. If a local edit conflicts with the other machine's
|
||||
change, the other machine's version stays in the working tree and your edit
|
||||
is kept in a git stash; `gstack-brain-sync --status` names the stash and the
|
||||
`git -C ~/.gstack --literal-pathspecs restore --source=<stash> -- <path>`
|
||||
command that brings it back.
|
||||
|
||||
## Cross-machine pull cadence
|
||||
|
||||
The preamble runs `git fetch` + `git merge --ff-only` once per 24 hours
|
||||
|
||||
+114
-16
@@ -75,10 +75,10 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p <name>` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) |
|
||||
| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences) |
|
||||
| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences). A `[]` that matches a deleted test does not skip it: the deleted test still runs its owning suite, and a file naming it that `[]` covers (a durations list) counts as covered. With this key present, even empty, the policy [declares lanes](#lane-requirements) |
|
||||
| `empty_lanes` | extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a `join()` of path segments, an import, or a `dependencies` entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`, and so does licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`, `licenses/*.txt`). Source code and files under a test directory never qualify. A test that reads the file still selects that test |
|
||||
| `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) |
|
||||
| `warn` | check ids downgraded to warn; `secrets` and required lanes never |
|
||||
| `warn` | check ids downgraded to warn; `secrets` and required lanes never. `"lanes"` downgrades only a touched file with no lane: the selected tests still run, a red lane stays `fail` (`PREGATE_LANE_FAILED`) and an unfinished one `incomplete`; a green run is `warn` `PREGATE_NO_LANE` naming the unmapped files |
|
||||
| `strays.allow` | intentional root files or new top-level directories |
|
||||
| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane |
|
||||
| `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) |
|
||||
@@ -88,7 +88,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
|
||||
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
|
||||
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
|
||||
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
|
||||
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)) |
|
||||
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
|
||||
| `remote_gate_runs` | `{ profiles, inventory }`: the run profiles that together clear `pregate.json/remote_gate`, and the command that prints the remote gate's test files, one per line, which their runs must cover (see [run evidence](#run-evidence)) |
|
||||
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
|
||||
|
||||
@@ -141,6 +141,19 @@ unmapped file is `PREGATE_NO_LANE` again: the warning is for the first /ship
|
||||
in a new repository (three Captains lost their first pass to it), never a way
|
||||
to hide missing coverage. `--policy-from head` with a head policy is strict too.
|
||||
|
||||
<a id="lane-requirements"></a>
|
||||
**Lane requirements.** "Any policy" above means a policy that declares lanes:
|
||||
one with a `dependencies`, `empty_lanes`, `selection` or `local` key, even an
|
||||
empty one (`gstack-pregate init` writes the first three). A policy with none of
|
||||
them, such as
|
||||
`{"timeout_s": 1800}` to give long tests more time, keeps the no-policy lane
|
||||
verdicts: an unmapped file is the `warn` `PREGATE_NO_POLICY`, the selected
|
||||
tests run, a red lane is `fail`, and the starter says the active policy
|
||||
declares no lanes instead of claiming there is none. So raising
|
||||
`timeout_s` never needs a `"**": []` catch-all. Add `dependencies` when you
|
||||
want unmapped files to fail. The same rule applies to the user-owned file and to
|
||||
a `--policy-from head` bootstrap (gbrain-evals #272, GST-775).
|
||||
|
||||
`gstack-pregate init` writes a draft built from the repo, not a fixed example.
|
||||
It prints the languages it found and the runner each language's tests would
|
||||
get, then maps every file the import graph cannot place to the tests of the
|
||||
@@ -160,7 +173,7 @@ The list lives in its own file, not in `pregate.json`, because older pregate rea
|
||||
|
||||
```
|
||||
gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
|
||||
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
|
||||
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
|
||||
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init
|
||||
```
|
||||
|
||||
@@ -266,9 +279,10 @@ Two stages with separate clocks, both printed:
|
||||
`.cjs`, `.mts`, `.cts`, then `index.ts`, `index.tsx`, `index.js`, `index.jsx`
|
||||
and `index.mjs` inside the directory; the first file that exists wins.
|
||||
A touched source file with no lane is `FAIL lanes — <file> has no test lane`
|
||||
once any policy exists (the base copy, the user-owned file or `--policy-from
|
||||
head`); with no policy anywhere it is the `warn` `PREGATE_NO_POLICY` with a
|
||||
starter printed ([no policy](#no-policy)); never a pass. Markdown that no
|
||||
once a policy that [declares lanes](#lane-requirements) exists (the base copy,
|
||||
the user-owned file or `--policy-from head`); with no policy anywhere, or one
|
||||
that declares no lanes, it is the `warn` `PREGATE_NO_POLICY` with a starter
|
||||
printed ([no policy](#no-policy)); never a pass. Markdown that no
|
||||
test depends on is a docs/no-tests empty lane
|
||||
instead (`docs/no-tests: N documentation file(s)`), including when the base
|
||||
branch has no `.gstack/pregate.json`. The dependency is the same import graph
|
||||
@@ -654,15 +668,20 @@ old the run may be, and `os` (`Linux`, `macOS`, `Windows`) requires
|
||||
`tree.json` to record that runner OS. Each workflow uploads `tree.json` from
|
||||
its own job: `run_id`, `run_attempt`, `event`, `sha` (or `head_sha`),
|
||||
`tree` (`git rev-parse HEAD^{tree}` as tested), `files` (every test file the
|
||||
run executed), `tests` (the count) and, with `os`, the runner OS.
|
||||
run executed), `tests` (the count) and, with `os`, the runner OS or a list of
|
||||
every OS the run's test jobs ran on (the profile's `os` must be one of them).
|
||||
|
||||
**What `clear --run` checks** (`lib/run-evidence.ts`), for every run, online,
|
||||
once:
|
||||
|
||||
- The run belongs to this repository and ran for a same-repository head. Its
|
||||
workflow file is the profile's, its event is `push` or `workflow_dispatch`
|
||||
(a `pull_request` run tests the merge commit, not the head tree, so no
|
||||
profile may accept it), and it completed with success. The API answers with
|
||||
workflow file is the profile's, its event is one the profile lists (`push`,
|
||||
`workflow_dispatch` or `pull_request`), and it completed with success. A
|
||||
`pull_request` run tested GitHub's merge commit, not its head, so its
|
||||
`tree.json` must name the head (`head_sha`) and the merge commit (`sha`), and
|
||||
the tree git computes for that merge commit must equal the head's tree: the
|
||||
head already contained its base, so the merge tested exactly the head tree
|
||||
(`tree_mismatch` otherwise). The API answers with
|
||||
the newest attempt, so an earlier green attempt under a newer red one is
|
||||
refused, and the run is read again after the evidence is collected: a new
|
||||
attempt meanwhile is `run_changed`.
|
||||
@@ -730,9 +749,21 @@ recording. When the base config or the GitHub origin cannot be read, `--run`
|
||||
fails, and so do `--run-url` and `--bundle-lane` unless the base was read and
|
||||
shows the lane has no profile; then the lane is recorded as before, with a
|
||||
warning naming the cause. `gstack-ship-queue ready --dispatch-ci` still
|
||||
records its dispatched runs by URL; routing it through the verifier is the next
|
||||
change (P3b), so a repository that declares lane profiles clears those lanes
|
||||
with `gstack-pregate clear --run`.
|
||||
records its dispatched runs by URL; routing it through the verifier is a
|
||||
follow-up (GST-555), so a repository that declares lane profiles clears those
|
||||
lanes with `gstack-pregate clear --run`.
|
||||
|
||||
**gstack's own profiles.** gstack's `.gstack/pregate.json` declares two, and
|
||||
names neither after a lane: `ci-reuse/free-tests` (the complete Free Tests run:
|
||||
every non-matrix job and the `free-tests` aggregate, `os` Linux) and
|
||||
`ci-reuse/windows-free-tests` (`windows-plan` and the `windows-free-tests`
|
||||
aggregate, `os` Windows). Each accepts `pull_request` and `workflow_dispatch`
|
||||
runs and reads the `tested-tree` artifact, which both aggregates upload only
|
||||
after their strict verification passes. The main-push reuse job and
|
||||
`--from-run` read them. No lane has a profile, so every macOS and Windows lane
|
||||
still clears by `--run-url` as before. A pull request cut before these
|
||||
workflows uploaded `tested-tree` has no record a lane profile could verify, so
|
||||
lane profiles wait until open pull requests carry the producer (GST-555).
|
||||
|
||||
**`verify` stays offline.** The check ran at `clear`. `gstack-pregate verify`,
|
||||
and `gstack-ship-queue ready` through it, refuse a run receipt whose `tree=`
|
||||
@@ -742,8 +773,75 @@ merge path.
|
||||
|
||||
**What is never reusable.** Security and advisory scans (osv, audit,
|
||||
gitleaks) depend on the day, not on the tree, so they are not run profiles:
|
||||
they run on every head as before. Pull-request required checks always run
|
||||
fresh.
|
||||
they run on every head as before, and `--from-run` never skips them.
|
||||
Pull-request required checks and dispatched runs always run fresh; only a
|
||||
push to main reuses ([below](#ci-reuse)).
|
||||
|
||||
<a id="from-run"></a>
|
||||
### The tests stage from a Free Tests run (`--from-run`)
|
||||
|
||||
`gstack-pregate --stage tests --from-run <run id|url>` takes a Free Tests run
|
||||
that already passed on this exact tree, for example the one CI ran on the
|
||||
pushed head, instead of running its files again here. The run is verified
|
||||
online, once, against the base branch's run profiles for this machine's OS
|
||||
whose workflow holds a lane this machine runs (gstack's `ci-reuse/free-tests`
|
||||
on Linux), with pregate.json's own head as a further veto head. Its tree must be this tree exactly (no
|
||||
stamp-only substitution). Then:
|
||||
|
||||
- The selected local test files the run's `tree.json` lists count as passed.
|
||||
The lanes check records them under `from_run` with the run receipt
|
||||
(`run tree=… tests=… runs=<id>/<attempt> profiles=… heads=… verified=…`) and
|
||||
prints `REUSE: accepted <n> of <m> selected local test file(s) from <receipt>;
|
||||
<k> still run`.
|
||||
- The rest run as they would have: locally, or recorded as the remote gate
|
||||
obligation on a small machine, or through `--remote`.
|
||||
- A run that does not verify prints `REUSE: refused reason=<code> — …; every
|
||||
selected test runs here` and changes nothing else. The codes are
|
||||
verifyRun's, plus `tree_mismatch` (another tree), `no_tests`, `no_profile`
|
||||
(no profile for this OS covers a local lane's workflow), `no_verified_run`
|
||||
(more than one profile applied and each refused; the detail lists every
|
||||
refusal) and `profiles_unavailable` (the base config or the GitHub origin
|
||||
could not be read).
|
||||
|
||||
`--from-run` applies to the tests stage only, so `--stage preflight` refuses it
|
||||
as a usage error. It changes nothing in the preflight stage: with `--stage all`
|
||||
the preflight checks, the secrets scan included, run as before, and
|
||||
`--stage tests` runs no preflight check in that invocation, with or without it.
|
||||
|
||||
<a id="ci-reuse"></a>
|
||||
### Main pushes reuse the merged pull request's run
|
||||
|
||||
A squash merge whose pull request head already held main produces a push with
|
||||
the identical tree, and Free Tests and Windows Free Tests used to run on it a
|
||||
second time. Now each workflow starts with a `reuse` job that runs only on a
|
||||
push to `main`, with job-level `contents: read` and `actions: read` (every other
|
||||
job keeps the workflow's `contents: read`). It runs
|
||||
`scripts/ci-tree-reuse.ts find`, which:
|
||||
|
||||
1. finds the pull request whose merge commit is the pushed commit
|
||||
(`repos/{repo}/commits/{sha}/pulls`, one page of at most 10), merged into
|
||||
main from a same-repository head (`no_pr`, `ambiguous_pr`, `fork_head`);
|
||||
2. requires that head's tree to equal the pushed tree (`tree_mismatch`);
|
||||
3. lists that head's `pull_request` and `workflow_dispatch` runs of the same
|
||||
workflow, newest first, at most 10 (`no_candidate`); and
|
||||
4. accepts the first one `verifyRun` accepts under the profile in the pushed
|
||||
tree's `.gstack/pregate.json` (`no_verified_run` lists each refusal; a newer
|
||||
red run on the head vetoes an older green one).
|
||||
|
||||
It writes `reused=true` with the run's id and link, or `reused=false` and the
|
||||
reason, and prints one `REUSE:` line in the step summary. Every suite job
|
||||
needs `reuse` and runs unless it succeeded with `reused=true`; their
|
||||
conditions start with `!cancelled()` because GitHub otherwise skips a job whose
|
||||
needed job was skipped, and `reuse` is skipped on every pull request and
|
||||
dispatch run. The required aggregates (`free-tests`, `windows-free-tests`)
|
||||
choose a path in their first step, before any download. A verified reuse writes
|
||||
the reused run's link to the summary and passes; anything else, a failed or
|
||||
skipped `reuse` job included, takes the strict path unchanged. Only the strict
|
||||
path records and uploads `tested-tree`, after its verification passes, so a
|
||||
red, partial or reused run never leaves a record and a reuse is never reused.
|
||||
The pull request lookup needs no `pull-requests` permission on a public
|
||||
repository; a private fork of this setup would need `pull-requests: read`,
|
||||
and without it every push falls back to the full run.
|
||||
|
||||
<a id="guards"></a>
|
||||
## Guards and the lane-done rule
|
||||
|
||||
@@ -82,7 +82,7 @@ a time; each is independent.
|
||||
|
||||
| File | Why | Without it |
|
||||
|---|---|---|
|
||||
| `.gstack/pregate.json` | Maps changed paths to test lanes, so `/ship` runs the tests that cover the diff and the remote gate selects correctly ([docs/pregate.md](pregate.md)) | `/ship` fails every touched file with `PREGATE_NO_LANE`. Write it once, from one owner, before the crew starts. |
|
||||
| `.gstack/pregate.json` | Maps changed paths to test lanes, so `/ship` runs the tests that cover the diff and the remote gate selects correctly ([docs/pregate.md](pregate.md)) | `/ship` warns on every touched file with no test lane (`PREGATE_NO_POLICY`) and prints a starter policy; the selected tests still run. A policy that sets only `timeout_s` or other keys without `dependencies`, `empty_lanes`, `selection` or `local` behaves the same. Write it once, from one owner, before the crew starts. |
|
||||
| `.gstack/ship-policy.json` | `queue_mode`, the `train` block (gate and free workflow names, the full-gate check, the train size and fill wait), the stamp files ([docs/ship-policy.md](ship-policy.md)) | Per-PR queue; `train …` refuses with `QUEUE_TRAIN_OFF`. |
|
||||
| `CHANGELOG.md` + `VERSION` | A stamp per merge and a twice-weekly release with notes ([docs/releases.md](releases.md)) | Skip the version and changelog steps; the train still works on the tree receipt. |
|
||||
| `AGENTS.md` validation discipline | Diagnose before fixing, focused checks during repair, one full suite at the end on a frozen tree | Threads retry blindly and report passes from older revisions. |
|
||||
|
||||
+11
-7
@@ -462,14 +462,18 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
|
||||
literal diff base, the plan's `DIFF_CMD` excludes, stack, test framework,
|
||||
learnings, checklist paths, the Red Team's merged findings and specialist count,
|
||||
and the outside voice's branch diff. A section that lost an anchor or placeholder
|
||||
refuses (`BRIEF_RENDER_FAILED`) instead of rendering something else.
|
||||
refuses (`BRIEF_RENDER_FAILED`) instead of rendering something else, and so does
|
||||
a slot the section leaves unfilled (every reader but fix-check and the outside voice).
|
||||
That check runs before the findings and learnings go in, and they reach the reader
|
||||
as written, so a finding that quotes `origin/<base>` neither fails the render nor
|
||||
gets filled (GST-797).
|
||||
2. The dispatch program passes that file's content unchanged as the reader's
|
||||
prompt and runs `sent` on what it passed: a hash that differs from the rendered
|
||||
one is `BRIEF_MISMATCH`, and that reader's review does not count.
|
||||
3. `collect` hashes each reader's raw output and appends its findings to
|
||||
`merged.jsonl` with the label as written (a JSON line's `severity`, or the most
|
||||
severe of a prose line's Critical, High, Medium, Low, P0-P3, FIXABLE or INVESTIGATE
|
||||
wherever they stand outside a negation (below), headings and table rows included; a top-level list item under a
|
||||
wherever they stand, headings and table rows included; a top-level list item under a
|
||||
label-only heading takes the stronger of the two). A `Severity:` or `Blocks:` line
|
||||
continues the finding above it until a list item, heading, bold paragraph opener or a
|
||||
second line for the same field starts another: `Severity` raises its label,
|
||||
@@ -478,11 +482,11 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
|
||||
adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167). An unnumbered, unlabeled bullet with no file:line
|
||||
under a paragraph finding whose Severity or Blocks line has not come yet is that finding's detail, so the field line after it
|
||||
lands on the finding; a numbered item is its own finding.
|
||||
A label a negation word governs ("found nothing CRITICAL", "no new **High** or Critical issues", "No P0/P1 issues":
|
||||
no, nothing, not, none, zero, without or never, optionally followed by new, remaining, other, further, additional, more
|
||||
or at, through Markdown, and carried to labels joined by a comma, slash, or, nor or and) is no finding unless it opens a
|
||||
field (`High:`, GST-766). A line whose labels are all negated closes the open finding and heads the next, so a finding
|
||||
worded around a negated label still arrives through its Severity or Blocks field. Each prose finding's line also
|
||||
A negated severity word still collects at its label ("found nothing CRITICAL", "No P0/P1 issues"), so a reader's
|
||||
closing summary is a known phantom: it stays at that label in the judged set, lead judgment can fold it only into a
|
||||
finding at least as severe, and it counts toward what blocks. GST-766 skipped negated labels and lost
|
||||
real blockers named in an exception clause ("No CRITICAL issues except the token leak in log.ts:3"); GSTA-167
|
||||
reverted that. Each prose finding's line also
|
||||
records the Severity and Blocks its own text states as `stated`, beside the collected label and never in its place;
|
||||
a classified finding that carries `stated` shows it in its deferred-review issue next to the label ("CRITICAL (the
|
||||
reviewer wrote Severity: Low, Blocks: no)"). /ship's Step 9.4 does not copy `stated` into its findings file yet: that
|
||||
|
||||
+9
-2
@@ -76,8 +76,14 @@ one program measure nothing: Capy books a call after its program ends.
|
||||
|
||||
A jam's own counter includes its own subagents, outside voices too (measured 2026-10-30: +10.92M
|
||||
across a six-reader dispatch with two ~0.35M parent calls between). `"this"` is the root thread,
|
||||
whose counter also holds every sibling tree, so a subagent running /ship passes its own jam id;
|
||||
a root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it
|
||||
whose counter also holds every sibling tree. A subagent running /ship cannot read its own jam id, so it
|
||||
reads `"this"` and starts with `--subagent --reading-from this --meter-scope tree` (`GSTACK_SHIP_SUBAGENT=1` in its
|
||||
brief stands in for `--subagent` only; `--reading-from this` is still required): the run is tree-scoped, and every BUDGET, NOTE, BUDGET_FINAL
|
||||
and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`. A
|
||||
subagent that asks for `--meter-scope self` while reading `"this"` is refused
|
||||
(`METER_SCOPE_PARENT`). A `--token-cap` on a tree-scoped run stops on that upper bound, so sibling
|
||||
lanes count against it. Exact per-run cost comes from a root replay thread: a root thread that runs
|
||||
only that /ship, whose `"this"` is its own counter. A root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it
|
||||
verifies with no reading and prints `METER: parent`; the parent's handoff and reconcile readings,
|
||||
taken on its own counter (which holds the child), measure it.
|
||||
|
||||
@@ -286,6 +292,7 @@ stop rules) before claiming a saving from the cap or from delegation.
|
||||
|
||||
[BUDGET_CAP_REACHED](troubleshooting.md#budget-cap-reached) ·
|
||||
[BUDGET_READING_MISSING](troubleshooting.md#budget-reading-missing) ·
|
||||
[METER_SCOPE_PARENT](troubleshooting.md#meter-scope-parent) ·
|
||||
[BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected) ·
|
||||
[BUDGET_METER_UNAVAILABLE](troubleshooting.md#budget-meter-unavailable) ·
|
||||
[BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid) ·
|
||||
|
||||
+34
-8
@@ -563,7 +563,7 @@ of required checks before it merges, one slot after another, so a wave of
|
||||
three saves two of those rounds and two versions.
|
||||
|
||||
```bash
|
||||
gstack-ship-queue wave plan # read-only: QUEUE, WAVE_ADVISED, WAVE_GROUP lines
|
||||
gstack-ship-queue wave plan # read-only (merge-tree scratch objects aside): QUEUE, WAVE_ADVISED, WAVE_GROUP lines
|
||||
gstack-ship-queue wave build --area ship --prs 41@<sha>,42@<sha>,43@<sha>
|
||||
gstack-ship-queue wave build --wave <nonce> --continue # after resolving a conflict in the printed worktree
|
||||
gstack-ship-queue wave build --wave <nonce> --drop 42 # rebuild without #42 as a new wave
|
||||
@@ -573,9 +573,19 @@ gstack-ship-queue wave settle --wave <nonce> [--dry-run] # after the wave
|
||||
```
|
||||
|
||||
- **When.** `wave plan` counts the live READY PRs (open, head equal to their
|
||||
READY receipt). Over the trigger (6) it prints `WAVE_ADVISED` and suggested
|
||||
groups of 3 to 6: PRs that change a common file always share a group (their
|
||||
conflicts are resolved once), each group takes its largest PR's functional
|
||||
READY receipt, CI green at that head; a READY head whose CI is red, still
|
||||
running or absent prints a `NOT_GREEN` line and stays out of the plan). A
|
||||
READY PR whose CI, head or diff cannot be read fails the whole plan with
|
||||
`QUEUE_PROOF_MISSING`. Over the
|
||||
trigger (6) it prints `WAVE_ADVISED` and suggested groups of 3 to 6: PRs
|
||||
whose changes to a common code file conflict (`git merge-tree` cannot merge
|
||||
the two heads, or the check itself fails) always share a group, so that
|
||||
conflict is resolved once. Hub
|
||||
files never link PRs: anything under `docs/`, Markdown (generated skill
|
||||
renders included; their `.tmpl` sources still link) and every `.json` or
|
||||
`.jsonl` file (ledgers and registries). Most PRs append to them, and
|
||||
linking through them once chained a 55-PR queue into a single area
|
||||
(GST-770). Each group takes its largest PR's functional
|
||||
area (a skill directory, the first word of a `lib/`, `bin/` or `test/`
|
||||
module name, or `docs`), smaller groups ride solo, and the integrator is
|
||||
the PR with the most changed lines. `train build` handed more than 6 pins
|
||||
@@ -1335,7 +1345,12 @@ falling back to the whitespace-stripping default, and a `git diff` or
|
||||
patch-ids differ, the refusal names up to `CARRY_NAMED_PATHS` (20) product
|
||||
paths whose patch against the base changed, walking only the paths the branch
|
||||
itself touched at either head (a base merge brings in every path the base
|
||||
changed, none of which can differ between the two base diffs). Product means every changed path that is not test code (`test/**`,
|
||||
changed, none of which can differ between the two base diffs), and of those
|
||||
only the paths that changed between the two heads or the two bases (a path with
|
||||
the same blob on both sides has the same patch). The product diff streams
|
||||
through a file under the git directory (`.git/gstack-patch-id`) into
|
||||
`git patch-id --verbatim`, so a PR with thousands of paths is not held in
|
||||
memory; a directory a killed run left there is removed after an hour. Product means every changed path that is not test code (`test/**`,
|
||||
`spec/**`, `e2e/**`, `**/*.test.<code ext>`, `**/*.spec.<code ext>` and the
|
||||
other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is
|
||||
product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, each
|
||||
@@ -1373,7 +1388,11 @@ along the receipt's own history, `rewritten` once any hop was a rebase;
|
||||
`CARRY: carried from <old> to <new> (<reason>)` and the `GSTACK_RESULT:
|
||||
skill=ship status=complete` line, and its summary line ends with
|
||||
`carried_from=<old12>`. `CARRY: current` means the receipt already names HEAD;
|
||||
`--dry-run` prints `CARRY: would carry …` and writes nothing.
|
||||
`--dry-run` prints `CARRY: would carry …` and writes nothing. When the only
|
||||
difference is base context next to the branch's unchanged hunks and none of
|
||||
carry's other checks would refuse, `--dry-run` refuses
|
||||
`RECEIPT_CARRY_SCOPED_REVIEW` without writing the packet; carry without
|
||||
`--dry-run` writes it.
|
||||
|
||||
A changed product path, or a product patch-id that differs, is
|
||||
[`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed)
|
||||
@@ -1406,7 +1425,12 @@ the carry when the branch has an open PR, in this order, and never reruns the
|
||||
review loop for a carried receipt:
|
||||
|
||||
1. `gstack-ship-receipt carry --pr <n> --base <base> --dry-run`. `CARRY: would
|
||||
carry` is eligibility only; a refusal continues the full ship.
|
||||
carry` is eligibility only; a refusal continues the full ship, except
|
||||
`RECEIPT_CARRY_SCOPED_REVIEW`, for which `gstack-ship-preflight` prints
|
||||
`NEXT: carry route`: fix what the base merge broke, commit and push, run carry
|
||||
without `--dry-run` to write the packet, have one fix-check reviewer read it,
|
||||
then carry with `--scoped-review <record.json>` and `--pregate`. A blocking
|
||||
finding continues the full ship.
|
||||
2. At that pinned HEAD: the CHANGELOG check, the size-cap check the policy
|
||||
declares, and `gstack-pregate` (the remote tests stage when
|
||||
`requires_remote` asks for it). A failing check, or HEAD moving, continues
|
||||
@@ -1696,7 +1720,9 @@ hand edit), and the replacement the current one, so a dependency pin
|
||||
that happens to equal the old version is `changed`; `stamp_patterns`
|
||||
templates count as stamp forms too). With `stamp_files` set, a file outside it
|
||||
is `changed (stamp_files: not in the repo's stamp_files allowlist)` whatever
|
||||
its diff. Any other byte is a change.
|
||||
its diff. Any other byte is a change. A diff git cannot produce between the two
|
||||
heads fails closed with `EVIDENCE_IDENTITY_UNKNOWN` and git's reason; it never
|
||||
reads as no change.
|
||||
|
||||
Equivalence is decided per gate. A lane in `stamp_sensitive_gates` always
|
||||
`rerun`s; a verified lane is `reusable` only when the tree is same-modulo-stamps
|
||||
|
||||
+54
-7
@@ -550,6 +550,25 @@ local `origin/<base>` at `<rev>`.
|
||||
**Fix.** Run `git fetch origin <base>` outside the sandbox and rerun /review if
|
||||
the base has moved.
|
||||
|
||||
<a id="review-content-changed"></a>
|
||||
### `review_freshness: STALE content changed during review: the tree moved from <start> to <end> while <skill> ran ...` / **Review records missing or mismatched**
|
||||
|
||||
**Meaning.** The working tree changed between the pass's `gstack-review-log --start` and
|
||||
its logged row: an edit, commit or merge landed while reviewers were still reading. The
|
||||
reviewers may not have seen that change, so the row's `review_binding.state` is `changed`.
|
||||
Step 11.5 cannot bind it and inserts `9 → 10 → 11 → 11.5`. Logging the same results again
|
||||
cannot repair it.
|
||||
|
||||
**Fix.** Keep the checkout frozen (no edits, commits, merges or pushes) from a pass's start
|
||||
until every reviewer in it has returned and its row is logged. If the mid-pass change was
|
||||
your own fix to that pass's findings, log the pass with `"converged":false` and the fix
|
||||
as `"action":"fixed"`. `gstack-review-plan` then picks the repeat: one fix-check reading
|
||||
the touched hunks when every fix is below the blocking floor, and 11.5 accepts that
|
||||
fix-check record as Step 9's. Rerun Step 11's native pass on the same frozen tree.
|
||||
|
||||
**Expected result.** `gstack-review-read` shows both records at one tree with
|
||||
`review_freshness` `CURRENT`, and Step 11.5 continues to Step 12.
|
||||
|
||||
<a id="review-fingerprint-tmpdir"></a>
|
||||
### `gstack-wtree: cannot create a temp index under <dir>; set TMPDIR to a writable directory` / `gstack-review-log: cannot create a private object directory under <dir>; no working-tree fingerprint. Set TMPDIR to a writable directory.`
|
||||
|
||||
@@ -1942,6 +1961,18 @@ owner's call: run the printed `RECOVER:` line; its resume needs
|
||||
**Fix.** In the execute program that runs the command, read
|
||||
`(await tools.thread.get({ thread_id: "<your own jam id>" })).cost.tokens` and pass it as `--reading <n>`.
|
||||
|
||||
<a id="meter-scope-parent"></a>
|
||||
### `gstack-ship-budget: ... (METER_SCOPE_PARENT)`
|
||||
|
||||
**Meaning.** `start` was told the run is a subagent (`--subagent` or `GSTACK_SHIP_SUBAGENT=1`) reading
|
||||
`"this"` (`--reading-from this`), the root thread's counter, which also holds every sibling lane, and
|
||||
asked for `--meter-scope self`. A self scope would print that counter as the run's own cost. Exit 2;
|
||||
no run started.
|
||||
|
||||
**Fix.** Start with `--meter-scope tree`. Every cost line then prints
|
||||
`METER: tree (root counter, upper bound)`. Exact per-run cost comes from a root replay thread
|
||||
(`docs/ship-budget.md`).
|
||||
|
||||
<a id="budget-reading-rejected"></a>
|
||||
### `gstack-ship-budget: ... (BUDGET_READING_REJECTED)`
|
||||
|
||||
@@ -3128,9 +3159,14 @@ or pass `--force-gate <reason>` in `/ship` to run the full gate again.
|
||||
**Meaning.** The bundle (or ledger record) lacks a committed tree id, runtime
|
||||
versions, lockfile hashes or the command hash, so no reuse decision can rest on
|
||||
it. Imported assertions without verification count as unknown too.
|
||||
`gstack-tree-receipt` also fails with this code when git cannot produce the
|
||||
diff between the gated and current heads (a missing object, a timeout): the
|
||||
error names git's reason, and a failed diff is never read as no change.
|
||||
|
||||
**Fix.** Rebuild the bundle on the gate machine after the lanes ran on a
|
||||
committed head: `gstack-evidence bundle --out <file> --label <lane>...`.
|
||||
committed head: `gstack-evidence bundle --out <file> --label <lane>...`. For
|
||||
the tree receipt, fix what git's reason names (fetch a missing head with
|
||||
`git fetch origin <sha>`), then rerun.
|
||||
|
||||
<a id="evidence-bundle-invalid"></a>
|
||||
### `EVIDENCE_BUNDLE_INVALID`
|
||||
@@ -3561,9 +3597,12 @@ never runs them: declare `"**/integration_test/**": []` (and
|
||||
them. A `[]` declaration added only on the branch is refused under
|
||||
`--policy-from head` ([`PREGATE_HEAD_POLICY_LOOSENS`](#pregate-head-policy-loosens)),
|
||||
so land it in its own PR first.
|
||||
With no policy anywhere (no base copy, no user-owned file) an unmapped file is
|
||||
With no policy anywhere (no base copy, no user-owned file), or a policy with no
|
||||
`dependencies`, `empty_lanes`, `selection` or `local` key (a timeout-only file), an unmapped file is
|
||||
the warning [`PREGATE_NO_POLICY`](#pregate-no-policy) with a starter policy
|
||||
printed, not this error; `gstack-pregate init` writes that draft and lists the
|
||||
printed, not this error. Under `warn: ["lanes"]` it is a `warn` with this code,
|
||||
given only after the selected tests ran green (a red lane is still
|
||||
`PREGATE_LANE_FAILED`); `gstack-pregate init` writes that draft and lists the
|
||||
files it could not place. `--explain` prints every mapping and why. For a file with no lane it also prints the import
|
||||
specifiers in test files that resolved to no repo file (for example a
|
||||
`tsconfig.json` `paths` alias such as `@/lib/x` whose target is missing) and any
|
||||
@@ -3701,8 +3740,9 @@ differing path is a change.
|
||||
### `PREGATE_NO_POLICY`
|
||||
|
||||
**Meaning.** The repository has no pregate policy yet (no `.gstack/pregate.json`
|
||||
on the base branch and no user-owned `<state root>/projects/<slug>/pregate.json`)
|
||||
and a touched file maps to no test lane. Under no policy that is a `warn`, not
|
||||
on the base branch and no user-owned `<state root>/projects/<slug>/pregate.json`),
|
||||
or its policy declares no lanes (no `dependencies`, `empty_lanes`, `selection` or `local` key, a
|
||||
timeout-only file for example), and a touched file maps to no test lane. Under no policy that is a `warn`, not
|
||||
`PREGATE_NO_LANE`: the selected tests still ran (a red lane is still `fail`),
|
||||
and the table printed a starter policy drafted from the repository
|
||||
(`PREGATE_STARTER:` lines: runner, test directories, CI lanes, the JSON between
|
||||
@@ -3713,7 +3753,8 @@ Details: [docs/pregate.md](pregate.md#no-policy).
|
||||
overwrite an existing file without `--force`), edit each `TODO` (a test that
|
||||
names the file, a `dependencies` entry, or `[]` for a file with no behaviour),
|
||||
run `gstack-pregate validate`, then commit `.gstack/pregate.json` on the base
|
||||
branch. From then on an unmapped file is `PREGATE_NO_LANE`.
|
||||
branch. When a policy already exists, add the starter's `dependencies` to it
|
||||
instead. From then on an unmapped file is `PREGATE_NO_LANE`.
|
||||
|
||||
<a id="pregate-environment"></a>
|
||||
### `PREGATE_ENVIRONMENT`
|
||||
@@ -5513,7 +5554,9 @@ compare (a receipt written before `head_tree`, whose head is not in this
|
||||
clone), the PR was retargeted to the base after its stack parent landed but
|
||||
the receipt records no `product_patch_id` and `origin/<parent>` no longer
|
||||
resolves, so the reviewed range cannot be recomputed, or git could not
|
||||
compare the two product diffs or read the working tree's status. It also refuses when an owner exception the receipt records
|
||||
compare the two product diffs (including when carry cannot create its temp
|
||||
directory under `.git/gstack-patch-id`; the message names it) or read the
|
||||
working tree's status. It also refuses when an owner exception the receipt records
|
||||
no longer stands, and, for a receipt from a gstack that records review
|
||||
depth, when HEAD needs a deeper review than the receipt records, `--head` is
|
||||
not the checked-out HEAD, or the receipt records `gstack_version` without
|
||||
@@ -5606,6 +5649,10 @@ the tree it is bound to. Finish also stops this way for test code changed since
|
||||
review with no gate edit (`tests=N`): the packet holds those test hunks, and the
|
||||
reviewer checks that each test still tests what it did. A packet that cannot be
|
||||
written (an unwritable `.git`) is a STOP or `RECEIPT_CARRY_REFUSED` naming the path.
|
||||
`carry --dry-run` reports this code without writing the packet, and only when
|
||||
none of carry's other checks would refuse; carry without `--dry-run` writes it.
|
||||
In /ship Step 1, `gstack-ship-preflight` prints the route as `NEXT: carry
|
||||
route (Step 1.4)`.
|
||||
|
||||
<a id="receipt-prose-mismatch"></a>
|
||||
### `RECEIPT_PROSE_MISMATCH`
|
||||
|
||||
+3
-2
@@ -240,8 +240,9 @@ now names HEAD with a `carry` object holding the `git range-diff` evidence, and
|
||||
counters are kept, because the reviewed product diff is unchanged or its only
|
||||
difference passed a scoped review. `CARRY: current` means nothing moved.
|
||||
`CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)` means the base edited context
|
||||
next to reviewed hunks: one fix-check reviewer reads the packet the refusal
|
||||
names, and a clean record reruns carry with `--scoped-review <record.json>`
|
||||
next to reviewed hunks. Carry without `--dry-run` writes the packet the
|
||||
refusal names (a dry run writes none), one fix-check reviewer reads it, and a
|
||||
clean record reruns carry with `--scoped-review <record.json>`
|
||||
([troubleshooting](troubleshooting.md#receipt-carry-scoped-review)). `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`,
|
||||
`(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused
|
||||
result line means the product diff changed, or history was rewritten onto a
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface PregateCheck {
|
||||
cause?: 'diff' | 'environment'; environment?: { kind: string; evidence: string; proof?: 'launch' | 'output' };
|
||||
/** `regen`: the touched outputs the generator recreated byte-identically from a deleted copy (what a receipt carry may set aside as generated). */
|
||||
proven?: string[];
|
||||
/** `lanes` under `--from-run`: the run receipt and the selected local test files that run already passed on this tree (not run again here). */
|
||||
from_run?: { receipt: string; files: string[] };
|
||||
}
|
||||
/** 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 }> }
|
||||
@@ -219,7 +221,7 @@ export const ARTIFACT_SCHEMAS = {
|
||||
type: 'object', required: ['schema_version', 'tree', 'checks'],
|
||||
properties: {
|
||||
schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num,
|
||||
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList } } },
|
||||
checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList, from_run: { type: 'object', required: ['receipt', 'files'], properties: { receipt: str, files: strList } } } } },
|
||||
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 } } } } } } } },
|
||||
@@ -549,9 +551,16 @@ export interface DerivedReviewStatus {
|
||||
findings_total: number; findings_open: number; findings_resolved: number;
|
||||
}
|
||||
export function isOpenFinding(row: Record<string, unknown>): boolean {
|
||||
if (row.action === 'open') return true;
|
||||
if (typeof row.disposition === 'string') return !RESOLVED.includes(row.disposition);
|
||||
if (typeof row.status === 'string') return !['resolved', 'fixed', 'closed'].includes(row.status);
|
||||
return row.action !== 'fixed';
|
||||
return isOpenByAction(row);
|
||||
}
|
||||
/** A finding's `action` alone: fixed and auto-fixed resolve it; deferred and noted resolve only one marked `blocking: false` that is not CRITICAL (review policy's floor). */
|
||||
export function isOpenByAction(row: Record<string, unknown>): boolean {
|
||||
if (row.action === 'fixed' || row.action === 'auto-fixed') return false;
|
||||
if (row.action === 'deferred' || row.action === 'noted') return row.blocking !== false || isCriticalFinding(row);
|
||||
return true;
|
||||
}
|
||||
export function isCriticalFinding(row: Record<string, unknown>): boolean {
|
||||
return typeof row.severity === 'string' && row.severity.toLowerCase() === 'critical';
|
||||
|
||||
+65
-13
@@ -4,10 +4,12 @@
|
||||
* `set -o pipefail` with the exit code and the runner's own summary judged
|
||||
* (zero tests ran is a failure, exit 0 or not); platform lanes this machine
|
||||
* cannot run become persisted `requires-remote` obligations. A touched file
|
||||
* with no lane is `FAIL lanes — <file> has no test lane` once any policy
|
||||
* exists; with no policy anywhere (no base `.gstack/pregate.json`, no
|
||||
* user-owned file) it is the `warn` PREGATE_NO_POLICY with a starter printed
|
||||
* (lib/pregate/starter.ts), never a pass either way.
|
||||
* with no lane is `FAIL lanes — <file> has no test lane` once the policy
|
||||
* declares lanes (a `dependencies`, `empty_lanes`, `selection` or `local` key); with no policy
|
||||
* anywhere, or one that declares neither (a timeout-only file), it is the
|
||||
* `warn` PREGATE_NO_POLICY with a starter printed (lib/pregate/starter.ts),
|
||||
* never a pass either way. `warn: ["lanes"]` downgrades only that condition:
|
||||
* the selected tests still run and a red lane stays `fail` (GST-775).
|
||||
* Markdown no test depends on is the docs/no-tests empty lane instead
|
||||
* (lib/pregate/docs-lane.ts): it does not fail, and unmapped code still does.
|
||||
* A release manifest whose diff against the merge base changes only version
|
||||
@@ -19,6 +21,9 @@
|
||||
* and a deleted test runs its owning suite or names the files that lost their
|
||||
* last test (issue #3529). Any other removal owes what an edit owes.
|
||||
* A test that reads the file still selects that test.
|
||||
* Under `--from-run <run>` the selected local test files a verified Free Tests
|
||||
* run on this exact tree lists count as passed (recorded as `from_run` with the
|
||||
* run receipt) and only the rest run; a refused run runs everything.
|
||||
* Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green),
|
||||
* p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun).
|
||||
*/
|
||||
@@ -34,6 +39,7 @@ import { selectLanes, type Selection } from '../lane-selection';
|
||||
import { launchRuntime } from '../runtime';
|
||||
import { cargoModel, type CargoModel } from '../rust';
|
||||
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
|
||||
import { verifyFromRun } from '../../run-evidence';
|
||||
import { matchesAny } from '../../ship-policy';
|
||||
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
|
||||
|
||||
@@ -347,8 +353,15 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
|
||||
return { code: 'PREGATE_INCOMPLETE', detail: `a runner process timed out after ${ctx.timeoutMs} ms (${ctx.timeoutSource ?? '--timeout'} applies to each runner process; ${total} files; last file started: ${timedOut.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest})`, fix: 'raise --timeout or split the selection; a timed-out lane never passes' };
|
||||
}
|
||||
|
||||
function describe(sel: Selection, noPolicy = false): string[] {
|
||||
const lines = noPolicy ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`] : sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`);
|
||||
/** How a touched file with no lane is judged: the no-policy warning, the `warn: ["lanes"]` downgrade, or the hard PREGATE_NO_LANE. */
|
||||
type NoLaneMode = 'no-policy' | 'warned' | 'strict';
|
||||
|
||||
function describe(ctx: PregateContext, sel: Selection, mode: NoLaneMode): string[] {
|
||||
const lines = !sel.noLane.length ? []
|
||||
: mode === 'warned' ? sel.noLane.map(f => `WARN lanes — ${f} has no test lane (warn: ["lanes"] in ${ctx.configLabel}; the selected tests are still judged)`)
|
||||
: mode === 'strict' ? sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`)
|
||||
: ctx.configKind === 'none' ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`]
|
||||
: [`no lane policy: ${sel.noLane.join(' ')} have no test lane (a warning while ${ctx.configLabel} declares no dependencies, empty_lanes, selection or local; the starter below maps them)`];
|
||||
for (const r of sel.removed) lines.push(`removed (no lane owed): ${r.file} — ${r.note}`);
|
||||
if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`);
|
||||
if (sel.inert.length) lines.push(`no-tests (assets and tool config): ${sel.inert.join(' ')}`);
|
||||
@@ -500,9 +513,25 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul
|
||||
*/
|
||||
function withStarter(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult {
|
||||
const lines = [...(result.lines ?? []), ...starterPolicy(ctx, sel.noLane, detectRunner(ctx, sel.localTests.length ? sel.localTests : sel.tests))];
|
||||
const noted = `${sel.noLane.length} touched file(s) with no test lane under no policy (starter printed)`;
|
||||
const noted = `${sel.noLane.length} touched file(s) with no test lane under ${ctx.configKind === 'none' ? 'no policy' : 'a policy that declares no lanes'} (starter printed)`;
|
||||
if (result.status !== 'pass') return { ...result, lines, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_POLICY' } : {}) };
|
||||
return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix: `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE` };
|
||||
const fix = ctx.configKind === 'none'
|
||||
? `review the starter above, write it with gstack-pregate init, edit each TODO, then commit ${PREGATE_CONFIG_PATH} on the base branch; from then on an unmapped file is PREGATE_NO_LANE`
|
||||
: `review the starter above and add its dependencies to ${ctx.configFile}${ctx.configKind === 'user' ? '' : ' on the base branch'}, editing each TODO; from then on an unmapped file is PREGATE_NO_LANE`;
|
||||
return { ...result, status: 'warn', code: 'PREGATE_NO_POLICY', lines, detail: `${result.detail}; ${noted}`, fix };
|
||||
}
|
||||
|
||||
/**
|
||||
* `warn: ["lanes"]` under a policy that declares lanes: the selected tests ran
|
||||
* and their verdict stands (a red lane is still `fail`, an unfinished one
|
||||
* `incomplete`); only a green run becomes `warn` PREGATE_NO_LANE naming the
|
||||
* files with no lane. Before GST-775 the downgraded failure returned before
|
||||
* any test ran, so a red test beside an unmapped file passed.
|
||||
*/
|
||||
function withNoLaneWarning(ctx: PregateContext, sel: Selection, result: CheckResult): CheckResult {
|
||||
const noted = `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')} (downgraded to warn by ${ctx.configLabel})`;
|
||||
if (result.status !== 'pass') return { ...result, detail: `${result.detail}; ${noted}`, ...(result.status === 'requires-remote' && !result.code ? { code: 'PREGATE_NO_LANE' } : {}) };
|
||||
return { ...result, status: 'warn', code: 'PREGATE_NO_LANE', detail: `${result.detail}; ${noted}`, fix: noLaneFix(ctx, sel.noLane) };
|
||||
}
|
||||
|
||||
async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
@@ -510,16 +539,39 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
ctx.explain.push(...sel.provenance);
|
||||
for (const [lane, set] of Object.entries(sel.laneSets)) ctx.explain.push(`lane ${lane}: ${set === '?' ? 'file set unknown' : `${set.length} files`}`);
|
||||
const inputs = baseInputs(ctx, { selection: sha256(sel.tests.join('\n')), localTests: sha256(sel.localTests.join('\n')), remote: sha256(sel.remote.map(r => `${r.lane}:${r.tests.join(',')}`).join('\n')) });
|
||||
const noPolicy = sel.noLane.length > 0 && ctx.configKind === 'none';
|
||||
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(sel, noPolicy), remote: sel.remote };
|
||||
const declared = ctx.configKind !== 'none' && ctx.policyDeclaresLanes !== false;
|
||||
const mode: NoLaneMode = !declared ? 'no-policy' : ctx.config.warn.includes('lanes') ? 'warned' : 'strict';
|
||||
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(ctx, sel, mode), remote: sel.remote };
|
||||
const lost = new Set(sel.removed.flatMap(r => r.lostCoverage)).size;
|
||||
const removedNote = sel.removed.length ? `${sel.removed.length} removed path(s)${lost ? `; ${lost} subject(s) lost their last test` : ''}` : '';
|
||||
const withRemoved = (r: CheckResult): CheckResult => (removedNote ? { ...r, detail: r.detail ? `${r.detail}; ${removedNote}` : removedNote } : r);
|
||||
if (sel.noLane.length && !noPolicy) {
|
||||
if (sel.noLane.length && mode === 'strict') {
|
||||
return withRemoved({ ...base, status: 'fail', cause: 'diff', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) });
|
||||
}
|
||||
const result = withRemoved(await runSelected(ctx, sel, base));
|
||||
return noPolicy ? withStarter(ctx, sel, result) : result;
|
||||
const reuse = ctx.fromRun ? fromRun(ctx, sel) : null;
|
||||
if (reuse) ctx.progress(reuse.line);
|
||||
const rest = reuse?.passed.length ? { ...sel, localTests: sel.localTests.filter(t => !reuse.passed.includes(t)) } : sel;
|
||||
const ran = await runSelected(ctx, rest, base);
|
||||
const reused = !reuse ? ran : { ...ran, lines: [reuse.line, ...(ran.lines ?? [])], ...(reuse.receipt ? { from_run: { receipt: reuse.receipt, files: reuse.passed }, detail: `${reuse.passed.length} of ${sel.localTests.length} local test file(s) passed in the --from-run run; ${ran.detail ?? ran.status}` } : {}) };
|
||||
const result = withRemoved(reused);
|
||||
if (!sel.noLane.length) return result;
|
||||
return mode === 'no-policy' ? withStarter(ctx, sel, result) : withNoLaneWarning(ctx, sel, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* `--from-run`: verify the run against the base branch's profiles for this machine's OS whose workflow holds a lane this
|
||||
* machine runs (not a platform lane), on exactly this tree; the selected local test files its tree.json lists are the
|
||||
* ones it already passed. Profiles are matched by workflow and OS, never by name, so none has to be named after a lane.
|
||||
*/
|
||||
function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: string[]; receipt?: string } {
|
||||
const { ref, check } = ctx.fromRun!;
|
||||
const os = ({ linux: 'Linux', darwin: 'macOS', win32: 'Windows' } as Record<string, string>)[ctx.platform];
|
||||
const names = 'error' in check ? [] : Object.entries(check.profiles).filter(([, p]) => p.os === os && ctx.lanes.some(l => l.tier !== 'platform' && l.id.startsWith(`${p.workflow}/`))).map(([n]) => n);
|
||||
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 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-8
@@ -13,8 +13,9 @@
|
||||
* depends on it; source code and test trees never do)
|
||||
* selection `<workflow.yml>/<job>` → the runner-owned command that lists
|
||||
* the lane's test files when the matrix is planner-computed
|
||||
* warn check ids downgraded to warn (never `secrets`; `lanes` keeps
|
||||
* its required-lane and requires-remote verdicts)
|
||||
* warn check ids downgraded to warn (never `secrets`; `lanes`
|
||||
* downgrades only a touched file with no lane, after the
|
||||
* selected tests ran: a red lane stays `fail`)
|
||||
* strays.allow root files or new top-level directories that are intentional
|
||||
* local `<workflow.yml>/<job>` lanes a local run satisfies (a
|
||||
* platform-agnostic toolchain, or a repo whose CI is off):
|
||||
@@ -49,6 +50,11 @@
|
||||
* `env.value` (a configured credential's real value) and never
|
||||
* the whole `secret` category
|
||||
*
|
||||
* A policy declares lanes when it has a `dependencies`, `empty_lanes`,
|
||||
* `selection` or `local` key (LANE_KEYS), even an empty one; only then is a touched file with no lane
|
||||
* the hard PREGATE_NO_LANE. A policy without either (a timeout-only file)
|
||||
* keeps the no-policy lane verdicts (lib/pregate/checks/lanes.ts).
|
||||
*
|
||||
* A repo that keeps `.gstack/` out of git can hold the same file at
|
||||
* `<state root>/projects/<slug>/pregate.json` (lib/state-root.ts), honoured
|
||||
* only when the base carries none. It must sit outside the repo, so a PR
|
||||
@@ -254,17 +260,20 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function parseConfig(text: string, knownChecks: readonly string[] = []): { config: PregateConfig; errors: ConfigError[] } {
|
||||
/** The keys that declare lanes: with either present, a touched file with no lane is PREGATE_NO_LANE. */
|
||||
export const LANE_KEYS = ['dependencies', 'empty_lanes', 'selection', 'local'] as const;
|
||||
|
||||
export function parseConfig(text: string, knownChecks: readonly string[] = []): { config: PregateConfig; errors: ConfigError[]; declaresLanes: boolean } {
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(text); }
|
||||
catch (e: any) { return { config: DEFAULT_CONFIG, errors: [{ code: 'PREGATE_CONFIG_INVALID', path: PREGATE_CONFIG_PATH, message: `not valid JSON: ${e.message}` }] }; }
|
||||
catch (e: any) { return { config: DEFAULT_CONFIG, errors: [{ code: 'PREGATE_CONFIG_INVALID', path: PREGATE_CONFIG_PATH, message: `not valid JSON: ${e.message}` }], declaresLanes: false }; }
|
||||
const errors = validateConfig(raw, knownChecks);
|
||||
if (errors.length) return { config: DEFAULT_CONFIG, errors };
|
||||
if (errors.length) return { config: DEFAULT_CONFIG, errors, declaresLanes: false };
|
||||
const o = raw as Partial<PregateConfig>;
|
||||
return { config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, errors: [] };
|
||||
return { declaresLanes: LANE_KEYS.some(k => k in o), config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, errors: [] };
|
||||
}
|
||||
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string } | { unavailable: true; base: string; detail: string };
|
||||
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string; /** The policy has a LANE_KEYS key; absent under kind 'none'. */ declaresLanes?: boolean } | { unavailable: true; base: string; detail: string };
|
||||
|
||||
function git(cwd: string, args: string[]): { ok: boolean; out: string } {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
|
||||
@@ -333,7 +342,8 @@ export function explainConfig(): string {
|
||||
'dependencies: glob → test files the import graph cannot see (subprocess targets, runtime reads, generated inputs); [] declares no lane needed',
|
||||
'empty_lanes: extra globs treated as docs/no-tests when nothing else maps them. Built-in, with or without this file: any Markdown file the import graph maps to no test (a quoted path, a join() of path segments, an import, or a dependencies entry), plus README/CHANGELOG/CONTRIBUTING/LICENSE/AGENTS/CLAUDE and licence text under any name; assets (images, fonts, media) and allowlisted tool config take their own empty lane. Source code and files under a test directory never qualify. A test that reads the file still selects that test',
|
||||
'selection: <workflow.yml>/<job> → the runner-owned command that lists that lane’s test files when its matrix is planner-computed',
|
||||
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes keeps its required-lane and requires-remote verdicts`,
|
||||
`warn: check ids downgraded to warn; never ${NON_DOWNGRADABLE.join(', ')}; lanes downgrades only a touched file with no lane, after the selected tests ran (a red lane stays fail)`,
|
||||
`lane requirements: a policy with any of ${LANE_KEYS.join(', ')} (even empty) makes a touched file with no lane PREGATE_NO_LANE; a policy with none of them (timeout_s only, say) keeps the no-policy warning (PREGATE_NO_POLICY) and starter`,
|
||||
'strays.allow: root files or new top-level directories that are intentional',
|
||||
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
|
||||
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
|
||||
|
||||
@@ -247,7 +247,7 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
return {
|
||||
ctx: {
|
||||
repoRoot: o.repoRoot, base, baseRef, baseSha: baseSha.out, mergeBase, head, wtree, touched: touchedFiles(o.repoRoot, mergeBase),
|
||||
config, configLabel: configLoad.label, configKind: configLoad.kind, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
config, configLabel: configLoad.label, configKind: configLoad.kind, policyDeclaresLanes: configLoad.declaresLanes !== false, scratch, configFile: configLoad.kind === 'user' && o.userConfigPath ? o.userConfigPath : PREGATE_CONFIG_PATH, ...(configLoad.digest ? { configDigest: configLoad.digest } : {}), ...(absentUserPolicy ? { absentUserPolicy } : {}), registry: registryLoad.registry as Registry, registryLabel: registryLoad.source.label,
|
||||
lanes, platform, allowRepoCommands: o.allowRepoCommands, timeoutMs: o.timeoutMs ?? (config.timeout_s ?? 600) * 1000, timeoutSource: o.timeoutMs !== undefined ? '--timeout' : config.timeout_s ? `timeout_s from ${configLoad.label}` : 'the 600 s default',
|
||||
stallTimeoutMs: o.stallTimeoutMs ?? (config.stall_timeout_s ?? DEFAULT_STALL_TIMEOUT_S) * 1000,
|
||||
progress: o.progress ?? (line => process.stderr.write(`${line}\n`)),
|
||||
|
||||
@@ -66,7 +66,7 @@ export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string,
|
||||
for (const [glob, tests] of Object.entries(adds)) dependencies[glob] = [...new Set([...(dependencies[glob] ?? []), ...tests])];
|
||||
const n = Object.keys(adds).length;
|
||||
return {
|
||||
load: { ...trusted, config: { ...trusted.config, dependencies }, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` },
|
||||
load: { ...trusted, config: { ...trusted.config, dependencies }, declaresLanes: trusted.declaresLanes === true || head.declaresLanes === true, kind: 'head', label: `head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} lane${n === 1 ? '' : 's'} over ${trusted.label}` },
|
||||
explain: Object.entries(adds).map(([glob, tests]) => `head policy adds: dependencies[${JSON.stringify(glob)}] → ${tests.join(', ')}`),
|
||||
};
|
||||
}
|
||||
@@ -37,12 +37,12 @@ export const PREGATE_TIER2 = ['ratchets', 'lint', 'links', 'hermetic', 'patches'
|
||||
|
||||
export interface RunOptions { stage: Stage | 'all'; only?: string[]; run?: string }
|
||||
|
||||
/** Apply the repo's `warn` downgrades: never `secrets`, and never a lane that could run here and failed. */
|
||||
/** Apply the repo's `warn` downgrades: never `secrets`. `lanes` applies its own (a touched file with no lane, after the selected tests ran), so nothing it fails is downgraded here. */
|
||||
export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: CheckDef): CheckResult {
|
||||
if (result.status !== 'fail') return result;
|
||||
if (!def.downgradable || (NON_DOWNGRADABLE as readonly string[]).includes(def.id)) return result;
|
||||
if (!ctx.config.warn.includes(def.id)) return result;
|
||||
if (def.id === 'lanes' && result.code === 'PREGATE_LANE_FAILED') return { ...result, detail: `${result.detail} (required lane; warn downgrade does not apply)` };
|
||||
if (def.id === 'lanes') return result.code === 'PREGATE_LANE_FAILED' ? { ...result, detail: `${result.detail} (required lane; warn downgrade does not apply)` } : result;
|
||||
return { ...result, status: 'warn', detail: `${result.detail} (downgraded to warn by ${ctx.configLabel})` };
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ function removedPath(ctx: PregateContext, graph: Graph, gone: ReadonlySet<string
|
||||
const uncovered = namedBy(ctx, file).filter(f => !gone.has(f)).filter(n => {
|
||||
const m = testsFor(graph, n, ctx.config.dependencies);
|
||||
for (const t of m.tests) namerSuite.add(t);
|
||||
return !m.tests.length;
|
||||
return !m.tests.length && !m.declaredNone;
|
||||
});
|
||||
if (uncovered.length) return { refused: `removed, but still named by ${shown(uncovered)}, which no test covers` };
|
||||
const namerNote = namerSuite.size ? `files naming it run: ${shown([...namerSuite].sort())}` : '';
|
||||
@@ -211,6 +211,11 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
const stampOnly: string[] = [];
|
||||
const seeds = new Map<string, string>();
|
||||
const touchedByPath = new Map(touched.map(t => [t.path, t]));
|
||||
const waive = (file: string, r: { removed: Removed; suite: string[] }) => {
|
||||
removed.push(r.removed);
|
||||
for (const t of r.suite) tests.add(t);
|
||||
provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`);
|
||||
};
|
||||
for (const t of touched) { seeds.set(t.path, t.status === 'D' ? 'deleted' : 'touched'); if (t.renamedFrom) seeds.set(t.renamedFrom, `renamed to ${t.path}`); }
|
||||
for (const e of ctx.registry.entries) {
|
||||
if (!touched.some(t => matchesAny(e.inputs, t.path) || matchesAny(e.outputs, t.path))) continue;
|
||||
@@ -221,8 +226,11 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
for (const t of m.tests) tests.add(t);
|
||||
provenance.push(...m.provenance.map(p => `${p} [${why}]`));
|
||||
if (m.tests.length || why.startsWith('generated')) continue;
|
||||
// An explicit `[]` already cleared the file. Label it docs/no-tests only when it is that class.
|
||||
// An explicit `[]` already cleared the file. A removed test still runs its owning suite; label the rest docs/no-tests only when it is that class.
|
||||
if (m.declaredNone) {
|
||||
const r = gone.includes(file) ? removedPath(ctx, graph, goneSet, file) : null;
|
||||
if (r && 'removed' in r) { waive(file, r); continue; }
|
||||
if (r) provenance.push(`${file}: ${r.refused}; cleared by its [] declaration`);
|
||||
if (isDocsEmptyLane(file, ctx.config.empty_lanes ?? [])) emptyLane.push(file);
|
||||
continue;
|
||||
}
|
||||
@@ -259,12 +267,7 @@ export function selectLanes(ctx: PregateContext): Selection {
|
||||
}
|
||||
if (gone.includes(file)) {
|
||||
const r = removedPath(ctx, graph, goneSet, file);
|
||||
if ('removed' in r) {
|
||||
removed.push(r.removed);
|
||||
for (const t of r.suite) tests.add(t);
|
||||
provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`);
|
||||
continue;
|
||||
}
|
||||
if ('removed' in r) { waive(file, r); continue; }
|
||||
provenance.push(`${file}: ${r.refused}; no lane until declared`);
|
||||
}
|
||||
noLane.push(file);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The run-evidence inputs one consumer needs (GSTA-156): the base branch's run
|
||||
* profiles, this repository on GitHub, the verifier's gh- and git-backed
|
||||
* dependencies, and the remote gate's inventory command (a pin-file command,
|
||||
* so it runs only with --allow-repo-commands). Shared by `gstack-pregate
|
||||
* clear` and `gstack-pregate --from-run`, so both read profiles from the
|
||||
* base and nowhere else.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { ghRunDeps, runInventory } 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 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.
|
||||
*/
|
||||
export function buildRunCheck(root: string, lane: string | null, allowRepoCommands: boolean, base = resolveBaseBranch(root)): RunCheck | { error: string; profiled?: boolean } {
|
||||
const loaded = 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(),
|
||||
inventory: (command, tree) => {
|
||||
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; };
|
||||
if (at() !== tree) return { error: `the working tree is not pregate.json's tree ${tree.slice(0, 12)}; the inventory must list that tree's gate files` };
|
||||
const listed = runInventory(command, root);
|
||||
if (at() !== tree) return { error: `the working tree changed while ${command} ran` };
|
||||
return listed;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* pregate/starter — the policy a repository with none would start from
|
||||
* (Lane D, GSTA-112; FOUN #292, harness-evals #81/#82). When no
|
||||
* `.gstack/pregate.json` exists on the base branch and no user-owned file is
|
||||
* present, the lanes check does not fail a touched file with no lane
|
||||
* present, or the active policy declares no lanes (no `dependencies` or
|
||||
* `empty_lanes`, `selection` or `local` key, GST-775), the lanes check does not fail a touched file with no lane
|
||||
* (PREGATE_NO_LANE); it warns (PREGATE_NO_POLICY) and prints this starter:
|
||||
* the detected runner, a `dependencies` entry for each unmapped touched file
|
||||
* (the tests nearest it, else the tests sharing its stem; never `[]`, which
|
||||
@@ -29,7 +30,7 @@ export function nearestTestsFor(tests: readonly string[], file: string): string[
|
||||
}
|
||||
|
||||
/** The starter for `noLane` (touched files with no lane) under `runner` (detected by the lanes check; null when none). */
|
||||
export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | 'config' | 'baseRef' | 'absentUserPolicy'>, noLane: readonly string[], runner: string | null): string[] {
|
||||
export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | 'config' | 'baseRef' | 'absentUserPolicy' | 'configKind' | 'configLabel' | 'configFile'>, noLane: readonly string[], runner: string | null): string[] {
|
||||
const tests = listRepoFiles(ctx.repoRoot).filter(isTestFile).sort();
|
||||
const dependencies: Record<string, string[]> = {};
|
||||
const todo: string[] = [];
|
||||
@@ -43,7 +44,9 @@ export function starterPolicy(ctx: Pick<PregateContext, 'repoRoot' | 'lanes' | '
|
||||
const dirs = [...dirCounts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_DIRS).map(([d]) => d);
|
||||
const lanes = ctx.lanes.filter(l => l.runsTests && !l.manual).map(l => `${l.id} (${l.platform}${l.matrixExpression && l.testFiles.length === 0 && !ctx.config.selection[l.id] ? ', matrix computed; declare selection' : ''})`);
|
||||
return [
|
||||
`PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)`,
|
||||
ctx.configKind === 'none'
|
||||
? `PREGATE_STARTER: no ${PREGATE_CONFIG_PATH} on ${ctx.baseRef}${ctx.absentUserPolicy ? ` and no user-owned policy at ${ctx.absentUserPolicy}` : ''}; a starter drafted from this repository follows. Review it, write it with gstack-pregate init, edit each TODO, then commit it on the base branch (from then on an unmapped file is PREGATE_NO_LANE)`
|
||||
: `PREGATE_STARTER: ${ctx.configLabel} declares no lanes (no dependencies, empty_lanes, selection or local key); a starter drafted from this repository follows. Review it, add its dependencies to ${ctx.configFile}${ctx.configKind === 'user' ? '' : ' on the base branch'} and edit each TODO (from then on an unmapped file is PREGATE_NO_LANE)`,
|
||||
`PREGATE_STARTER: runner ${runner ?? 'null (none detected; set runner with {files})'}`,
|
||||
`PREGATE_STARTER: test directories: ${dirs.join(', ') || '(no test files found)'}${dirCounts.size > MAX_DIRS ? ` (+${dirCounts.size - MAX_DIRS} more)` : ''}`,
|
||||
`PREGATE_STARTER: CI lanes: ${lanes.join(', ') || '(no test job in .github/workflows)'}`,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import type { PregateCheck } from '../headless-artifacts';
|
||||
import type { Registry } from '../regen';
|
||||
import type { RunSource } from '../run-evidence';
|
||||
import type { PregateConfig } from './config';
|
||||
import type { Lane } from './workflows';
|
||||
|
||||
@@ -35,6 +36,8 @@ export interface PregateContext {
|
||||
configLabel: string;
|
||||
/** Where the policy came from: the base branch, the unreviewed head, the user-owned file, or nowhere (defaults). */
|
||||
configKind: 'base' | 'head' | 'user' | 'none';
|
||||
/** The active policy has a `dependencies`, `empty_lanes`, `selection` or `local` key (config LANE_KEYS); false keeps the no-policy lane verdicts. Absent counts as declared. */
|
||||
policyDeclaresLanes?: boolean;
|
||||
/** This run's scratch directory under the state root (lib/pregate/run-scratch.ts): logs and scan files, never os.tmpdir(). */
|
||||
scratch: string;
|
||||
/** The policy file to edit: the user-owned path when it is the active policy, else `.gstack/pregate.json`. */
|
||||
@@ -59,6 +62,8 @@ export interface PregateContext {
|
||||
cores: number;
|
||||
testBackend: string;
|
||||
policyFrom: 'base' | 'head';
|
||||
/** `--from-run`: a Free Tests run whose tree.json files count as passed for this tree (lanes, lib/run-evidence.ts verifyFromRun), with the base branch's run check or why it could not be built. */
|
||||
fromRun?: { ref: string; check: RunSource | { error: string } };
|
||||
/** `--remote`: run the declared remote gate now, under this spend cap. */
|
||||
remote: { spendCapUsd: number } | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -17,7 +17,12 @@ export const BUDGET_RESULT_CODES = {
|
||||
BUDGET_READING_MISSING: {
|
||||
anchor: 'budget-reading-missing',
|
||||
summary: 'the Capy meter needs a fresh counter reading and none was passed',
|
||||
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included, never the root thread) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
|
||||
fix: 'read cost.tokens from tools.thread.get({ thread_id: "<your own jam id>" }) (your own jam, its subagents included; a subagent that can read only the root thread starts with --subagent --reading-from this --meter-scope tree) inside the execute program that runs the command and pass it with --reading <n> (ship/sections/budget-handoff.md has the recipe)',
|
||||
},
|
||||
METER_SCOPE_PARENT: {
|
||||
anchor: 'meter-scope-parent',
|
||||
summary: 'a subagent asked for a self-scoped run while its reading comes from "this", the root thread\'s counter, which also holds every sibling lane',
|
||||
fix: 'start with --meter-scope tree (every cost line then prints METER: tree (root counter, upper bound)); exact per-run cost comes from a root replay thread',
|
||||
},
|
||||
BUDGET_READING_REJECTED: {
|
||||
anchor: 'budget-reading-rejected',
|
||||
|
||||
@@ -303,8 +303,8 @@ export const RELEASE_RESULT_CODES = {
|
||||
// Lane D (GSTA-112): the no-policy warning and the environment-cause verdict.
|
||||
PREGATE_NO_POLICY: {
|
||||
anchor: 'pregate-no-policy',
|
||||
summary: 'the repository has no pregate policy yet (no .gstack/pregate.json on the base branch and no user-owned file) and a touched file maps to no test lane; a warn, with a starter policy printed',
|
||||
fix: 'review the printed starter, write it with `gstack-pregate init`, edit the TODO entries, then commit .gstack/pregate.json on the base branch; from then on an unmapped file is PREGATE_NO_LANE',
|
||||
summary: 'the repository has no pregate policy yet (no .gstack/pregate.json on the base branch and no user-owned file), or its policy declares no lanes (no dependencies, empty_lanes, selection or local key), and a touched file maps to no test lane; a warn, with a starter policy printed',
|
||||
fix: 'review the printed starter, write it with `gstack-pregate init` (or add its dependencies to the existing policy), edit the TODO entries, then commit .gstack/pregate.json on the base branch; from then on an unmapped file is PREGATE_NO_LANE',
|
||||
},
|
||||
PREGATE_ENVIRONMENT: {
|
||||
anchor: 'pregate-environment',
|
||||
@@ -383,7 +383,7 @@ export const RELEASE_RESULT_CODES = {
|
||||
RECEIPT_CARRY_PRODUCT_CHANGED: {
|
||||
anchor: 'receipt-carry-product-changed',
|
||||
summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD',
|
||||
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged',
|
||||
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged; base context next to unchanged reviewed hunks is RECEIPT_CARRY_SCOPED_REVIEW instead (one scoped fix-check, no /ship)',
|
||||
},
|
||||
RECEIPT_CARRY_SCOPED_REVIEW: {
|
||||
anchor: 'receipt-carry-scoped-review',
|
||||
|
||||
+13
-4
@@ -15,7 +15,7 @@
|
||||
* targeted classes, brief (review/specialists/targeted.md's text), checklist (its path), diff_base, stack, test_fw, learnings
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
|
||||
@@ -83,12 +83,21 @@ const SPECIALIST_DIFF = 'run\n`DIFF_BASE=<diff-base> && git diff "$DIFF_BASE" <e
|
||||
|
||||
const PLACEHOLDERS = ['<diff-base>', '<exclude>', '<base>', '{checklist path}', '{STACK}', '{TEST_FW}', '{merged findings summary}', '{red-team checklist path}', '{learnings'];
|
||||
|
||||
/** Render one reader's brief. Throws BriefError on a missing slot, a section that no longer carries an anchor or placeholder, or a placeholder left unfilled. */
|
||||
/**
|
||||
* Render one reader's brief. Throws BriefError on a missing slot, a section that no longer carries an anchor or placeholder, or a placeholder left unfilled.
|
||||
* Findings and learnings are reviewer text that may quote a slot name such as origin/<base> (GST-797): they render as a token, so the
|
||||
* placeholder check and the slot fills never see them, and go in last, trimmed as renderRole trims them. The token carries a
|
||||
* per-render nonce, so no other slot's text (a diff, a hunk) can name it.
|
||||
*/
|
||||
export function renderBrief(role: BriefRole, slots: Slots, t: SectionTexts): string {
|
||||
const text = renderRole(role, slots, t);
|
||||
const nonce = randomUUID();
|
||||
const keys = ['findings', 'learnings'].filter(k => slots[k]?.trim());
|
||||
const held: Slots = { ...slots };
|
||||
for (const k of keys) held[k] = `\u0000${nonce}:${k}\u0000`;
|
||||
const text = renderRole(role, held, t);
|
||||
const left = role === 'fix-check' || role === 'outside-voice' ? [] : PLACEHOLDERS.filter(p => text.includes(p));
|
||||
if (left.length) throw new BriefError(`${role}: unfilled placeholder ${left.join(', ')}`);
|
||||
return text;
|
||||
return keys.reduce((out, k) => out.split(held[k]!).join(slots[k]!.trim()), text);
|
||||
}
|
||||
|
||||
function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string {
|
||||
|
||||
+21
-5
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { deriveReviewStatus, readJsonl } from './headless-artifacts';
|
||||
import { deriveReviewStatus, isCriticalFinding, isOpenByAction, readJsonl } from './headless-artifacts';
|
||||
import { resultLine, type ResultCodeName } from './result-codes';
|
||||
|
||||
const DIFF_REVIEWS = new Set(['review', 'review-fix-check', 'adversarial-review', 'codex-review', 'design-review-lite', 'ship']);
|
||||
@@ -248,11 +248,12 @@ const DERIVED_KEYS = ['status', 'unresolved', 'issues_found', 'critical_gaps'] a
|
||||
* severity + disposition/action). A claimed value that disagrees rejects the
|
||||
* row with REVIEW_STATUS_MISMATCH; the file is the record, never the claim.
|
||||
*/
|
||||
export function deriveFromFindings(rec: Record<string, any>, findingsPath: string): Record<string, any> {
|
||||
export function deriveFromFindings(rec: Record<string, any>, findingsPath: string, sessionKind?: string): Record<string, any> {
|
||||
let read;
|
||||
try { read = readJsonl(findingsPath); }
|
||||
catch (e: any) { throw new ReviewLogError('ARTIFACT_MISSING', resultLine('review-log', 'ARTIFACT_MISSING', `${findingsPath}: ${e.message}`)); }
|
||||
if (read.errors.length) throw new ReviewLogError('ARTIFACT_MALFORMED_JSONL', resultLine('review-log', 'ARTIFACT_MALFORMED_JSONL', `${read.errors[0]!.path}: ${read.errors[0]!.message}`));
|
||||
stampGateFindings(read.rows, sessionKind);
|
||||
const derived = deriveReviewStatus(read.rows);
|
||||
const mismatches = DERIVED_KEYS.filter(key => rec[key] !== undefined && rec[key] !== derived[key])
|
||||
.map(key => `claimed ${key} ${JSON.stringify(rec[key])}, findings file has ${JSON.stringify(derived[key])}`);
|
||||
@@ -266,11 +267,20 @@ export function bindReview(rec: Record<string, any>, token: string, env = proces
|
||||
for (const key of ['commit_full', 'tree', 'wtree', 'dirty', 'review_binding', 'review_freshness', 'shared_libs_coverage_version', 'status_source', 'findings_total', 'findings_open', 'findings_resolved', 'findings_file', 'scope', 'fix_base']) delete rec[key];
|
||||
// The findings file is validated BEFORE the start token is consumed below:
|
||||
// a rejected row must leave the token for the retry.
|
||||
rec = findingsPath ? deriveFromFindings(rec, findingsPath) : { ...rec, status_source: 'claimed' };
|
||||
rec = findingsPath ? deriveFromFindings(rec, findingsPath, env.GSTACK_STAMP_SESSION_KIND) : { ...rec, status_source: 'claimed' };
|
||||
if (env.GSTACK_STAMP_COMMIT_FULL) rec.commit_full = env.GSTACK_STAMP_COMMIT_FULL;
|
||||
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);
|
||||
// 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.
|
||||
if (!findingsPath && (rec.skill === 'review' || rec.skill === 'review-fix-check') && rec.status === 'issues_found' && rec.completed === true &&
|
||||
Array.isArray(rec.findings) && rec.findings.length > 0 && rec.findings.length >= Number(rec.issues_found ?? 0) &&
|
||||
rec.findings.filter(f => record(f) && isCriticalFinding(f)).length >= Number(rec.critical ?? 0) &&
|
||||
rec.findings.every(f => record(f) && typeof f.action === 'string' && !isOpenByAction(f))) {
|
||||
Object.assign(rec, { status: 'clean', issues_found: 0, critical: 0, informational: 0 });
|
||||
}
|
||||
if (!DIFF_REVIEWS.has(rec.skill)) {
|
||||
if (env.GSTACK_STAMP_WTREE) rec.wtree = env.GSTACK_STAMP_WTREE;
|
||||
return rec;
|
||||
@@ -326,7 +336,10 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
|
||||
if (!DIFF_REVIEWS.has(rec.skill)) return;
|
||||
if (rec.skill === 'ship') return { status: 'UNVERIFIED', reason: 'ship telemetry is not a review pass' };
|
||||
const binding = rec.review_binding;
|
||||
if (binding?.state === 'changed') return { status: 'STALE', reason: 'content changed during review' };
|
||||
if (binding?.state === 'changed') {
|
||||
const short = (w: unknown) => typeof w === 'string' ? w.slice(0, 12) : 'unknown';
|
||||
return { status: 'STALE', reason: `content changed during review: the tree moved from ${short(binding.start_wtree)} to ${short(binding.end_wtree)} while ${rec.skill} ran (an edit, commit or merge landed before its row was logged), so the record cannot bind. Repair: freeze the checkout until every reviewer returns, then rerun the steps 11.5 names on the frozen tree; when the edit was your own fix, log it as a fix so gstack-review-plan picks the repeat depth (docs/troubleshooting.md#review-content-changed)` };
|
||||
}
|
||||
if (rec.scope === 'fix-range') return { status: 'UNVERIFIED', reason: 'fix-range pass; only a later full pass can clear review' };
|
||||
if (binding?.state !== 'verified' || rec.completed !== true || rec.converged !== true ||
|
||||
!rec.wtree || binding.start_wtree !== rec.wtree || binding.end_wtree !== rec.wtree) {
|
||||
@@ -340,7 +353,10 @@ export function reviewFreshness(rec: Record<string, any>, currentWtree: string):
|
||||
// issues_found, and the legacy `critical` and Codex counters stay as they were.
|
||||
const open = typeof rec.findings_open === 'number' ? rec.findings_open : rec.issues_found;
|
||||
if (rec.status !== 'clean' || open > 0 || rec.critical > 0 ||
|
||||
(rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0))) {
|
||||
(rec.skill === 'codex-review' && rec.findings > (rec.findings_fixed ?? 0)) ||
|
||||
(Array.isArray(rec.findings) && rec.findings.some(f => record(f) && (f.action === 'open' ||
|
||||
((f.action === 'deferred' || f.action === 'noted') && (f.blocking === true || isCriticalFinding(f))) ||
|
||||
(!f.gate && isCriticalFinding(f) && isOpenByAction(f)))))) {
|
||||
return { status: 'UNVERIFIED', reason: 'review has unresolved findings or did not finish clean' };
|
||||
}
|
||||
return { status: 'CURRENT', reason: 'completed clean pass on unchanged content' };
|
||||
|
||||
+1
-21
@@ -59,14 +59,6 @@ const stronger = (a: string, b: string) => ((rank(a) ?? 0) >= (rank(b) ?? 0) ? a
|
||||
/** A prose voice's `Blocks: yes` (GSTA-167 normalizer v2): the finding is CRITICAL whatever its label; `Blocks: no` never lowers one. */
|
||||
const BLOCKS_YES = /(?<![A-Za-z0-9])Blocks:?\**:?\s*\**\s*yes(?![A-Za-z0-9])/i;
|
||||
const ALL_LABELS = new RegExp(ANY_LABEL.source, 'g');
|
||||
/**
|
||||
* A label a negation word governs ("found nothing CRITICAL", "no new **High** or Critical issues", "No P0/P1 issues") is no finding
|
||||
* (GST-766), unless it opens a field ("High:"). Such a line still closes the open finding and heads the next, so a prose finding
|
||||
* phrased around a negated label reaches merged.jsonl through its required Severity or Blocks field.
|
||||
*/
|
||||
const NEGATED = /(?<![A-Za-z0-9-])(?:no|nothing|not|none|(?<!\bby\s)zero|without|never)\s+(?:(?:new|remaining|other|further|additional|more|at)\s+)?$/i;
|
||||
const NEGATION_LIST = /^\s*(?:,|\/|or|nor|and|,\s*(?:or|nor|and))\s*$/i;
|
||||
const MARKUP = /[*_`[\]]/g;
|
||||
/** A file:line locus ("auth.ts:9"): prose naming one after a finding's fields starts the next finding. */
|
||||
const LOCUS = /[\w./-]+\.\w+:\d+/;
|
||||
/** A line that is only a finding's `Severity` or `Blocks` field ("Severity: High", "- Blocks: yes", "Severity High"). */
|
||||
@@ -105,18 +97,7 @@ export function parseFindings(reader: string, output: string): Array<{ label: st
|
||||
let treeSeen = false;
|
||||
const role = reader.split('/')[0];
|
||||
const named = (l: string) => (role === 'adversarial-native' && l.toUpperCase() === 'FIXABLE' ? 'CRITICAL' : l);
|
||||
const asserted = (t: string) => {
|
||||
const all = [...t.matchAll(ALL_LABELS)];
|
||||
let end = -1, negated = false;
|
||||
return all.filter(m => {
|
||||
const at = m.index!;
|
||||
negated = !/^\s*:/.test(t.slice(at + m[0].length, at + m[0].length + 8).replace(MARKUP, ''))
|
||||
&& (NEGATED.test(t.slice(Math.max(0, at - 60), at).replace(MARKUP, '')) || (negated && NEGATION_LIST.test(t.slice(end, at).replace(MARKUP, ''))));
|
||||
end = at + m[0].length;
|
||||
return !negated;
|
||||
});
|
||||
};
|
||||
const strongest = (t: string) => asserted(t).map(m => named(m[1]!)).reduce<string | undefined>((a, b) => (a === undefined ? b : stronger(a, b)), undefined);
|
||||
const strongest = (t: string) => [...t.matchAll(ALL_LABELS)].map(m => named(m[1]!)).reduce<string | undefined>((a, b) => (a === undefined ? b : stronger(a, b)), undefined);
|
||||
const push = (label: string, raw: string, fields: string[] = []): Finding => { const f = { label, raw, fields: new Set(fields), list: LIST_ITEM.test(raw) }; out.push(f); return f; };
|
||||
for (const line of output.replace(/\r\n/g, '\n').split('\n')) {
|
||||
const t = line.trim();
|
||||
@@ -151,7 +132,6 @@ export function parseFindings(reader: string, output: string): Array<{ label: st
|
||||
if (typeof o.severity === 'string') { current = push(o.severity, t); continue; }
|
||||
} catch { /* prose that starts with a brace */ }
|
||||
}
|
||||
if (!label && !blocks && !(section && listItem) && ANY_LABEL.test(t)) { current = null; head = t; continue; }
|
||||
const detail = listItem && /^[-*+]\s/.test(t) && !locus && !label && !section && !!current && !current.list && !current.fields.size;
|
||||
if (detail && !blocks) { current!.raw += ` | ${t}`; continue; }
|
||||
const fresh = (listItem && !detail) || (!indented && !detail && (t.startsWith('**') || afterGap)) || (!!current?.fields.size && locus);
|
||||
|
||||
+43
-5
@@ -22,7 +22,10 @@
|
||||
* not hide a failure in between); it finished within the profile's
|
||||
* max_age_hours (default 24); its runner OS matches when the profile names
|
||||
* one; and a second read of the run still shows the same successful attempt
|
||||
* (run_changed otherwise).
|
||||
* (run_changed otherwise). A pull_request run tested GitHub's merge commit,
|
||||
* not its head, so it counts only when its tree.json names that head and a
|
||||
* merge commit (sha) whose tree git computes equal to the head's tree: the
|
||||
* head already contained its base, so the merge tested the head tree.
|
||||
*
|
||||
* Profiles are the base branch's `.gstack/pregate.json` `run_profiles`, so a
|
||||
* pull request can tighten them but never relax them. The workflow a run
|
||||
@@ -65,6 +68,7 @@ export interface RunEvidence { run_id: number; run_attempt: number; head_sha: st
|
||||
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 WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/;
|
||||
const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/;
|
||||
@@ -96,7 +100,7 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
|
||||
const v = p[k];
|
||||
if (!Array.isArray(v) || v.length === 0 || v.length > 32 || v.some(s => typeof s !== 'string' || !s.trim())) errors.push({ path: `${at}.${k}`, message: 'must be a non-empty array of up to 32 names' });
|
||||
}
|
||||
if (Array.isArray(p.events) && p.events.some(e => e !== 'push' && e !== 'workflow_dispatch')) errors.push({ path: `${at}.events`, message: 'events are push or workflow_dispatch (a pull_request run tests the merge commit, not the head tree)' });
|
||||
if (Array.isArray(p.events) && p.events.some(e => !RUN_EVENTS.includes(e))) errors.push({ path: `${at}.events`, message: 'events are push, workflow_dispatch or pull_request (a pull_request run counts only when its merge commit\'s tree is its head\'s tree)' });
|
||||
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' });
|
||||
@@ -164,13 +168,21 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
catch (e: any) { return fail('artifact_missing', `${at} has no readable ${p.artifact} tree.json: ${String(e?.message ?? e).slice(0, 200)}`); }
|
||||
if (!rec || typeof rec !== 'object') return fail('artifact_mismatch', `${at}'s ${p.artifact} tree.json is not a JSON object`);
|
||||
if (rec.reused === true || rec.reuse === true) return fail('artifact_reused', `${at} reused another run; a reuse is never evidence`);
|
||||
const recHeads = rec.head_sha === undefined && rec.sha === undefined ? [['head', undefined, run.head_sha] as const] : ([['sha', rec.sha, run.head_sha], ['head_sha', rec.head_sha, run.head_sha]] as const).filter(([, got]) => got !== undefined);
|
||||
const pr = run.event === 'pull_request';
|
||||
const recHeads = pr ? [['head_sha', rec.head_sha, run.head_sha] as const] : rec.head_sha === undefined && rec.sha === undefined ? [['head', undefined, run.head_sha] as const] : ([['sha', rec.sha, run.head_sha], ['head_sha', rec.head_sha, run.head_sha]] as const).filter(([, got]) => got !== undefined);
|
||||
const wrong = ([['run_id', rec.run_id, id], ['run_attempt', rec.run_attempt, run.run_attempt], ['event', rec.event, run.event], ...recHeads] as const).filter(([, got, want]) => got !== want);
|
||||
if (wrong.length) return fail('artifact_mismatch', `${at}'s ${p.artifact} records ${wrong.map(([k, got, want]) => `${k} ${String(got)} (expected ${want})`).join(', ')}`);
|
||||
const headTree = deps.commitTree(run.head_sha);
|
||||
if (!headTree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`);
|
||||
if (rec.tree !== headTree) return fail('tree_mismatch', `${at} recorded tree ${String(rec.tree)}, but its head ${run.head_sha.slice(0, 12)} has tree ${headTree}`);
|
||||
if (p.os && rec.os !== p.os) return fail('os_mismatch', `${at} ran on ${String(rec.os ?? 'an unrecorded OS')}; ${o.profileName} requires ${p.os}`);
|
||||
if (pr) {
|
||||
if (typeof rec.sha !== 'string' || !SHA.test(rec.sha)) return fail('artifact_mismatch', `${at}'s ${p.artifact} names no merge commit (sha) for its pull_request run`);
|
||||
const mergeTree = deps.commitTree(rec.sha);
|
||||
if (!mergeTree) return fail('api_error', `git cannot read the tree of merge commit ${rec.sha}`);
|
||||
if (mergeTree !== headTree) return fail('tree_mismatch', `${at} tested merge commit ${rec.sha.slice(0, 12)} (tree ${mergeTree}), not its head ${run.head_sha.slice(0, 12)}'s tree ${headTree}: the pull request was behind its base`);
|
||||
}
|
||||
const oses = Array.isArray(rec.os) ? rec.os : [rec.os];
|
||||
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`);
|
||||
const files = ((rec.files ?? []) as string[]).map(normalPath);
|
||||
@@ -202,6 +214,29 @@ export function uncoveredFiles(inventory: string[], evidence: RunEvidence[]): st
|
||||
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<string, RunProfile>; now: Date }
|
||||
export type FromRunVerdict = { ok: true; evidence: RunEvidence; receipt: string } | { ok: false; reason: string; detail: string };
|
||||
|
||||
/**
|
||||
* `gstack-pregate --stage tests --from-run <run>`: the run as evidence for `tree` under the first profile in `names`
|
||||
* it verifies against (the base branch's profiles for this machine's OS whose workflow holds a local lane), with `head` as a further veto
|
||||
* head. The run must have tested exactly this tree (no stamp-only substitution) and list the files it ran.
|
||||
*/
|
||||
export function verifyFromRun(src: RunSource, ref: string, names: string[], tree: string, head: string): FromRunVerdict {
|
||||
if (!names.length) return { ok: false, reason: 'no_profile', detail: 'the base branch declares no run_profiles entry for this machine\'s OS whose workflow holds a lane this machine runs (gstack\'s ci-reuse/free-tests, for example)' };
|
||||
const refusals: Array<{ reason: string; detail: string }> = [];
|
||||
for (const name of names) {
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: ref, profileName: name, profile: src.profiles[name]!, vetoHeads: SHA.test(head) ? [head] : [], now: src.now });
|
||||
if (!v.ok) { refusals.push({ reason: v.reason, detail: `${name}: ${v.detail}` }); continue; }
|
||||
const e = v.evidence;
|
||||
if (e.tree !== tree) return { ok: false, reason: 'tree_mismatch', detail: `run ${e.run_id} tested tree ${e.tree.slice(0, 12)}, not this tree ${tree.slice(0, 12)}` };
|
||||
if (!e.files.length || e.tests === 0) return { ok: false, reason: 'no_tests', detail: `run ${e.run_id}'s tree.json ${e.files.length ? `records 0 tests for ${e.files.length} file(s)` : 'lists no test files'}` };
|
||||
return { ok: true, evidence: e, receipt: `run tree=${tree.slice(0, 12)} tests=${e.tests ?? e.files.length} ${runReceiptFields([e], src.now)}` };
|
||||
}
|
||||
return refusals.length === 1 ? { ok: false, ...refusals[0]! } : { ok: false, reason: 'no_verified_run', detail: refusals.map(r => `${r.detail} (${r.reason})`).join(' | ') };
|
||||
}
|
||||
|
||||
/** The receipt suffix a run clear records: runs, profiles and the verification time, read back by `parseRunReceipt`. */
|
||||
export function runReceiptFields(evidence: RunEvidence[], now: Date): string {
|
||||
return `runs=${evidence.map(e => `${e.run_id}/${e.run_attempt}`).join(',')} profiles=${evidence.map(e => e.profile).join(',')} heads=${[...new Set(evidence.map(e => e.head_sha.slice(0, 12)))].join(',')} verified=${now.toISOString()}`;
|
||||
@@ -215,7 +250,7 @@ export function parseRunReceipt(receipt: string): { runs: string[]; profiles: st
|
||||
}
|
||||
|
||||
/** Egress receipts for the reads below (fail-open, like contributor-mode): only this repository's name, run ids and a commit sha leave the machine. */
|
||||
const receiptFor = (host: string, payloadClass: string, request: string) => ({ sink: 'run-evidence', host, payloadClass, bytes: 0, sha256: sha256Hex(request), consent: 'user ran gstack-pregate clear --run (or --run-url on a profiled lane); reads its own repository\'s Actions runs and fetches a run head by sha' });
|
||||
const receiptFor = (host: string, payloadClass: string, request: string) => ({ sink: 'run-evidence', host, payloadClass, bytes: 0, sha256: sha256Hex(request), consent: 'a run verification the user or the repository\'s own workflow started (gstack-pregate clear --run or --from-run, the main-push reuse job); reads its own repository\'s Actions runs and fetches a run head by sha' });
|
||||
const receiptFailed = (err: unknown) => process.stderr.write(`[run-evidence] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`);
|
||||
|
||||
function gh(args: string[], timeoutMs = 60_000): string {
|
||||
@@ -225,6 +260,9 @@ function gh(args: string[], timeoutMs = 60_000): string {
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
/** One `gh api` read on this repository's own data, receipted like every read here. */
|
||||
export function ghApiJson<T>(apiPath: string): T { return JSON.parse(gh(['api', apiPath])) as T; }
|
||||
|
||||
/** The gh- and git-backed dependencies for `repo`, reading trees in `repoRoot` (fetching a missing head from origin once). */
|
||||
export function ghRunDeps(repo: string, repoRoot: string): RunDeps {
|
||||
const json = <T>(p: string): T => JSON.parse(gh(['api', p]));
|
||||
|
||||
+15
-6
@@ -14,7 +14,7 @@ import * as path from 'node:path';
|
||||
import { resultLine } from './result-codes';
|
||||
import {
|
||||
BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS,
|
||||
aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
|
||||
aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, labelScope, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
|
||||
parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths,
|
||||
runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger,
|
||||
type Ledger, type MeterKind, type Next, type Note,
|
||||
@@ -237,9 +237,15 @@ function pickMeter(o: Opts): MeterKind | undefined {
|
||||
}
|
||||
|
||||
export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode']);
|
||||
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode', 'meter-scope', 'reading-from'], ['subagent']);
|
||||
const mode = str(o, 'mode') ?? 'admit';
|
||||
if (mode !== 'admit' && mode !== 'observe') throw usageError(`--mode '${mode}' is not admit or observe`);
|
||||
const scope = str(o, 'meter-scope');
|
||||
if (scope !== undefined && scope !== 'self' && scope !== 'tree') throw usageError(`--meter-scope '${scope}' is not self or tree`);
|
||||
const from = str(o, 'reading-from');
|
||||
if (from !== undefined && from !== 'this' && from !== 'own') throw usageError(`--reading-from '${from}' is not this or own`);
|
||||
const parentCounter = (o.subagent === true || ctx.env.GSTACK_SHIP_SUBAGENT === '1') && from === 'this';
|
||||
if (parentCounter && scope === 'self') throw budgetError('METER_SCOPE_PARENT', 'start --subagent --reading-from this --meter-scope self');
|
||||
const record = str(o, 'record');
|
||||
if (!record) throw usageError('start needs --record <invocation record path>');
|
||||
const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP;
|
||||
@@ -249,8 +255,10 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`);
|
||||
const existing = parseRecordBlock(record);
|
||||
if (existing?.ledger && fs.existsSync(existing.ledger) && !readLedger(existing.ledger).closed_at) {
|
||||
const l = readLedger(existing.ledger);
|
||||
return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 };
|
||||
return mutateLedger(existing.ledger, ctx.env, l => {
|
||||
if (scope === 'tree' || parentCounter) l.meter_scope = 'tree';
|
||||
return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 };
|
||||
});
|
||||
}
|
||||
const meter = pickMeter(o);
|
||||
if (!meter) {
|
||||
@@ -271,6 +279,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
|
||||
marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}),
|
||||
...(callFloor !== undefined ? { call_floor: callFloor } : {}), ...(mode === 'observe' ? { mode } : {}),
|
||||
...(scope === 'tree' || parentCounter ? { meter_scope: 'tree' as const } : {}),
|
||||
};
|
||||
if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy');
|
||||
if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!;
|
||||
@@ -279,7 +288,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
const out = decide(l, '0.9', { kind: 'step', step: '1' }, call, true);
|
||||
writeLedger(l, ctx.env);
|
||||
pointMarker(l.marker, l);
|
||||
return { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit };
|
||||
return labelScope(l, { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -472,7 +481,7 @@ function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Op
|
||||
meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null },
|
||||
epochs: [], carried_used: num('used'), estimate_used: 0, unknown: num('unknown') + num('reserved'), next_id: 1, reservations: [], handoffs: [], cap_raises: [],
|
||||
...(() => { try { return { marker: reviewPaths(ctx.cwd, ctx.env).marker, review_log: reviewPaths(ctx.cwd, ctx.env).reviewLog }; } catch { return {}; } })(),
|
||||
...(b.mode === 'observe' ? { mode: 'observe' as const } : {}),
|
||||
...(b.mode === 'observe' ? { mode: 'observe' as const } : {}), ...(b.meter_scope === 'tree' ? { meter_scope: 'tree' as const } : {}),
|
||||
};
|
||||
if (meter === 'estimate') l.estimate_used = 0;
|
||||
fs.mkdirSync(l.dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
+16
-2
@@ -91,6 +91,8 @@ export interface Ledger {
|
||||
/** Capy cost.credits readings beside the token meter: baseline is the first reading, last the newest. */
|
||||
credits?: { baseline: number; last: number };
|
||||
notes?: Note[];
|
||||
/** tree: the reading is the root thread's counter (a subagent cannot read its own jam id), so every cost line carries TREE_METER. */
|
||||
meter_scope?: 'tree';
|
||||
stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string;
|
||||
}
|
||||
|
||||
@@ -359,10 +361,22 @@ export function mutateLedger<T>(file: string, env: NodeJS.ProcessEnv, fn: (l: Le
|
||||
const ledger = readLedger(file);
|
||||
const out = fn(ledger);
|
||||
writeLedger(ledger, env);
|
||||
return out;
|
||||
return labelScope(ledger, out);
|
||||
});
|
||||
}
|
||||
|
||||
export const TREE_METER = 'METER: tree (root counter, upper bound)';
|
||||
|
||||
/** A tree-scoped run never prints a cost line without TREE_METER on the line after it. */
|
||||
export function labelScope<T>(l: Ledger, out: T): T {
|
||||
if (l.meter_scope !== 'tree') return out;
|
||||
const label = (lines: string[]) => lines.flatMap((line, i) => /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/.test(line) && lines[i + 1] !== TREE_METER ? [line, TREE_METER] : [line]);
|
||||
if (Array.isArray(out)) return label(out) as T;
|
||||
const o = out as { stdout?: unknown };
|
||||
if (Array.isArray(o?.stdout)) o.stdout = label(o.stdout);
|
||||
return out;
|
||||
}
|
||||
|
||||
function childUsed(l: Ledger, r: Reservation): number {
|
||||
const h = l.handoffs.find(x => x.reservation === r.id);
|
||||
if (!h || h.reading_at === null || h.epoch !== l.meter.epoch || l.meter.last === null) return 0;
|
||||
@@ -463,7 +477,7 @@ export function recordBlock(l: Ledger): string {
|
||||
const t = tally(l);
|
||||
const open = l.handoffs.filter(h => !h.reconciled_at).map(h => h.sha).join(',') || 'none';
|
||||
const credits = creditsUsed(l);
|
||||
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}`;
|
||||
return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}${l.meter_scope === 'tree' ? ' meter_scope=tree' : ''}`;
|
||||
}
|
||||
|
||||
/** The record's fenced gstack-budget block: written by the bin, never by the agent. */
|
||||
|
||||
+6
-6
@@ -25,7 +25,7 @@ import { resolveClaudeCommand } from './claude-bin';
|
||||
import { wtreeFingerprint } from './script-invocation';
|
||||
import { resultLine } from './result-codes';
|
||||
import {
|
||||
MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
|
||||
MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, labelScope, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
|
||||
projector, readLedger, reserveIn, runDirFor, segmentEstimate, settleIn, tally, usageError, writeLedger, type Handoff, type Ledger,
|
||||
} from './ship-budget';
|
||||
import { loadMeterState, saveMeterState } from './claude-usage';
|
||||
@@ -195,7 +195,7 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
|
||||
...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) },
|
||||
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
|
||||
marker: parent.marker, review_log: parent.review_log, calibration: parent.calibration,
|
||||
...(parentMetered ? { parent_metered: true } : {}),
|
||||
...(parentMetered ? { parent_metered: true } : {}), ...(parent.meter_scope ? { meter_scope: parent.meter_scope } : {}),
|
||||
};
|
||||
if (parent.meter.contract.meter === 'claude' && !str(o, 'session')) throw usageError('verify-handoff on meter=claude needs --session <this child session id>');
|
||||
readMeter(child, o, !parentMetered);
|
||||
@@ -205,9 +205,9 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
|
||||
});
|
||||
writeLedger(child, ctx.env);
|
||||
pointMarker(child.marker, child);
|
||||
return { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`,
|
||||
return labelScope(child, { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`,
|
||||
...(parentMetered ? [`${PARENT_METERED} (no counter of its own: the parent's handoff and reconcile readings measure Steps ${block.from}-${block.to})`] : []),
|
||||
budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 };
|
||||
budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 });
|
||||
}
|
||||
|
||||
export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
|
||||
@@ -229,11 +229,11 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
|
||||
const unreconciled = l.handoffs.filter(h => !h.reconciled_at);
|
||||
const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id));
|
||||
const result = status === 'complete' && (unreconciled.length || dropped.length) ? 'incomplete' : status;
|
||||
const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
|
||||
const lines = labelScope(l, [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
|
||||
...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`),
|
||||
...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`),
|
||||
`RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, ...(l.parent_metered ? [PARENT_METERED] : []), budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }),
|
||||
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`];
|
||||
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`]);
|
||||
writeReturnFile(l.parent.return_file, lines);
|
||||
l.closed_at = new Date().toISOString();
|
||||
return { stdout: lines, exit: 0 };
|
||||
|
||||
@@ -53,6 +53,10 @@ function integrate(cwd: string, base: string, r: Report): boolean {
|
||||
r.next(`carry route (Step 1.4): fix what the base merge broke (CHANGELOG, size caps), gstack-pregate --allow-repo-commands, then gstack-ship-receipt carry --pr ${n} --base ${base} --pregate .gstack/tmp/pregate.json`);
|
||||
return false;
|
||||
}
|
||||
if (line === 'CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)') {
|
||||
r.next(`carry route (Step 1.4): the base edited context next to reviewed hunks: fix what the base merge broke (CHANGELOG, size caps), commit and push; then gstack-ship-receipt carry --pr ${n} --base ${base} writes the packet for that head; one fix-check reviewer reads it; gstack-pregate --allow-repo-commands, then carry again with --scoped-review <record.json> --pregate .gstack/tmp/pregate.json; a blocking finding runs Steps 2-16`);
|
||||
return false;
|
||||
}
|
||||
} else r.add('PR: none open');
|
||||
const apple = git(cwd, ['ls-files', '--', '*.xcodeproj/*', '*.xcworkspace/*', 'Package.swift']).stdout.split('\n').filter(Boolean).map(p => p.replace(/(\.xcodeproj|\.xcworkspace)\/.*$/, '$1'));
|
||||
if (apple.length) r.add(`APPLE: ${short([...new Set(apple)], 3)}`).next('Step 0.9: for an App Store/TestFlight ask, confirm the app target and follow apple-release.md; otherwise ignore');
|
||||
|
||||
+36
-15
@@ -3,7 +3,8 @@
|
||||
* when the queue is long, 3-6 PRs of one functional area travel as one wave PR
|
||||
* that takes one train slot, one /ship, one READY and one version.
|
||||
*
|
||||
* wave plan read-only: live READY PRs, WAVE_ADVISED over the trigger, suggested area groups
|
||||
* wave plan read-only (git merge-tree leaves only unreferenced objects): live READY PRs with green
|
||||
* CI, WAVE_ADVISED over the trigger, suggested area groups
|
||||
* wave build the integrator branch wave/<nonce>: main plus each constituent's pinned head merged
|
||||
* unchanged with the train's merge rules (a merge commit whose second parent is the
|
||||
* pin); a conflict stops in a worktree under the state root (--continue resumes);
|
||||
@@ -24,7 +25,7 @@ import { EXIT, formatResult } from './headless-artifacts';
|
||||
import { RESULT_CODES } from './result-codes';
|
||||
import { versionAtRef } from './restamp';
|
||||
import { globToRegExp, type ShipPolicy } from './ship-policy';
|
||||
import type { QueueContext } from './ship-queue';
|
||||
import { ciAtHead, type QueueContext } from './ship-queue';
|
||||
import { git, sha256, type QueueFailure } from './ship-queue-gate';
|
||||
import { fetchBase, mergeCommit, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { prInfo, type PrInfo } from './ship-queue-train-github';
|
||||
@@ -79,20 +80,34 @@ export function dominantArea(files: string[], areaOf: (f: string) => string): st
|
||||
const largestFirst = (a: PlanPr, b: PlanPr) => b.lines - a.lines || Number(a.pr) - Number(b.pr);
|
||||
|
||||
/**
|
||||
* Group PRs into waves: PRs that change a common file always travel together (one integrator
|
||||
* resolves their conflicts once), each cluster takes its largest PR's area, clusters of one
|
||||
* area are packed in PR order into waves of at most max, and a wave below min rides solo.
|
||||
* The integrator is the PR with the most changed lines.
|
||||
* Hub files: prose (docs/**, Markdown, generated SKILL.md renders) and JSON ledgers and registries.
|
||||
* Most PRs append to them, so their conflicts are kept-both-sides edits that any train slot
|
||||
* resolves; linking PRs through them chained a 55-PR queue into one area (GST-770). They never
|
||||
* link PRs into a wave. Skill templates (.md.tmpl) are source and link on a real conflict.
|
||||
*/
|
||||
export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX): { groups: WaveGroup[]; solo: string[] } {
|
||||
export const isHubFile = (file: string) => file.startsWith('docs/') || /\.(md|jsonl?)$/.test(file);
|
||||
|
||||
/**
|
||||
* Group PRs into waves: PRs whose changes to a common code file conflict always travel together
|
||||
* (one integrator resolves the conflict once); hub files never link. conflicts(a, b) names the
|
||||
* files a merge of the two heads cannot resolve, or null when unknown, which counts every shared
|
||||
* file as conflicting. Each cluster takes its largest PR's area, clusters of one area are packed
|
||||
* in PR order into waves of at most max, and a wave below min rides solo. The integrator is the
|
||||
* PR with the most changed lines.
|
||||
*/
|
||||
export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[] } {
|
||||
const sorted = [...prs].sort((a, b) => Number(a.pr) - Number(b.pr));
|
||||
const root = new Map(sorted.map(p => [p.pr, p.pr]));
|
||||
const find = (x: string): string => { const r = root.get(x)!; return r === x ? x : find(r); };
|
||||
const owner = new Map<string, string>();
|
||||
for (const p of sorted) for (const f of p.files) {
|
||||
const o = owner.get(f);
|
||||
if (o && find(o) !== find(p.pr)) root.set(find(p.pr), find(o)); else if (!o) owner.set(f, p.pr);
|
||||
}
|
||||
const sharers = new Map<string, PlanPr[]>();
|
||||
for (const p of sorted) for (const f of p.files) if (!isHubFile(f)) sharers.set(f, [...(sharers.get(f) ?? []), p]);
|
||||
const clash = new Map<string, string[] | null>();
|
||||
for (const [f, list] of sharers) list.forEach((a, i) => list.slice(i + 1).forEach(b => {
|
||||
const key = `${a.pr} ${b.pr}`;
|
||||
if (!clash.has(key)) clash.set(key, conflicts(a, b));
|
||||
const hit = clash.get(key);
|
||||
if ((!hit || hit.includes(f)) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr));
|
||||
}));
|
||||
const clusters = new Map<string, PlanPr[]>();
|
||||
for (const p of sorted) clusters.set(find(p.pr), [...(clusters.get(find(p.pr)) ?? []), p]);
|
||||
const byArea = new Map<string, PlanPr[][]>();
|
||||
@@ -212,7 +227,7 @@ function readyReceipts(env: TrainEnv): ReadyReceipt[] {
|
||||
|
||||
function changed(env: TrainEnv, head: string): { files: string[]; lines: number } | null {
|
||||
const fork = git(env.ctx.repoRoot, ['merge-base', `${env.remote}/${env.ctx.base}`, head]);
|
||||
const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', fork.out, head]) : fork;
|
||||
const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', '--no-renames', fork.out, head]) : fork;
|
||||
if (!stat.ok) return null;
|
||||
let lines = 0;
|
||||
const files: string[] = [];
|
||||
@@ -250,15 +265,21 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m
|
||||
const info = prInfo(ctx, r.pr);
|
||||
if ('error' in info) { unreadable.push(`#${r.pr} (${info.error})`); continue; }
|
||||
if (info.state !== 'OPEN' || info.headRefOid !== r.head || info.isCrossRepository || info.headRefName.startsWith('wave/')) continue;
|
||||
const ci = ciAtHead(ctx, r.head);
|
||||
if (ci.line.startsWith('unknown')) { unreadable.push(`#${r.pr} (CI ${ci.line})`); continue; }
|
||||
if (!ci.green) { lines.push(`NOT_GREEN: #${r.pr} (${ci.line}; left out until CI is green at the READY head)`); continue; }
|
||||
if (!ensureCommit(env, r.head, r.pr)) { unreadable.push(`#${r.pr} (head ${short(r.head)} not fetchable)`); continue; }
|
||||
const c = changed(env, r.head);
|
||||
if (!c) { unreadable.push(`#${r.pr} (diff unreadable)`); continue; }
|
||||
live.push({ pr: r.pr, head: r.head, ...c });
|
||||
}
|
||||
if (unreadable.length) return outFailed(fail('QUEUE_PROOF_MISSING', `cannot read ${unreadable.length} READY PR(s): ${unreadable.join('; ')}; a plan from a partial queue would mislead`), lines, `${Q} wave plan (after gh and git can read every PR)`, 'none');
|
||||
lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt); wave trigger ${o.trigger}`);
|
||||
lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there); wave trigger ${o.trigger}`);
|
||||
if (live.length <= o.trigger) return outOk([...lines, `WAVE: not needed (${live.length} <= ${o.trigger}); build trains of single PRs`], `${Q} train build --prs <pins>`, 'none');
|
||||
const plan = groupWaves(live, areaOf(env), o.min, o.max);
|
||||
const plan = groupWaves(live, areaOf(env), o.min, o.max, (a, b) => {
|
||||
const m = git(env.ctx.repoRoot, ['merge-tree', '--write-tree', '--name-only', '--no-messages', a.head, b.head]);
|
||||
return m.ok ? [] : m.status === 1 ? m.out.split('\n').slice(1).filter(Boolean) : null;
|
||||
});
|
||||
lines.push(`WAVE_ADVISED: ${live.length} READY PRs over the wave trigger ${o.trigger}; group by area into waves of ${o.min}-${o.max} (Garry R46)`);
|
||||
for (const g of plan.groups) {
|
||||
lines.push(`WAVE_GROUP: area=${g.area} prs=${g.prs.map(p => '#' + p.pr).join(',')} integrator=#${g.integrator} (most changed lines)`);
|
||||
|
||||
+6
-1
@@ -170,7 +170,12 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string;
|
||||
const versionRel = o.versionRel ?? ('rel' in cv ? cv.rel : 'rel' in gv ? gv.rel : 'VERSION');
|
||||
const oldV = 'version' in gv ? gv.version : null;
|
||||
const newV = 'version' in cv ? cv.version : null;
|
||||
const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? '';
|
||||
const d = spawnSync('git', ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead], { cwd: o.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
if (d.status !== 0) {
|
||||
const why = (d.error as NodeJS.ErrnoException | undefined)?.code ?? (d.signal ? `killed by ${d.signal}` : (d.stderr ?? '').trim().split('\n')[0]?.slice(0, 200) || `exit ${d.status}`);
|
||||
return { error: `git diff ${gatedHead.slice(0, 12)} ${currentHead.slice(0, 12)} failed (${why}), so the changed files cannot be classified (GST-448)`, code: 'EVIDENCE_IDENTITY_UNKNOWN' };
|
||||
}
|
||||
const diffText = d.stdout.replace(/\n$/, '');
|
||||
const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV);
|
||||
const residual = files.filter(f => f.verdict === 'changed').map(f => f.path);
|
||||
const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.33",
|
||||
"version": "1.92.43",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* ci-tree-reuse — run each free suite once per tree (GSTA-156, docs/pregate.md#ci-reuse).
|
||||
*
|
||||
* bun run scripts/ci-tree-reuse.ts record --plan <plan.json> --results <dir> --os <OS>[,<OS>…] --out <tree.json>
|
||||
* The producer. A Free Tests or Windows Free Tests aggregate runs it only after its strict verification
|
||||
* passed, and uploads the file as the `tested-tree` artifact: run_id, run_attempt, event, sha (GITHUB_SHA),
|
||||
* head_sha (the pull request head for pull_request, else GITHUB_SHA), tree (git's tree of GITHUB_SHA),
|
||||
* workflow, os (the OSes the run's test jobs ran on), files (every test file in the plan) and tests.
|
||||
*
|
||||
* bun run scripts/ci-tree-reuse.ts find --workflow <file.yml> --profile <run_profiles name>
|
||||
* The main-push consumer (the `reuse` job). A push to main whose tree a pull request already tested green
|
||||
* needs no second run: it finds the pull request whose merge commit is the pushed commit (same-repository
|
||||
* head, merged into main), requires that head's tree to equal the pushed tree, lists that head's runs of
|
||||
* this workflow (pull_request or workflow_dispatch, newest first, at most MAX_CANDIDATES), and accepts the
|
||||
* first one lib/run-evidence.ts verifyRun accepts under the profile in the pushed tree's
|
||||
* .gstack/pregate.json. A pull_request run counts only when its merge commit's tree is its head's tree
|
||||
* (verifyRun). It writes reused=true, run_id, run_url and tree to GITHUB_OUTPUT, or reused=false and a
|
||||
* reason, with one REUSE: line in the step summary. Every lookup failure is a refusal, never a pass: the
|
||||
* workflow then runs every job as before. Pull request and dispatch runs never consume.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ghApiJson, ghRunDeps, runProfileErrors, verifyRun, type ApiRun, type RunDeps, type RunProfile } from '../lib/run-evidence';
|
||||
|
||||
export const MAX_PULLS = 10;
|
||||
export const MAX_CANDIDATES = 10;
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const OSES = ['Linux', 'macOS', 'Windows'];
|
||||
|
||||
export interface ApiPull { number: number; merged_at: string | null; merge_commit_sha: string | null; base: { ref: string; repo?: { full_name: string } | null }; head: { sha: string; repo: { full_name: string } | null } }
|
||||
export interface ReuseDeps extends RunDeps { pullsForCommit(sha: string): ApiPull[] }
|
||||
export type ReuseVerdict =
|
||||
| { reused: true; run_id: number; run_attempt: number; event: string; pr: number; head_sha: string; tree: string }
|
||||
| { reused: false; reason: string; detail: string };
|
||||
|
||||
const refuse = (reason: string, detail: string): ReuseVerdict => ({ reused: false, reason, detail });
|
||||
const startedAt = (r: ApiRun) => r.run_started_at ?? r.created_at ?? '';
|
||||
|
||||
/** The run a push of `pushSha` to `base` may reuse, or why none. Pure over `deps`, so every refusal has a fixture. */
|
||||
export function findReuse(deps: ReuseDeps, o: { repo: string; base: string; pushSha: string; workflow: string; profileName: string; profile: RunProfile; now: Date }): ReuseVerdict {
|
||||
if (!SHA.test(o.pushSha)) return refuse('usage', `${JSON.stringify(o.pushSha)} is not a commit sha`);
|
||||
if (o.profile.workflow !== o.workflow) return refuse('profile_mismatch', `profile ${o.profileName} names ${o.profile.workflow}, not ${o.workflow}`);
|
||||
try {
|
||||
const pulls = deps.pullsForCommit(o.pushSha).slice(0, MAX_PULLS);
|
||||
const merged = pulls.filter(p => p.merged_at && p.base.ref === o.base && p.merge_commit_sha === o.pushSha && (!p.base.repo || p.base.repo.full_name.toLowerCase() === o.repo.toLowerCase()));
|
||||
if (!merged.length) return refuse('no_pr', `no pull request merged into ${o.base} has ${o.pushSha.slice(0, 12)} as its merge commit (${pulls.length} associated)`);
|
||||
if (merged.length > 1) return refuse('ambiguous_pr', `${merged.length} merged pull requests name ${o.pushSha.slice(0, 12)}: ${merged.map(p => `#${p.number}`).join(', ')}`);
|
||||
const pr = merged[0]!;
|
||||
if ((pr.head.repo?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return refuse('fork_head', `#${pr.number}'s head is on ${pr.head.repo?.full_name ?? 'a deleted repository'}, not ${o.repo}`);
|
||||
if (!SHA.test(pr.head.sha)) return refuse('api_error', `#${pr.number} has no head commit`);
|
||||
const pushTree = deps.commitTree(o.pushSha);
|
||||
const headTree = deps.commitTree(pr.head.sha);
|
||||
if (!pushTree || !headTree) return refuse('api_error', `cannot read the tree of ${pushTree ? pr.head.sha : o.pushSha}`);
|
||||
if (headTree !== pushTree) return refuse('tree_mismatch', `#${pr.number}'s head ${pr.head.sha.slice(0, 12)} has tree ${headTree.slice(0, 12)}, the push has ${pushTree.slice(0, 12)}`);
|
||||
const events: string[] = o.profile.events.filter(e => e === 'pull_request' || e === 'workflow_dispatch');
|
||||
const candidates = deps.runsForHead(o.workflow, pr.head.sha).filter(r => events.includes(r.event))
|
||||
.sort((a, b) => startedAt(b).localeCompare(startedAt(a)) || b.id - a.id).slice(0, MAX_CANDIDATES);
|
||||
if (!candidates.length) return refuse('no_candidate', `#${pr.number}'s head ${pr.head.sha.slice(0, 12)} has no ${events.join(' or ') || 'pull_request or workflow_dispatch'} run of ${o.workflow}`);
|
||||
const refusals: string[] = [];
|
||||
for (const run of candidates) {
|
||||
const v = verifyRun(deps, { repo: o.repo, runRef: String(run.id), profileName: o.profileName, profile: o.profile, now: o.now });
|
||||
if (!v.ok) { refusals.push(`run ${run.id} (${run.event}) reason=${v.reason} ${v.detail}`); continue; }
|
||||
if (v.evidence.tree !== pushTree) { refusals.push(`run ${run.id} tested tree ${v.evidence.tree.slice(0, 12)}`); continue; }
|
||||
return { reused: true, run_id: v.evidence.run_id, run_attempt: v.evidence.run_attempt, event: v.evidence.event, pr: pr.number, head_sha: pr.head.sha, tree: pushTree };
|
||||
}
|
||||
return refuse('no_verified_run', refusals.join(' | '));
|
||||
} catch (e: any) {
|
||||
return refuse('api_error', String(e?.message ?? e).slice(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
/** tree.json for the `tested-tree` artifact; throws on any missing or malformed input, so a broken record fails the aggregate instead of uploading. */
|
||||
export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{ files: string[] }> }; results: Array<{ outcome?: { summary?: { testsRan?: number } } }>; os: string[]; tree: string }) {
|
||||
const int = (name: string) => { const v = Number(env[name]); if (!Number.isSafeInteger(v) || v < 1) throw new Error(`${name} is not a positive integer`); return v; };
|
||||
const sha = env.GITHUB_SHA ?? '';
|
||||
const event = env.GITHUB_EVENT_NAME ?? '';
|
||||
const head = event === 'pull_request' ? env.PR_HEAD_SHA ?? '' : sha;
|
||||
if (!SHA.test(sha) || !SHA.test(head)) throw new Error(`GITHUB_SHA ${JSON.stringify(sha)} or the head ${JSON.stringify(head)} is not a commit sha (PR_HEAD_SHA is required for pull_request)`);
|
||||
if (!/^[a-z_]+$/.test(event)) throw new Error(`GITHUB_EVENT_NAME ${JSON.stringify(event)} is not an event name`);
|
||||
const workflow = /^[^/]+\/[^/]+\/(\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml)@/.exec(env.GITHUB_WORKFLOW_REF ?? '')?.[1];
|
||||
if (!workflow) throw new Error(`GITHUB_WORKFLOW_REF ${JSON.stringify(env.GITHUB_WORKFLOW_REF)} names no workflow file`);
|
||||
if (!/^[0-9a-f]{40,64}$/.test(o.tree)) throw new Error(`${JSON.stringify(o.tree)} is not a tree id`);
|
||||
if (!o.os.length || o.os.some(x => !OSES.includes(x))) throw new Error(`--os must list some of ${OSES.join(', ')}`);
|
||||
const files = [...new Set(o.plan.shards.flatMap(s => s.files))].sort();
|
||||
if (!files.length) throw new Error('the plan lists no test files');
|
||||
const counts = o.results.map(r => r.outcome?.summary?.testsRan);
|
||||
const tests = o.results.length && counts.every(n => Number.isSafeInteger(n) && n! >= 0) ? counts.reduce((a, b) => a! + b!, 0)! : files.length;
|
||||
return { run_id: int('GITHUB_RUN_ID'), run_attempt: int('GITHUB_RUN_ATTEMPT'), event, sha, head_sha: head, tree: o.tree, workflow, os: o.os, files, tests };
|
||||
}
|
||||
|
||||
function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
|
||||
function need(args: string[], name: string): string { const v = flag(args, name); if (!v || v.startsWith('--')) throw new Error(`${name} <value> is required`); return v; }
|
||||
|
||||
function record(args: string[]): number {
|
||||
const plan = JSON.parse(fs.readFileSync(need(args, '--plan'), 'utf8'));
|
||||
const dir = need(args, '--results');
|
||||
const results = fs.readdirSync(dir).filter(f => f.endsWith('.json')).map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
|
||||
const git = spawnSync('git', ['rev-parse', '--verify', `${process.env.GITHUB_SHA ?? ''}^{tree}`], { encoding: 'utf8', timeout: 30_000 });
|
||||
const rec = treeRecord(process.env, { plan, results, os: need(args, '--os').split(','), tree: git.status === 0 ? git.stdout.trim() : '' });
|
||||
const out = need(args, '--out');
|
||||
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
||||
fs.writeFileSync(out, JSON.stringify(rec, null, 2) + '\n');
|
||||
console.log(`TESTED_TREE: run ${rec.run_id}/${rec.run_attempt} ${rec.event} head ${rec.head_sha.slice(0, 12)} tree ${rec.tree.slice(0, 12)} files=${rec.files.length} tests=${rec.tests} os=${rec.os.join(',')}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The consumer's GITHUB_OUTPUT and step-summary text for a verdict. reused=true is written last, after the run it names. */
|
||||
export function reuseOutputs(v: ReuseVerdict, repo: string, server = 'https://github.com'): { line: string; output: string } {
|
||||
if (!v.reused) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every job runs`, output: `reused=false\nreason=${v.reason}\n` };
|
||||
const url = `${server}/${repo}/actions/runs/${v.run_id}`;
|
||||
return { line: `REUSE: accepted run ${v.run_id} (${v.event}, attempt ${v.run_attempt}) of #${v.pr} at ${v.head_sha.slice(0, 12)}, tree ${v.tree.slice(0, 12)}: ${url}`, output: `run_id=${v.run_id}\nrun_url=${url}\ntree=${v.tree}\nreused=true\n` };
|
||||
}
|
||||
|
||||
function find(args: string[]): number {
|
||||
const env = process.env;
|
||||
const workflow = need(args, '--workflow');
|
||||
const profileName = need(args, '--profile');
|
||||
const repo = env.GITHUB_REPOSITORY ?? '';
|
||||
const settle = (v: ReuseVerdict) => {
|
||||
const { line, output } = reuseOutputs(v, repo, env.GITHUB_SERVER_URL);
|
||||
console.log(line);
|
||||
if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, `${line}\n`);
|
||||
if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, output);
|
||||
return 0;
|
||||
};
|
||||
if (env.GITHUB_EVENT_NAME !== 'push' || env.GITHUB_REF !== 'refs/heads/main') return settle(refuse('not_main_push', `${env.GITHUB_EVENT_NAME ?? 'no'} event on ${env.GITHUB_REF ?? 'no ref'}; only a push to main consumes`));
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) return settle(refuse('usage', `GITHUB_REPOSITORY ${JSON.stringify(repo)} is not owner/name`));
|
||||
let profiles: Record<string, RunProfile>;
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync('.gstack/pregate.json', 'utf8'));
|
||||
const errors = runProfileErrors(config.run_profiles, undefined);
|
||||
if (errors.length) return settle(refuse('profile_invalid', errors.map(e => `${e.path}: ${e.message}`).join('; ')));
|
||||
profiles = config.run_profiles ?? {};
|
||||
} catch (e: any) { return settle(refuse('profile_invalid', `.gstack/pregate.json: ${String(e?.message ?? e).slice(0, 200)}`)); }
|
||||
const profile = profiles[profileName];
|
||||
if (!profile) return settle(refuse('profile_invalid', `.gstack/pregate.json declares no run profile ${profileName}`));
|
||||
const deps: ReuseDeps = {
|
||||
...ghRunDeps(repo, process.cwd()),
|
||||
commitTree: sha => (SHA.test(sha) ? ghApiJson<{ tree?: { sha?: string } }>(`repos/${repo}/git/commits/${sha}`).tree?.sha ?? null : null),
|
||||
pullsForCommit: sha => ghApiJson<ApiPull[]>(`repos/${repo}/commits/${sha}/pulls?per_page=${MAX_PULLS}`),
|
||||
};
|
||||
return settle(findReuse(deps, { repo, base: 'main', pushSha: env.GITHUB_SHA ?? '', workflow, profileName, profile, now: new Date() }));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const [verb, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
if (verb === 'record') process.exitCode = record(args);
|
||||
else if (verb === 'find') process.exitCode = find(args);
|
||||
else { console.error('usage: ci-tree-reuse.ts record --plan <f> --results <dir> --os <OS,…> --out <f> | find --workflow <f.yml> --profile <name>'); process.exitCode = 2; }
|
||||
} catch (e: any) {
|
||||
console.error(`ci-tree-reuse ${verb}: ${e?.message ?? e}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -740,6 +740,7 @@ run_tiers() {
|
||||
vm_rows() {
|
||||
local loc name owner created expires
|
||||
cli vm list -N -f location,name | while read -r loc name; do
|
||||
[ -n "$name" ] || continue
|
||||
owner=. created="" expires=-
|
||||
if [[ "$name" =~ ^${PREFIX}-([a-z][a-z0-9]{0,11})-([0-9]{10})- ]]; then
|
||||
owner=${BASH_REMATCH[1]} created=${BASH_REMATCH[2]}
|
||||
@@ -804,21 +805,39 @@ cmd_list() {
|
||||
}
|
||||
|
||||
# usage: VMs and vCPUs per owner, from each VM's size (standard-16 = 16 vCPUs).
|
||||
# Exits 1 with no table when the VM list or any VM's size cannot be read, so a
|
||||
# partial count is never taken for the project's usage. A VM destroyed between
|
||||
# the list and its size read (404) no longer counts and is not a failure.
|
||||
cmd_usage() {
|
||||
local tmp loc name owner i=0
|
||||
local tmp rows loc name owner f i=0 unread=0
|
||||
rows=$(vm_rows) || { log "usage: could not list the project's VMs"; return 1; }
|
||||
tmp=$(mktemp -d)
|
||||
while read -r loc name owner _; do
|
||||
[ -n "$name" ] || continue
|
||||
i=$(( i + 1 ))
|
||||
cli vm "$loc/$name" show 2>/dev/null | sed -n "s/^size: /$owner /p" >"$tmp/$i" &
|
||||
{
|
||||
case $(api "$tmp/$i.show" vm "$loc/$name" show) in
|
||||
200) sed -n "s/^size: /$owner /p" "$tmp/$i.show" >"$tmp/$i" ;;
|
||||
404) ;;
|
||||
*) : >"$tmp/$i" ;;
|
||||
esac
|
||||
rm -f "$tmp/$i.show"
|
||||
} &
|
||||
[ $(( i % 8 )) -ne 0 ] || wait
|
||||
done < <(vm_rows)
|
||||
done <<<"$rows"
|
||||
wait
|
||||
cat "$tmp"/* 2>/dev/null | awk -v me="$OWNER" '
|
||||
for f in "$tmp"/*; do [ ! -e "$f" ] || [ -s "$f" ] || unread=$(( unread + 1 )); done
|
||||
if [ "$unread" -gt 0 ]; then
|
||||
rm -rf "$tmp"
|
||||
log "usage: could not read the size of $unread of $i VMs"
|
||||
return 1
|
||||
fi
|
||||
find "$tmp" -type f -exec cat {} + | awk -v me="$OWNER" '
|
||||
{ n = $2; sub(/^.*-/, "", n); o = $1 == "-" ? "(untagged)" : $1 == "." ? "(other)" : $1
|
||||
vms[o]++; cpu[o] += n; total += n; count++ }
|
||||
END {
|
||||
printf "%-14s %4s %6s\n", "OWNER", "VMS", "VCPUS"
|
||||
for (o in cpu) printf "%-14s %4d %6d%s\n", o, vms[o], cpu[o], o == me ? " (you)" : "" | "sort -k3,3nr"
|
||||
for (o in cpu) printf "%-14s %4d %6d%s\n", o, vms[o], cpu[o], (o == me ? " (you)" : "") | "sort -k3,3nr"
|
||||
close("sort -k3,3nr")
|
||||
printf "%-14s %4d %6d\n", "total", count, total
|
||||
}'
|
||||
@@ -881,8 +900,17 @@ quota_preflight() {
|
||||
# between them: a slow first usage read must not shave the printed budget.
|
||||
remaining=$QUOTA_WAIT
|
||||
while :; do
|
||||
used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}')
|
||||
[[ "$used" =~ ^[0-9]+$ ]] || return 0
|
||||
if ! used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}') || ! [[ "$used" =~ ^[0-9]+$ ]]; then
|
||||
if [ "$remaining" -le 0 ]; then
|
||||
log "quota preflight: could not read the project's vCPU usage; the API's own answer decides"
|
||||
return 0
|
||||
fi
|
||||
log "quota: could not read the project's vCPU usage; retrying, up to ${remaining}s more"
|
||||
step=$(( remaining < 30 ? remaining : 30 ))
|
||||
sleep "$step"
|
||||
remaining=$(( remaining - step ))
|
||||
continue
|
||||
fi
|
||||
[ $(( used + need )) -gt "$max" ] || return 0
|
||||
if [ "$remaining" -le 0 ]; then
|
||||
log "quota preflight refused $location/$name ($size): it needs $need vCPUs and the project's VMs already use $used of $max (ceiling from $source; other usage such as managed runners counts on top)"
|
||||
@@ -1074,7 +1102,12 @@ cmd_down() {
|
||||
# pointer into the main repository, useless on the VM; the same snapshot
|
||||
# replaces it, on the worktree's own branch and index. Not carried: submodule
|
||||
# repositories (.git/modules) and a partial clone's promisor (the fetch pulls
|
||||
# every missing blob first); gstack has neither.
|
||||
# every missing blob first); gstack has neither. The archive is built in two
|
||||
# passes inside the snapshot directory because macOS's bsdtar stops reading
|
||||
# options at the first operand and resolves -T names after a -C, so no single
|
||||
# tar command line works on both it and GNU tar (#3680). COPYFILE_DISABLE=1
|
||||
# keeps Apple's tar from adding ._* AppleDouble entries for files with
|
||||
# extended attributes, which the VM would see as untracked files.
|
||||
cmd_pack() {
|
||||
local src tmp gitdir branch head entry
|
||||
src=$(cd "${1:-.}" && pwd)
|
||||
@@ -1119,15 +1152,18 @@ cmd_pack() {
|
||||
fi
|
||||
(
|
||||
cd "$src"
|
||||
export COPYFILE_DISABLE=1
|
||||
git ls-files -z -co --exclude-standard \
|
||||
| while IFS= read -r -d '' f; do
|
||||
if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"; fi
|
||||
done \
|
||||
| tar --null -T - -c -C "$tmp" .git -zf -
|
||||
| tar --null -T - -cf "$tmp/files.tar" \
|
||||
&& tar -rf "$tmp/files.tar" -C "$tmp" .git \
|
||||
&& gzip -c "$tmp/files.tar"
|
||||
)
|
||||
rm -rf "$tmp"; trap - EXIT
|
||||
else
|
||||
tar -C "$src" -czf - .
|
||||
COPYFILE_DISABLE=1 tar -C "$src" -czf - .
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -120,8 +120,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
|
||||
|
||||
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
|
||||
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
|
||||
- Step 20: the ship metrics row via `gstack-review-log`.
|
||||
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
|
||||
- Step 20: the metrics row via `gstack-review-log`.
|
||||
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
|
||||
|
||||
## Where each STOP sends you (paths under `<root>/ship/`)
|
||||
|
||||
|
||||
+2
-2
@@ -118,8 +118,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an
|
||||
|
||||
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
|
||||
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> --body <body>`; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
|
||||
- Step 20: the ship metrics row via `gstack-review-log`.
|
||||
- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
|
||||
- Step 20: the metrics row via `gstack-review-log`.
|
||||
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
|
||||
|
||||
## Where each STOP sends you (paths under `<root>/ship/`)
|
||||
|
||||
|
||||
+13
-13
@@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
|
||||
`NEXT: carry route` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -457,7 +458,7 @@ not web services already deployed.
|
||||
|
||||
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
|
||||
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
|
||||
|
||||
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
|
||||
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
|
||||
@@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
|
||||
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
|
||||
use the first changed component from `baseVersion` to `currentVersion`
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
|
||||
not another automatic bump.
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
|
||||
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
|
||||
Success follows ALREADY_BUMPED, including its queue check; failure stops.
|
||||
Repair alone never re-bumps.
|
||||
@@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
|
||||
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
|
||||
Wait; cancel stops before release writes or push and preserves existing work.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
|
||||
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
@@ -696,7 +695,8 @@ Classify the comparison in this order:
|
||||
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
|
||||
This repair excludes Step 14.5 because the rebuild can change generated docs.
|
||||
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
|
||||
documentation freshness. Further repairs use the same work list.
|
||||
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
|
||||
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
|
||||
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
|
||||
report and counts. Recheck affected plan items using their recorded verification
|
||||
and append current evidence to the invocation record. If a classification is no
|
||||
@@ -719,6 +719,7 @@ audit or risk decision never qualifies.
|
||||
|
||||
Report changed inputs, blockers and attempts used:
|
||||
|
||||
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
|
||||
- **An attempt remains, with changed inputs or an available repair:** insert
|
||||
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
|
||||
Validate the outcome before Step 15, then restart Step 16 stage 1.
|
||||
@@ -756,9 +757,9 @@ every allowed edit is release metadata:
|
||||
|---|---|
|
||||
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
|
||||
| STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
|
||||
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
|
||||
No test lanes: require Step 5's explicit untested-scope approval for final content,
|
||||
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
|
||||
@@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass.
|
||||
|
||||
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
|
||||
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
|
||||
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
|
||||
keeps its existing audit count; it does not authorize a third attempt.
|
||||
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
|
||||
does not authorize a third attempt.
|
||||
|
||||
### 5. Report, then push
|
||||
|
||||
@@ -777,8 +778,7 @@ Preserve unrelated user files.
|
||||
|
||||
Paste build/docs/test results. Reuse waivers only for the same verified
|
||||
pre-existing failures and approved scope; cite the actual approval and failing
|
||||
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
|
||||
stage 4's recovery before publication.
|
||||
counts, never FRESH or all-green.
|
||||
|
||||
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
|
||||
its records:
|
||||
|
||||
+13
-13
@@ -142,7 +142,8 @@ act on every `STOP:` and `NEXT:` line.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
|
||||
`NEXT: carry route` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -187,7 +188,7 @@ not web services already deployed.
|
||||
|
||||
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
|
||||
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
|
||||
|
||||
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
|
||||
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
|
||||
@@ -252,8 +253,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
|
||||
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
|
||||
use the first changed component from `baseVersion` to `currentVersion`
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
|
||||
not another automatic bump.
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
|
||||
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
|
||||
Success follows ALREADY_BUMPED, including its queue check; failure stops.
|
||||
Repair alone never re-bumps.
|
||||
@@ -272,8 +272,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
|
||||
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
|
||||
Wait; cancel stops before release writes or push and preserves existing work.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
|
||||
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
@@ -417,7 +416,8 @@ Classify the comparison in this order:
|
||||
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
|
||||
This repair excludes Step 14.5 because the rebuild can change generated docs.
|
||||
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
|
||||
documentation freshness. Further repairs use the same work list.
|
||||
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
|
||||
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
|
||||
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
|
||||
report and counts. Recheck affected plan items using their recorded verification
|
||||
and append current evidence to the invocation record. If a classification is no
|
||||
@@ -440,6 +440,7 @@ audit or risk decision never qualifies.
|
||||
|
||||
Report changed inputs, blockers and attempts used:
|
||||
|
||||
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
|
||||
- **An attempt remains, with changed inputs or an available repair:** insert
|
||||
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
|
||||
Validate the outcome before Step 15, then restart Step 16 stage 1.
|
||||
@@ -477,9 +478,9 @@ every allowed edit is release metadata:
|
||||
|---|---|
|
||||
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
|
||||
| STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
|
||||
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
|
||||
No test lanes: require Step 5's explicit untested-scope approval for final content,
|
||||
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
|
||||
@@ -487,8 +488,8 @@ Report the gap, never FRESH; builds must pass.
|
||||
|
||||
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
|
||||
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
|
||||
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
|
||||
keeps its existing audit count; it does not authorize a third attempt.
|
||||
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
|
||||
does not authorize a third attempt.
|
||||
|
||||
### 5. Report, then push
|
||||
|
||||
@@ -498,8 +499,7 @@ Preserve unrelated user files.
|
||||
|
||||
Paste build/docs/test results. Reuse waivers only for the same verified
|
||||
pre-existing failures and approved scope; cite the actual approval and failing
|
||||
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
|
||||
stage 4's recovery before publication.
|
||||
counts, never FRESH or all-green.
|
||||
|
||||
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
|
||||
its records:
|
||||
|
||||
@@ -825,6 +825,198 @@ describe('gstack-brain-sync per-file hold', () => {
|
||||
expect(remoteFiles()).not.toContain(CLEAN);
|
||||
});
|
||||
|
||||
describe('a rejected push merges the remote without wedging (#3704)', () => {
|
||||
const SHARED = 'developer-profile.json';
|
||||
const OTHER = 'projects/p/ceo-plans/other.md';
|
||||
function peerCommit(p: string, body: string) {
|
||||
const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-'));
|
||||
try {
|
||||
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
|
||||
fs.mkdirSync(path.dirname(path.join(peer, p)), { recursive: true });
|
||||
fs.writeFileSync(path.join(peer, p), body);
|
||||
expect(git(['add', '-f', '--', p], peer).status).toBe(0);
|
||||
expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', `peer ${p}`], peer).status).toBe(0);
|
||||
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(peer, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
function setup() {
|
||||
init();
|
||||
write(SHARED, '# shared v1\n');
|
||||
write(OTHER, '# other v1\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
// The retry merge commits with the ambient identity; a CI runner has none.
|
||||
expect(git(['config', 'user.name', 'fixture']).status).toBe(0);
|
||||
expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0);
|
||||
}
|
||||
const unmerged = () => git(['ls-files', '--unmerged']).stdout.trim();
|
||||
const mergeHead = () => fs.existsSync(path.join(tmpHome, '.git', 'MERGE_HEAD'));
|
||||
|
||||
test('a dirty tracked file the remote did not touch rides through the merge', () => {
|
||||
setup();
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
write(OTHER, '# other, local edit not queued\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
expect(remoteFiles()).toContain(CLEAN);
|
||||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||||
});
|
||||
|
||||
/** The restore command the status names, run as printed. */
|
||||
function recover(message: string) {
|
||||
const m = /get them back: (git -C \S+ --literal-pathspecs restore --source=([0-9a-f]{40}) -- .+?)\)/.exec(message);
|
||||
expect(m).not.toBeNull();
|
||||
const r = spawnSync('bash', ['-c', m![1]], { encoding: 'utf-8', timeout: 30_000 });
|
||||
expect(r.status).toBe(0);
|
||||
return m![2];
|
||||
}
|
||||
|
||||
test('a dirty tracked file the remote also changed no longer leaves the push failed', () => {
|
||||
setup();
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
write(SHARED, '# shared, local edit not queued\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('ok');
|
||||
expect(remoteFiles()).toContain(CLEAN);
|
||||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||||
// The merged version stays in the working tree: no conflict markers,
|
||||
// nothing staged, and nothing stale for a later sync to publish.
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||||
expect(unmerged()).toBe('');
|
||||
expect(staged()).toBe('');
|
||||
expect(s.message).toContain(`local edits to ${SHARED} conflicted`);
|
||||
const sha = /saved in stash ([0-9a-f]{40})/.exec(s.message)?.[1];
|
||||
expect(sha).toBeTruthy();
|
||||
// A second sync run (same file re-queued, plus another) keeps the peer's
|
||||
// edit on the remote and the stashed local edit intact.
|
||||
run(['gstack-brain-enqueue', SHARED]);
|
||||
write(OTHER, '# other v2\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||||
expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('# shared, local edit not queued\n');
|
||||
// The printed command brings the local edit back.
|
||||
expect(recover(s.message)).toBe(sha!);
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
});
|
||||
|
||||
test('a local deletion of a file the remote changed keeps the remote file and stashes the deletion', () => {
|
||||
setup();
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
fs.rmSync(path.join(tmpHome, SHARED));
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('ok');
|
||||
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
|
||||
expect(unmerged()).toBe('');
|
||||
expect(s.message).toContain(`local edits to ${SHARED} conflicted`);
|
||||
recover(s.message);
|
||||
expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false);
|
||||
});
|
||||
|
||||
test('a local edit of a file the remote deleted keeps the deletion and stashes the edit', () => {
|
||||
setup();
|
||||
const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-'));
|
||||
try {
|
||||
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
|
||||
expect(git(['rm', '-q', '--', SHARED], peer).status).toBe(0);
|
||||
expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer rm'], peer).status).toBe(0);
|
||||
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(peer, { recursive: true, force: true });
|
||||
}
|
||||
write(SHARED, '# shared, local edit not queued\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('ok');
|
||||
expect(remoteFiles()).not.toContain(SHARED);
|
||||
expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false);
|
||||
expect(unmerged()).toBe('');
|
||||
recover(s.message);
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
});
|
||||
|
||||
test('a conflicting file whose name looks like a pattern resets only itself', () => {
|
||||
setup();
|
||||
const GLOB = '[d]eveloper-profile.json';
|
||||
peerCommit(GLOB, '# glob v1\n');
|
||||
expect(git(['fetch', '-q', 'origin']).status).toBe(0);
|
||||
expect(git(['merge', '-q', '--ff-only', 'origin/main']).status).toBe(0);
|
||||
peerCommit(GLOB, '# glob from the peer\n');
|
||||
write(GLOB, '# glob, local edit not queued\n', false);
|
||||
write(SHARED, '# shared, local edit not queued\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('ok');
|
||||
expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob from the peer\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
recover(s.message);
|
||||
expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob, local edit not queued\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
});
|
||||
|
||||
test('a file that never checks out clean does not strand the other local edits', () => {
|
||||
setup();
|
||||
const CRLF = 'projects/p/ceo-plans/crlf.md';
|
||||
write(CRLF, '# crlf\r\nline\r\n');
|
||||
expect(drain().status).toBe(0);
|
||||
fs.mkdirSync(path.join(tmpHome, '.git', 'info'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, '.git', 'info', 'attributes'), '*.md text eol=lf\n');
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
write(OTHER, '# other, local edit not queued\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().status).toBe('ok');
|
||||
expect(remoteFiles()).toContain(CLEAN);
|
||||
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n');
|
||||
expect(git(['stash', 'list']).stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
test('a stash push that saves nothing never pops an older stash entry', () => {
|
||||
setup();
|
||||
write(OTHER, '# other, older stashed work\n', false);
|
||||
expect(git(['stash', 'push', '-q', '-m', 'older work']).status).toBe(0);
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
write(SHARED, '# shared, local edit not queued\n', false);
|
||||
write(CLEAN, '# a plan\n');
|
||||
const shim = gitShim('case " $* " in *" stash push "*) exit 0;; esac');
|
||||
expect(drain(shim.env).status).toBe(0);
|
||||
expect(statusJson().status).toBe('push_failed');
|
||||
expect(git(['stash', 'list']).stdout).toContain('older work');
|
||||
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other v1\n');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
|
||||
});
|
||||
|
||||
test('a conflicting merge is aborted and git\'s reason reaches the status', () => {
|
||||
setup();
|
||||
peerCommit(SHARED, '# shared from the peer\n');
|
||||
write(SHARED, '# shared, local commit\n');
|
||||
expect(drain().status).toBe(0);
|
||||
const s = statusJson();
|
||||
expect(s.status).toBe('push_failed');
|
||||
expect(s.message).toContain(`Merge conflict in ${SHARED}`);
|
||||
expect(mergeHead()).toBe(false);
|
||||
expect(unmerged()).toBe('');
|
||||
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local commit\n');
|
||||
// The next drain is not blocked by a half-finished merge.
|
||||
write(OTHER, '# other v2\n');
|
||||
expect(drain().status).toBe(0);
|
||||
expect(statusJson().message).not.toContain('unmerged');
|
||||
expect(statusJson().message).toContain(`Merge conflict in ${SHARED}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('a git add failure is reported, never swallowed', () => {
|
||||
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root)
|
||||
init();
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Main-push CI reuse wiring (GSTA-156 P3b) for free-tests.yml and windows-free-tests.yml. Pins the properties that
|
||||
* make reuse safe: only a push to main consumes, the reuse job alone reads Actions with a job-level actions: read,
|
||||
* a skipped or failed reuse job runs every job exactly as before (GitHub skips a job whose needed job was skipped
|
||||
* unless its condition uses a status function, so every dependent condition starts with !cancelled()), the
|
||||
* aggregate's reuse branch runs before any download and the strict path is otherwise unchanged, tested-tree is
|
||||
* uploaded only after strict verification, and the run profiles in .gstack/pregate.json name real jobs.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { parseWorkflow } from '../lib/pregate/workflows';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
const FILES = { 'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: [] as string[] }, 'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'] } };
|
||||
const REUSE_IF = "github.event_name == 'push' && github.ref == 'refs/heads/main'";
|
||||
const SKIP = "steps.route.outputs.reused != 'true'";
|
||||
const PROFILES = JSON.parse(read('.gstack/pregate.json')).run_profiles as Record<string, { workflow: string; jobs: string[]; artifact: string; events: string[]; os: string }>;
|
||||
|
||||
/** Evaluate a job condition the way the runner would for one reuse outcome (status functions true: nothing cancelled). */
|
||||
function runs(cond: string, s: { result: string; reused: string; plan?: string }): boolean {
|
||||
const expr = cond.replace(/^\$\{\{\s*|\s*\}\}$/g, '')
|
||||
.replace(/!cancelled\(\)|always\(\)/g, 'true')
|
||||
.replace(/needs\.reuse\.result/g, JSON.stringify(s.result)).replace(/needs\.reuse\.outputs\.reused/g, JSON.stringify(s.reused))
|
||||
.replace(/needs\.(free|windows)-plan\.result/g, JSON.stringify(s.plan ?? 'success')).replace(/inputs\.record_durations/g, 'false');
|
||||
expect(expr).not.toMatch(/needs\.|inputs\.|github\.|steps\./);
|
||||
return new Function(`return (${expr});`)() as boolean;
|
||||
}
|
||||
|
||||
for (const [file, spec] of Object.entries(FILES)) {
|
||||
describe(`${file} main-push reuse wiring`, () => {
|
||||
const source = read(`.github/workflows/${file}`);
|
||||
const wf = Bun.YAML.parse(source) as any;
|
||||
const jobs = wf.jobs as Record<string, any>;
|
||||
const needs = (name: string): string[] => [jobs[name].needs ?? []].flat();
|
||||
const suite = Object.keys(jobs).filter(n => n !== 'reuse' && n !== spec.aggregate && !spec.exempt.includes(n));
|
||||
|
||||
test('only a push to main consumes, with job-level actions: read on the reuse job alone and no secrets', () => {
|
||||
expect(jobs.reuse.if).toBe(REUSE_IF);
|
||||
expect(jobs.reuse.permissions).toEqual({ contents: 'read', actions: 'read' });
|
||||
expect(wf.permissions).toEqual({ contents: 'read' });
|
||||
for (const name of Object.keys(jobs).filter(n => n !== 'reuse')) expect([name, jobs[name].permissions]).toEqual([name, undefined]);
|
||||
expect(source).not.toContain('secrets.');
|
||||
expect(source).not.toContain('pull_request_target');
|
||||
const find = jobs.reuse.steps.find((s: any) => s.id === 'find');
|
||||
expect(find.env).toEqual({ GH_TOKEN: '${{ github.token }}' });
|
||||
expect(find.run).toBe(`bun run scripts/ci-tree-reuse.ts find --workflow ${file} --profile ${spec.profile}`);
|
||||
expect(jobs.reuse.steps[0].with['persist-credentials']).toBe(false);
|
||||
expect(Object.keys(jobs.reuse.outputs).sort()).toEqual(['reused', 'run_id', 'run_url', 'tree']);
|
||||
});
|
||||
|
||||
test('every suite job needs reuse and runs unless reuse succeeded with reused=true; a skipped or failed reuse runs them all', () => {
|
||||
expect(suite.length).toBeGreaterThan(1);
|
||||
for (const name of suite) {
|
||||
expect([name, needs(name)]).toEqual([name, expect.arrayContaining(['reuse'])]);
|
||||
const cond = jobs[name].if as string;
|
||||
expect([name, cond]).toEqual([name, expect.stringMatching(/^\$\{\{ !cancelled\(\) && /)]);
|
||||
expect([name, runs(cond, { result: 'skipped', reused: '' })]).toEqual([name, true]);
|
||||
expect([name, runs(cond, { result: 'success', reused: 'false' })]).toEqual([name, true]);
|
||||
expect([name, runs(cond, { result: 'failure', reused: '' })]).toEqual([name, true]);
|
||||
expect([name, runs(cond, { result: 'failure', reused: 'true' })]).toEqual([name, true]);
|
||||
expect([name, runs(cond, { result: 'success', reused: 'true' })]).toEqual([name, false]);
|
||||
}
|
||||
for (const name of suite.filter(n => needs(n).includes(spec.plan))) expect([name, runs(jobs[name].if, { result: 'skipped', reused: '', plan: 'failure' })]).toEqual([name, false]);
|
||||
expect(needs(spec.aggregate)).toContain('reuse');
|
||||
expect(runs(jobs[spec.aggregate].if, { result: 'success', reused: 'true' })).toBe(true);
|
||||
});
|
||||
|
||||
test('every job downstream of reuse uses a status function, so a skipped reuse never skips it', () => {
|
||||
const downstream = (name: string): boolean => needs(name).some(n => n === 'reuse' || downstream(n));
|
||||
for (const name of Object.keys(jobs).filter(downstream)) expect([name, jobs[name].if]).toEqual([name, expect.stringMatching(/!cancelled\(\)|always\(\)/)]);
|
||||
});
|
||||
|
||||
test('pregate still sees every test lane as required (no condition reads inputs or workflow_dispatch)', () => {
|
||||
for (const lane of parseWorkflow(file, source, 'linux').filter(l => l.runsTests && !spec.exempt.includes(l.job))) expect([lane.id, lane.manual]).toEqual([lane.id, false]);
|
||||
});
|
||||
|
||||
test('the aggregate routes first, before any download; the strict path follows unchanged and alone uploads tested-tree after verification', () => {
|
||||
const steps = jobs[spec.aggregate].steps as any[];
|
||||
expect(steps[0].id).toBe('route');
|
||||
expect(steps[0].if).toBeUndefined();
|
||||
for (const step of steps.slice(1)) expect([step.name ?? step.uses, step.if]).toEqual([step.name ?? step.uses, SKIP]);
|
||||
const verify = steps.findIndex(s => s.run?.includes('--ci-verify'));
|
||||
const record = steps.findIndex(s => s.run?.includes('scripts/ci-tree-reuse.ts record'));
|
||||
const upload = steps.findIndex(s => s.with?.name === 'tested-tree');
|
||||
expect(steps.findIndex(s => s.uses?.startsWith('actions/download-artifact@'))).toBeGreaterThan(0);
|
||||
expect(verify).toBeGreaterThan(0);
|
||||
expect(record).toBe(verify + 1);
|
||||
expect(upload).toBe(record + 1);
|
||||
expect(steps.length).toBe(upload + 1);
|
||||
expect(steps[upload].uses).toStartWith('actions/upload-artifact@');
|
||||
expect(steps[upload].with['if-no-files-found']).toBe('error');
|
||||
expect(steps[record].env).toEqual({ PR_HEAD_SHA: '${{ github.event.pull_request.head.sha }}' });
|
||||
expect(steps[1].run).toMatch(/set -eu\n(test "\$[A-Z_]+_RESULT" = success\n)+$/);
|
||||
});
|
||||
|
||||
test.skipIf(!Bun.which('bash'))('the route step takes the reuse branch only for a well-formed reuse on a main push, and fails rather than passes on anything else claiming reuse', () => {
|
||||
const route = jobs[spec.aggregate].steps[0];
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reuse route '));
|
||||
const shell = (p: string) => p.split(path.sep).join('/');
|
||||
try {
|
||||
const go = (env: Record<string, string>) => {
|
||||
const out = path.join(dir, 'out'); const summary = path.join(dir, 'summary');
|
||||
fs.writeFileSync(out, ''); fs.writeFileSync(summary, '');
|
||||
const r = spawnSync('bash', ['-e', '-c', route.run], { env: { PATH: process.env.PATH ?? '', GITHUB_OUTPUT: shell(out), GITHUB_STEP_SUMMARY: shell(summary), GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main', GITHUB_SERVER_URL: 'https://github.com', GITHUB_REPOSITORY: 'garrytan/gstack', REUSE_RESULT: '', REUSED: '', REUSE_RUN_ID: '', REUSE_RUN_URL: '', REUSE_TREE: '', ...env }, encoding: 'utf8', timeout: 5000 });
|
||||
return { status: r.status, out: fs.readFileSync(out, 'utf8'), summary: fs.readFileSync(summary, 'utf8') };
|
||||
};
|
||||
const good = { REUSE_RESULT: 'success', REUSED: 'true', REUSE_RUN_ID: '123', REUSE_RUN_URL: 'https://github.com/garrytan/gstack/actions/runs/123', REUSE_TREE: 'a'.repeat(40) };
|
||||
expect(go({ REUSE_RESULT: 'skipped', GITHUB_EVENT_NAME: 'pull_request' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
expect(go({ REUSE_RESULT: 'success', REUSED: 'false' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
expect(go({ ...good, REUSE_RESULT: 'failure' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
const reused = go(good);
|
||||
expect([reused.status, reused.out]).toEqual([0, 'reused=true\n']);
|
||||
expect(reused.summary).toContain('Reused https://github.com/garrytan/gstack/actions/runs/123');
|
||||
for (const bad of <Record<string, string>[]>[{ REUSE_RUN_ID: '' }, { REUSE_RUN_ID: '12x' }, { REUSE_RUN_URL: 'https://github.com/other/repo/actions/runs/123' }, { REUSE_TREE: '' }, { GITHUB_EVENT_NAME: 'pull_request' }, { GITHUB_REF: 'refs/heads/feature' }]) {
|
||||
const r = go({ ...good, ...bad });
|
||||
expect([JSON.stringify(bad), r.status === 0, r.out]).toEqual([JSON.stringify(bad), false, '']);
|
||||
}
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('its run profile names this workflow, its real non-matrix jobs and the uploaded artifact, and is never named after a lane (lane clears keep their URL path until GST-555)', () => {
|
||||
const profiles = Object.entries(PROFILES).filter(([, p]) => p.workflow === file);
|
||||
expect(profiles.map(([n]) => n)).toContain(spec.profile);
|
||||
for (const [name, p] of profiles) {
|
||||
expect([name, p.events.sort()]).toEqual([name, ['pull_request', 'workflow_dispatch']]);
|
||||
expect([name, p.artifact]).toEqual([name, 'tested-tree']);
|
||||
for (const job of p.jobs) expect([name, job, !!jobs[job] && !jobs[job].strategy]).toEqual([name, job, true]);
|
||||
expect(p.jobs).toContain(spec.aggregate);
|
||||
expect([name, /\.ya?ml\//.test(name)]).toEqual([name, false]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('every workflow that calls one of these grants the calling job actions: read, which the reuse job needs even when it is skipped', () => {
|
||||
const dir = path.join(ROOT, '.github/workflows');
|
||||
const callers: string[] = [];
|
||||
for (const name of fs.readdirSync(dir).filter(f => /\.ya?ml$/.test(f))) {
|
||||
const jobs = (Bun.YAML.parse(read(`.github/workflows/${name}`)) as any)?.jobs ?? {};
|
||||
for (const [id, job] of Object.entries(jobs) as Array<[string, any]>) {
|
||||
const called = /^\.\/\.github\/workflows\/(.+)$/.exec(job?.uses ?? '')?.[1];
|
||||
if (!called || !(called in FILES)) continue;
|
||||
callers.push(`${name}/${id}`);
|
||||
expect([`${name}/${id}`, job.permissions?.actions]).toEqual([`${name}/${id}`, expect.stringMatching(/^(read|write)$/)]);
|
||||
}
|
||||
}
|
||||
expect(callers).toContain('free-durations-record.yml/windows');
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* scripts/ci-tree-reuse.ts (GSTA-156 P3b): the main-push consumer's candidate discovery and the aggregate's tree.json
|
||||
* producer. The GitHub API is a fake (ReuseDeps), so every refusal has a fixture and nothing here reaches GitHub.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import type { ApiJob, ApiRun, RunProfile, TreeRecord } from '../lib/run-evidence';
|
||||
import { MAX_CANDIDATES, findReuse, reuseOutputs, treeRecord, type ApiPull, type ReuseDeps } from '../scripts/ci-tree-reuse';
|
||||
|
||||
const REPO = 'garrytan/gstack';
|
||||
const PUSH = '1'.repeat(40);
|
||||
const HEAD = '2'.repeat(40);
|
||||
const MERGE = '3'.repeat(40);
|
||||
const TREE = '4'.repeat(40);
|
||||
const NOW = new Date('2026-10-30T12:00:00Z');
|
||||
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['free-plan', 'free-tests'], artifact: 'tested-tree', os: 'Linux' };
|
||||
const SCRIPT = path.resolve(import.meta.dir, '..', 'scripts', 'ci-tree-reuse.ts');
|
||||
|
||||
interface Fake { id: number; event?: string; conclusion?: string | null; status?: string; started?: string; head?: string; record?: Partial<TreeRecord> | Error; jobs?: ApiJob[] }
|
||||
interface World { pulls: ApiPull[] | Error; runs: Fake[]; trees: Record<string, string | null> }
|
||||
const pull = (over: Partial<ApiPull> = {}): ApiPull => ({ number: 42, merged_at: '2026-10-30T11:00:00Z', merge_commit_sha: PUSH, base: { ref: 'main', repo: { full_name: REPO } }, head: { sha: HEAD, repo: { full_name: REPO } }, ...over });
|
||||
function world(over: Partial<World> = {}): World {
|
||||
return { pulls: [pull()], runs: [{ id: 100 }], trees: { [PUSH]: TREE, [HEAD]: TREE, [MERGE]: TREE }, ...over };
|
||||
}
|
||||
function deps(w: World, reads: number[] = []): ReuseDeps {
|
||||
const api = (f: Fake): ApiRun => ({
|
||||
id: f.id, run_attempt: 1, status: f.status ?? 'completed', conclusion: f.conclusion === undefined ? 'success' : f.conclusion, event: f.event ?? 'workflow_dispatch', head_sha: f.head ?? HEAD,
|
||||
path: '.github/workflows/free-tests.yml', created_at: f.started ?? '2026-10-30T10:00:00Z', run_started_at: f.started ?? '2026-10-30T10:00:00Z', updated_at: '2026-10-30T10:30:00Z',
|
||||
repository: { full_name: REPO }, head_repository: { full_name: REPO },
|
||||
});
|
||||
const byId = (id: number) => { const f = w.runs.find(r => r.id === id); if (!f) throw new Error('HTTP 404'); return f; };
|
||||
return {
|
||||
pullsForCommit: () => { if (w.pulls instanceof Error) throw w.pulls; return w.pulls; },
|
||||
run: id => { reads.push(id); return api(byId(id)); },
|
||||
runsForHead: (wf, sha) => (wf === 'free-tests.yml' ? w.runs.filter(r => (r.head ?? HEAD) === sha).map(api) : []),
|
||||
jobs: id => byId(id).jobs ?? [{ name: 'free-plan', conclusion: 'success' }, { name: 'free-tests', conclusion: 'success' }],
|
||||
artifact: id => {
|
||||
const f = byId(id);
|
||||
if (f.record instanceof Error) throw f.record;
|
||||
const pr = (f.event ?? 'workflow_dispatch') === 'pull_request';
|
||||
return { run_id: id, run_attempt: 1, event: f.event ?? 'workflow_dispatch', sha: pr ? MERGE : f.head ?? HEAD, head_sha: f.head ?? HEAD, tree: TREE, os: ['Linux', 'macOS', 'Windows'], files: ['test/a.test.ts'], tests: 9, ...f.record };
|
||||
},
|
||||
commitTree: sha => w.trees[sha] ?? null,
|
||||
};
|
||||
}
|
||||
const find = (w: World, reads?: number[]) => findReuse(deps(w, reads), { repo: REPO, base: 'main', pushSha: PUSH, workflow: 'free-tests.yml', profileName: 'ci-reuse/free-tests', profile: PROFILE, now: NOW });
|
||||
const why = (w: World) => { const v = find(w); return v.reused ? 'reused' : v.reason; };
|
||||
|
||||
describe('findReuse: candidate discovery for a main push', () => {
|
||||
test('the merged pull request\'s green dispatch run of the pushed tree is reused', () => {
|
||||
expect(find(world())).toEqual({ reused: true, run_id: 100, run_attempt: 1, event: 'workflow_dispatch', pr: 42, head_sha: HEAD, tree: TREE });
|
||||
});
|
||||
|
||||
test('a green pull_request run counts when its merge commit tested the head tree, and not when the head was behind main', () => {
|
||||
expect(find(world({ runs: [{ id: 100, event: 'pull_request' }] }))).toMatchObject({ reused: true, run_id: 100, event: 'pull_request' });
|
||||
const behind = find(world({ runs: [{ id: 100, event: 'pull_request' }], trees: { [PUSH]: TREE, [HEAD]: TREE, [MERGE]: '5'.repeat(40) } }));
|
||||
expect(behind).toMatchObject({ reused: false, reason: 'no_verified_run', detail: expect.stringContaining('reason=tree_mismatch') });
|
||||
});
|
||||
|
||||
test('no pull request, an unmerged one, one merged elsewhere, another merge commit, or two candidates: no reuse', () => {
|
||||
expect(why(world({ pulls: [] }))).toBe('no_pr');
|
||||
expect(why(world({ pulls: [pull({ merged_at: null })] }))).toBe('no_pr');
|
||||
expect(why(world({ pulls: [pull({ base: { ref: 'release', repo: { full_name: REPO } } })] }))).toBe('no_pr');
|
||||
expect(why(world({ pulls: [pull({ merge_commit_sha: MERGE })] }))).toBe('no_pr');
|
||||
expect(why(world({ pulls: [pull(), pull({ number: 43 })] }))).toBe('ambiguous_pr');
|
||||
});
|
||||
|
||||
test('a fork head is never reused', () => {
|
||||
expect(why(world({ pulls: [pull({ head: { sha: HEAD, repo: { full_name: 'someone/gstack' } } })] }))).toBe('fork_head');
|
||||
expect(why(world({ pulls: [pull({ head: { sha: HEAD, repo: null } })] }))).toBe('fork_head');
|
||||
});
|
||||
|
||||
test('a pull request head whose tree is not the pushed tree is refused before any run is read', () => {
|
||||
const reads: number[] = [];
|
||||
expect(find(world({ trees: { [PUSH]: TREE, [HEAD]: '6'.repeat(40) } }), reads)).toMatchObject({ reused: false, reason: 'tree_mismatch' });
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
test('a reuse record is never evidence: a run that itself reused another run is refused', () => {
|
||||
expect(find(world({ runs: [{ id: 100, record: { reused: true } }] }))).toMatchObject({ reused: false, reason: 'no_verified_run', detail: expect.stringContaining('reason=artifact_reused') });
|
||||
expect(find(world({ runs: [{ id: 100, record: new Error('no artifact named tested-tree') }] }))).toMatchObject({ reused: false, detail: expect.stringContaining('reason=artifact_missing') });
|
||||
});
|
||||
|
||||
test('a newer red run on the head vetoes an older green one', () => {
|
||||
const v = find(world({ runs: [{ id: 100, started: '2026-10-30T10:00:00Z' }, { id: 101, conclusion: 'failure', started: '2026-10-30T10:40:00Z' }] }));
|
||||
expect(v).toMatchObject({ reused: false, reason: 'no_verified_run' });
|
||||
expect(!v.reused && v.detail).toContain('run 101 (workflow_dispatch) reason=run_not_success');
|
||||
expect(!v.reused && v.detail).toContain('run 100 (workflow_dispatch) reason=newer_run_vetoes');
|
||||
expect(why(world({ runs: [{ id: 100, started: '2026-10-30T10:00:00Z' }, { id: 101, status: 'in_progress', conclusion: null, started: '2026-10-30T10:40:00Z' }] }))).toBe('no_verified_run');
|
||||
});
|
||||
|
||||
test('push runs are never candidates, and a red candidate with no older green one is refused', () => {
|
||||
expect(why(world({ runs: [{ id: 100, event: 'push' }] }))).toBe('no_candidate');
|
||||
expect(why(world({ runs: [{ id: 100, conclusion: 'cancelled' }] }))).toBe('no_verified_run');
|
||||
expect(why(world({ runs: [{ id: 100, jobs: [{ name: 'free-plan', conclusion: 'success' }, { name: 'free-tests', conclusion: 'skipped' }] }] }))).toBe('no_verified_run');
|
||||
});
|
||||
|
||||
test('candidates are bounded: at most MAX_CANDIDATES runs are verified, newest first', () => {
|
||||
const runs: Fake[] = Array.from({ length: MAX_CANDIDATES + 5 }, (_, i) => ({ id: 200 + i, conclusion: 'failure', started: `2026-10-30T10:${String(10 + i).padStart(2, '0')}:00Z` }));
|
||||
const reads: number[] = [];
|
||||
expect(find(world({ runs }), reads)).toMatchObject({ reused: false, reason: 'no_verified_run' });
|
||||
expect(new Set(reads).size).toBe(MAX_CANDIDATES);
|
||||
expect(reads[0]).toBe(200 + MAX_CANDIDATES + 4);
|
||||
});
|
||||
|
||||
test('an API failure is a refusal, never a pass', () => {
|
||||
expect(why(world({ pulls: new Error('HTTP 403') }))).toBe('api_error');
|
||||
expect(why(world({ trees: { [HEAD]: TREE } }))).toBe('api_error');
|
||||
});
|
||||
|
||||
test('outputs: reused=true is written last with the run link; a refusal writes reused=false and its reason', () => {
|
||||
const ok = reuseOutputs({ reused: true, run_id: 100, run_attempt: 2, event: 'pull_request', pr: 42, head_sha: HEAD, tree: TREE }, REPO);
|
||||
expect(ok.output).toBe(`run_id=100\nrun_url=https://github.com/${REPO}/actions/runs/100\ntree=${TREE}\nreused=true\n`);
|
||||
expect(ok.line).toStartWith('REUSE: accepted run 100 (pull_request, attempt 2) of #42');
|
||||
expect(reuseOutputs({ reused: false, reason: 'no_pr', detail: 'x' }, REPO)).toEqual({ line: 'REUSE: refused reason=no_pr — x; every job runs', output: 'reused=false\nreason=no_pr\n' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeRecord: the tested-tree producer', () => {
|
||||
const plan = { shards: [{ files: ['test/b.test.ts', 'test/a.test.ts'] }, { files: ['test/c.test.ts'] }] };
|
||||
const results = [{ outcome: { summary: { testsRan: 7 } } }, { outcome: { summary: { testsRan: 5 } } }];
|
||||
const env = (over: Record<string, string> = {}) => ({ GITHUB_RUN_ID: '300', GITHUB_RUN_ATTEMPT: '2', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_SHA: HEAD, GITHUB_WORKFLOW_REF: `${REPO}/.github/workflows/free-tests.yml@refs/heads/x`, ...over });
|
||||
test('a dispatch or push run records its own commit as the head; a pull_request run records the PR head beside the merge commit', () => {
|
||||
expect(treeRecord(env(), { plan, results, os: ['Linux', 'macOS', 'Windows'], tree: TREE })).toEqual({ run_id: 300, run_attempt: 2, event: 'workflow_dispatch', sha: HEAD, head_sha: HEAD, tree: TREE, workflow: '.github/workflows/free-tests.yml', os: ['Linux', 'macOS', 'Windows'], files: ['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts'], tests: 12 });
|
||||
expect(treeRecord(env({ GITHUB_EVENT_NAME: 'pull_request', GITHUB_SHA: MERGE, PR_HEAD_SHA: HEAD }), { plan, results, os: ['Windows'], tree: TREE })).toMatchObject({ event: 'pull_request', sha: MERGE, head_sha: HEAD });
|
||||
expect(treeRecord(env(), { plan, results: [{ outcome: {} }], os: ['Linux'], tree: TREE }).tests).toBe(3);
|
||||
});
|
||||
test('missing or malformed inputs throw, so the aggregate fails instead of uploading a record', () => {
|
||||
expect(() => treeRecord(env({ GITHUB_EVENT_NAME: 'pull_request', GITHUB_SHA: MERGE }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/PR_HEAD_SHA/);
|
||||
expect(() => treeRecord(env({ GITHUB_RUN_ID: '' }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/GITHUB_RUN_ID/);
|
||||
expect(() => treeRecord(env({ GITHUB_WORKFLOW_REF: 'x' }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/GITHUB_WORKFLOW_REF/);
|
||||
expect(() => treeRecord(env(), { plan, results, os: ['BeOS'], tree: TREE })).toThrow(/--os/);
|
||||
expect(() => treeRecord(env(), { plan, results, os: ['Linux'], tree: '' })).toThrow(/tree id/);
|
||||
expect(() => treeRecord(env(), { plan: { shards: [] }, results, os: ['Linux'], tree: TREE })).toThrow(/no test files/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('the CLI', () => {
|
||||
const run = (args: string[], env: Record<string, string>, cwd: string) => spawnSync(process.execPath, [...bunIsolationArgs(), SCRIPT, ...args], { cwd, env: { PATH: process.env.PATH ?? '', HOME: cwd, ...env }, encoding: 'utf8', timeout: 30_000 });
|
||||
|
||||
test('find consumes only on a push to main: pull_request and dispatch runs write reused=false without reading anything', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-reuse-'));
|
||||
try {
|
||||
for (const [event, ref] of [['pull_request', 'refs/pull/1/merge'], ['workflow_dispatch', 'refs/heads/main'], ['push', 'refs/heads/feature']]) {
|
||||
const out = path.join(dir, 'out');
|
||||
fs.writeFileSync(out, '');
|
||||
const r = run(['find', '--workflow', 'free-tests.yml', '--profile', 'ci-reuse/free-tests'], { GITHUB_EVENT_NAME: event!, GITHUB_REF: ref!, GITHUB_REPOSITORY: REPO, GITHUB_SHA: PUSH, GITHUB_OUTPUT: out, PATH: '/nonexistent' }, dir);
|
||||
expect([event, r.status]).toEqual([event, 0]);
|
||||
expect(fs.readFileSync(out, 'utf8')).toBe('reused=false\nreason=not_main_push\n');
|
||||
}
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('record writes tree.json from the checkout\'s own tree and the plan', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-reuse-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gitconfig-fixture'), '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n');
|
||||
const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_CONFIG_GLOBAL: path.join(dir, '.gitconfig-fixture'), GIT_CONFIG_NOSYSTEM: '1' } }).stdout.trim();
|
||||
git('init', '-q');
|
||||
fs.writeFileSync(path.join(dir, 'f'), 'x\n');
|
||||
git('add', 'f', '.gitconfig-fixture');
|
||||
git('commit', '-qm', 'c');
|
||||
const sha = git('rev-parse', 'HEAD');
|
||||
fs.writeFileSync(path.join(dir, 'plan.json'), JSON.stringify({ shards: [{ files: ['test/a.test.ts'] }] }));
|
||||
fs.mkdirSync(path.join(dir, 'results'));
|
||||
fs.writeFileSync(path.join(dir, 'results', 'shard-1.json'), JSON.stringify({ outcome: { summary: { testsRan: 4 } } }));
|
||||
const r = run(['record', '--plan', 'plan.json', '--results', 'results', '--os', 'Linux', '--out', 'tt/tree.json'], { GITHUB_RUN_ID: '9', GITHUB_RUN_ATTEMPT: '1', GITHUB_EVENT_NAME: 'push', GITHUB_SHA: sha, GITHUB_WORKFLOW_REF: `${REPO}/.github/workflows/free-tests.yml@refs/heads/main` }, dir);
|
||||
expect([r.status, r.stderr]).toEqual([0, '']);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'tt', 'tree.json'), 'utf8'))).toMatchObject({ run_id: 9, event: 'push', sha, head_sha: sha, tree: git('rev-parse', 'HEAD^{tree}'), files: ['test/a.test.ts'], tests: 4, os: ['Linux'] });
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
@@ -408,8 +408,16 @@ describe('CSO runtime staging gates', () => {
|
||||
expect(gate['continue-on-error']).not.toBe(true);
|
||||
const required = workflow.jobs['free-tests'];
|
||||
expect(required.if).toBe('always()');
|
||||
expect(required.needs).toEqual(['free-suite', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']);
|
||||
expect(required.steps[0].run).toContain('test "$CSO_DOCKER_RESULT" = success');
|
||||
expect(required.needs).toEqual(['reuse', 'free-suite', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']);
|
||||
expect(job.if).toBe("${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}");
|
||||
const route = required.steps[0];
|
||||
expect(route.id).toBe('route');
|
||||
expect(route.env.REUSE_RESULT).toBe('${{ needs.reuse.result }}');
|
||||
expect(route.run).toContain('if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then');
|
||||
const strict = required.steps.find((step: any) => step.run?.includes('test "$CSO_DOCKER_RESULT" = success'));
|
||||
expect(strict.env.CSO_DOCKER_RESULT).toBe('${{ needs.cso-docker-integration.result }}');
|
||||
expect(strict.if).toBe("steps.route.outputs.reused != 'true'");
|
||||
expect(required.steps.indexOf(strict)).toBe(1);
|
||||
for (const current of Object.values(workflow.jobs) as any[]) for (const step of current.steps) {
|
||||
if (step.uses?.startsWith('oven-sh/setup-bun')) expect(step.uses).toBe('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6');
|
||||
}
|
||||
|
||||
+13
-13
@@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
|
||||
`NEXT: carry route` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -457,7 +458,7 @@ not web services already deployed.
|
||||
|
||||
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
|
||||
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
|
||||
|
||||
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
|
||||
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
|
||||
@@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
|
||||
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
|
||||
use the first changed component from `baseVersion` to `currentVersion`
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
|
||||
not another automatic bump.
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
|
||||
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
|
||||
Success follows ALREADY_BUMPED, including its queue check; failure stops.
|
||||
Repair alone never re-bumps.
|
||||
@@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
|
||||
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
|
||||
Wait; cancel stops before release writes or push and preserves existing work.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
|
||||
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
@@ -696,7 +695,8 @@ Classify the comparison in this order:
|
||||
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
|
||||
This repair excludes Step 14.5 because the rebuild can change generated docs.
|
||||
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
|
||||
documentation freshness. Further repairs use the same work list.
|
||||
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
|
||||
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
|
||||
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
|
||||
report and counts. Recheck affected plan items using their recorded verification
|
||||
and append current evidence to the invocation record. If a classification is no
|
||||
@@ -719,6 +719,7 @@ audit or risk decision never qualifies.
|
||||
|
||||
Report changed inputs, blockers and attempts used:
|
||||
|
||||
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
|
||||
- **An attempt remains, with changed inputs or an available repair:** insert
|
||||
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
|
||||
Validate the outcome before Step 15, then restart Step 16 stage 1.
|
||||
@@ -756,9 +757,9 @@ every allowed edit is release metadata:
|
||||
|---|---|
|
||||
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
|
||||
| STALE/MISSING: changed content, command or age, or no proven run | Run `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
|
||||
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
|
||||
No test lanes: require Step 5's explicit untested-scope approval for final content,
|
||||
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
|
||||
@@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass.
|
||||
|
||||
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
|
||||
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
|
||||
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
|
||||
keeps its existing audit count; it does not authorize a third attempt.
|
||||
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
|
||||
does not authorize a third attempt.
|
||||
|
||||
### 5. Report, then push
|
||||
|
||||
@@ -777,8 +778,7 @@ Preserve unrelated user files.
|
||||
|
||||
Paste build/docs/test results. Reuse waivers only for the same verified
|
||||
pre-existing failures and approved scope; cite the actual approval and failing
|
||||
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
|
||||
stage 4's recovery before publication.
|
||||
counts, never FRESH or all-green.
|
||||
|
||||
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
|
||||
its records:
|
||||
|
||||
+13
-13
@@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
|
||||
`NEXT: carry route` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -457,7 +458,7 @@ not web services already deployed.
|
||||
|
||||
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
|
||||
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
|
||||
|
||||
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
|
||||
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
|
||||
@@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
|
||||
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
|
||||
use the first changed component from `baseVersion` to `currentVersion`
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
|
||||
not another automatic bump.
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
|
||||
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
|
||||
Success follows ALREADY_BUMPED, including its queue check; failure stops.
|
||||
Repair alone never re-bumps.
|
||||
@@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
|
||||
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
|
||||
Wait; cancel stops before release writes or push and preserves existing work.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
|
||||
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
@@ -696,7 +695,8 @@ Classify the comparison in this order:
|
||||
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
|
||||
This repair excludes Step 14.5 because the rebuild can change generated docs.
|
||||
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
|
||||
documentation freshness. Further repairs use the same work list.
|
||||
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
|
||||
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
|
||||
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
|
||||
report and counts. Recheck affected plan items using their recorded verification
|
||||
and append current evidence to the invocation record. If a classification is no
|
||||
@@ -719,6 +719,7 @@ audit or risk decision never qualifies.
|
||||
|
||||
Report changed inputs, blockers and attempts used:
|
||||
|
||||
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
|
||||
- **An attempt remains, with changed inputs or an available repair:** insert
|
||||
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
|
||||
Validate the outcome before Step 15, then restart Step 16 stage 1.
|
||||
@@ -756,9 +757,9 @@ every allowed edit is release metadata:
|
||||
|---|---|
|
||||
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
|
||||
| STALE/MISSING: changed content, command or age, or no proven run | Run `~/.claude/skills/gstack/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
|
||||
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
|
||||
No test lanes: require Step 5's explicit untested-scope approval for final content,
|
||||
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
|
||||
@@ -766,8 +767,8 @@ Report the gap, never FRESH; builds must pass.
|
||||
|
||||
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
|
||||
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
|
||||
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
|
||||
keeps its existing audit count; it does not authorize a third attempt.
|
||||
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
|
||||
does not authorize a third attempt.
|
||||
|
||||
### 5. Report, then push
|
||||
|
||||
@@ -777,8 +778,7 @@ Preserve unrelated user files.
|
||||
|
||||
Paste build/docs/test results. Reuse waivers only for the same verified
|
||||
pre-existing failures and approved scope; cite the actual approval and failing
|
||||
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
|
||||
stage 4's recovery before publication.
|
||||
counts, never FRESH or all-green.
|
||||
|
||||
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
|
||||
its records:
|
||||
|
||||
+13
-13
@@ -334,7 +334,8 @@ act on every `STOP:` and `NEXT:` line.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
|
||||
`NEXT: carry route` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -456,7 +457,7 @@ not web services already deployed.
|
||||
|
||||
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
|
||||
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
|
||||
|
||||
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
|
||||
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
|
||||
@@ -529,8 +530,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
|
||||
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
|
||||
use the first changed component from `baseVersion` to `currentVersion`
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
|
||||
not another automatic bump.
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
|
||||
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
|
||||
Success follows ALREADY_BUMPED, including its queue check; failure stops.
|
||||
Repair alone never re-bumps.
|
||||
@@ -549,8 +549,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
|
||||
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
|
||||
Wait; cancel stops before release writes or push and preserves existing work.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
|
||||
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
@@ -698,7 +697,8 @@ Classify the comparison in this order:
|
||||
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
|
||||
This repair excludes Step 14.5 because the rebuild can change generated docs.
|
||||
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
|
||||
documentation freshness. Further repairs use the same work list.
|
||||
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
|
||||
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
|
||||
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
|
||||
report and counts. Recheck affected plan items using their recorded verification
|
||||
and append current evidence to the invocation record. If a classification is no
|
||||
@@ -721,6 +721,7 @@ audit or risk decision never qualifies.
|
||||
|
||||
Report changed inputs, blockers and attempts used:
|
||||
|
||||
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
|
||||
- **An attempt remains, with changed inputs or an available repair:** insert
|
||||
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
|
||||
Validate the outcome before Step 15, then restart Step 16 stage 1.
|
||||
@@ -759,9 +760,9 @@ $GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '<tests>' --la
|
||||
|---|---|
|
||||
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
|
||||
| STALE/MISSING: changed content, command or age, or no proven run | Run `$GSTACK_ROOT/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
|
||||
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
|
||||
No test lanes: require Step 5's explicit untested-scope approval for final content,
|
||||
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
|
||||
@@ -769,8 +770,8 @@ Report the gap, never FRESH; builds must pass.
|
||||
|
||||
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
|
||||
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
|
||||
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
|
||||
keeps its existing audit count; it does not authorize a third attempt.
|
||||
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
|
||||
does not authorize a third attempt.
|
||||
|
||||
### 5. Report, then push
|
||||
|
||||
@@ -780,8 +781,7 @@ Preserve unrelated user files.
|
||||
|
||||
Paste build/docs/test results. Reuse waivers only for the same verified
|
||||
pre-existing failures and approved scope; cite the actual approval and failing
|
||||
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
|
||||
stage 4's recovery before publication.
|
||||
counts, never FRESH or all-green.
|
||||
|
||||
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
|
||||
its records:
|
||||
|
||||
+13
-13
@@ -336,7 +336,8 @@ act on every `STOP:` and `NEXT:` line.
|
||||
`CARRY: would carry` (base merge committed, same product patch-id): skip review, fix
|
||||
what it broke (CHANGELOG, size caps), run `gstack-pregate --allow-repo-commands`, then carry
|
||||
without `--dry-run` plus `--pregate .gstack/tmp/pregate.json`. `CARRY: carried` binds HEAD and
|
||||
ends the run. A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.
|
||||
ends the run. A failed check, HEAD moving after pregate, `current`, or `refused` with no
|
||||
`NEXT: carry route` re-runs Steps 2–16.
|
||||
|
||||
5. Display historical readiness using the dashboard below, then finish Step 1.
|
||||
Prior CLEAR reviews or dashboard skips never replace Step 9's gates.
|
||||
@@ -458,7 +459,7 @@ not web services already deployed.
|
||||
|
||||
The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge).
|
||||
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight.
|
||||
**Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**: show the choices, ask via AskUserQuestion, wait for the answer before any edit; then commit and rerun the preflight.
|
||||
|
||||
**Ship policy:** `POLICY:` is `gstack-ship-policy show-effective --base <base>`
|
||||
(docs/ship-policy.md); its `ship:` lines bind Steps 12-16 and history. `RUNTIME_LINES:` goes
|
||||
@@ -531,8 +532,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
- **FRESH** → use the recorded level or choose it in item 2, then check the queue and write.
|
||||
- **ALREADY_BUMPED** → keep `NEW_VERSION=currentVersion`. If `BUMP_LEVEL` is missing,
|
||||
use the first changed component from `baseVersion` to `currentVersion`
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3,
|
||||
not another automatic bump.
|
||||
(major/minor/patch/micro; an absent fourth component is zero). Continue at item 3; never bump again.
|
||||
- **DRIFT_STALE_PKG** → run `gstack-version-bump repair`, then reclassify.
|
||||
Success follows ALREADY_BUMPED, including its queue check; failure stops.
|
||||
Repair alone never re-bumps.
|
||||
@@ -551,8 +551,7 @@ runs items 1 and 3–5; each decision left to you is a `STOP:` naming its rerun
|
||||
**MAJOR**: ask for milestones or breaking changes (`ship: bump patch` never asks).
|
||||
AskUserQuestion: recommended level with rationale, smaller level, or cancel.
|
||||
Wait; cancel stops before release writes or push and preserves existing work.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the next available number
|
||||
forward, but cannot change the chosen MICRO/PATCH/MINOR/MAJOR level.
|
||||
Save lowercase `BUMP_LEVEL`. A claimed version may move the number forward, never the level.
|
||||
|
||||
3. **Queue-aware pick:**
|
||||
```bash
|
||||
@@ -700,7 +699,8 @@ Classify the comparison in this order:
|
||||
Insert `5–11.5 → 12–14 → 16` before the pending Step 17, then stop this step.
|
||||
This repair excludes Step 14.5 because the rebuild can change generated docs.
|
||||
Step 16 restarts at stage 1: rebuild and compare again before stage 3 decides
|
||||
documentation freshness. Further repairs use the same work list.
|
||||
documentation freshness. Further repairs use the same work list. A STOP naming a scoped-review packet
|
||||
instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.
|
||||
2. **Only authored docs or release metadata changed:** Keep Step 8's original child
|
||||
report and counts. Recheck affected plan items using their recorded verification
|
||||
and append current evidence to the invocation record. If a classification is no
|
||||
@@ -723,6 +723,7 @@ audit or risk decision never qualifies.
|
||||
|
||||
Report changed inputs, blockers and attempts used:
|
||||
|
||||
- **A fix the audit's result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.
|
||||
- **An attempt remains, with changed inputs or an available repair:** insert
|
||||
`14.5 → 15 → 16` before Step 17. Use Blocked recovery with the existing count.
|
||||
Validate the outcome before Step 15, then restart Step 16 stage 1.
|
||||
@@ -761,9 +762,9 @@ $GSTACK_ROOT/bin/gstack-evidence check --label tests --expect-cmd '<tests>' --la
|
||||
|---|---|
|
||||
| FRESH (exit 0) | Cite the label, exit, timestamp, log and `tests_ran`. |
|
||||
| STALE/MISSING: changed content, command or age, or no proven run | Run `$GSTACK_ROOT/bin/gstack-evidence run --label <lane> -- '<command>'`, read the result and recheck once. Handle failures as described below. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: the lane proved nothing. Use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| ZERO-RUN: the receipt's `tests_ran` is 0 | STALE-equivalent: use Step 5's ZERO-RUN triage (a failed lane), never FRESH, even with exit 0. |
|
||||
| `tests_ran: unknown` | Non-blocking: keep the exit-0 verdict, cite the lane as `count unavailable`. Never record it as ZERO-RUN or as a counted pass. |
|
||||
| Only receipt storage/readback failed | Independently prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
| Only receipt storage/readback failed | Prove unchanged final content, the same command and valid age from the successful run's evidence. Cite its exact command, exit, timestamp and log as **ledger unavailable**, never FRESH. Without that proof, use STALE/MISSING. |
|
||||
|
||||
No test lanes: require Step 5's explicit untested-scope approval for final content,
|
||||
or run Steps 5–15, including the no-tests decision, then return to Step 16 stage 1.
|
||||
@@ -771,8 +772,8 @@ Report the gap, never FRESH; builds must pass.
|
||||
|
||||
**New, changed or unwaived test failure:** STOP publication. Run Steps 5–15,
|
||||
starting with Step 5's triage, then return to Step 16 stage 1. This recovery also
|
||||
applies if a failure appears while reporting in stage 5. Reentry to Step 14.5
|
||||
keeps its existing audit count; it does not authorize a third attempt.
|
||||
covers a failure seen in stage 5. Reentry to Step 14.5 keeps its audit count and
|
||||
does not authorize a third attempt.
|
||||
|
||||
### 5. Report, then push
|
||||
|
||||
@@ -782,8 +783,7 @@ Preserve unrelated user files.
|
||||
|
||||
Paste build/docs/test results. Reuse waivers only for the same verified
|
||||
pre-existing failures and approved scope; cite the actual approval and failing
|
||||
counts, never FRESH or all-green. A new, changed or unwaived test failure uses
|
||||
stage 4's recovery before publication.
|
||||
counts, never FRESH or all-green.
|
||||
|
||||
Then the honesty inventory, two lines, from Step 9's latest `Gate edits:` line and
|
||||
its records:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{"severity":"INFORMATIONAL","path":"CHANGELOG.md","line":5,"summary":"The reworded sentence describes /ship behaviour that gstack 1.91.358.0 does not have. Under bump never, Step 12 prints 'VERSION: <v> kept', and Step 13's Kept branch (ship/sections/changelog.md:49-51) puts an entry 'where the repo's own rule puts unit-PR notes (its CHANGELOG preamble or a fragment directory it names), else under ## [Unreleased]'. Only NO_VERSION or changelog: null skip the entry. The preamble still names no location for unit-PR notes (for example the PR body), so a lane that reads Step 13 literally still reaches the [Unreleased] fallback on its next /ship. That half of the original finding is still open. Fix: say where the notes go ('unit-PR notes go in the PR body'), or set changelog: null in ship-policy.json so Step 13 really writes nothing."}
|
||||
{"severity":"INFORMATIONAL","path":"CHANGELOG.md","line":5,"summary":"The fix leaves the original finding's fragment question open. changelog.d/gsta-30-laneb-quick-3-results.md is still in the tree, and CREW.md still says 'changelog entries go in changelog.d/ fragments'. The new sentence covers CHANGELOG.md only. docs/ship-policy.md:42 says bump never cannot be combined with changelog_fragments, so nothing folds that fragment at release, and a lane following CREW.md can keep adding fragments the preamble never mentions. Fix: name changelog.d/ in the preamble, either as banned or as folded by the Captain at release, and decide what happens to the GSTA-30 fragment."}
|
||||
|
||||
Neither finding is CRITICAL. The change is one prose line, and pregate, tests (1264), typecheck, errors and wiki all pass after the fix. I checked the claim against the installed gstack 1.91.358.0: ship/sections/changelog.md and docs/ship-policy.md.
|
||||
@@ -0,0 +1,42 @@
|
||||
The range from 6c8fcfe to HEAD (8dc6584f5) has no blocking defect in today's code paths, because nothing sets `upstream.provider` yet. It does have one cheap authentication hole and two silent-miscount risks to deal with before the provider wiring lands.
|
||||
|
||||
**Coverage:** I read the full source diff: `world/services/gbrain-tap.ts`, `world/services/server.ts`, `world/services/events.ts`, `scripts/v03/mechanism.ts`, the docs and the version files. I also read the helpers they depend on (`describeCall`, `savedFactsOf`, `grantFor`, `turnStarts`, `ingestedFacts`, `resolveActor`).
|
||||
|
||||
**Fixtures were reviewed in summary mode only.** I read the stat and test names for the two new test files, `tests/gbrain-provider-off-identity.test.ts` (+145) and `tests/gbrain-tap-provider.test.ts` (+276), not their payloads:
|
||||
- the off-path identity test replays the v0.3 pilot events and the denied batches, and checks the mechanism output is byte-identical;
|
||||
- the provider test covers recall versus query, the cap, `isError`, timeout, forged `acting_user`, `x-qm-actor`, overlapping tokens, `qmDelivery` and the mechanism counts.
|
||||
|
||||
Neither file tests an empty token or a malformed `content` entry.
|
||||
|
||||
I checked docs line truncation with `wc -L` and grep on the paragraph tails; no paragraph lost its tail. No gstack workflow ran on this review voice.
|
||||
|
||||
**Findings**
|
||||
|
||||
1. **An empty provider token skips authentication (FIXABLE, medium).** At `gbrain-tap.ts` ~376-378, the bearer regex plus `.trim()` turns `Authorization: Bearer ` followed by any whitespace into `""`. I confirmed this with bun on `"Bearer "` and `"Bearer \t"`.
|
||||
- If `provider.tokens` ever contains `""`, for example when the wiring fills it from an unset env var, any client can send a blank bearer. It skips `resolveActor`, gets logged as QM's provider, and is attributed to `provider.actor`.
|
||||
- The overlap guard at `server.ts:102` doesn't refuse empty, whitespace-only or padded tokens. Padded tokens can never match after the trim, so QM gets a 401.
|
||||
- It also doesn't check the sidecar's own tokens (`upstream.token`, `tokens.members`, `ingest.tokens`).
|
||||
- Fix: in `setGbrain`, require a non-empty list of non-empty tokens with no surrounding whitespace, and require `bearer` to be non-empty in the tap.
|
||||
|
||||
2. **A token mismatch produces a silent zero (INVESTIGATE, medium).** A provider request whose bearer isn't in the static list falls through to `resolveActor` and gets a 401 at ~379, before any event is logged. That covers two cases: a client-credentials access token refreshed mid-trial, and a capture write credential nobody listed (the docs admit the second).
|
||||
- QM swallows provider errors, so the trial goes on.
|
||||
- `mechanism --provider-aware` never sees an event, so neither `MECH-CALLER-MISSING` nor `MECH-PROVIDER-OP-UNKNOWN` fires, and the table reports "provider recall 0 of N" as a real result.
|
||||
- This needs a call when the wiring is designed: log a rejected-auth gbrain event in provider mode, and/or have the mechanism refuse a QM-provider trial whose consumer turn has no provider recall event.
|
||||
|
||||
3. **Fact delivery is judged by substring (INVESTIGATE, medium).** `qmDelivery` marks a fact delivered when `delivered.includes(f.fact)`.
|
||||
- In correction tasks, the needed and superseded facts are often near-duplicates. If the needed text is a prefix or substring of the superseded one ("Launch moved to Friday" inside "Launch moved to Friday at 3pm" returns true), the needed fact counts as delivered when the cap kept only the superseded row.
|
||||
- Short facts also match unrelated text.
|
||||
- This inflates `provider_delivered_needed`, `search delivered it` and `model read delivered it` silently, and the correction tasks are where this table matters most.
|
||||
- Fix: match on row boundaries (the `Saved fact (...): <fact>` line or the row's end) rather than a bare substring.
|
||||
|
||||
4. **A malformed result now crashes the request (FIXABLE, low).** `qmDelivery` reads `c.type` on each `result.content` entry. A `null` entry throws (confirmed: "null is not an object").
|
||||
- `tagged` runs inside the logging loop after upstream has already answered. So in provider mode, a reply that used to pass through becomes an uncaught exception: the harness gets a 500 instead of gbrain's reply, and the rest of the batch goes unlogged.
|
||||
- Fix: guard with `c && c.type === 'text'`.
|
||||
|
||||
5. **Timeout drops lean towards "delivered" (INVESTIGATE, low).** `dropped: 'timeout'` compares the tap-side batch `latency_ms`, which is a lower bound on QM's session time, with `timeout_ms`. Calls just under the threshold at the tap but over it at QM count as delivered. The docs disclose this, but the bias only ever goes towards "delivered", so the report should state that direction.
|
||||
|
||||
6. **The trust premise is unverified (INVESTIGATE, low).** The design depends on the model never seeing QM core's client credentials in `MEMORY_PROVIDER_CONFIG`. If QM gives the model any shell, file or env tool inside the container that holds that config, the model can present the provider token, and its own calls get tagged `provider`. This needs checking against QM b6786d67's tool set before a paid cell.
|
||||
|
||||
7. **Nit (FIXABLE).** In the model-call branch, `JSON.stringify(structured ?? '')` records an empty result as `text_chars` and `delivered_chars` of 2 (the string `""`). The mechanism doesn't read it, but the event data is wrong.
|
||||
|
||||
Recommendation: Fix the empty or whitespace provider-token acceptance at world/services/server.ts:102 and gbrain-tap.ts ~376 before merge, because a blank token in `provider.tokens` would let any client sending a blank bearer skip `resolveActor` and be recorded as QM's provider; the fix is a two-line validation, and findings 2 and 3 can be settled with the wiring since nothing turns provider mode on yet.
|
||||
@@ -0,0 +1,17 @@
|
||||
The fixes are sound. None of them breaks a caller, opens or closes a guard wrongly, or changes behaviour with the option off, so there's nothing at CRITICAL. I have two INFORMATIONAL findings. The first is the one worth acting on: the tap half of the blank-bearer test never reaches the fixed line.
|
||||
|
||||
```
|
||||
{"severity":"INFORMATIONAL","path":"tests/gbrain-tap-provider.test.ts","line":299,"summary":"The tap half of the blank-bearer test can't catch the bug it targets. Header normalization turns 'Bearer ' + ' ' into \"Bearer\", so the regex gives a null bearer and the old line (bearer !== null) also answers 401. I put the old line back in a scratch copy and the test still passed. An input that does reach the fix is 'Bearer \\u00a0' with tokens [''], because HTTP header normalization keeps U+00A0 while trim() strips it. With that input the old line authenticates the request as the provider, and the fixed line answers 401 (checked both ways in a scratch copy). The setGbrain half of the test does pin the server guard."}
|
||||
{"severity":"INFORMATIONAL","path":"world/services/gbrain-tap.ts","line":404,"summary":"Fix 3 covers provider calls only. A model call whose HTTP status is not 2xx but whose body carries a JSON-RPC result still gets qmDelivery(result) with facts set to true. trialProviderMechanism (scripts/v03/mechanism.ts:117 on) counts model_returned and model_delivered without checking the call's outcome, so a model call QM's client would have thrown on counts as delivering the needed fact. This was already there before these commits, and the input is rare: gbrain would have to answer 500 with a result body."}
|
||||
```
|
||||
|
||||
**What I checked:**
|
||||
- **Blank bearer, fix 1:** `!!bearer` changes only the empty-string case. In the model path, `resolveActor` is unchanged. The `setGbrain` guard throws before `gbrain = u`, so a refused upstream leaves no partial state. The trial.ts:194 caller never sets `provider`, so it never reaches the new check.
|
||||
- **Dropped calls, fix 2:** The `delivery?.dropped` skip comes after the `provider_searches++` count, so a dropped search still counts as a search. A dropped recall already had `delivered_chars` 0. Only the provider path ever sets `dropped`, so model calls aren't affected.
|
||||
- **Failed calls, fix 3:** `ok` is `d.outcome === 'ok'` at both call sites, the all-denied branch included. Scope denials already came back as an `isError` result, so they were tool_error before this change as well. Without `provider`, `tagged` still returns `{}`, so events with the option off are unchanged.
|
||||
- **Null content entry, fix 4:** `c?.type` short-circuits before `c.text` is read. The structuredContent paths don't iterate `content`.
|
||||
- **Tests for fixes 2 to 5:** I traced the tests for fixes 2 to 4 and the code before these commits would fail each one. For the per-row test (fix 5), I worked through the sums: `provider_searches` is 2 from t2's two calls without `max_chars`, `model_calls` is 2 from the search plus the remember, and `provider_recalls` is 2, all from t1.
|
||||
|
||||
I didn't rerun the full suite. The 41 passing tests and the clean tsc run are your results; I ran only the blank-bearer test and a scratch U+00A0 test, both in a scratch copy I've since removed. Your checkout is untouched.
|
||||
|
||||
gstack: ok 1.91.346.0 (`--check` only, no workflow run; 2 behind latest 1.91.348.0)
|
||||
@@ -0,0 +1,27 @@
|
||||
I found six problems in the range 2ae5923..HEAD (cdce7c76d) and none of them blocks. The one to fix before merge is the sandbox adoption at `qm-stack.ts:106`. When a sandbox create call fails, it can delete containers and volumes that belong to other QM stacks on the same host. The fix is one narrower filter.
|
||||
|
||||
gstack: ok 1.91.366.0 require=claude,codex,patch (pins: skipped (no project); behind=3 (latest: 1.91.369.0)). That is the result of the `--check` run on this shared machine, which is above the 1.91.364.0 floor. This review did not run any gstack workflow.
|
||||
|
||||
**Scope.** I read in full all non-fixture source that changed: `harnesses/qm/gstack/{qm-stack,s1-stage,smoke}.ts`, the gstack `README.md` and `CHANGELOG.md`. I reviewed the fixtures and tests in **summary mode only**. `tests/qm-gstack-smoke.test.ts` gained 39 lines and four tests: a successful import after a failed register reports no refusal, `lockHost` refuses a lock held by a live process and takes over a dead one, `receiptOut` keeps the committed receipt only for its own gstack ref, and a transport failure comes back as http 0 instead of throwing. I did not pull any payload bytes. With a scrubbed environment, `bun test tests/qm-gstack-smoke.test.ts` passed 16 of 16 and `bunx tsc --noEmit` was clean.
|
||||
|
||||
**Findings**
|
||||
|
||||
1. **A failed sandbox create adopts sandboxes from other stacks** (`qm-stack.ts:106-108`, `145-149`). `sandboxNames()` lists every container on the host with `label=qm.sandbox=1`. When the create call fails, or is cut off by `qmClient`'s 300 s timeout, `adoptNew()` takes ownership of every such container that appeared during that window. `down()` then runs `docker rm -f` on each one, plus `volume rm -f qm-home-*` and `network rm`. A live trial's QM stack uses the same label (`capture.sh:20`), so one failed s1 create during a trial deletes that trial's sandbox and its home volume. The lock does not help, because it only excludes other qm-gstack runs. The interface comment and the new README line ("never another stack's") claim more than the code does. Fix: add `--filter ancestor=<sandboxImage>` to the adoption query. s1's image `harness-eval/qm-sandbox:gstack-<ref8>` is unique to it, and the project lock already rules out a second s1. Alternatively, filter on whatever label QM puts on sandboxes to tie them to their core. FIXABLE. Severity: Medium. Blocks: no, because it needs a concurrent QM stack, which the README already says not to run.
|
||||
|
||||
2. **One bad container name skips the whole teardown** (`qm-stack.ts:105`, `108`, `123`). `own()` throws for any name that is not `qm-sbx-*`. `adoptNew()` calls it inside `down()`'s `finally` before `removeSandboxes()`, `dc down` and `unlock()`. One labelled container with an unexpected name aborts all three steps. Because `stopping` is already true, nothing retries, so pg, core and the owned sandboxes stay running. The same throw inside `createSandbox`'s catch leaves `creating` set, so the same failure repeats at teardown. Fix: skip names that don't match in `adoptNew` instead of throwing, or put each teardown step in its own `try`. FIXABLE. Severity: Low. Blocks: no.
|
||||
|
||||
3. **A sandbox created after the call is cut off leaks** (`qm-stack.ts:108`, `147`). If the client times out while QM is still creating the container, `adoptNew()` takes its snapshot and sets `creating = null` before the container exists. Nothing tracks it afterwards, and `compose down --remove-orphans` doesn't remove it either, because core created it through the Docker socket and not through compose. Fix: keep the pre-create baseline until `down()`, and diff against it after `dc down` has stopped core. FIXABLE. Severity: Low. Blocks: no.
|
||||
|
||||
4. **The host lock is not atomic** (`qm-stack.ts:66-76`). The lock code reads the file, checks the pid and then writes, with no `O_EXCL`. Two runs launched close together can both take the lock. The second run's opening `dc down -v --remove-orphans` then removes the first run's stack, which is the exact case the lock exists to stop. `release()` also deletes the file without checking it still holds its own pid, so the run that lost the race clears the winner's lock. Two smaller gaps:
|
||||
- The lock directory comes from `tmpdir()`, so a run with `TMPDIR` set and one without lock in different places.
|
||||
- A reused pid makes a dead lock look alive.
|
||||
|
||||
Fix: create the file with `writeFileSync(path, pid, { flag: 'wx' })`. On `EEXIST`, check whether the holder is alive, then unlink and retry `wx` once. Release only if the file still holds this process's pid. Use a fixed directory. FIXABLE. Severity: Low. Blocks: no, since this is a manual tool.
|
||||
|
||||
5. **A failed s1 run still overwrites the committed router file** (`s1-stage.ts:104`, `117`). The receipt now goes to `--work` on failure, but `routerOut` is still derived from `out` and written at line 117, before the stack runs. At the committed ref, a failed or aborted run overwrites `receipts/s1-router-21277291.md`. The content comes from `gstack-harness ... --print` run under the caller's `HOME`, so it may not be byte-identical, and the committed receipt's `router_sha256` would then no longer match the file. Fix: decide the destination for the router the same way as for the receipt, or write it after `ok` is known. FIXABLE. Severity: Low. Blocks: no.
|
||||
|
||||
6. **`receiptOut` treats an unreadable receipt as permission to overwrite it** (`qm-stack.ts:156-160`). A committed receipt that is missing, has bad JSON, or lacks `gstack.ref` gives `committedRef === null`. Any default run at any ref then overwrites the committed path. Today both committed receipts carry the full sha `21277291c850...` under `gstack.ref`, which matches the newly normalised `ref`, so this is latent. INVESTIGATE: decide whether a parse failure should go to `work` instead. Severity: Low. Blocks: no.
|
||||
|
||||
I checked these and found them sound: the lock now runs before the opening `compose down`; the signed `whoami` check (the committed smoke receipt shows `/v1/admin/whoami` answers 200 at this QM pin); running `prepare.sh` before the lock (it only creates directories and prints values); `refusal` being null after a successful import; `--ref` now resolving to a full commit sha; and the CHANGELOG count (61 skills plus `connect-chrome` plus the root file makes 63).
|
||||
|
||||
Recommendation: Fix the sandbox adoption filter at qm-stack.ts:106 before merge because a failed s1 create runs `docker rm -f` and `volume rm -f` on every new `qm.sandbox=1` container on the host, including a live trial's; the change is one filter and everything else can ship as-is.
|
||||
@@ -0,0 +1,12 @@
|
||||
None of the fixes blocks landing; I found four INFORMATIONAL defects and no CRITICAL. Three are in the one-run lock and sandbox-ownership fixes (3 and 6), and one is in the receipt-routing fix (9).
|
||||
|
||||
Checks I ran:
|
||||
- **Whoami (fix 5):** the endpoint exists. The committed smoke receipt shows `GET /v1/admin/whoami` at http 200 as `org_admin`.
|
||||
- **Sandbox names (fix 6):** QM's `machineId` really is `qm-sbx-…`. The committed s1 receipt has `qm-sbx-sandbox-949f45aa-…-1b711b`, so `own()` accepts the success path.
|
||||
- **Image pin check (fix 7):** passing `QM_SANDBOX_IMAGE=harness-eval/qm-sandbox:gstack-<ref8>` to `prepare.sh` doesn't break s1. `Dockerfile.skills` builds `FROM qm-sandbox:dev`, so the image inherits the `harness-eval.commit` label.
|
||||
- **Receipt ref match (fixes 8 and 9):** both committed receipts record the full 40-character `gstack.ref`, so the full sha from `--verify` matches them.
|
||||
|
||||
{"severity":"INFORMATIONAL","path":"harnesses/qm/gstack/qm-stack.ts","line":108,"summary":"adoptNew() owns every qm.sandbox=1 container that appeared while create() ran. When the create call fails or returns no machineId (http 0, a 5xx, or a body without machineId), teardown runs `docker rm -f` and removes the qm-home-/qm-net- volume and network of any sandbox a concurrent trial, or a second stack on other ports, created in that window. The lock error invites a second stack on other ports, and README and the Stack doc comment still promise 'never another stack's'."}
|
||||
{"severity":"INFORMATIONAL","path":"harnesses/qm/gstack/qm-stack.ts","line":123,"summary":"adoptNew() runs inside down()'s finally and calls own(), which throws for any qm.sandbox=1 container not named qm-sbx-*. If that happens during a cut-off createSandbox (SIGINT mid-call), removeSandboxes, compose down, unlock and the handler removal are all skipped. pg and core are left running and the lock file stays behind, and the next run refuses until it finds the dead pid. `creating` is also never reset, so later down() calls are no-ops because `stopping` is already true."}
|
||||
{"severity":"INFORMATIONAL","path":"harnesses/qm/gstack/s1-stage.ts","line":114,"summary":"The one-run lock is taken inside upStack, after main has already mutated the shared --work directory. A second s1 run with the same --work is refused only after stage() has run rmSync on <work>/context and docker build has re-tagged harness-eval/qm-sandbox:gstack-<ref8>. The live run then reads its staged texts from <work>/context (stagedText) and can hit ENOENT or rewritten files, and its receipt records the new image id. smoke.ts:114 likewise truncates the live run's fake-requests.ndjson before it is refused. This contradicts README's 'refuses a second run while the first is alive'."}
|
||||
{"severity":"INFORMATIONAL","path":"harnesses/qm/gstack/s1-stage.ts","line":117,"summary":"Fix 9 sends a failed default run's receipt into --work, but routerOut is still computed beside `out` and written before the outcome is known. A failed default s1 run at the committed ref therefore overwrites receipts/s1-router-<ref8>.md in the repository, and its --work receipt's gstack.router_file points at that committed file."}
|
||||
@@ -0,0 +1,13 @@
|
||||
I found three INFORMATIONAL issues and nothing CRITICAL. All three are in the fix commit `cdce7c76d`, which fixes the earlier findings. The new code behind each one is the lock, the sandbox adoption and the receipt-overwrite gate.
|
||||
|
||||
**Fixture coverage was reduced.** I read `tests/qm-gstack-smoke.test.ts` in summary mode only (+39/-1). It adds four tests: the outcome success path, `lockHost`, `receiptOut`, and the http 0 transport failure. No test covers `createSandbox`, `adoptNew` or the teardown order in `down()`.
|
||||
|
||||
**Where the QM facts come from.** QM's container naming and labels come from the QM source copy at `~/.capy/work/local-sandbox.ts`. I assumed it matches the b6786d67 pin.
|
||||
|
||||
**gstack check on this machine:** `gstack: ok 1.91.366.0 require=claude,codex,patch` (3 behind the latest, 1.91.369.0; above the 1.91.364.0 floor).
|
||||
|
||||
```json
|
||||
{"severity":"INFORMATIONAL","confidence":7,"path":"harnesses/qm/gstack/qm-stack.ts","line":108,"category":"red-team","summary":"adoptNew lists qm.sandbox=1 containers host-wide with no qm.org or qm.scope filter, and calls own(), which throws on any name outside qm-sbx-*. QM's scratch sandboxes (local-sandbox.ts:314-323, runContainer(qm-scratch-<key>, undefined, false)) carry the same qm.sandbox=1 label. Failure 1: if any qm-scratch-* container appears on the host while a create call fails or returns no machineId, own() throws partway through the loop and `creating` is never reset. down() then calls adoptNew() first in its finally, so it throws again and skips removeSandboxes, compose down, unlock and the signal-handler removal. That brings back the teardown bypass outside-voice#3 reported, now triggered by another process's container. Failure 2: a live trial's freshly created qm-sbx-* sandbox (also org harness-eval) that appears in the same window, up to the 300 s client timeout, is adopted and removed with docker rm -f, its qm-home volume and its qm-net network. This contradicts the updated README line 'never another stack's' and brings back a narrower version of the label sweep that was dropped to protect live trials.","fix":"Narrow the diff to this call: filter on label=qm.org=harness-eval plus label=qm.scope=<the scope being created>. QM names the container deterministically from the scope (localContainerName = qm-sbx-<slug(scopeId)>), so an exact name or scope match is possible. In adoptNew, skip names that fail the regex instead of throwing, and reset `creating` in a finally. In down(), wrap adoptNew in its own try so removeSandboxes, compose down and unlock always run. Add a fake-docker test where a qm-scratch-* name appears during a failed create, and assert that compose down still runs.","fingerprint":"harnesses/qm/gstack/qm-stack.ts:108:red-team","specialist":"red-team"}
|
||||
{"severity":"INFORMATIONAL","confidence":7,"path":"harnesses/qm/gstack/qm-stack.ts","line":102,"category":"red-team","summary":"The host lock is taken inside upStack, after the caller has already modified the shared default --work directory. Both scripts default to fixed work directories (~/.capy/work/qm-gstack-smoke and qm-gstack-s1). Before reaching the lock, a second run truncates fake-requests.ndjson (smoke.ts:114, s1-stage.ts:135). s1 has also already run stage(), which does rmSync(work/context) and rewrites it (s1-stage.ts:114), rewritten the router, and re-tagged the overlay image. The run that holds the lock then reads the clobbered state after its turn. smoke's fake_model_requests counts lines in the truncated log. s1's stagedText() reads work/context/gstack/..., which is missing or from another ref if the second run failed after its rmSync, so the first run crashes with no receipt or records the wrong comparison. The lock only refuses the second run after this damage is done. The lock itself is also not atomic: it reads, checks liveness and then writes without O_EXCL, so two runs started together can both take it. It also lives under tmpdir(), so runs launched with different TMPDIR values (env -i or not) never see each other's locks.","fix":"Take the lock at the top of main(), before touching --work, and key it on the resolved work directory as well as the project and ports. Create each lock file with writeFileSync(path, pid, { flag: 'wx' }), and take over a dead owner's lock only through unlink-and-retry. Use a fixed lock directory such as /tmp or the work root, not tmpdir().","fingerprint":"harnesses/qm/gstack/qm-stack.ts:102:red-team","specialist":"red-team"}
|
||||
{"severity":"INFORMATIONAL","confidence":6,"path":"harnesses/qm/gstack/qm-stack.ts","line":156,"category":"red-team","summary":"receiptOut lets a default run overwrite the committed receipt whenever the gstack ref matches, and nothing else is checked. The smoke's success gate is only result.registered (smoke.ts:154). Case 1: `smoke.ts --core-url ... --secret-file ...` at 21277291 replaces receipts/smoke-b6786d67.json with qm.commit null and an unchecked external core, while the file name and CHANGELOG still claim QM b6786d67. Case 2: a default run at the same ref where core can't reach GitHub, or QM's refusal changes, still registers the pack. It then rewrites the published refusal_kind bytes_cap receipt as clone_timeout or other and exits 0, so the README and CHANGELOG cite numbers the committed receipt no longer shows. s1 has the same gap: `ok` checks only the complete read, so a run whose over-cap read (the cited 16,384 cut) fails or changes still overwrites. Also, if pin.json later moves QM, the b6786d67-named file is overwritten with a receipt from the new QM build.","fix":"Overwrite the committed path only when the run's qm.commit equals the pinned commit (refuse when it is null, as with --core-url) and the outcome fields the docs cite (smoke: refusal_kind and refusal; s1: complete_read.identical and over_cap_read.cut) match the committed receipt. Otherwise write to <work>/ and print the difference. Or simply require an explicit --out to touch the committed receipts.","fingerprint":"harnesses/qm/gstack/qm-stack.ts:156:red-team","specialist":"red-team"}
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
Red-team pass is done: I found three INFORMATIONAL issues and no CRITICAL ones. Both earlier specialist findings are already fixed in the working tree. `outcome()` now sets refusal to null when the import succeeds, and both suggested tests are in `tests/qm-gstack-smoke.test.ts`. That file passes 14/14 under `env -i`.
|
||||
|
||||
**Coverage note:** the one test file (`tests/qm-gstack-smoke.test.ts`, +175, 14 tests) was reviewed in summary mode only. It covers request signing, the `upStack` dummy-key refusal, the smoke call sequence against a fake QM, refusal classification, pack sizing and the S1 staging helpers. All other changed files were read in full. Those are `qm-stack.ts`, `smoke.ts`, `s1-stage.ts`, `Dockerfile.skills`, both READMEs, the receipts, CHANGELOG, VERSION and package.json. I also checked `compose.yaml`, `prepare.sh`, `capture.sh` and `fake-anthropic.ts` at the integration boundaries. The only `sk-` token in the receipts is the `sk-ant-capture-dummy` prefix.
|
||||
|
||||
```
|
||||
{"severity":"INFORMATIONAL","confidence":8,"path":"harnesses/qm/gstack/s1-stage.ts","line":102,"category":"red-team","summary":"If you rerun either script the way the README says, it overwrites the committed receipt with a different gstack commit, even when the run fails. --ref defaults to the checkout HEAD, but --out is a fixed repository path named only after the QM pin (smoke.ts:105, s1-stage.ts:102). Both scripts write the receipt unconditionally (smoke.ts:154, and s1-stage.ts:200 before the ok check). This machine's ~/.local/share/gstack is already at 12dc6782 (1.91.366.0), not the receipt's 21277291 (1.91.345.0). A default rerun today rewrites s1-staging-b6786d67.json against new gstack, adds s1-router-12dc6782.md next to it, and leaves the README's 4,727 / 29,581 / 17-of-63 / 12,186 figures pointing at a receipt that no longer shows them. To reproduce the published receipt, s1-stage's own refusal says to check out 21277291 in the live gstack install, which puts that machine below the crew's 1.91.364.0 floor.","fix":"Default --ref to the pinned 21277291 commit, and tell users in the README and in the refusal message to pass --gstack pointing at a separate `git worktree add <dir> 21277291` instead of checking out the live install. Put the gstack ref in the receipt file name, or refuse to overwrite an existing --out whose gstack.ref differs. On failure, write the receipt to <work>/ rather than to the committed path.","fingerprint":"harnesses/qm/gstack/s1-stage.ts:102:red-team","specialist":"red-team"}
|
||||
{"severity":"INFORMATIONAL","confidence":7,"path":"harnesses/qm/gstack/s1-stage.ts","line":56,"category":"red-team","summary":"The router gstack-harness prints has a defect that the fixture hides: it lists `open-gstack-browser` twice. One line points at `connect-chrome/SKILL.md`, a symlink that only gstack's setup creates and that is not in git. stage() follows the symlink, so the fixture serves that path, but a QM deployment built from the gstack tree would return `[no such file` there. Under the README's counting rule, that is a 'fixture fault, rerun', so a real router defect would never be counted. The duplicate also inflates the published counts. The receipt's router_skills=62 and the CHANGELOG/README '62 router skills' are 61 distinct skills plus one alias (the manifest has connect-chrome and open-gstack-browser with the same sha256 2dd3227c..., 18,560 chars). The 63 staged files hold the same text twice. The 17 that fit inline is unaffected, because both copies are over the cap.","fix":"Report it to GSTA-36 as a gstack finding: `register generic --print` emits a setup-only alias path under another skill's name. In the fixture, mark the alias in the manifest (alias_of: open-gstack-browser) and state the counts as 61 distinct skills plus 1 alias, or drop the alias line from the staged router. Count a request that routes to connect-chrome as a router defect, not a fixture fault.","fingerprint":"harnesses/qm/gstack/s1-stage.ts:56:red-team","specialist":"red-team"}
|
||||
{"severity":"INFORMATIONAL","confidence":4,"path":"harnesses/qm/gstack/qm-stack.ts","line":107,"category":"red-team","summary":"The health check cannot tell this run's core from another process on the same port. Core runs with network_mode: host (compose.yaml) and only pg publishes a port. If another process already holds 127.0.0.1:58080 and 55432 is free, `compose up -d` still exits 0, because depends_on waits only for pg. This run's core then fails to bind, and /healthz is answered by whatever holds the port. Every signed call goes to that foreign service and gets refused or garbled answers, and with the default --out (finding 1) that bad receipt replaces the committed one. The trial rig uses the same default port pair.","fix":"After the health check passes, call the signed GET /v1/admin/whoami and confirm a 200 before continuing. Or check that `docker compose ps core` is running and was not restarted.","fingerprint":"harnesses/qm/gstack/qm-stack.ts:107:red-team","specialist":"red-team"}
|
||||
```
|
||||
@@ -46,7 +46,7 @@ test('Linux records on the free-suite runner image, turns red on any red file, a
|
||||
});
|
||||
|
||||
test('Windows records through the windows-free-tests record_durations path, and propose needs both complete recordings', () => {
|
||||
expect(recorder.jobs.windows).toEqual({ uses: './.github/workflows/windows-free-tests.yml', with: { record_durations: true } });
|
||||
expect(recorder.jobs.windows).toEqual({ uses: './.github/workflows/windows-free-tests.yml', permissions: { contents: 'read', actions: 'read' }, with: { record_durations: true } });
|
||||
expect(recorder.jobs.propose.needs).toEqual(['linux', 'windows']);
|
||||
expect(recorder.jobs.propose.if).toBe('${{ !cancelled() }}');
|
||||
const downloads = recorder.jobs.propose.steps.filter((step: any) => step.uses?.startsWith('actions/download-artifact@'));
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('free-tests workflow wiring', () => {
|
||||
const suite = workflow.jobs['free-suite'];
|
||||
const aggregate = workflow.jobs['free-tests'];
|
||||
expect(planner.steps.find((step: any) => step.id === 'plan').run).toContain('--ci-plan');
|
||||
expect(suite.needs).toBe('free-plan');
|
||||
expect(suite.needs).toEqual(['free-plan', 'reuse']);
|
||||
expect(suite.strategy.matrix).toBe('${{ fromJSON(needs.free-plan.outputs.matrix) }}');
|
||||
expect(suite.strategy['fail-fast']).toBe(false);
|
||||
expect(suite.strategy['max-parallel']).toBe(20);
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface Rule {
|
||||
export interface ShimData {
|
||||
repo: string; origin: string; nextId: number; prs: Record<string, unknown>; runs: Array<Record<string, unknown>>;
|
||||
artifacts: Record<string, Record<string, unknown>>; checks: Record<string, Array<{ name: string; status: string; conclusion: string | null }>>;
|
||||
ci: Record<string, unknown[]>; rules: Record<string, Rule>; artifact: string; log: Array<Record<string, unknown>>;
|
||||
ci: Record<string, unknown[]>; ciFail?: string[]; rules: Record<string, Rule>; artifact: string; log: Array<Record<string, unknown>>;
|
||||
/** Commit statuses (the legacy status API) at a sha, beside the check runs in `ci`. */
|
||||
statuses?: Record<string, Array<{ state: string; context: string }>>;
|
||||
/** Per PR, how many more `gh pr view` calls answer before the PR reads as not found (a GitHub answer that changes between two reads). */
|
||||
@@ -206,6 +206,7 @@ function shimSource(): string {
|
||||
" const named = /commits\\/([0-9a-f]+)\\/check-runs\\?check_name=([^&]+)/.exec(a[1] || '');",
|
||||
" if (named) { const list = (d.checks[named[1]] || []).filter(c => c.name === decodeURIComponent(named[2])); out({ total_count: list.length, check_runs: list }); }",
|
||||
" const paged = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || '');",
|
||||
" if (paged && (d.ciFail || []).includes(paged[1])) fail('HTTP 502: gateway timeout');",
|
||||
" if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : ((d.statuses || {})[paged[1]] || []); const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';",
|
||||
" out(paged[3] === '1' ? { total_count: list.length, state: 'success', [key]: list } : { total_count: list.length, [key]: [] }); }",
|
||||
"}",
|
||||
|
||||
@@ -39,8 +39,8 @@ export function fakeVm(opts: { listed?: string; refuse?: string } = {}) {
|
||||
'case "$body" in\n' +
|
||||
' *\'"create"\'*) if [ -n ' + JSON.stringify(opts.refuse ?? '') + ' ]; then printf \'%s\' ' + JSON.stringify(opts.refuse ?? '') + ' > "$out"; printf 400; else echo "$loc $name" >> "$listed"; : > "$out"; printf 200; fi ;;\n' +
|
||||
' *destroy*) : > "$out"; if [ -f "$listed.destroy-fails" ]; then printf 500; else grep -v " $name$" "$listed" > "$listed.tmp"; mv "$listed.tmp" "$listed"; printf 200; fi ;;\n' +
|
||||
' *\'"list"\'*) if [ -f "$listed.list-fails" ]; then echo down > "$out"; printf 503; else cat "$listed" > "$out"; printf 200; fi ;;\n' +
|
||||
' *\'"show"\'*) [ -z "${FAKE_SHOW_DELAY:-}" ] || sleep "$FAKE_SHOW_DELAY"; if grep -q " $name$" "$listed"; then printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;\n' +
|
||||
' *\'"list"\'*) if [ -f "$listed.list-fails" ]; then echo down > "$out"; printf 503; else cat "$listed" "$listed.ghost" > "$out" 2>/dev/null; printf 200; fi ;;\n' +
|
||||
' *\'"show"\'*) [ -z "${FAKE_SHOW_DELAY:-}" ] || sleep "$FAKE_SHOW_DELAY"; if [ -f "$listed.show-fails" ]; then echo down > "$out"; printf 503; elif grep -q " $name$" "$listed"; then printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;\n' +
|
||||
' *) : > "$out"; printf 200 ;;\n' +
|
||||
'esac\n');
|
||||
writeFileSync(join(bin, 'ssh'), '#!/usr/bin/env bash\n' +
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* gstack-pregate --stage tests --from-run <run> (GSTA-156 P3b): the selected local test files a verified Free Tests
|
||||
* run on this exact tree lists count as passed and are recorded with its receipt; the rest still run. A refused run
|
||||
* prints its REUSE: refused line and runs everything. The run API is a fake; trees come from the fixture repository.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, type PregateFile } from '../lib/headless-artifacts';
|
||||
import { buildContext } from '../lib/pregate/context';
|
||||
import { readPregateFile, runPregate, writePregateFile } from '../lib/pregate/index';
|
||||
import type { ApiRun, RunProfile, RunSource } from '../lib/run-evidence';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const NOW = new Date('2026-10-30T12:00:00Z');
|
||||
const LOCAL_OS = process.platform === 'darwin' ? 'macOS' : 'Linux';
|
||||
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['unit'], artifact: 'tested-tree', os: LOCAL_OS };
|
||||
const repos: FixtureRepo[] = [];
|
||||
const savedRoot = process.env.GSTACK_STATE_ROOT;
|
||||
afterEach(() => {
|
||||
while (repos.length) cleanup(repos.pop()!);
|
||||
if (savedRoot === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = savedRoot;
|
||||
});
|
||||
|
||||
function fixture(): { repo: FixtureRepo; parent: string; head: string } {
|
||||
const repo = makeFixtureRepo();
|
||||
repos.push(repo);
|
||||
process.env.GSTACK_STATE_ROOT = repo.home;
|
||||
const parent = repo.git('rev-parse', 'HEAD');
|
||||
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n', 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = true;\n' }, 'touch two subjects');
|
||||
return { repo, parent, head: repo.git('rev-parse', 'HEAD') };
|
||||
}
|
||||
|
||||
function source(repo: FixtureRepo, runHead: string, files: string[], profiles: Record<string, RunProfile> = { 'ci-reuse/free-tests': PROFILE }): RunSource {
|
||||
const api: ApiRun = { id: 100, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: runHead, path: '.github/workflows/free-tests.yml', created_at: '2026-10-30T10:00:00Z', updated_at: '2026-10-30T10:30:00Z', repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
|
||||
return {
|
||||
repo: 'fx/fx', profiles, now: NOW,
|
||||
deps: {
|
||||
run: () => api, runsForHead: (_wf, sha) => (sha === runHead ? [api] : []), jobs: () => [{ name: 'unit', conclusion: 'success' }],
|
||||
artifact: () => ({ run_id: 100, run_attempt: 1, event: 'workflow_dispatch', sha: runHead, head_sha: runHead, tree: repo.git('rev-parse', `${runHead}^{tree}`), os: [LOCAL_OS], files, tests: files.length * 4 }),
|
||||
commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function lanes(repo: FixtureRepo, fromRun?: { ref: string; check: RunSource | { error: string } }) {
|
||||
const progress: string[] = [];
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: l => progress.push(l), cores: 16, testBackend: 'local' });
|
||||
if (!('ctx' in loaded)) throw new Error('fixture context refused');
|
||||
if (fromRun) loaded.ctx.fromRun = fromRun;
|
||||
const file = await runPregate(loaded.ctx, { stage: 'tests' });
|
||||
return { file, check: file.checks.find(c => c.id === 'lanes')!, progress };
|
||||
}
|
||||
const ranLine = (c: PregateFile['checks'][number]) => (c.lines ?? []).find(l => l.startsWith('ran: ')) ?? '';
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('--from-run: a verified Free Tests run covers part of the tests stage', () => {
|
||||
test('without --from-run every selected file runs (the baseline)', async () => {
|
||||
const { repo } = fixture();
|
||||
const { check } = await lanes(repo);
|
||||
expect(check.status).toBe('pass');
|
||||
expect(ranLine(check)).toContain('test/util.test.js');
|
||||
expect(ranLine(check)).toContain('test/retry.test.js');
|
||||
expect(check.from_run).toBeUndefined();
|
||||
}, 120_000);
|
||||
|
||||
test('the files the run lists count as passed and are recorded with its receipt; the rest still run, and pregate.json validates', async () => {
|
||||
const { repo, head } = fixture();
|
||||
const { file, check, progress } = await lanes(repo, { ref: '100', check: source(repo, head, ['test/util.test.js', 'test/unrelated.test.js']) });
|
||||
expect(check.status).toBe('pass');
|
||||
expect(check.from_run).toEqual({ receipt: expect.stringMatching(new RegExp(`^run tree=${file.tree.slice(0, 12)} tests=8 runs=100/1 profiles=ci-reuse/free-tests heads=${head.slice(0, 12)} verified=`)), files: ['test/util.test.js'] });
|
||||
expect(ranLine(check)).toContain('test/retry.test.js');
|
||||
expect(ranLine(check)).not.toContain('test/util.test.js');
|
||||
expect(check.detail).toStartWith('1 of 3 local test file(s) passed in the --from-run run; ');
|
||||
expect(progress).toContain(`REUSE: accepted 1 of 3 selected local test file(s) from ${check.from_run!.receipt}; 2 still run`);
|
||||
const out = path.join(repo.work, '.gstack', 'tmp', 'pregate.json');
|
||||
writePregateFile(repo.work, out, file);
|
||||
expect(readPregateFile(out).file?.checks.find(c => c.id === 'lanes')?.from_run?.files).toEqual(['test/util.test.js']);
|
||||
}, 120_000);
|
||||
|
||||
test('a run covering every selected file leaves nothing to run', async () => {
|
||||
const { repo, head } = fixture();
|
||||
const all = ['test/nightly/retry-heavy.test.js', 'test/retry.test.js', 'test/util.test.js'];
|
||||
const { check } = await lanes(repo, { ref: '100', check: source(repo, head, all) });
|
||||
expect([check.status, check.from_run?.files.sort(), ranLine(check)]).toEqual(['pass', all, '']);
|
||||
}, 120_000);
|
||||
|
||||
test('a refused run prints its REUSE line and runs everything: another tree, no profile for a local lane or this OS, unreadable profiles', async () => {
|
||||
const { repo, parent, head } = fixture();
|
||||
const cases: Array<[string, RunSource | { error: string }]> = [
|
||||
['tree_mismatch', source(repo, parent, ['test/util.test.js', 'test/retry.test.js'])],
|
||||
['no_profile', source(repo, head, ['test/util.test.js'], { 'ci-reuse/windows': { ...PROFILE, workflow: 'windows.yml', os: 'Windows' } })],
|
||||
['no_profile', source(repo, head, ['test/util.test.js'], { 'ci-reuse/free-tests': { ...PROFILE, os: 'Windows' } })],
|
||||
['no_profile', source(repo, head, ['test/util.test.js'], { 'free-tests.yml/unit': { ...PROFILE, workflow: 'other.yml' } })],
|
||||
['profiles_unavailable', { error: 'origin is not a github.com repository' }],
|
||||
];
|
||||
for (const [reason, check] of cases) {
|
||||
const r = await lanes(repo, { ref: '100', check });
|
||||
expect([reason, r.progress.find(l => l.startsWith('REUSE: '))]).toEqual([reason, expect.stringMatching(new RegExp(`^REUSE: refused reason=${reason} — .*; every selected test runs here$`))]);
|
||||
expect([reason, r.check.from_run, ranLine(r.check).includes('test/util.test.js') && ranLine(r.check).includes('test/retry.test.js')]).toEqual([reason, undefined, true]);
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
test('the CLI: --from-run is a tests-stage flag, and without a GitHub origin it refuses and runs everything', () => {
|
||||
const { repo } = fixture();
|
||||
const preflight = runBin(repo, 'gstack-pregate', ['--stage', 'preflight', '--from-run', '100']);
|
||||
expect([preflight.status, preflight.err]).toEqual([EXIT.usage, expect.stringContaining('--from-run applies to the tests stage')]);
|
||||
const tests = runBin(repo, 'gstack-pregate', ['--stage', 'tests', '--from-run', '100', '--allow-repo-commands']);
|
||||
expect(tests.err).toContain('REUSE: refused reason=profiles_unavailable — origin is not a github.com repository');
|
||||
expect(tests.status).toBe(EXIT.ok);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* GST-775 (from GBEV-3, gbrain-evals #272 and GBEV-22): `.gstack/pregate.json` could not raise the test timeout without
|
||||
* changing lane verdicts. (1) Any policy file, even `{"timeout_s": 1800}`, turned a touched file with no test lane into a
|
||||
* hard PREGATE_NO_LANE that returned before any test ran; with no file it only warned. (2) `warn: ["lanes"]` failed open:
|
||||
* the downgraded PREGATE_NO_LANE returned before the selected tests ran, so a red test beside an unmapped file passed.
|
||||
* (3) A `[]` declaration that matched a deleted test (the `"**": []` workaround) cleared it before the removed-test
|
||||
* handling, so the deleted test's owning suite no longer ran. A policy that declares lanes (a `dependencies`,
|
||||
* `empty_lanes`, `selection` or `local` key) keeps the hard PREGATE_NO_LANE.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
/** The fixture's planner-computed Windows lane replaced by a lint job, so the selected tests run here instead of becoming a requires-remote obligation. */
|
||||
const NO_PLATFORM_LANE = { '.github/workflows/windows.yml': 'name: Lint\non: [push]\njobs:\n lint:\n runs-on: ubuntu-24.04\n steps:\n - run: echo lint\n' };
|
||||
const SCHEMA = BASE_PREGATE.$schema;
|
||||
const UNMAPPED = { 'lib/orphan.js': 'exports.lonely = () => 2;\n' };
|
||||
const GREEN = { ...UNMAPPED, 'lib/util.js': 'exports.add = (a, b) => b + a;\n' };
|
||||
const RED = { ...UNMAPPED, 'lib/util.js': 'exports.add = (a, b) => a - b;\n' };
|
||||
const UTIL_V2 = "const test = require('node:test');\nconst assert = require('node:assert');\nconst { add } = require('../lib/util.js');\ntest('adds again', () => assert.strictEqual(add(2, 2), 4));\n";
|
||||
|
||||
function lanes(repo: FixtureRepo, extra: string[] = [], env: Record<string, string> = {}) {
|
||||
const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--only', 'lanes', ...extra], env);
|
||||
const out = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8'));
|
||||
const row = out.checks.find((c: { id: string }) => c.id === 'lanes');
|
||||
const lines: string[] = row.lines ?? [];
|
||||
return { ...r, row, lines, ran: lines.find(l => l.startsWith('ran: ')) ?? '', removed: lines.filter(l => l.startsWith('removed (no lane owed): ')) };
|
||||
}
|
||||
|
||||
describe('a policy that declares no lanes keeps the no-policy lane verdicts (GST-775 bug 1)', () => {
|
||||
// Value: protects=a timeout-only policy (gbrain-evals #272's intent) leaves an unmapped file a warning with the selected tests run,
|
||||
// exactly as with no policy; fails_when=any policy file turns the unmapped file into a hard PREGATE_NO_LANE before the tests run;
|
||||
// why_new=the lane-requirement condition is new; seam=none; no_claim=the 600 s default changes
|
||||
test.each([
|
||||
['timeout-only', { $schema: SCHEMA, timeout_s: 1800 }],
|
||||
['runner, warn and strays', { $schema: SCHEMA, runner: 'node --test {files}', warn: ['strays'], strays: { allow: ['Makefile'] } }],
|
||||
])('%s policy: unmapped file warns and the selected test runs, like no policy', (_name, policy) => {
|
||||
const none = fixture({ pregate: null, baseFiles: NO_PLATFORM_LANE });
|
||||
commit(none, GREEN);
|
||||
const n = lanes(none);
|
||||
const withPolicy = fixture({ pregate: policy, baseFiles: NO_PLATFORM_LANE });
|
||||
commit(withPolicy, GREEN);
|
||||
const p = lanes(withPolicy);
|
||||
expect(p.err).not.toContain('PREGATE_NO_LANE');
|
||||
expect([p.status, p.row.status, p.row.code]).toEqual([n.status, n.row.status, n.row.code]);
|
||||
expect([p.status, p.row.status, p.row.code]).toEqual([EXIT.ok, 'warn', 'PREGATE_NO_POLICY']);
|
||||
expect(p.ran).toContain('test/util.test.js');
|
||||
expect(p.row.detail).toMatch(/\d+ tests ran in \d+ file\(s\)/);
|
||||
expect(p.lines.some(l => l.startsWith('PREGATE_STARTER: '))).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=the warning never hides a red test under a lane-less policy; fails_when=the timeout-only branch returns warn for a
|
||||
// failing lane; why_new=new branch; seam=none; no_claim=none
|
||||
test('timeout-only policy: a red selected test beside an unmapped file is PREGATE_LANE_FAILED', () => {
|
||||
const repo = fixture({ pregate: { $schema: SCHEMA, timeout_s: 1800 }, baseFiles: NO_PLATFORM_LANE });
|
||||
commit(repo, RED);
|
||||
const r = lanes(repo);
|
||||
expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']);
|
||||
expect(r.ran).toContain('test/util.test.js');
|
||||
});
|
||||
|
||||
// Value: protects=a fully mapped change prints no empty "have no test lane" line under no policy or a lane-less one; fails_when=the
|
||||
// no-lane line is rendered for an empty list; why_new=the mode switch is new; seam=none; no_claim=none
|
||||
test.each([['no policy', null], ['timeout-only', { $schema: SCHEMA, timeout_s: 1800 }]])('%s: a fully mapped change passes with no no-lane line', (_name, policy) => {
|
||||
const repo = fixture({ pregate: policy, baseFiles: NO_PLATFORM_LANE });
|
||||
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' });
|
||||
const r = lanes(repo);
|
||||
expect([r.status, r.row.status]).toEqual([EXIT.ok, 'pass']);
|
||||
expect(r.lines.some(l => l.includes('have no test lane'))).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=the starter names the policy that exists instead of claiming there is none; fails_when=the starter header says
|
||||
// "no .gstack/pregate.json" while one is on the base; why_new=new wording; seam=none; no_claim=none
|
||||
test('the starter says the active policy declares no lanes, not that there is no policy', () => {
|
||||
const repo = fixture({ pregate: { $schema: SCHEMA, timeout_s: 1800 }, baseFiles: NO_PLATFORM_LANE });
|
||||
commit(repo, GREEN);
|
||||
const r = lanes(repo);
|
||||
const header = r.lines.find(l => l.startsWith('PREGATE_STARTER: ')) ?? '';
|
||||
expect(header).toContain('declares no lanes');
|
||||
expect(header).not.toContain('no .gstack/pregate.json on');
|
||||
});
|
||||
});
|
||||
|
||||
describe('warn: ["lanes"] never lets a red test pass (GST-775 bug 2)', () => {
|
||||
const warnLanes = { ...BASE_PREGATE, warn: ['strays', 'lanes'] };
|
||||
|
||||
// Value: protects=the documented escape runs the selected tests and keeps a red lane fail; fails_when=the downgraded PREGATE_NO_LANE
|
||||
// returns before the tests run (GBEV-22's fail-open); why_new=the warn path is new; seam=none; no_claim=none
|
||||
test('a red selected test beside an unmapped file fails with PREGATE_LANE_FAILED', () => {
|
||||
const repo = fixture({ pregate: warnLanes });
|
||||
commit(repo, RED);
|
||||
const r = lanes(repo);
|
||||
expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']);
|
||||
expect(r.ran).toContain('test/util.test.js');
|
||||
});
|
||||
|
||||
// Value: protects=only the missing-lane condition becomes the warning, after the tests ran green; fails_when=the warn row carries
|
||||
// no test run, or the unmapped file is dropped from the row; why_new=new; seam=none; no_claim=none
|
||||
test('a green run with an unmapped file is warn PREGATE_NO_LANE, naming the file, after the tests ran', () => {
|
||||
const repo = fixture({ pregate: warnLanes });
|
||||
commit(repo, GREEN);
|
||||
const r = lanes(repo);
|
||||
expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.ok, 'warn', 'PREGATE_NO_LANE']);
|
||||
expect(r.ran).toContain('test/util.test.js');
|
||||
expect(r.row.detail).toMatch(/\d+ tests ran in \d+ file\(s\)/);
|
||||
expect(r.row.detail).toContain('lib/orphan.js');
|
||||
expect(r.row.detail).toContain('downgraded to warn');
|
||||
});
|
||||
|
||||
// Value: protects=a selected test this machine cannot run stays a requires-remote obligation under the downgrade, never a warn or a
|
||||
// pass; fails_when=withNoLaneWarning turns an unrun platform lane into warn; why_new=new arm; seam=none; no_claim=none
|
||||
test('a selected test only a platform lane runs stays requires-remote, coded PREGATE_NO_LANE', () => {
|
||||
const repo = fixture({ pregate: warnLanes });
|
||||
commit(repo, { ...UNMAPPED, 'test/windows-paths.test.js': "const test = require('node:test');\ntest('windows paths again', () => {});\n" });
|
||||
const r = lanes(repo);
|
||||
expect([r.row.status, r.row.code]).toEqual(['requires-remote', 'PREGATE_NO_LANE']);
|
||||
expect(r.row.detail).toContain('lib/orphan.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a [] declaration does not skip a deleted test\'s owning suite (GST-775 bug 3)', () => {
|
||||
const catchAll = { ...BASE_PREGATE, dependencies: { '**': [] } };
|
||||
|
||||
// Value: protects=gbrain-evals' "**": [] catch-all still runs a deleted test's owning suite; fails_when=the [] declaration clears
|
||||
// the deleted test before removedPath(); why_new=the ordering is new; seam=none; no_claim=none
|
||||
test('deleting a test under "**": [] runs its owning suite', () => {
|
||||
const repo = fixture({ pregate: catchAll, baseFiles: { 'test/util-v2.test.js': UTIL_V2 } });
|
||||
commit(repo, { 'test/util.test.js': null });
|
||||
const r = lanes(repo);
|
||||
expect(r.row.status).toBe('pass');
|
||||
expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']);
|
||||
expect(r.ran).toContain('test/util-v2.test.js');
|
||||
});
|
||||
|
||||
// Value: protects=a file naming the deleted test that [] declares needs no lane (a durations manifest) does not stop the suite;
|
||||
// fails_when=the namer reads as uncovered and the removal is cleared without its suite; why_new=new; seam=none; no_claim=none
|
||||
test('a curation file that names the deleted test and is declared [] does not stop the owning suite', () => {
|
||||
const repo = fixture({ pregate: catchAll, baseFiles: { 'test/util-v2.test.js': UTIL_V2, 'scripts/durations.json': '{"test/util.test.js": 1}\n' } });
|
||||
commit(repo, { 'test/util.test.js': null });
|
||||
const r = lanes(repo);
|
||||
expect(r.row.status).toBe('pass');
|
||||
expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']);
|
||||
expect(r.ran).toContain('test/util-v2.test.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('negative controls: a policy that declares lanes still fails hard', () => {
|
||||
// Value: protects=declaring dependencies, empty_lanes, selection or local, even empty, keeps PREGATE_NO_LANE a hard stop before any test runs;
|
||||
// fails_when=the lane-less branch fires for every policy; why_new=the boundary is new; seam=none; no_claim=none
|
||||
test.each([
|
||||
['dependencies', { ...BASE_PREGATE }],
|
||||
['an empty dependencies object', { $schema: SCHEMA, timeout_s: 1800, dependencies: {} }],
|
||||
['an empty empty_lanes list', { $schema: SCHEMA, timeout_s: 1800, empty_lanes: [] }],
|
||||
['an empty selection object', { $schema: SCHEMA, timeout_s: 1800, selection: {} }],
|
||||
['an empty local list', { $schema: SCHEMA, timeout_s: 1800, local: [] }],
|
||||
])('a base policy with %s keeps PREGATE_NO_LANE', (_name, policy) => {
|
||||
const repo = fixture({ pregate: policy, baseFiles: NO_PLATFORM_LANE });
|
||||
commit(repo, GREEN);
|
||||
const r = lanes(repo);
|
||||
expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_NO_LANE']);
|
||||
expect(r.ran).toBe('');
|
||||
expect(r.lines.some(l => l.startsWith('PREGATE_STARTER: '))).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=the user-owned policy follows the same boundary; fails_when=the user file is treated differently from the base
|
||||
// copy; why_new=new; seam=none; no_claim=none
|
||||
test('a user-owned policy that declares dependencies keeps PREGATE_NO_LANE', () => {
|
||||
const repo = fixture({ pregate: null, baseFiles: NO_PLATFORM_LANE });
|
||||
commit(repo, GREEN);
|
||||
const userPolicy = path.join(repo.home, 'projects', 'gst775slug', 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
|
||||
fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } }));
|
||||
const r = lanes(repo, [], { GSTACK_PROJECT_SLUG: 'gst775slug' });
|
||||
expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_NO_LANE']);
|
||||
});
|
||||
|
||||
// Value: protects=a --policy-from head file that adds a lane over a lane-less base makes the run strict; fails_when=the head merge
|
||||
// keeps the base's declaresLanes only; why_new=the merge is new; seam=none; no_claim=none
|
||||
test('--policy-from head adding dependencies over a timeout-only base keeps PREGATE_NO_LANE', () => {
|
||||
const repo = fixture({ pregate: { $schema: SCHEMA, timeout_s: 1800 }, baseFiles: NO_PLATFORM_LANE });
|
||||
commit(repo, { ...GREEN, '.gstack/pregate.json': JSON.stringify({ $schema: SCHEMA, timeout_s: 1800, dependencies: { 'bin/tool': ['test/util.test.js'] } }) + '\n' });
|
||||
const r = lanes(repo, ['--policy-from', 'head']);
|
||||
expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_NO_LANE']);
|
||||
});
|
||||
|
||||
// Value: protects=without a [] declaration a deleted test still runs its owning suite and an unmapped deleted source still fails;
|
||||
// fails_when=moving the removed-test handling waives non-test removals; why_new=new; seam=none; no_claim=none
|
||||
test('a deleted source file under a lane-declaring policy still owes a lane', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, { 'test/util.test.js': null, 'lib/util.js': null });
|
||||
const r = lanes(repo);
|
||||
expect(r.row.code).toBe('PREGATE_NO_LANE');
|
||||
expect(r.row.detail).toContain('lib/util.js');
|
||||
});
|
||||
|
||||
// Value: protects=warn: ["lanes"] does not downgrade a red lane even with every file mapped; fails_when=the lanes downgrade starts
|
||||
// applying to PREGATE_LANE_FAILED; why_new=regression guard; seam=none; no_claim=none
|
||||
test('warn: ["lanes"] with every file mapped keeps a red lane fail', () => {
|
||||
const repo = fixture({ pregate: { ...BASE_PREGATE, warn: ['strays', 'lanes'] } });
|
||||
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a - b;\n' });
|
||||
const r = lanes(repo);
|
||||
expect([r.status, r.row.status, r.row.code]).toEqual([EXIT.fail, 'fail', 'PREGATE_LANE_FAILED']);
|
||||
});
|
||||
});
|
||||
@@ -85,8 +85,8 @@ describe('no policy anywhere: warn plus a printed starter (never PREGATE_NO_LANE
|
||||
expect(r.out).toContain('no policy: lib/orphan.js scripts/report.js have no test lane');
|
||||
});
|
||||
|
||||
// Value: protects=a repository with a policy keeps the hard PREGATE_NO_LANE (base copy, and the user-owned file); fails_when=the
|
||||
// warn branch fires whenever an entry is unmapped; why_new=the branch condition is new; seam=none; no_claim=none
|
||||
// Value: protects=a repository with a policy that declares lanes keeps the hard PREGATE_NO_LANE (base copy, the user-owned file and a
|
||||
// head bootstrap; a runner-only policy declares none since GST-775); fails_when=the warn branch fires whenever an entry is unmapped; why_new=the branch condition is new; seam=none; no_claim=none
|
||||
test('a base policy or a user-owned policy keeps PREGATE_NO_LANE as a hard failure', () => {
|
||||
const withBase = fixture();
|
||||
commit(withBase, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' });
|
||||
@@ -98,14 +98,14 @@ describe('no policy anywhere: warn plus a printed starter (never PREGATE_NO_LANE
|
||||
commit(withUser, { 'lib/orphan.js': 'exports.lonely = () => 2;\n' });
|
||||
const userPolicy = path.join(withUser.home, 'projects', 'starterslug', 'pregate.json');
|
||||
fs.mkdirSync(path.dirname(userPolicy), { recursive: true });
|
||||
fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}' }));
|
||||
fs.writeFileSync(userPolicy, JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } }));
|
||||
const u = lanes(withUser, [], { GSTACK_PROJECT_SLUG: 'starterslug' });
|
||||
expect(u.status).toBe(EXIT.fail);
|
||||
expect(u.err).toContain('(PREGATE_NO_LANE)');
|
||||
expect(u.out).not.toContain('PREGATE_STARTER');
|
||||
// --policy-from head with a head policy (no base copy) is strict too.
|
||||
const withHead = fixture({ pregate: null });
|
||||
commit(withHead, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ runner: 'node --test {files}' }) + '\n' });
|
||||
commit(withHead, { 'lib/orphan.js': 'exports.lonely = () => 2;\n', '.gstack/pregate.json': JSON.stringify({ runner: 'node --test {files}', dependencies: { 'docs/**': [] } }) + '\n' });
|
||||
const h = lanes(withHead, ['--policy-from', 'head']);
|
||||
expect(h.status).toBe(EXIT.fail);
|
||||
expect(h.err).toContain('(PREGATE_NO_LANE)');
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Regression tests for #3680 and #3679: scripts/ubicloud/ubi-runner.sh on the
|
||||
* BSD userland macOS ships.
|
||||
*
|
||||
* #3680 pack built its archive with `tar --null -T - -c -C "$tmp" .git -zf -`.
|
||||
* bsdtar stops reading options at the first operand (so -zf and - were
|
||||
* file names) and resolves -T names after the -C (so the work-tree
|
||||
* files were looked up in the temp dir): exit 1 and an archive holding
|
||||
* only .git, after the VM was already created.
|
||||
* #3679 usage's END block printed `o == me ? " (you)" : ""` unparenthesized
|
||||
* inside a printf list, a syntax error in the one-true-awk, so usage
|
||||
* exited 2 and the vCPU quota preflight read no total and let every
|
||||
* create through.
|
||||
*
|
||||
* Both need the real tools: macOS's /usr/bin/tar and /usr/bin/awk, or on Linux
|
||||
* `bsdtar` (libarchive-tools) and `original-awk`. Where a tool is missing its
|
||||
* cases are skipped, and the skip names the package. No network, no VM.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const RUNNER = join(ROOT, 'scripts/ubicloud/ubi-runner.sh');
|
||||
const darwin = process.platform === 'darwin';
|
||||
const bsdTar = darwin ? '/usr/bin/tar' : Bun.which('bsdtar');
|
||||
const bwkAwk = darwin ? '/usr/bin/awk' : Bun.which('original-awk');
|
||||
|
||||
/** A directory to put first on PATH whose `tar` or `awk` is the BSD one. */
|
||||
function bsdPath(root: string, tools: { tar?: string | null; awk?: string | null }) {
|
||||
const bin = join(root, 'bsd-bin');
|
||||
mkdirSync(bin, { recursive: true });
|
||||
if (tools.tar) symlinkSync(tools.tar, join(bin, 'tar'));
|
||||
if (tools.awk) symlinkSync(tools.awk, join(bin, 'awk'));
|
||||
return `${bin}:${process.env.PATH}`;
|
||||
}
|
||||
|
||||
const gitEnv = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
|
||||
|
||||
describe(`ubi-runner pack under bsdtar (#3680)${bsdTar ? '' : ': skipped, no bsdtar (install libarchive-tools)'}`, () => {
|
||||
// Value: protects=pack writes a whole checkout under bsdtar (macOS's default tar), not an archive of .git alone; fails_when=pack passes tar options after an operand or resolves its -T list after a -C again; why_new=test/ubicloud-runner.test.ts runs pack only under GNU tar on Linux, so #3680 shipped green; seam=PATH (tar resolves to bsdtar); no_claim=the VM side's tar -xzf (GNU tar on Ubuntu, exercised by ubicloud-runner.test.ts)
|
||||
test.skipIf(!bsdTar)('pack ships tracked, staged, untracked, symlinked, spaced and option-like names, a linked worktree and an empty file list', () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'ubi-bsd-pack-'));
|
||||
try {
|
||||
const PATH = bsdPath(d, { tar: bsdTar });
|
||||
const sh = (cmd: string, cwd: string) => {
|
||||
const r = Bun.spawnSync(['bash', '-c', cmd], { cwd, timeout: 30_000, env: { ...process.env, ...gitEnv, PATH } });
|
||||
expect(r.exitCode, `${cmd}\n${r.stderr.toString()}`).toBe(0);
|
||||
return r.stdout.toString();
|
||||
};
|
||||
expect(sh('tar --version', d)).toContain('bsdtar');
|
||||
const pack = (src: string, out: string) => sh(`bash ${JSON.stringify(RUNNER)} pack ${JSON.stringify(src)} > ${JSON.stringify(out)}`, d);
|
||||
const unpack = (tgz: string, dir: string) => { mkdirSync(join(d, dir)); sh(`tar -xzf ${JSON.stringify(tgz)} -C ${JSON.stringify(dir)}`, d); return join(d, dir); };
|
||||
const status = (cwd: string) => sh('git status --short', cwd).split('\n').filter(Boolean).sort();
|
||||
|
||||
sh('git init -q plain && cd plain && echo a > a && echo b > gone && echo s > "with space" && ln -s a link && echo o > ./-C && echo o > ./--files-from=a && git add -A && git commit -qm one && rm gone && echo st > staged && git add staged && echo u > untracked && echo x > "untracked space"', d);
|
||||
pack('plain', 'plain.tgz');
|
||||
const listing = sh('tar -tzf plain.tgz', d).split('\n');
|
||||
expect(listing.some(l => l.endsWith('files.tar'))).toBe(false);
|
||||
const vm = unpack('plain.tgz', 'vm-plain');
|
||||
expect(status(vm)).toEqual(status(join(d, 'plain')));
|
||||
expect(status(vm)).toEqual([' D gone', '?? "untracked space"', '?? untracked', 'A staged']);
|
||||
expect(readFileSync(join(vm, 'with space'), 'utf8')).toBe('s\n');
|
||||
expect(sh('readlink link', vm).trim()).toBe('a');
|
||||
expect(existsSync(join(vm, '-C')) && existsSync(join(vm, '--files-from=a'))).toBe(true);
|
||||
|
||||
sh('git init -q main && cd main && echo a > a && git add a && git commit -qm one && git branch feat && git worktree add -q ../wt feat', d);
|
||||
sh('echo b > b && git add b && git commit -qm two && echo u > u && echo ignored > ig && echo ig > .gitignore', join(d, 'wt'));
|
||||
pack('wt', 'wt.tgz');
|
||||
const vmWt = unpack('wt.tgz', 'vm-wt');
|
||||
expect(existsSync(join(vmWt, 'ig'))).toBe(false);
|
||||
expect(sh('git rev-parse --abbrev-ref HEAD && git status --short && git log --oneline | wc -l', vmWt).replace(/\s+/g, ' ').trim()).toBe('feat ?? .gitignore ?? u 2');
|
||||
|
||||
sh('git init -q empty && cd empty && echo a > a && git add a && git commit -qm one && rm a', d);
|
||||
pack('empty', 'empty.tgz');
|
||||
expect(status(unpack('empty.tgz', 'vm-empty'))).toEqual([' D a']);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe(`ubi-runner usage and the quota preflight under the one-true-awk (#3679)${bwkAwk ? '' : ': skipped, no one-true-awk (install original-awk)'}`, () => {
|
||||
/** Fake Ubicloud API: two VMs, a standard-4 owned by lane7 and a standard-16 another tool named; every call body goes to calls.log. */
|
||||
function fakeApi() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ubi-bsd-awk-'));
|
||||
const bin = join(root, 'bin'), log = join(root, 'calls.log');
|
||||
mkdirSync(bin); mkdirSync(join(root, 'state'));
|
||||
writeFileSync(join(bin, 'curl'), `#!/usr/bin/env bash
|
||||
out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done
|
||||
body=$(cat); echo "$body" >> ${JSON.stringify(log)}
|
||||
case "$body" in
|
||||
*'"list"'*) printf 'eu-central-h1 ubirun-lane7-1761900000-ab12-t3\\neu-north-h1 someone-elses-vm\\n' > "$out" ;;
|
||||
*lane7*'"show"'*) printf 'state: running\\nsize: standard-4\\n' > "$out" ;;
|
||||
*'"show"'*) printf 'state: running\\nsize: standard-16\\n' > "$out" ;;
|
||||
*) : > "$out" ;;
|
||||
esac
|
||||
printf 200
|
||||
`);
|
||||
chmodSync(join(bin, 'curl'), 0o755);
|
||||
const PATH = bsdPath(root, { awk: bwkAwk });
|
||||
const env = { ...process.env, PATH: `${bin}:${PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(root, 'state'), UBI_VM_RECORD: join(root, 'vms.tsv'), UBI_OWNER: 'lane7', UBI_REAP: '0', UBI_POLL_SECONDS: '1' };
|
||||
const run = (args: string[], extra: Record<string, string> = {}) => Bun.spawnSync(['bash', RUNNER, ...args], { env: { ...env, ...extra }, timeout: 60_000 });
|
||||
return { root, run, env, calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : '') };
|
||||
}
|
||||
|
||||
// Value: protects=usage runs under macOS's awk and the vCPU preflight refuses over its ceiling there; fails_when=an awk program in the runner uses syntax the one-true-awk rejects, so usage dies and the preflight silently passes; why_new=the runner's awk ran only under mawk/gawk in CI, so #3679 shipped green; seam=PATH (awk resolves to the one-true-awk) and a fake curl; no_claim=real Ubicloud sizes and quota
|
||||
test.skipIf(!bwkAwk)('usage prints the per-owner table, and up refuses a create that would pass UBI_QUOTA_VCPUS', () => {
|
||||
const f = fakeApi();
|
||||
try {
|
||||
expect(Bun.spawnSync(['awk', '--version'], { env: f.env, timeout: 10_000 }).stdout.toString()).toContain('awk version');
|
||||
const usage = f.run(['usage']);
|
||||
expect(usage.stderr.toString()).toBe('');
|
||||
expect(usage.exitCode).toBe(0);
|
||||
expect(usage.stdout.toString()).toBe('OWNER VMS VCPUS\n(other) 1 16\nlane7 1 4 (you)\ntotal 2 20\n');
|
||||
const up = f.run(['up', '-s', 'standard-4'], { UBI_QUOTA_VCPUS: '16' });
|
||||
expect(up.exitCode).toBe(1);
|
||||
expect(up.stderr.toString()).toContain('it needs 4 vCPUs and the project\'s VMs already use 20 of 16');
|
||||
expect(up.stderr.toString()).toContain('create refused before any request: vCPU quota exhausted');
|
||||
expect(f.calls()).not.toContain('"create"');
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
});
|
||||
+158
-16
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { BriefError, checkReviewedTree, promptHash, renderBrief, sectionTexts, workspaceParagraph } from '../lib/review-brief';
|
||||
import { collect, judge, parseFindings, type MergedLine } from '../lib/review-ledger';
|
||||
import { collect, judge, parseFindings, readJsonl, type MergedLine } from '../lib/review-ledger';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-review-dispatch');
|
||||
@@ -106,6 +106,48 @@ describe('gstack-review-dispatch render: briefs from the sections, slots only',
|
||||
expect(fs.readFileSync(field(r.stdout, 'file'), 'utf8')).toContain(`Read each checklist at ${lists} (tag each finding's`);
|
||||
expect(run(['render', '--role', 'specialist-batched', '--out', out, '--diff-base', SHA, '--checklists', 'testing,maintenance']).stderr).toContain('BRIEF_RENDER_FAILED: checklist not found');
|
||||
});
|
||||
|
||||
// Value: protects=a finding or learning that quotes a slot name reaches its reader byte for byte instead of failing the render;
|
||||
// fails_when=the unfilled-slot check runs after findings or learnings are pasted in, or a later slot fill rewrites the pasted text
|
||||
// (#3795: a finding quoting `origin/<base>` failed with BRIEF_RENDER_FAILED: red-team: unfilled placeholder <base>);
|
||||
// why_new=GST-797; seam=lib/review-brief.ts renderBrief via gstack-review-dispatch render; no_claim=fix-check or outside-voice content
|
||||
test('findings and learnings that quote slot names render verbatim', () => {
|
||||
const quoted = ['[HIGH] bin/x.ts:4 runs `git merge-base origin/<base> HEAD` against a stale ref',
|
||||
'[MEDIUM] lib/y.ts:9 builds `DIFF_BASE=<diff-base> && git diff "$DIFF_BASE" <exclude>` by hand'].join('\n');
|
||||
const out = tmp();
|
||||
const w = (name: string, text: string) => { const f = path.join(out, name); fs.writeFileSync(f, text); return f; };
|
||||
const red = run(['render', '--role', 'red-team', '--out', out, '--diff-base', SHA, '--findings-file', w('findings.txt', `${quoted}\n`), '--specialists', '2']);
|
||||
expect(red.status, red.stderr).toBe(0);
|
||||
expect(fs.readFileSync(field(red.stdout, 'file'), 'utf8')).toContain(quoted);
|
||||
const spec = run(['render', '--role', 'specialist', '--name', 'testing', '--out', out, '--diff-base', SHA, '--exclude', "-- . ':(exclude)*.lock'", '--learnings-file', w('learnings.txt', quoted)]);
|
||||
expect(spec.status, spec.stderr).toBe(0);
|
||||
const brief = fs.readFileSync(field(spec.stdout, 'file'), 'utf8');
|
||||
expect(brief).toContain(`Past learnings: ${quoted}`);
|
||||
expect(brief).toContain(`git diff "$DIFF_BASE" -- . ':(exclude)*.lock'`);
|
||||
});
|
||||
|
||||
// Value: protects=the unfilled-slot refusal still fires when a section really leaves a slot open, quoted findings or not;
|
||||
// fails_when=the GST-797 fix skips the check, or quoted content masks a real open slot; seam=lib/review-brief.ts renderBrief
|
||||
test('a slot the section really leaves open still refuses the render', () => {
|
||||
const t = sectionTexts(ROOT);
|
||||
const slots = { diff_base: SHA, checklist: '/x/red-team.md', specialists: '2' };
|
||||
const open = { ...t, redTeam: `${t.redTeam} Compare against origin/<base>.` };
|
||||
expect(() => renderBrief('red-team', slots, open)).toThrow(/^red-team: unfilled placeholder <base>$/);
|
||||
expect(() => renderBrief('red-team', { ...slots, findings: 'quotes `origin/<base>`' }, open)).toThrow(/^red-team: unfilled placeholder <base>$/);
|
||||
expect(() => renderBrief('specialist', { ...slots, name: 'testing', learnings: 'see <exclude>' }, { ...t, specialist: `${t.specialist} See <exclude>.` })).toThrow(/^specialist: unfilled placeholder <exclude>$/);
|
||||
});
|
||||
|
||||
// Value: protects=a diff or hunk that carries NUL bytes reaches its reader unchanged and never pulls in another slot's text;
|
||||
// fails_when=renderBrief's put-back matches a token the caller can type, so a diff with \0learnings\0 splices the project's
|
||||
// learnings into the outside voice's brief and \0findings\0 crashes the render (GST-797 adversarial pass); seam=lib/review-brief.ts renderBrief
|
||||
test('reviewer text is put back only where the render held it', () => {
|
||||
const t = sectionTexts(ROOT);
|
||||
const diff = '+a \u0000learnings\u0000 b \u0000findings\u0000 c';
|
||||
const outside = renderBrief('outside-voice', { diff_base: SHA, diff, learnings: 'SECRET-LEARN', findings: 'F' }, t);
|
||||
expect(outside).toContain(diff);
|
||||
expect(outside).not.toContain('SECRET-LEARN');
|
||||
expect(renderBrief('fix-check', { fix_list: 'a.ts:3 fixed', hunks: diff, tests: '3 pass' }, t)).toContain(diff);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-review-dispatch sent, collect, judge and verify: nothing changed on the way in or out', () => {
|
||||
@@ -249,10 +291,23 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
|
||||
expect(judge(m, [{ id: 'r#1', severity: 'INFORMATIONAL', reason: 'a real defect' }]).ok).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=a reader's closing prose that negates a severity word ("found nothing CRITICAL") is not collected as a finding,
|
||||
// while a label stated as a finding still is; fails_when=collect records the negated word as a CRITICAL the judge cannot drop;
|
||||
// why_new=GST-766: GEVA-92's fix-check#5 on gstack-evals #108 (1.92.17.0) became a phantom CRITICAL; seam=parseFindings; no_claim=prose voices' summaries that state a count
|
||||
test('a negated severity word is not a finding: GEVA-92 fix-check#5 and its negated forms', () => {
|
||||
// Value: protects=a blocker named in an exception clause after a negated severity word collects at its label (GSTA-167 S1: GST-766's
|
||||
// negation drop collected nothing for each of these on 8cef24ce, where 8cef24ce^ collected CRITICAL); fails_when=any probe collects
|
||||
// nothing or a weaker label; why_new=GST-766 skipped any label a negation word governs and lost these blockers; seam=parseFindings;
|
||||
// no_claim=that the reader's negated wording is clear (lead judgment reads the raw line)
|
||||
test('a blocker in an exception clause after a negated severity word collects at its label', () => {
|
||||
for (const prose of ['No CRITICAL issues except the token leak in log.ts:3', 'There is no CRITICAL issue other than the SQL injection at db.ts:12',
|
||||
'Nothing CRITICAL apart from the auth bypass (auth.ts:9).', 'No new CRITICAL findings, but the token leak in log.ts:3 still blocks.',
|
||||
'None CRITICAL besides the race in queue.ts:40, which loses writes.', '- **No CRITICAL** issues remain except F2 (store.ts:22 corrupts data).'])
|
||||
for (const reader of ['outside-voice', 'fix-check', 'red-team', 'adversarial-native'])
|
||||
expect([prose, reader, parseFindings(reader, prose).map(f => f.label)]).toEqual([prose, reader, ['CRITICAL']]);
|
||||
});
|
||||
|
||||
// Value: protects=a negated severity word still collects at its label, so a reader's closing prose ("found nothing CRITICAL") is a
|
||||
// known phantom lead judgment folds and never a dropped line (GSTA-167 S1 reverts GST-766's negation drop); fails_when=any negated
|
||||
// form collects nothing or a weaker label; why_new=GST-766's drop lost blockers named in exception clauses; seam=parseFindings;
|
||||
// no_claim=that a negated line is a real finding (judge folds phantoms)
|
||||
test('a negated severity word still collects at its label: GEVA-92 fix-check#5 and its negated forms are known phantoms', () => {
|
||||
const fixCheck = [
|
||||
'REVIEWED_TREE: bedd511efa0fa16c9277821073c68e438dc760d2 /workspace/gstack-evals',
|
||||
'{"severity":"INFORMATIONAL","path":"wiki/board/README.md","line":229,"summary":"Section 9\'s opening still says every ruling is recorded in the approvals file."}',
|
||||
@@ -262,19 +317,22 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
|
||||
'- The new secondary: 47/60, 15/60, 5/40, 7/40, 4/40 and 11.5/40.',
|
||||
'- The minimum p of 2^-15.',
|
||||
].join('\n');
|
||||
expect(parseFindings('fix-check', fixCheck).map(f => f.label)).toEqual(['INFORMATIONAL', 'INFORMATIONAL']);
|
||||
for (const prose of ['Found no CRITICAL issues in the fix range.', 'Nothing CRITICAL or HIGH remains.', 'This is not CRITICAL.', 'I found zero Critical findings, and none High.', 'The retry path is safe; without CRITICAL impact.',
|
||||
'No new CRITICAL issues introduced by the fix.', 'No remaining CRITICAL or HIGH findings.', '**No** CRITICAL issues found.', 'No **CRITICAL** issues found.', 'No `CRITICAL` issue.', 'No [P1] issues remain.', 'Nothing at CRITICAL severity remains.',
|
||||
'No CRITICAL regressions introduced by the fix.', 'Nothing CRITICAL to report.', 'There is nothing CRITICAL in the fix range.', 'No CRITICAL — the fix is sound.', 'Found no CRITICAL or HIGH regressions.', 'No HIGH-risk changes.',
|
||||
'No CRITICAL/HIGH findings.', 'No P0/P1 issues.', 'No CRITICAL, HIGH or MEDIUM issues.', 'Checked collect() at lib/review-ledger.ts:189; no CRITICAL issues remain.', 'No CRITICAL issues; served on localhost:3000.'])
|
||||
expect([prose, parseFindings('fix-check', prose)]).toEqual([prose, []]);
|
||||
expect(parseFindings('r', 'Not CRITICAL, but High: the cache drops a row (c.ts:4)').map(f => f.label)).toEqual(['High']);
|
||||
expect(parseFindings('r', 'No CRITICAL, High: the cache drops a row (c.ts:4)').map(f => f.label)).toEqual(['High']);
|
||||
expect(parseFindings('r', 'Nothing CRITICAL / HIGH: race in q.ts:7').map(f => f.label)).toEqual(['HIGH']);
|
||||
expect(parseFindings('fix-check', fixCheck).map(f => f.label)).toEqual(['INFORMATIONAL', 'INFORMATIONAL', 'CRITICAL']);
|
||||
const kept: Array<[string, string]> = [['Found no CRITICAL issues in the fix range.', 'CRITICAL'], ['Nothing CRITICAL or HIGH remains.', 'CRITICAL'], ['This is not CRITICAL.', 'CRITICAL'],
|
||||
['I found zero Critical findings, and none High.', 'Critical'], ['The retry path is safe; without CRITICAL impact.', 'CRITICAL'], ['No new CRITICAL issues introduced by the fix.', 'CRITICAL'],
|
||||
['No remaining CRITICAL or HIGH findings.', 'CRITICAL'], ['**No** CRITICAL issues found.', 'CRITICAL'], ['No **CRITICAL** issues found.', 'CRITICAL'], ['No `CRITICAL` issue.', 'CRITICAL'],
|
||||
['No [P1] issues remain.', 'P1'], ['Nothing at CRITICAL severity remains.', 'CRITICAL'], ['No CRITICAL regressions introduced by the fix.', 'CRITICAL'], ['Nothing CRITICAL to report.', 'CRITICAL'],
|
||||
['There is nothing CRITICAL in the fix range.', 'CRITICAL'], ['No CRITICAL — the fix is sound.', 'CRITICAL'], ['Found no CRITICAL or HIGH regressions.', 'CRITICAL'], ['No HIGH-risk changes.', 'HIGH'],
|
||||
['No CRITICAL/HIGH findings.', 'CRITICAL'], ['No P0/P1 issues.', 'P0'], ['No CRITICAL, HIGH or MEDIUM issues.', 'CRITICAL'],
|
||||
['Checked collect() at lib/review-ledger.ts:189; no CRITICAL issues remain.', 'CRITICAL'], ['No CRITICAL issues; served on localhost:3000.', 'CRITICAL']];
|
||||
for (const [prose, label] of kept) expect([prose, parseFindings('fix-check', prose).map(f => f.label)]).toEqual([prose, [label]]);
|
||||
expect(parseFindings('r', 'Not CRITICAL, but High: the cache drops a row (c.ts:4)').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('r', 'No CRITICAL, High: the cache drops a row (c.ts:4)').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('r', 'Nothing CRITICAL / HIGH: race in q.ts:7').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('r', 'No CRITICAL lock guards the write in Makefile:7\nSeverity: CRITICAL').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('r', '1. auth.ts:3 LOW: log line too verbose\nNo CRITICAL lock guards the write in Makefile:7\nSeverity: CRITICAL').map(f => [f.label, f.raw])).toEqual([['LOW', '1. auth.ts:3 LOW: log line too verbose'], ['CRITICAL', 'No CRITICAL lock guards the write in Makefile:7 | Severity: CRITICAL']]);
|
||||
expect(parseFindings('r', '1. auth.ts:3 LOW: log line too verbose\nNo CRITICAL issues remain.').map(f => f.label)).toEqual(['LOW']);
|
||||
expect(parseFindings('outside-voice', '- a.ts:3 race. Severity: Low. Blocks: no\n Separately, nothing CRITICAL about b.ts:9, but it leaks fds.\n Severity: High').map(f => [f.label, f.raw])).toEqual([['Low', '- a.ts:3 race. Severity: Low. Blocks: no'], ['High', 'Separately, nothing CRITICAL about b.ts:9, but it leaks fds. | Severity: High']]);
|
||||
expect(parseFindings('r', '1. auth.ts:3 LOW: log line too verbose\nNo CRITICAL issues remain.').map(f => f.label)).toEqual(['LOW', 'CRITICAL']);
|
||||
expect(parseFindings('outside-voice', '- a.ts:3 race. Severity: Low. Blocks: no\n Separately, nothing CRITICAL about b.ts:9, but it leaks fds.\n Severity: High').map(f => [f.label, f.raw])).toEqual([['Low', '- a.ts:3 race. Severity: Low. Blocks: no'], ['CRITICAL', 'Separately, nothing CRITICAL about b.ts:9, but it leaks fds. | Severity: High']]);
|
||||
expect(parseFindings('r', '1. A CRITICAL race in queue.ts:78 that no lock covers').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('r', '- A divide by zero CRITICAL bug in parse()').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('adversarial-native', 'FIXABLE: no timeout on the fetch in a.ts:3').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
@@ -484,6 +542,90 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
|
||||
expect(json.lines.map(l => [l.label, l.stated])).toEqual([['INFORMATIONAL', undefined]]);
|
||||
});
|
||||
|
||||
// Value: protects=collect keeps every label on a prose line, at the strongest one (GST-435 rule (a)): each line that three review
|
||||
// rounds found losing its label under a negation-drop draft (an exception clause, a negated subject, "else"/"other", a blocker named in an
|
||||
// earlier sentence), fix-check 5's table rows and INFORMATIONAL cases, and harness-evals #152's native (FIXABLE, medium) findings collect
|
||||
// at their label (adversarial.md); fails_when=any of them collects nothing or a weaker label; why_new=GST-435's negation drops lost a
|
||||
// blocker in each round and were removed; seam=parseFindings; no_claim=that every kept line is a real finding (judge folds phantoms)
|
||||
test('collect keeps every label at the strongest on its line: no wording drops a blocker', () => {
|
||||
const kept: Array<[string, string]> = [['| 1 | `CRITICAL` | High | token leak in log.ts:3 |', 'CRITICAL'], ['| log.ts:3 | `CRITICAL` | High | token leak |', 'CRITICAL'],
|
||||
['None of the writes are atomic and are CRITICAL (queue.ts:78); High confidence', 'CRITICAL'], ['Nothing guards the write path the failure of which is CRITICAL', 'CRITICAL'],
|
||||
['| a.ts:3 | race | not CRITICAL, Low |', 'CRITICAL'], ['| a.ts:3 | maps `FIXABLE` to `CRITICAL` in a comment | Low |', 'CRITICAL'],
|
||||
['Neither path checks auth so this is CRITICAL (a.ts:3)', 'CRITICAL'], ['None of the inputs are sanitized so this is CRITICAL (a.ts:3)', 'CRITICAL'],
|
||||
['Nothing checks the token which is CRITICAL (a.ts:3)', 'CRITICAL'], ['No bounds check lets CRITICAL data leak (a.ts:3)', 'CRITICAL'],
|
||||
['No defects in parsing but the upload handler is CRITICAL.', 'CRITICAL'], ['1. Without auth this is CRITICAL (a.ts:3)', 'CRITICAL'],
|
||||
['Not checking expiry at auth.ts:9 lets stale tokens through. Severity: High', 'High'], ["The lock isn't released on error (pool.ts:12): High", 'High'],
|
||||
['A retry storm could escalate up to CRITICAL.', 'CRITICAL'], ['Low: the parser now maps `FIXABLE` to `CRITICAL` in a comment (a.ts:3)', 'CRITICAL'],
|
||||
['No input validation is CRITICAL (upload.ts:4)', 'CRITICAL'], ['No CSRF token is a CRITICAL gap at form.ts:12', 'CRITICAL'],
|
||||
['Nothing escaped here is CRITICAL: stored XSS at view.ts:8', 'CRITICAL'], ['No timeout here is High (fetch.ts:3)', 'High'],
|
||||
['Upgraded: not High, CRITICAL (a.ts:3)', 'CRITICAL'], ['Severity: not Medium, CRITICAL', 'CRITICAL'],
|
||||
['Nothing else is CRITICAL except the token leak in log.ts:3.', 'CRITICAL'], ['No other issue is CRITICAL besides the auth bypass in auth.ts:9.', 'CRITICAL'],
|
||||
['None of the other findings are CRITICAL, but the SQL injection in db.ts:12 is.', 'CRITICAL'],
|
||||
['Nothing here is CRITICAL apart from the unescaped query (db.ts:12).', 'CRITICAL'], ['Nothing else is CRITICAL except the token leak.', 'CRITICAL'],
|
||||
['Fix the token leak in log.ts:3 first; nothing else is CRITICAL.', 'CRITICAL'], ['The token leak in log.ts:3 blocks the merge. Nothing else is CRITICAL.', 'CRITICAL'],
|
||||
['1. Token leak in log.ts:3 must be fixed before landing.\n\nNothing else is CRITICAL.', 'CRITICAL'], ['The token leak blocks the merge.\nNo other issue is CRITICAL.', 'CRITICAL'],
|
||||
['The token leak in log.ts:3 blocks the merge. None of the other findings are CRITICAL.', 'CRITICAL'],
|
||||
['The token leak in log.ts:3 must block the merge. The other two are nits. Neither finding is CRITICAL.', 'CRITICAL'],
|
||||
['Apart from the token leak in log.ts:3, I found two nits. Neither finding is CRITICAL.', 'CRITICAL'],
|
||||
['Besides the auth bypass at auth.ts:9 there are three nits. None of the three findings are CRITICAL.', 'CRITICAL'],
|
||||
['The token leak in log.ts:3 must block the merge. Nothing here is CRITICAL.', 'CRITICAL'], ['No auth check guards the admin route, so nothing CRITICAL.', 'CRITICAL']];
|
||||
for (const [line, label] of kept) expect([line, parseFindings('outside-voice', line).map(f => f.label)]).toEqual([line, [label]]);
|
||||
const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/harn15-152-p1-adversarial-native.txt'), 'utf8');
|
||||
expect(parseFindings('adversarial-native', native).map(f => f.label)).toEqual(['CRITICAL', 'INVESTIGATE', 'INVESTIGATE', 'CRITICAL', 'INVESTIGATE', 'INVESTIGATE', 'CRITICAL']);
|
||||
expect(parseFindings('adversarial-native', '**1. Race in a.ts:3 (`FIXABLE`).**\nSeverity: Low. Blocks: no').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('red-team', '1. Race in a.ts:3 (FIXABLE).').map(f => f.label)).toEqual(['FIXABLE']);
|
||||
expect(parseFindings('adversarial-native', 'No rate limit is FIXABLE (login.ts:9)').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
});
|
||||
|
||||
// Value: protects=a reader's own closing summary is a known phantom that keeps its label (HARN-15's Red Team and fix-check replies,
|
||||
// GEVA-77's "Neither finding is CRITICAL.", "found nothing CRITICAL"): it collects, and judge refuses to drop or lower it; the
|
||||
// follow-up tags such lines summary_candidate so judge may dismiss a tagged one on record; fails_when=a summary line collects nothing
|
||||
// or a weaker label, which would mean a drop path is back; why_new=GST-435 removed the drop after it lost blockers in three review
|
||||
// rounds; seam=parseFindings, collect and judge; no_claim=that a phantom is free (it stays CRITICAL in the judged set)
|
||||
test('known phantoms: closing summaries keep their label until the summary_candidate follow-up', () => {
|
||||
const fixture = (name: string) => fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger', name), 'utf8');
|
||||
const replies: Array<[string, string, number]> = [['red-team', 'harn15-ship2-red-team.txt', 3], ['red-team', 'harn15-ship2-p2-red-team.txt', 3],
|
||||
['fix-check', 'harn15-ship2-p2-fix-check.txt', 4], ['fix-check', 'harn15-152-p2-fix-check.txt', 2]];
|
||||
for (const [reader, name, json] of replies) {
|
||||
const found = parseFindings(reader, fixture(name));
|
||||
expect([name, found.map(f => f.label)]).toEqual([name, ['CRITICAL', ...Array(json).fill('INFORMATIONAL')]]);
|
||||
expect(found[0]!.raw).toMatch(/(no|nothing( at)?) CRITICAL/);
|
||||
}
|
||||
const fc = fixture('geva-77-fix-check.txt');
|
||||
const d = tmp();
|
||||
const merged = path.join(d, 'merged.jsonl');
|
||||
const r = collect(path.join(d, 'l.jsonl'), 'fix-check', fc, merged);
|
||||
expect([r.review, r.lines.map(l => [l.id, l.label])]).toEqual([true, [['fix-check#1', 'INFORMATIONAL'], ['fix-check#2', 'INFORMATIONAL'], ['fix-check#3', 'CRITICAL']]]);
|
||||
expect(r.lines[2]!.raw).toStartWith('Neither finding is CRITICAL.');
|
||||
const judged = path.join(d, 'judged.jsonl');
|
||||
fs.writeFileSync(judged, r.lines.map(l => JSON.stringify({ id: l.id, severity: 'INFORMATIONAL' })).join('\n') + '\n');
|
||||
expect(judge(readJsonl<MergedLine>(merged), readJsonl(judged)).ok).toBe(false);
|
||||
for (const line of ['There are no CRITICAL findings.', 'No CRITICAL or High findings to report.', 'I found nothing CRITICAL.', "So there's nothing at CRITICAL.",
|
||||
'Neither finding is CRITICAL.', 'Nothing here is CRITICAL.', 'Neither fix is CRITICAL.', 'Nothing in this diff is CRITICAL.', 'This is not CRITICAL.'])
|
||||
expect([line, parseFindings('outside-voice', line).map(f => f.label)]).toEqual([line, ['CRITICAL']]);
|
||||
expect(parseFindings('outside-voice', 'Severity: Low (not High)').map(f => f.label)).toEqual(['High']);
|
||||
});
|
||||
|
||||
// Value: protects=item layouts collect as on main: an indented JSON finding keeps its JSON severity, an indented label heading sets
|
||||
// the section for the next item, and a later Blocks: yes raises the finding a Severity field line or JSON line opened;
|
||||
// fails_when=any of these collects a lower label or a different finding than main; why_new=GST-435's paragraph-split draft broke each
|
||||
// of these and was deferred; seam=parseFindings; no_claim=that main's reading is the right one (known, deferred to the follow-up:
|
||||
// HARN-15 ship2 native item 4 collects without its title, and an indented label heading rates the next item, not the one above it)
|
||||
test('item layouts collect as on main; the later-paragraph split and indented label headings are known deferred shapes', () => {
|
||||
const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/harn15-ship2-p2-adversarial-native.txt'), 'utf8');
|
||||
const found = parseFindings('adversarial-native', native);
|
||||
expect(found.map(f => f.label)).toEqual(['CRITICAL', 'CRITICAL', 'CRITICAL', 'CRITICAL', 'CRITICAL', 'Low']);
|
||||
expect(parseFindings('outside-voice', '1. **Race on the lock** (a.ts:3)\n - the writer holds no lock\n\n Fix: take it first. Severity: High. Blocks: no.').map(f => f.label)).toEqual(['High']);
|
||||
expect(parseFindings('outside-voice', '1. Lock race (a.ts:3)\n\n Fix: take it first. Blocks: yes\n2. Cache drift (c.ts:4)').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('red-team', '1. Unescaped query\n {"severity":"critical","summary":"Low-level race in q.ts"}').map(f => [f.label, f.raw]))
|
||||
.toEqual([['critical', '{"severity":"critical","summary":"Low-level race in q.ts"}']]);
|
||||
expect(parseFindings('red-team', '1. Unescaped query\n **Critical**\n2. Token leak in the logger').map(f => [f.label, f.raw])).toEqual([['Critical', '2. Token leak in the logger']]);
|
||||
expect(parseFindings('red-team', '1. Unescaped query\n ## High\n2. Token leak in the logger').map(f => [f.label, f.raw])).toEqual([['High', '2. Token leak in the logger']]);
|
||||
expect(parseFindings('red-team', '1. Race in queue\n- Severity: Low\n this one blocks. Blocks: yes').map(f => f.label)).toEqual(['CRITICAL']);
|
||||
expect(parseFindings('red-team', '1. Unescaped query\n {"severity":"low","summary":"race"}\n this blocks the merge. Blocks: yes').map(f => [f.label, f.raw]))
|
||||
.toEqual([['CRITICAL', '{"severity":"low","summary":"race"} | this blocks the merge. Blocks: yes']]);
|
||||
});
|
||||
|
||||
// Value: protects=a non-review the same dispatch later answers with a review is voided (counted, VERIFY clean), and a failed outside
|
||||
// voice recorded with --unavailable reads UNAVAILABLE (missing coverage, never a review) without blocking, while --unavailable is
|
||||
// refused for any other reader; fails_when=one outside-voice timeout or one refusal makes VERIFY dirty for good, or --unavailable
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* GST-430 (CAPY-2, qm#2837): a /ship review pass whose only findings were notes or deferrals has no
|
||||
* open defect, so the logger records it clean with issues_found 0 and Step 11.5 can bind it. A finding
|
||||
* still open (skipped, gate open, no action) keeps the claimed status. A row whose tree moved during
|
||||
* the pass names that cause and the cheapest repair.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { isOpenFinding } from '../lib/headless-artifacts';
|
||||
import { reviewFreshness } from '../lib/review-evidence';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-review-log');
|
||||
|
||||
let home: string;
|
||||
beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-notes-only-')); });
|
||||
afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
function run(args: string[]) {
|
||||
const r = spawnSync(BIN, args, { cwd: ROOT, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_HOME: home, GSTACK_SESSION_KIND: 'unattended' } });
|
||||
return { code: r.status, err: r.stderr, out: r.stdout };
|
||||
}
|
||||
function lastRow(): any {
|
||||
const projects = path.join(home, 'projects');
|
||||
const rows = fs.readdirSync(projects).flatMap(slug => fs.readdirSync(path.join(projects, slug)).filter(f => f.endsWith('-reviews.jsonl'))
|
||||
.flatMap(f => fs.readFileSync(path.join(projects, slug, f), 'utf8').trim().split('\n').map(l => JSON.parse(l))));
|
||||
return rows.at(-1);
|
||||
}
|
||||
const note = (i: number, action: string, extra: Record<string, unknown> = {}) =>
|
||||
({ fingerprint: `lib/a.ts:${i}:testing`, severity: 'INFORMATIONAL', action, locus: 'diff', kind: 'test-gap', blocking: false, raised_by: 'specialist-batched', ...extra });
|
||||
const pass = (findings: unknown[], extra: Record<string, unknown> = {}) => JSON.stringify({
|
||||
skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', status: 'issues_found', issues_found: findings.length, critical: 0,
|
||||
informational: findings.length, quality_score: 9, specialists: {}, findings, completed: true, converged: true, cycles: 0, pass: 2,
|
||||
review_scope: 'frozen', range_base: null, ...extra,
|
||||
});
|
||||
|
||||
describe('a notes-only /ship review pass (GST-430)', () => {
|
||||
// Value: protects=Step 11.5 binding a pass whose 3 notes are not defects; catches=a notes-only pass logged as
|
||||
// issues_found and left UNVERIFIED, which cost CAPY-2 an extra rebind pass.
|
||||
test('three noted findings are recorded clean with 0 issues and read CURRENT', () => {
|
||||
const token = run(['--start', 'review']).out.trim();
|
||||
const r = run([pass([note(1, 'noted'), note(2, 'noted'), note(3, 'deferred', { issue: null })]), '--finish', token]);
|
||||
expect(r.code).toBe(0);
|
||||
const row = lastRow();
|
||||
expect([row.status, row.issues_found, row.critical, row.informational, row.review_binding.state]).toEqual(['clean', 0, 0, 0, 'verified']);
|
||||
expect(reviewFreshness(row, row.wtree)).toEqual({ status: 'CURRENT', reason: 'completed clean pass on unchanged content' });
|
||||
});
|
||||
|
||||
// Value: protects=open defects still blocking 11.5; catches=a normalization that clears a skipped finding, a
|
||||
// gate finding the unattended logger forced open, a finding with no action, or an incomplete pass.
|
||||
test('negative controls keep issues_found', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['skipped', pass([note(1, 'noted'), note(2, 'skipped')])],
|
||||
['gate open', pass([note(1, 'noted'), note(2, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r' })])],
|
||||
['no action', pass([note(1, 'noted'), { fingerprint: 'lib/a.ts:9:testing', severity: 'CRITICAL' }])],
|
||||
['incomplete', pass([note(1, 'noted')], { completed: false })],
|
||||
['caller status over a stamped-open gate', pass([note(1, 'noted'), note(2, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r', severity: 'CRITICAL', status: 'resolved' })])],
|
||||
['critical noted', pass([note(1, 'noted'), note(2, 'noted', { severity: 'CRITICAL' })])],
|
||||
['blocking deferred', pass([note(1, 'noted'), note(2, 'deferred', { blocking: true, issue: null })])],
|
||||
['counts beyond the findings', pass([note(1, 'noted')], { issues_found: 2, critical: 1 })],
|
||||
['noted with no blocking field', pass([note(1, 'noted'), note(2, 'noted', { blocking: undefined })])],
|
||||
];
|
||||
for (const [name, row] of cases) {
|
||||
expect(run([row]).code).toBe(0);
|
||||
expect([name, lastRow().status]).toEqual([name, 'issues_found']);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a claimed clean row cannot carry a gate finding the unattended logger stamped open past Step 11.5;
|
||||
// catches=a reader that trusts the claimed status and counts over the stamped finding (red-team#2, pass 1).
|
||||
test('a claimed clean row with a stamped-open gate finding is not CURRENT', () => {
|
||||
const token = run(['--start', 'review']).out.trim();
|
||||
const gate = note(1, 'kept', { gate: 'RH1', gate_id: 'g', reason: 'r', severity: 'CRITICAL' });
|
||||
expect(run([pass([gate], { status: 'clean', issues_found: 0, critical: 0, informational: 0 }), '--finish', token]).code).toBe(0);
|
||||
const row = lastRow();
|
||||
expect([row.status, row.findings[0].action, row.review_binding.state]).toEqual(['clean', 'open', 'verified']);
|
||||
expect(reviewFreshness(row, row.wtree)?.status).toBe('UNVERIFIED');
|
||||
});
|
||||
|
||||
// Value: protects=a claimed clean row reads open findings by the same rule the rewrite uses; catches=a CRITICAL
|
||||
// deferral or a skipped finding under a claimed clean status reading CURRENT (adversarial-native#3, Step 11).
|
||||
// Kept and restored interactive gates, skipped advisories and an INFORMATIONAL deferral stay CURRENT, as before this PR
|
||||
// (fix-check and adversarial-native, Step 11 round 2).
|
||||
test('a claimed clean row with a CRITICAL deferral is not CURRENT; resolved gate, skipped and below-floor actions are', () => {
|
||||
const w = 'c'.repeat(40);
|
||||
const row = (findings: unknown[]) => ({ skill: 'review', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, wtree: w,
|
||||
review_binding: { state: 'verified', start_wtree: w, end_wtree: w }, findings, status_source: 'claimed' });
|
||||
expect(reviewFreshness(row([note(1, 'deferred', { severity: 'CRITICAL', issue: null })]), w)?.status).toBe('UNVERIFIED');
|
||||
expect(reviewFreshness(row([note(1, 'deferred', { blocking: true })]), w)?.status).toBe('UNVERIFIED');
|
||||
expect(reviewFreshness(row([note(1, 'open', { gate: 'RH1' })]), w)?.status).toBe('UNVERIFIED');
|
||||
expect([note(1, 'skipped', { severity: 'CRITICAL' }), { fingerprint: 'lib/a.ts:2:x', severity: 'CRITICAL' }, note(1, 'mystery', { severity: 'CRITICAL' })]
|
||||
.map(f => reviewFreshness(row([f]), w)?.status)).toEqual(['UNVERIFIED', 'UNVERIFIED', 'UNVERIFIED']);
|
||||
expect(reviewFreshness(row([note(1, 'kept', { gate: 'RH1', reason: 'r', actor: 'interactive', severity: 'CRITICAL' })]), w)?.status).toBe('CURRENT');
|
||||
const gate = (action: string) => note(1, action, { gate: 'RH1', reason: 'r', actor: 'interactive' });
|
||||
expect([gate('kept'), gate('restored'), note(1, 'skipped'), note(1, 'deferred', { blocking: undefined }), note(1, 'noted')]
|
||||
.map(f => reviewFreshness(row([f]), w)?.status)).toEqual(['CURRENT', 'CURRENT', 'CURRENT', 'CURRENT', 'CURRENT']);
|
||||
});
|
||||
|
||||
// Value: protects=a findings file cannot dispose of a gate finding the unattended logger must force open; catches=gate rows
|
||||
// from --findings derived as resolved without stamping (outside-voice#1, Step 11).
|
||||
test('a --findings file gate row is stamped open before its status is derived', () => {
|
||||
const file = path.join(home, 'findings.jsonl');
|
||||
fs.writeFileSync(file, JSON.stringify({ schema_version: 1, run: 'r', phase: 'review', voice: 'native', title: 't', fingerprint: 'lib/a.ts:1:x', severity: 'CRITICAL', action: 'auto-fixed', gate: 'RH1', gate_id: 'g' }) + '\n');
|
||||
const token = run(['--start', 'review']).out.trim();
|
||||
run([JSON.stringify({ skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', completed: true, converged: true }), '--findings', file, '--finish', token]);
|
||||
const row = lastRow();
|
||||
expect([row.findings_open, row.status === 'clean']).toEqual([1, false]);
|
||||
expect(reviewFreshness(row, row.wtree)?.status).not.toBe('CURRENT');
|
||||
});
|
||||
|
||||
// Value: protects=the forced-open stamp wins over a findings file's own status or disposition; catches=a gate row marked
|
||||
// status resolved deriving clean after it was stamped open (outside-voice#1, Step 11 round 2).
|
||||
test('a --findings gate row stamped open stays open whatever its status or disposition says', () => {
|
||||
for (const extra of [{ status: 'resolved' }, { disposition: 'fixed' }]) {
|
||||
const file = path.join(home, 'findings.jsonl');
|
||||
fs.writeFileSync(file, JSON.stringify({ schema_version: 1, run: 'r', phase: 'review', voice: 'native', title: 't', fingerprint: 'lib/a.ts:1:x', severity: 'CRITICAL', action: 'auto-fixed', gate: 'RH1', gate_id: 'g', ...extra }) + '\n');
|
||||
const token = run(['--start', 'review']).out.trim();
|
||||
run([JSON.stringify({ skill: 'review', via: 'ship', timestamp: '2026-10-31T00:00:00Z', completed: true, converged: true }), '--findings', file, '--finish', token]);
|
||||
expect([JSON.stringify(extra), lastRow().findings_open]).toEqual([JSON.stringify(extra), 1]);
|
||||
}
|
||||
});
|
||||
|
||||
test('isOpenFinding: fixed and auto-fixed are not open; noted and deferred only below the floor; skipped and missing actions are', () => {
|
||||
expect(['fixed', 'auto-fixed', 'deferred', 'noted', 'skipped', undefined].map(action => isOpenFinding({ action, blocking: false })))
|
||||
.toEqual([false, false, false, false, true, true]);
|
||||
expect(['deferred', 'noted'].map(action => isOpenFinding({ action }))).toEqual([true, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tree that changed during the pass (GST-430)', () => {
|
||||
// Value: protects=a refusal that says why the record cannot bind and what the cheapest repair is; catches=the
|
||||
// bare 'content changed during review' that sent CAPY-2 through a ~$20 full pass without saying why.
|
||||
test('the STALE reason names both trees, the cause and the repair', () => {
|
||||
const s = 'a'.repeat(64), e = 'b'.repeat(64);
|
||||
const fresh = reviewFreshness({ skill: 'adversarial-review', review_binding: { state: 'changed', start_wtree: s, end_wtree: e } }, e);
|
||||
expect(fresh?.status).toBe('STALE');
|
||||
expect(fresh?.reason).toContain(`content changed during review: the tree moved from ${s.slice(0, 12)} to ${e.slice(0, 12)} while adversarial-review ran`);
|
||||
expect(fresh?.reason).toContain('freeze the checkout until every reviewer returns');
|
||||
expect(fresh?.reason).toContain('docs/troubleshooting.md#review-content-changed');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
* the dependencies are fakes, so nothing here reaches GitHub.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseRunReceipt, parseRunRef, runInventory, runProfileErrors, runReceiptFields, uncoveredFiles, verifyRun, type ApiJob, type ApiRun, type RunDeps, type RunProfile, type TreeRecord } from '../lib/run-evidence';
|
||||
import { parseRunReceipt, parseRunRef, runInventory, runProfileErrors, runReceiptFields, uncoveredFiles, verifyFromRun, verifyRun, type ApiJob, type ApiRun, type RunDeps, type RunProfile, type TreeRecord } from '../lib/run-evidence';
|
||||
|
||||
const REPO = 'garrytan/gbrain';
|
||||
const HEAD = 'a'.repeat(40);
|
||||
@@ -159,7 +159,7 @@ describe('runInventory', () => {
|
||||
describe('profiles, coverage and receipts', () => {
|
||||
test('runProfileErrors accepts a well-formed pair and names every malformed field', () => {
|
||||
expect(runProfileErrors({ test: PROFILE, e2e: { ...PROFILE, workflow: 'e2e.yml', jobs: ['e2e-full-suite'], artifact: 'e2e-full-suite-tree', os: 'Linux', max_age_hours: 12 } }, { profiles: ['test', 'e2e'], inventory: 'bash scripts/pregate-remote-gate.sh --list' })).toEqual([]);
|
||||
const errors = runProfileErrors({ bad: { workflow: '../x.yml', events: ['pull_request'], jobs: [], artifact: '', max_age_hours: 0, os: 'BeOS', extra: 1 } }, { profiles: ['missing'], inventory: 'a\nb' }).map(e => e.path);
|
||||
const errors = runProfileErrors({ bad: { workflow: '../x.yml', events: ['pull_request_target'], jobs: [], artifact: '', max_age_hours: 0, os: 'BeOS', extra: 1 } }, { profiles: ['missing'], inventory: 'a\nb' }).map(e => e.path);
|
||||
expect(errors).toEqual(expect.arrayContaining(['run_profiles["bad"].extra', 'run_profiles["bad"].workflow', 'run_profiles["bad"].events', 'run_profiles["bad"].jobs', 'run_profiles["bad"].artifact', 'run_profiles["bad"].max_age_hours', 'run_profiles["bad"].os', 'remote_gate_runs.profiles', 'remote_gate_runs.inventory']));
|
||||
expect(runProfileErrors({ test: PROFILE }, { profiles: ['test', 'test'], inventory: 'true' })).toEqual([{ path: 'remote_gate_runs.profiles', message: 'names each profile once' }]);
|
||||
});
|
||||
@@ -181,3 +181,69 @@ describe('profiles, coverage and receipts', () => {
|
||||
expect(parseRunRef('123', REPO)).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pull_request runs (P3b)', () => {
|
||||
const MERGE = 'e'.repeat(40);
|
||||
const PR_PROFILE: RunProfile = { ...PROFILE, events: ['pull_request', 'workflow_dispatch'] };
|
||||
const pr = (over: Partial<TreeRecord> = {}, trees: Record<string, string | null> = { [HEAD]: TREE, [MERGE]: TREE }) => {
|
||||
const base = world();
|
||||
return world({ run: { ...base.run, event: 'pull_request' }, artifact: { ...(base.artifact as TreeRecord), event: 'pull_request', sha: MERGE, head_sha: HEAD, ...over }, trees });
|
||||
};
|
||||
const prReason = (w: World) => reason(w, { profile: PR_PROFILE });
|
||||
|
||||
test('a pull_request run counts when its merge commit tested its head tree', () => {
|
||||
const v = check(pr(), { profile: PR_PROFILE });
|
||||
expect(v).toMatchObject({ ok: true, evidence: { event: 'pull_request', head_sha: HEAD, tree: TREE } });
|
||||
});
|
||||
|
||||
test('a pull_request run is refused when the merge tree is not the head tree, or the record names no merge commit or another head', () => {
|
||||
expect(prReason(pr({}, { [HEAD]: TREE, [MERGE]: 'f'.repeat(40) }))).toBe('tree_mismatch');
|
||||
const behind = check(pr({}, { [HEAD]: TREE, [MERGE]: 'f'.repeat(40) }), { profile: PR_PROFILE });
|
||||
expect(!behind.ok && behind.detail).toContain('the pull request was behind its base');
|
||||
expect(prReason(pr({ sha: undefined }))).toBe('artifact_mismatch');
|
||||
expect(prReason(pr({ sha: 'not-a-sha' }))).toBe('artifact_mismatch');
|
||||
expect(prReason(pr({ head_sha: undefined }))).toBe('artifact_mismatch');
|
||||
expect(prReason(pr({ head_sha: MERGE }))).toBe('artifact_mismatch');
|
||||
expect(prReason(pr({}, { [HEAD]: TREE }))).toBe('api_error');
|
||||
expect(prReason(pr({ tree: 'f'.repeat(40) }))).toBe('tree_mismatch');
|
||||
});
|
||||
|
||||
test('a profile without pull_request still refuses a pull_request run, and validation accepts pull_request but nothing else new', () => {
|
||||
expect(reason(pr())).toBe('event_mismatch');
|
||||
expect(runProfileErrors({ p: PR_PROFILE }, undefined)).toEqual([]);
|
||||
expect(runProfileErrors({ p: { ...PR_PROFILE, events: ['pull_request_target'] } }, undefined).map(e => e.path)).toEqual(['run_profiles["p"].events']);
|
||||
});
|
||||
|
||||
test('a record may name every OS its test jobs ran on; the profile OS must be one of them', () => {
|
||||
const base = world();
|
||||
const multi = (os: unknown) => world({ artifact: { ...(base.artifact as TreeRecord), os } });
|
||||
expect(reason(multi(['Linux', 'macOS', 'Windows']), { profile: { ...PROFILE, os: 'macOS' } })).toBe('ok');
|
||||
expect(reason(multi(['Linux']), { profile: { ...PROFILE, os: 'macOS' } })).toBe('os_mismatch');
|
||||
expect(reason(multi('Windows'), { profile: { ...PROFILE, os: 'Windows' } })).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyFromRun (P3b --from-run)', () => {
|
||||
const src = (w: World, profiles: Record<string, RunProfile> = { 'test.yml/unit': PROFILE }) => ({ repo: REPO, deps: deps(w), profiles, now: NOW });
|
||||
test('a run on exactly this tree is evidence with a run receipt naming its files', () => {
|
||||
const v = verifyFromRun(src(world()), '100', ['test.yml/unit'], TREE, HEAD);
|
||||
expect(v).toEqual({ ok: true, evidence: expect.objectContaining({ run_id: 100, files: ['test/a.test.ts', 'test/b.test.ts'] }), receipt: `run tree=${TREE.slice(0, 12)} tests=12 runs=100/1 profiles=test.yml/unit heads=${HEAD.slice(0, 12)} verified=2026-10-29T12:00:00.000Z` });
|
||||
});
|
||||
test('another tree, no profile, an empty file list, zero tests and a failed run each refuse with their reason', () => {
|
||||
const why = (v: ReturnType<typeof verifyFromRun>) => (v.ok ? 'ok' : v.reason);
|
||||
const base = world();
|
||||
expect(why(verifyFromRun(src(world()), '100', ['test.yml/unit'], 'c'.repeat(40), HEAD))).toBe('tree_mismatch');
|
||||
expect(why(verifyFromRun(src(world()), '100', [], TREE, HEAD))).toBe('no_profile');
|
||||
expect(why(verifyFromRun(src(world({ artifact: { ...(base.artifact as TreeRecord), files: [] } })), '100', ['test.yml/unit'], TREE, HEAD))).toBe('no_tests');
|
||||
expect(why(verifyFromRun(src(world({ artifact: { ...(base.artifact as TreeRecord), tests: 0 } })), '100', ['test.yml/unit'], TREE, HEAD))).toBe('no_tests');
|
||||
expect(why(verifyFromRun(src(world({ run: { ...base.run, conclusion: 'failure' } })), '100', ['test.yml/unit'], TREE, HEAD))).toBe('run_not_success');
|
||||
const two = verifyFromRun(src(world({ run: { ...base.run, conclusion: 'failure' } }), { a: PROFILE, b: PROFILE }), '100', ['a', 'b'], TREE, HEAD);
|
||||
expect(two).toMatchObject({ ok: false, reason: 'no_verified_run', detail: expect.stringContaining('a: run 100') });
|
||||
});
|
||||
test('a newer red run on the consumer\'s own head vetoes', () => {
|
||||
const base = world();
|
||||
const OWN = 'c'.repeat(40);
|
||||
const red: ApiRun = { ...base.run, id: 101, head_sha: OWN, conclusion: 'failure', created_at: '2026-10-29T11:00:00Z' };
|
||||
expect(verifyFromRun(src(world({ others: [red] })), '100', ['test.yml/unit'], TREE, OWN)).toMatchObject({ ok: false, reason: 'newer_run_vetoes' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* GST-467: a subagent running /ship cannot read its own jam id, so its Capy reading is the root
|
||||
* thread's counter, which also holds every sibling lane. Such a run is tree-scoped and every cost
|
||||
* line it prints carries METER: tree (root counter, upper bound); a subagent that asks for a self
|
||||
* scope while metering from "this" is refused (METER_SCOPE_PARENT).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { RESULT_CODES } from '../lib/result-codes';
|
||||
import { labelScope, type Ledger } from '../lib/ship-budget';
|
||||
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const TREE = 'METER: tree (root counter, upper bound)';
|
||||
const COST = /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/;
|
||||
const harnesses: Harness[] = [];
|
||||
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
|
||||
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
|
||||
|
||||
function costLinesLabeled(out: string): boolean[] {
|
||||
const lines = out.split('\n');
|
||||
return lines.flatMap((l, i) => COST.test(l) ? [lines[i + 1] === TREE] : []);
|
||||
}
|
||||
|
||||
function observedRun(h: Harness, start: string[], env: NodeJS.ProcessEnv = {}) {
|
||||
const s = h.sb(['start', '--record', h.record(), '--mode', 'observe', '--reading', '1000000', '--credits', '100', ...start], env);
|
||||
if (s.code !== 0) return { start: s, note: s, close: s };
|
||||
const run = h.field(s.out, 'BUDGET_RUN', 'id');
|
||||
const note = h.sb(['note', '--run', run, '--step', '6', '--reading', '1500000', '--credits', '150']);
|
||||
const close = h.sb(['close', '--run', run, '--reading', '2000000', '--credits', '200']);
|
||||
return { start: s, note, close };
|
||||
}
|
||||
|
||||
describe('meter scope (GST-467)', () => {
|
||||
// Value: protects=a run metered off the root counter never prints a cost without saying it is the root counter, an upper bound; fails_when=a tree-scoped run prints a BUDGET, NOTE, BUDGET_FINAL or BUDGET_TIMELINE line with no METER: tree line under it, or a self run is labeled tree;
|
||||
// why_new=#3686's ledger baseline 5,769,355,282 was the root thread's counter, sibling lanes included (GST-467); seam=none; no_claim=the exact per-run cost, which only a root replay thread measures
|
||||
test('every cost line of a tree-scoped run is labeled, whether asked for or implied by a subagent reading "this"; a self run is not', () => {
|
||||
for (const [start, env] of [[['--meter-scope', 'tree'], {}], [['--subagent', '--reading-from', 'this'], {}], [['--reading-from', 'this'], { GSTACK_SHIP_SUBAGENT: '1' }]] as const) {
|
||||
const r = observedRun(fresh(), [...start], env);
|
||||
for (const out of [r.start, r.note, r.close]) {
|
||||
expect(out.code, out.err).toBe(0);
|
||||
const labels = costLinesLabeled(out.out);
|
||||
expect(labels.length, out.out).toBeGreaterThan(0);
|
||||
expect(labels.every(Boolean), out.out).toBe(true);
|
||||
}
|
||||
expect(r.close.out).toMatch(/^BUDGET_FINAL: [^\n]*\nMETER: tree \(root counter, upper bound\)$/m);
|
||||
expect(r.close.out).toMatch(/^BUDGET_TIMELINE: [^\n]*\nMETER: tree \(root counter, upper bound\)$/m);
|
||||
}
|
||||
for (const start of [['--meter-scope', 'self'], ['--reading-from', 'this', '--meter-scope', 'self'], []]) {
|
||||
const r = observedRun(fresh(), start);
|
||||
for (const out of [r.start, r.note, r.close]) {
|
||||
expect(out.code, out.err).toBe(0);
|
||||
expect(out.out).not.toContain('METER: tree');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=resume and a handoff child of a tree-scoped run keep the label; fails_when=resume or verify-handoff prints a BUDGET line with no METER: tree line under it;
|
||||
// why_new=the test above drives start, note and close only; seam=none; no_claim=a child's exact cost
|
||||
test('resume and a handoff child of a tree-scoped run label their cost lines too', () => {
|
||||
const h = fresh();
|
||||
const s = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']);
|
||||
const run = h.field(s.out, 'BUDGET_RUN', 'id');
|
||||
const resumed = h.sb(['resume', '--run', run, '--reading', '1200000']);
|
||||
const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']);
|
||||
expect(ho.code, ho.out + ho.err).toBe(0);
|
||||
const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]);
|
||||
for (const out of [resumed, ho, child]) {
|
||||
expect(out.code, out.out + out.err).toBe(0);
|
||||
const labels = costLinesLabeled(out.out);
|
||||
expect(labels.length, out.out).toBeGreaterThan(0);
|
||||
expect(labels.every(Boolean), out.out).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a tree run rebuilt from its record after a lost ledger keeps the label; fails_when=the record block drops meter_scope or rebuildLedger does not restore it (red-team, ship gst467-p1);
|
||||
// why_new=the cases above never lose the ledger; seam=none; no_claim=checkpoint restore, which copies the whole ledger
|
||||
test('a tree run rebuilt from its record still labels its cost lines', () => {
|
||||
const h = fresh();
|
||||
const record = h.record();
|
||||
const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000', '--meter-scope', 'tree']);
|
||||
const ledger = h.field(s.out, 'BUDGET_RUN', 'ledger');
|
||||
fs.writeFileSync(ledger, '{"kind": "gstack-ship-budget", oops');
|
||||
const rebuilt = h.sb(['resume', '--record', record, '--rebuild-from-record', '--reading', '1500000']);
|
||||
expect(rebuilt.out, rebuilt.out + rebuilt.err).toContain('REBUILT:');
|
||||
const close = h.sb(['close', '--record', record, '--reading', '2000000']);
|
||||
for (const out of [rebuilt, close]) {
|
||||
const labels = costLinesLabeled(out.out);
|
||||
expect(labels.length, out.out).toBeGreaterThan(0);
|
||||
expect(labels.every(Boolean), out.out).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=re-running start with tree flags on an open self run upgrades it, and a tree child's saved return file keeps the label the parent reads; fails_when=the re-start returns before applying the scope, or the return file is written before labelScope (outside voice, ship gst467-p1);
|
||||
// why_new=start's resume branch and the return file were never driven with tree scope; seam=none; no_claim=a downgrade, which tree never allows
|
||||
test('a re-start asking for tree upgrades an open run, and a tree child return file carries the label once', () => {
|
||||
const h = fresh();
|
||||
const record = h.record();
|
||||
const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000']);
|
||||
expect(s.out).not.toContain(TREE);
|
||||
const again = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1100000', '--subagent', '--reading-from', 'this', '--meter-scope', 'tree']);
|
||||
const close = h.sb(['close', '--record', record, '--reading', '2000000']);
|
||||
for (const out of [again, close]) {
|
||||
expect(out.code, out.out + out.err).toBe(0);
|
||||
const labels = costLinesLabeled(out.out);
|
||||
expect(labels.length, out.out).toBeGreaterThan(0);
|
||||
expect(labels.every(Boolean), out.out).toBe(true);
|
||||
}
|
||||
const p = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']);
|
||||
const run = h.field(p.out, 'BUDGET_RUN', 'id');
|
||||
const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']);
|
||||
const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!;
|
||||
const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]);
|
||||
const ret = h.sb(['return', '--run', h.field(child.out, 'BUDGET_RUN', 'id'), '--to', '11.5']);
|
||||
expect(ret.code, ret.out + ret.err).toBe(0);
|
||||
for (const text of [ret.out, fs.readFileSync(returnFile, 'utf8')]) {
|
||||
const labels = costLinesLabeled(text);
|
||||
expect(labels.length, text).toBeGreaterThan(0);
|
||||
expect(labels.every(Boolean), text).toBe(true);
|
||||
expect(text.split(TREE).length - 1, text).toBe(labels.length);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=every root-counter line a tree run prints is labeled once, drift and reconcile lines included; fails_when=labelScope misses BUDGET_DRIFT or RECONCILED, or labels an already-labeled line twice (adversarial, ship gst467-p1);
|
||||
// why_new=no CLI case above prints drift or reconcile lines; seam=labelScope; no_claim=which commands print them
|
||||
test('labelScope labels drift and reconcile lines too, and never twice', () => {
|
||||
const tree = { meter_scope: 'tree' } as Ledger;
|
||||
const lines = ['RECONCILED: handoff=x child=complete child_used=5', 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', 'GSTACK_RESULT: skill=ship'];
|
||||
expect(labelScope(tree, [...lines])).toEqual(['RECONCILED: handoff=x child=complete child_used=5', TREE, 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', TREE, 'GSTACK_RESULT: skill=ship']);
|
||||
expect(labelScope({} as Ledger, [...lines])).toEqual(lines);
|
||||
});
|
||||
|
||||
// Value: protects=a subagent cannot claim its own cost while reading the root counter; fails_when=start accepts --meter-scope self from a subagent metering "this", or the refusal lacks its code and fix;
|
||||
// why_new=GST-467; seam=none; no_claim=whether the brief sets the subagent flag
|
||||
test('a subagent metering "this" that asks for a self scope is refused with METER_SCOPE_PARENT and a fix', () => {
|
||||
const h = fresh();
|
||||
for (const [args, env] of [[['--subagent'], {}], [[], { GSTACK_SHIP_SUBAGENT: '1' }]] as const) {
|
||||
const r = h.sb(['start', '--record', h.record(), '--mode', 'observe', '--reading', '1000000', '--reading-from', 'this', '--meter-scope', 'self', ...args], env);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.err).toContain('(METER_SCOPE_PARENT)');
|
||||
expect(r.err).toContain('--meter-scope tree');
|
||||
}
|
||||
const bad = h.sb(['start', '--record', h.record(), '--reading', '1', '--meter-scope', 'mine']);
|
||||
expect(bad.code).toBe(2);
|
||||
expect(bad.err).toContain('--meter-scope');
|
||||
expect(RESULT_CODES.METER_SCOPE_PARENT.fix).toContain('--meter-scope tree');
|
||||
expect(fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf8')).toContain(`<a id="${RESULT_CODES.METER_SCOPE_PARENT.anchor}"></a>`);
|
||||
});
|
||||
|
||||
// Value: protects=the /ship brief tells a subagent to meter tree-scoped and the docs say where exact cost comes from; fails_when=the Capy skill or docs/ship-budget.md drops --meter-scope tree or the root-replay pointer;
|
||||
// why_new=GST-467; seam=none; no_claim=agent compliance
|
||||
test('the Capy skill and docs/ship-budget.md tell a subagent to pass --meter-scope tree; exact per-run cost comes from root replay threads', () => {
|
||||
const skill = fs.readFileSync(path.join(ROOT, 'capy/skill/gstack/SKILL.md'), 'utf8').replace(/\s+/g, ' ');
|
||||
expect(skill).toContain('--subagent --reading-from this --meter-scope tree');
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'docs/ship-budget.md'), 'utf8').replace(/\s+/g, ' ');
|
||||
expect(doc).toContain('--meter-scope tree');
|
||||
expect(doc).toContain(TREE);
|
||||
expect(doc).toContain('root replay thread');
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,7 @@ describe('ship source controller', () => {
|
||||
|
||||
test('Step 1 carries a receipt only after a dry run and a pregate at HEAD', () => {
|
||||
const carry = compact(entry.slice(entry.indexOf('4. **Carry check.**'), entry.indexOf('5. Display historical readiness')));
|
||||
const order = ['carry --pr <n> --base <base> --dry-run', '`CARRY: would carry`', 'skip review', '`gstack-pregate --allow-repo-commands`', 'carry without `--dry-run`', '`--pregate .gstack/tmp/pregate.json`', '`CARRY: carried` binds HEAD', 'A failed check, HEAD moving after pregate, `refused` or `current` re-runs Steps 2–16.'];
|
||||
const order = ['carry --pr <n> --base <base> --dry-run', '`CARRY: would carry`', 'skip review', '`gstack-pregate --allow-repo-commands`', 'carry without `--dry-run`', '`--pregate .gstack/tmp/pregate.json`', '`CARRY: carried` binds HEAD', 'A failed check, HEAD moving after pregate, `current`, or `refused` with no `NEXT: carry route` re-runs Steps 2–16.'];
|
||||
const at = order.map(t => carry.indexOf(t));
|
||||
expect(at.every(i => i >= 0)).toBe(true);
|
||||
expect(at).toEqual([...at].sort((a, b) => a - b));
|
||||
@@ -338,6 +338,16 @@ describe('ship source controller', () => {
|
||||
expect(docs).toMatch(/never silently refresh hashes/i);
|
||||
});
|
||||
|
||||
// Value: protects=finish's scoped-review STOP and --docs-requested carry are routes Step 16 names, so a driver that reads
|
||||
// the template instead of the STOP text keeps the GST-384/GST-391 saving; fails_when=stage 2 sends every tests=/context=
|
||||
// STOP to the full loop or stage 3 offers only a re-audit or a named risk (GST-444); seam=none; no_claim=the bins' own STOP text
|
||||
test('Step 16 routes the scoped-review STOP and the --docs-requested fix (GST-444)', () => {
|
||||
const behavior = gate.slice(gate.indexOf('1. **Behavior'), gate.indexOf('2. **Only authored'));
|
||||
expect(behavior).toContain('A STOP naming a scoped-review packet instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.');
|
||||
const docs = gate.slice(gate.indexOf('### 3.'), gate.indexOf('### 4.'));
|
||||
expect(docs).toContain('**A fix the audit\'s result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.');
|
||||
});
|
||||
|
||||
test('tests, absent test approval and new writes reopen final verification', () => {
|
||||
const tests = gate.slice(gate.indexOf('### 4.'), gate.indexOf('### 5.'));
|
||||
expect(tests).toMatch(/\*\*New, changed or unwaived test failure:\*\* stop publication\. Run Steps 5–15/i);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { WAVE_HELP, areaKey, groupWaves, type PlanPr } from '../lib/ship-queue-wave';
|
||||
import { WAVE_HELP, areaKey, groupWaves, isHubFile, type PlanPr } from '../lib/ship-queue-wave';
|
||||
import { cleanup, runBin } from './helpers/restamp-fixture';
|
||||
import { line, openPr, queue, readyReceipt, shippedBody, trainFixture, type TrainFixture } from './helpers/ship-queue-train-fixture';
|
||||
|
||||
@@ -66,6 +66,19 @@ describe('areas and grouping', () => {
|
||||
]);
|
||||
expect(solo).toEqual(['7']);
|
||||
});
|
||||
|
||||
test('groupWaves links PRs through a shared code file or skill template only when the merge conflicts there, never through a hub file (GST-770)', () => {
|
||||
const pr = (n: string, files: string[]): PlanPr => ({ pr: n, head: n.repeat(40).slice(0, 40), files, lines: 10 });
|
||||
const areaOf = (f: string) => areaKey(f, d => d === 'ship');
|
||||
const linked = (files: string[], conflicts?: (a: PlanPr, b: PlanPr) => string[] | null) =>
|
||||
groupWaves([pr('1', ['lib/pregate-a.ts', 'test/pregate-a.test.ts', ...files]), pr('2', ['lib/review-b.ts', 'test/review-b.test.ts', ...files])], areaOf, 2, 6, conflicts).groups.map(g => g.prs.map(p => p.pr));
|
||||
expect(linked(['lib/ship-a.ts'])).toEqual([['1', '2']]);
|
||||
expect(linked(['lib/ship-a.ts'], () => ['lib/ship-a.ts'])).toEqual([['1', '2']]);
|
||||
expect(linked(['lib/ship-a.ts'], () => [])).toEqual([]);
|
||||
expect(linked(['ship/SKILL.md.tmpl'], () => ['ship/SKILL.md.tmpl'])).toEqual([['1', '2']]);
|
||||
for (const hub of ['docs/troubleshooting.md', 'ship/SKILL.md', 'scripts/harness-version.json']) expect(linked([hub], () => [hub])).toEqual([]);
|
||||
expect([isHubFile('docs/x.ts'), isHubFile('TODOS.md'), isHubFile('test/fixtures/x.json'), isHubFile('ship/SKILL.md.tmpl'), isHubFile('lib/x.ts')]).toEqual([true, true, true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wave build', () => {
|
||||
@@ -235,6 +248,7 @@ describe('ready and train build read the wave', () => {
|
||||
const pins = ['1', '2', '3', '4', '5', '6', '7'].map(pr => {
|
||||
const h = openPr(fx, pr, { file: pr === '7' ? 'docs/seven.md' : `lib/ship-${pr}.ts`, changelog: false });
|
||||
readyReceipt(fx, pr, h);
|
||||
fx.write(d => { d.ci[h] = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }]; });
|
||||
return `${pr}@${h}`;
|
||||
});
|
||||
const build = queue(fx, ['train', 'build', '--prs', pins.join(','), '--dry-run']);
|
||||
@@ -246,6 +260,55 @@ describe('ready and train build read the wave', () => {
|
||||
expect(line(plan.out, 'SOLO:')).toContain('#7');
|
||||
expect(line(queue(fx, ['wave', 'plan', '--trigger', '7']).out, 'WAVE:')).toContain('not needed');
|
||||
}, 180_000);
|
||||
|
||||
test('wave plan links PRs only on a real code conflict, so hub docs and ledgers do not chain areas together, and leaves out READY heads whose CI is not green (GST-770)', () => {
|
||||
const fx = make();
|
||||
const moved = (first: string) => Array.from({ length: 10 }, (_, i) => i === 0 && first ? `export const o0 = '${first}';\n` : `export const o${i} = ${i};\n`).join('');
|
||||
const seed = { 'docs/troubleshooting.md': '# Troubleshooting\n', 'test/fixtures/ledger.json': '{}\n', 'lib/ship-a.ts': 'export const a = 0;\n', 'lib/ship-old.ts': moved('') };
|
||||
fx.repo.git('checkout', '-q', 'origin/main', '-B', 'seed');
|
||||
for (const [f, t] of Object.entries(seed)) { fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(fx.repo.work, f), t); }
|
||||
fx.repo.git('add', '-A');
|
||||
fx.repo.git('commit', '-q', '-m', 'seed hub files');
|
||||
fx.repo.git('push', '-q', 'origin', 'HEAD:main');
|
||||
fx.repo.git('fetch', '-q', 'origin');
|
||||
const pr = (n: string, area: string, extra: Record<string, string | null> = {}, ci = 'success') => {
|
||||
fx.repo.git('checkout', '-q', 'origin/main', '-B', `pr-${n}`);
|
||||
const files = { [`lib/${area}-${n}.ts`]: `export const x${n} = 1;\n`, [`test/${area}-${n}.test.ts`]: `// #${n}\n`,
|
||||
'docs/troubleshooting.md': `# Troubleshooting\n\n## #${n}\n`, 'test/fixtures/ledger.json': `{ "pr": ${n} }\n`, ...extra };
|
||||
for (const [f, t] of Object.entries(files)) if (t === null) fs.rmSync(path.join(fx.repo.work, f)); else fs.writeFileSync(path.join(fx.repo.work, f), t);
|
||||
fx.repo.git('add', '-A');
|
||||
fx.repo.git('commit', '-q', '-m', `feat #${n}`);
|
||||
const head = fx.repo.git('rev-parse', 'HEAD');
|
||||
fx.repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${n}/head`);
|
||||
fx.write(d => {
|
||||
d.prs[n] = { headRefOid: head, isCrossRepository: false, state: 'OPEN', headRefName: `pr-${n}`, url: `https://github.com/fixture/repo/pull/${n}`, body: shippedBody(n, head) };
|
||||
d.ci[head] = [{ name: 'free-tests', status: 'completed', conclusion: ci }];
|
||||
});
|
||||
readyReceipt(fx, n, head);
|
||||
};
|
||||
pr('1', 'ship', { 'lib/ship-a.ts': Array.from({ length: 40 }, (_, i) => `export const a${i} = 'one';\n`).join('') });
|
||||
pr('2', 'ship', { 'lib/ship-old.ts': null, 'lib/ship-new.ts': moved('two') });
|
||||
pr('3', 'ship');
|
||||
for (const n of ['4', '5', '6']) pr(n, 'pregate');
|
||||
pr('8', 'review', { 'lib/ship-a.ts': "export const a = 'eight';\n" });
|
||||
pr('9', 'pregate', {}, 'failure');
|
||||
pr('10', 'pregate');
|
||||
pr('11', 'pregate', { 'lib/ship-old.ts': null, 'lib/ship-new.ts': moved('eleven') });
|
||||
fx.write(d => { d.ci[(d.prs['10'] as any).headRefOid] = [{ name: 'free-tests', status: 'in_progress', conclusion: null }]; });
|
||||
const plan = queue(fx, ['wave', 'plan']);
|
||||
expect(plan.status).toBe(EXIT.ok);
|
||||
expect(plan.out.split('\n').filter(l => l.startsWith('NOT_GREEN:')).map(l => l.replace(/ at [0-9a-f]{12} .*/, ''))).toEqual(['NOT_GREEN: #9 (red', 'NOT_GREEN: #10 (pending']);
|
||||
expect(line(plan.out, 'QUEUE:')).toContain('8 live READY PR(s)');
|
||||
expect(plan.out.split('\n').filter(l => l.startsWith('WAVE_GROUP:'))).toEqual([
|
||||
'WAVE_GROUP: area=pregate prs=#4,#5,#6 integrator=#4 (most changed lines)',
|
||||
'WAVE_GROUP: area=ship prs=#1,#8,#2,#11,#3 integrator=#1 (most changed lines)',
|
||||
]);
|
||||
expect(line(plan.out, 'SOLO:')).toBe('');
|
||||
fx.write(d => { d.ciFail = [(d.prs['4'] as any).headRefOid]; });
|
||||
const unread = queue(fx, ['wave', 'plan']);
|
||||
expect([unread.status, unread.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_PROOF_MISSING')]);
|
||||
expect(unread.err).toContain('#4 (CI unknown at');
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('wave settle', () => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* gstack-ship-receipt carry on a PR whose own diff is large (GST-448, harness-evals#147): a results PR that adds thousands
|
||||
* of run-receipt files has a name-only listing and a product diff over the child-process output buffer, so carry refused
|
||||
* with "git could not compute HEAD's product patch-id: git diff … failed" after every base move.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt } from '../lib/ship-receipt';
|
||||
import { cleanup, commit, makeFixtureRepo as makeRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
const makeFixtureRepo = (): FixtureRepo => { const r = makeRepo(); r.git('config', 'core.longpaths', 'true'); return r; };
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const DIR = `results/${'harness-eval-run-receipts-'.repeat(8)}`;
|
||||
/** `count` run receipts under a long directory name: about 1.4 MB of paths and 2 MB of diff, past the 1 MB default buffer. */
|
||||
const receipts = (count: number, tag: string) => Object.fromEntries(Array.from({ length: count }, (_, i) => [`${DIR}/${tag}-run-${String(i).padStart(5, '0')}.json`, `{"run":${i},"tag":"${tag}","score":0.${i % 10}}\n`]));
|
||||
|
||||
describe('carry with a large product diff (GST-448)', () => {
|
||||
// Value: protects=a results PR keeps its receipt across a base move when its own diff is megabytes of added files
|
||||
// (harness-evals#147 paid ~3h of /ship per base move); fails_when=carry buffers git's name-only listing or the diff in
|
||||
// a bounded child-process buffer and refuses; seam=none; no_claim=gh pr edit (--body stands in)
|
||||
test('a base merge carries the receipt of a PR that adds 6000 files', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
commit(repo, receipts(6000, 'base'), 'results: 6000 run receipts');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]);
|
||||
expect(w.status).toBe(EXIT.ok);
|
||||
const reviewed = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.product_patch_id;
|
||||
expect(reviewed).toMatch(/^[0-9a-f]{40}$/);
|
||||
repo.git('checkout', '-q', 'main');
|
||||
write(repo.work, 'docs/README.md', '# docs moved on main\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:')), out.err.split('\n').find(l => l.includes('RECEIPT_CARRY'))]).toEqual([EXIT.ok, expect.stringMatching(/^CARRY: carried/), undefined]);
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.product_patch_id).toBe(reviewed);
|
||||
});
|
||||
|
||||
// Value: protects=negative control: the large-diff path still refuses when the PR's own receipts change after review;
|
||||
// fails_when=the streamed patch-id reads as unchanged for a changed diff; seam=none; no_claim=none
|
||||
test('one changed receipt in the large diff still refuses the carry', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
commit(repo, receipts(6000, 'base'), 'results: 6000 run receipts');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok);
|
||||
commit(repo, { [`${DIR}/base-run-00042.json`]: '{"run":42,"tag":"base","score":0.9}\n' }, 'results: rescore run 42');
|
||||
const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)']);
|
||||
});
|
||||
|
||||
// Value: protects=the patch-id diff lives under the git dir (on disk, not a tmpfs TMPDIR), and a spot it cannot write is a
|
||||
// named refusal, never a stack trace preflight reads as 'CARRY: check failed'; fails_when=patchId writes to TMPDIR or
|
||||
// creates its directory outside its error handling; seam=TMPDIR, a file where the directory goes; no_claim=none
|
||||
test('patch-id diffs go under the git dir: an unwritable TMPDIR still carries, an unwritable git-dir spot refuses by name', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
commit(repo, receipts(3, 'base'), 'results: 3 run receipts');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok);
|
||||
repo.git('checkout', '-q', 'main');
|
||||
write(repo.work, 'docs/README.md', '# docs moved on main\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('merge', '-q', '--no-edit', 'origin/main');
|
||||
const dry = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--dry-run'], { TMPDIR: path.join(repo.root, 'no-such-dir') });
|
||||
expect([dry.status, dry.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.ok, expect.stringMatching(/^CARRY: would carry/)]);
|
||||
fs.rmSync(path.join(repo.work, '.git', 'gstack-patch-id'), { recursive: true, force: true });
|
||||
fs.writeFileSync(path.join(repo.work, '.git', 'gstack-patch-id'), 'not a directory\n');
|
||||
const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--dry-run']);
|
||||
expect([out.status, out.out.split('\n').find(l => l.startsWith('CARRY:'))]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']);
|
||||
expect(out.err).toContain('cannot create a temp file under');
|
||||
});
|
||||
|
||||
// Value: protects=a carry killed mid patch-id (preflight's 120 s timeout) does not leave product diffs in TMPDIR for good;
|
||||
// fails_when=nothing removes a gstack-patch-id-* directory a killed run left; seam=TMPDIR, an aged directory; no_claim=none
|
||||
test('a stale patch-id directory a killed run left is swept; a fresh one is kept', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
commit(repo, receipts(3, 'base'), 'results: 3 run receipts');
|
||||
const tmp = path.join(repo.work, '.git', 'gstack-patch-id'); fs.mkdirSync(tmp, { recursive: true });
|
||||
const stale = path.join(tmp, 'run-stale'); const fresh = path.join(tmp, 'run-fresh');
|
||||
for (const d of [stale, fresh]) { fs.mkdirSync(d); fs.writeFileSync(path.join(d, 'diff.patch'), 'diff\n'); }
|
||||
const old = new Date(Date.now() - 2 * 60 * 60 * 1000); fs.utimesSync(stale, old, old);
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '9', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok);
|
||||
expect([fs.existsSync(stale), fs.existsSync(fresh)]).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { EXIT } from '../lib/headless-artifacts';
|
||||
import { comparePath } from '../lib/ship-patch';
|
||||
import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt';
|
||||
import { cleanup, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(90_000);
|
||||
const BIN = path.dirname(RELEASE);
|
||||
@@ -484,3 +484,44 @@ describe('test-only and audit-requested edits after the docs audit (GST-391, gbr
|
||||
expect([reset.status, reset.stdout.includes('ATTEMPT: counted (a second install refusal in this invocation)')]).toEqual([3, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preflight carry route for an adjacent base edit (GST-442)', () => {
|
||||
// Value: protects=/ship Step 1's carry check offers the scoped review when the base edited context next to a reviewed hunk,
|
||||
// instead of treating the refusal as "run the full loop", and its dry run writes nothing; fails_when=preflight acts only on
|
||||
// 'CARRY: would carry' or carry --dry-run writes the packet; seam=gstack-ship-receipt carry --dry-run; no_claim=the reviewer
|
||||
test('preflight routes RECEIPT_CARRY_SCOPED_REVIEW to one scoped review, and the dry run writes no packet', () => {
|
||||
const src = (edit: Record<number, string> = {}) => Array.from({ length: 12 }, (_, i) => edit[i + 1] ?? `exports.v${i + 1} = ${i + 1};`).join('\n') + '\n';
|
||||
const f = fx({ 'lib/rows.js': src() }, { 'lib/rows.js': src({ 6: 'exports.v6 = 600;' }) });
|
||||
const reviewed = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
f.advanceBase({ 'lib/rows.js': src({ 5: 'exports.v5 = "five";' }) });
|
||||
mergeMain(f, { 'lib/rows.js': src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) });
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const view = path.join(f.root, 'view.json');
|
||||
fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any), headRefOid: head }));
|
||||
f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`);
|
||||
const r = f.run(PREFLIGHT, ['--base', 'main']);
|
||||
expect(lineOf(r.stdout, 'CARRY')).toBe('CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)');
|
||||
expect(r.stdout).toMatch(/^NEXT: carry route \(Step 1\.4\): .*fix what the base merge broke.*commit and push.*writes the packet.*one fix-check reviewer.*--scoped-review <record\.json>/m);
|
||||
expect(fs.existsSync(path.join(f.work, '.git', 'gstack', `scoped-review-${head.slice(0, 12)}.md`))).toBe(false);
|
||||
const wrote = f.run(path.join(BIN, 'gstack-ship-receipt'), ['carry', '--pr', '9', '--base', 'main']);
|
||||
expect([wrote.status, fs.existsSync(path.join(f.work, '.git', 'gstack', `scoped-review-${head.slice(0, 12)}.md`))]).toEqual([EXIT.refused, true]);
|
||||
});
|
||||
|
||||
// Value: protects=the dry run offers the scoped route only when nothing else would refuse the carry, so preflight never sends
|
||||
// the agent through a commit, packet, reviewer and pregate that end in another refusal (adversarial-native#5, Step 11
|
||||
// round 2); fails_when=the dry run returns the scoped refusal before the receipt's other carry checks; seam=gh; no_claim=none
|
||||
test('a dry run that the receipt would refuse anyway names that refusal, not the scoped route', () => {
|
||||
const src = (edit: Record<number, string> = {}) => Array.from({ length: 12 }, (_, i) => edit[i + 1] ?? `exports.v${i + 1} = ${i + 1};`).join('\n') + '\n';
|
||||
const f = fx({ 'lib/rows.js': src() }, { 'lib/rows.js': src({ 6: 'exports.v6 = 600;' }) });
|
||||
const reviewed = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
f.advanceBase({ 'lib/rows.js': src({ 5: 'exports.v5 = "five";' }) });
|
||||
mergeMain(f, { 'lib/rows.js': src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) });
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const view = path.join(f.root, 'view.json');
|
||||
fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', gstack_version: '1.0.0' } as any), headRefOid: head }));
|
||||
f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`);
|
||||
const r = f.run(path.join(BIN, 'gstack-ship-receipt'), ['carry', '--pr', '9', '--base', 'main', '--dry-run']);
|
||||
expect([r.status, lineOf(r.stdout, 'CARRY')]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": -6,
|
||||
"why": "GST-442/GST-444: Step 1.4 follows a scoped-review NEXT; Step 16 stage 2 and 3 route finish scoped-review STOP and --docs-requested with its --docs-result; trims in Steps 3, 12 and 16 keep the skill under its cap, and a Step 3 merge-conflict trim keeps the skeleton under maxSkeletonBytes with train 77 on main"
|
||||
}
|
||||
@@ -224,6 +224,23 @@ describe('prefix trees modulo stamps', () => {
|
||||
expect(stampFreeTree(git, 'f'.repeat(40), repo.work, policy)).toHaveProperty('error');
|
||||
});
|
||||
|
||||
// Value: protects=a gated-to-current diff git cannot produce refuses with its cause instead of reading as 'no changes'
|
||||
// (GST-448); fails_when=buildReceipt treats a failed diff as an empty one or drops why it failed; seam=a missing blob; no_claim=none
|
||||
test('a diff git cannot produce refuses the receipt with EVIDENCE_IDENTITY_UNKNOWN and git\'s reason', () => {
|
||||
const policy = loadPolicyFromHead(repo.work).policy;
|
||||
repo.git('checkout', '-q', '-B', 'lost-blob', sha.t1!);
|
||||
fs.writeFileSync(path.join(repo.work, 'lost.txt'), 'a blob this test deletes from the object store\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'add a blob');
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const blobId = repo.git('rev-parse', `${head}:lost.txt`);
|
||||
const gitDir = repo.git('rev-parse', '--absolute-git-dir');
|
||||
fs.rmSync(path.join(gitDir, 'objects', blobId.slice(0, 2), blobId.slice(2)));
|
||||
const r = buildReceipt({ repoRoot: repo.work, gated: sha.t1!, current: head, policy, policyLabel: 'fixture' }) as { error?: string; code?: string };
|
||||
expect(r.code).toBe('EVIDENCE_IDENTITY_UNKNOWN');
|
||||
expect(r.error).toMatch(/^git diff [0-9a-f]{12} [0-9a-f]{12} failed \(.+\), so the changed files cannot be classified/);
|
||||
repo.git('checkout', '-q', '-f', 'squash');
|
||||
});
|
||||
|
||||
// #3512: a stamp path carrying a tentative `*_KEY_VERSION = '<next minor>'` (gstack's own convention since #3511) digests the
|
||||
// same before and after the slot stamp moves it, so train-backstop finds the record that covers main; a base-version mention
|
||||
// on a line the base already carried digests the same at the prefix (where it equals VERSION) and on the stamped main.
|
||||
|
||||
@@ -125,6 +125,60 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=the first up on a fresh project reaches its create with the default reap on; fails_when=the reap before up dies on an empty list again (#3678); why_new=no up case ran against a project with no VMs; seam=none; no_claim=real create and boot (fake API and ssh)
|
||||
test('up on an empty project reaps nothing, creates its VM and comes up (#3678)', () => {
|
||||
const f = fakeVm({ listed: '\n' });
|
||||
try {
|
||||
const up = run(f, ['up']);
|
||||
expect(up.stderr.toString()).toContain('reap: no expired VMs');
|
||||
expect(up.exitCode).toBe(0);
|
||||
expect(f.calls()).toContain('"create"');
|
||||
expect(f.calls()).not.toContain('destroy');
|
||||
const name = up.stdout.toString().trim();
|
||||
expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/);
|
||||
expect(run(f, ['down', name]).exitCode).toBe(0);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=usage never reports a partial or empty count as the project's real usage, and the quota preflight says when it cannot read usage instead of passing silently; fails_when=a failed vm list or vm show prints a total again (the empty-project fix's `|| true` did that) or the preflight drops its log line; why_new=only successful reads were tested, and #3679 showed an unreadable usage switching the preflight off without a word; seam=none; no_claim=the real API's failure bodies
|
||||
test('usage exits 1 without a total when the VM list or a size read fails, skips a VM destroyed since the list, and the quota preflight logs an unreadable usage and leaves the call to the API', () => {
|
||||
const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' });
|
||||
const listed = join(f.root, 'listed');
|
||||
try {
|
||||
writeFileSync(`${listed}.list-fails`, '');
|
||||
const noList = run(f, ['usage']);
|
||||
expect(noList.exitCode).toBe(1);
|
||||
expect(noList.stdout.toString()).not.toContain('total');
|
||||
expect(noList.stderr.toString()).toContain("usage: could not list the project's VMs");
|
||||
const up = run(f, ['up'], { UBI_REAP: '0', UBI_QUOTA_VCPUS: '16' });
|
||||
expect(up.stderr.toString()).toContain("quota preflight: could not read the project's vCPU usage; the API's own answer decides");
|
||||
expect(f.calls()).toContain('"create"');
|
||||
// With a wait budget, an unreadable usage is retried until the budget is spent, as a full project is.
|
||||
const waited = run(f, ['up'], { UBI_REAP: '0', UBI_QUOTA_VCPUS: '16', UBI_QUOTA_WAIT: '2' });
|
||||
expect(waited.stderr.toString()).toContain("quota: could not read the project's vCPU usage; retrying, up to 2s more");
|
||||
expect(waited.stderr.toString()).toContain("quota preflight: could not read the project's vCPU usage; the API's own answer decides");
|
||||
const waitedName = waited.stdout.toString().trim();
|
||||
rmSync(`${listed}.list-fails`);
|
||||
for (const name of [up.stdout.toString().trim(), waitedName]) {
|
||||
expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/);
|
||||
expect(run(f, ['down', name]).exitCode).toBe(0);
|
||||
}
|
||||
|
||||
writeFileSync(`${listed}.show-fails`, '');
|
||||
const noShow = run(f, ['usage']);
|
||||
expect(noShow.exitCode).toBe(1);
|
||||
expect(noShow.stdout.toString()).not.toContain('total');
|
||||
expect(noShow.stderr.toString()).toContain('usage: could not read the size of 1 of 1 VMs');
|
||||
rmSync(`${listed}.show-fails`);
|
||||
|
||||
// A VM destroyed between the list and its size read (show answers 404) is gone, not unread.
|
||||
writeFileSync(`${listed}.ghost`, 'eu-central-h1 ubirun-gone-9999999999-dddd-t3\n');
|
||||
const ghost = run(f, ['usage']);
|
||||
expect(ghost.exitCode).toBe(0);
|
||||
expect(ghost.stdout.toString()).toBe('OWNER VMS VCPUS\npeer 1 16\ntotal 1 16\n');
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=the quota wait budget counts seconds slept, not usage reads: a slow usage read keeps the printed budget, a wait above 30 s counts down in 30 s steps, and the wait releases when room appears; fails_when=the budget goes back to a wall-clock deadline taken before the first usage read (the first log line shrinks to 1s), the step cap or the decrement is lost, or room found mid-wait still refuses; why_new=the WAIT=3 case above passes under both the old deadline and the new countdown because its fake usage read is instant and it never sees a second step; seam=none (the fixture's bin/ is already first on PATH, so sleep and curl are stubbed there); no_claim=real elapsed time of the long wait (the 70 s case stubs sleep)
|
||||
test('quota wait: a slow usage read does not shave the printed budget; a long wait counts down in 30s steps and releases when room appears', () => {
|
||||
const slow = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' });
|
||||
|
||||
@@ -250,6 +250,25 @@ esac
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 30_000);
|
||||
|
||||
// Value: protects=a project with no VMs (Ubicloud answers vm list with a bare newline) reads as empty everywhere vm_rows feeds; fails_when=a blank list row becomes a VM named "-" again and reap dies on it or usage asks to show it; why_new=every fake list in this file had at least one VM, so #3678 shipped green; seam=none; no_claim=the real API's empty-body shape (taken from #3678's reading of ubi_cli.rb)
|
||||
test('an empty project (vm list answers a bare or blank line) has no VMs: reap destroys nothing and exits 0, usage prints total 0 0, list --mine prints nothing (#3678)', () => {
|
||||
for (const listed of ['\n', ' \n']) {
|
||||
const f = fakeApi({ destroy: 200, listed });
|
||||
try {
|
||||
const reap = f.run('reap');
|
||||
expect(reap.stderr.toString()).toContain('reap: no expired VMs');
|
||||
expect(reap.stderr.toString()).not.toContain('invalid VM name');
|
||||
expect(reap.exitCode).toBe(0);
|
||||
const usage = f.run('usage');
|
||||
expect(usage.exitCode).toBe(0);
|
||||
expect(usage.stdout.toString()).toBe('OWNER VMS VCPUS\ntotal 0 0\n');
|
||||
expect(f.run('list', '--mine').stdout.toString()).toBe('');
|
||||
expect(f.calls()).not.toContain('destroy');
|
||||
expect(f.calls()).not.toContain('"show"');
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}
|
||||
});
|
||||
|
||||
test('up and run refuse without UBI_OWNER before any API call; list, usage, reap and down still work without it', () => {
|
||||
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 vm-a\n', owner: '' });
|
||||
try {
|
||||
@@ -376,6 +395,25 @@ test('pack ships a linked worktree with a self-contained .git on the checked-out
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=pack never lets macOS tar add AppleDouble ._* entries (files with extended attributes), which the VM's tree check would count as untracked files and fail the run with UBI_VM_TREE_MISMATCH; fails_when=a tar call in pack runs without COPYFILE_DISABLE=1; why_new=only Apple's tar reads the variable, so no Linux run can see the ._* entries themselves; seam=PATH (a tar shim records its environment, then runs the real tar); no_claim=Apple tar's metadata behavior itself (checked by hand on a Mac)
|
||||
test('pack runs every tar with COPYFILE_DISABLE=1, for a checkout and for a plain directory', () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'ubi-pack-copyfile-'));
|
||||
try {
|
||||
const realTar = Bun.which('tar');
|
||||
expect(realTar).toBeTruthy();
|
||||
mkdirSync(join(d, 'bin'));
|
||||
writeFileSync(join(d, 'bin/tar'), `#!/usr/bin/env bash\necho "COPYFILE_DISABLE=${'$'}{COPYFILE_DISABLE:-unset}" >> ${JSON.stringify(join(d, 'tar.log'))}\nexec ${JSON.stringify(realTar)} "$@"\n`);
|
||||
chmodSync(join(d, 'bin/tar'), 0o755);
|
||||
const env = { ...process.env, PATH: `${join(d, 'bin')}:${process.env.PATH}`, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
|
||||
const sh = (cmd: string) => { const r = Bun.spawnSync(['bash', '-c', cmd], { cwd: d, env, timeout: 30_000 }); expect(r.exitCode, r.stderr.toString()).toBe(0); };
|
||||
sh(`git init -q repo && cd repo && echo a > a && git add a && git commit -qm one && bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack . > ../repo.tgz`);
|
||||
sh(`mkdir plain && echo p > plain/p && bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack plain > plain.tgz`);
|
||||
const lines = readFileSync(join(d, 'tar.log'), 'utf8').trim().split('\n');
|
||||
expect(lines.length).toBe(3);
|
||||
expect(lines.every(l => l === 'COPYFILE_DISABLE=1')).toBe(true);
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('up records the VM in the scratch file before the create request is sent', () => {
|
||||
const runner = read('scripts/ubicloud/ubi-runner.sh');
|
||||
const create = runner.indexOf('cli "${args[@]}" >/dev/null 2>"$dir/create.err"');
|
||||
|
||||
@@ -34,7 +34,7 @@ test('the Windows lane plans, runs one strict shard per job and verifies every r
|
||||
expect(planStep.env.GSTACK_FREE_TEST_DURATIONS).toBe('scripts/free-test-durations-windows.json');
|
||||
const shard = windows.jobs['windows-free-shard'];
|
||||
expect(shard['runs-on']).toBe('windows-latest');
|
||||
expect(shard.needs).toBe('windows-plan');
|
||||
expect(shard.needs).toEqual(['windows-plan', 'reuse']);
|
||||
expect(shard.strategy).toEqual({ 'fail-fast': false, matrix: '${{ fromJSON(needs.windows-plan.outputs.matrix) }}' });
|
||||
const run = shard.steps.find((step: any) => step.name === 'Run Windows-safe shard');
|
||||
expect(run.run).toStartWith('bun run test:windows --ci-run "$RUNNER_TEMP/windows-plan.json" --shard ${{ matrix.shard }} --result ');
|
||||
@@ -42,9 +42,9 @@ test('the Windows lane plans, runs one strict shard per job and verifies every r
|
||||
expect(shard.steps.find((step: any) => step.name === 'Upload strict shard result').if).toBe('always()');
|
||||
const aggregate = windows.jobs['windows-free-tests'];
|
||||
expect(aggregate.if).toBe('${{ always() && !inputs.record_durations }}');
|
||||
expect(plan.if).toBe('${{ !inputs.record_durations }}');
|
||||
expect(aggregate.needs).toEqual(['windows-plan', 'windows-free-shard']);
|
||||
expect(aggregate.steps.at(-1).run).toContain('--windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results ');
|
||||
expect(plan.if).toBe("${{ !cancelled() && !inputs.record_durations && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}");
|
||||
expect(aggregate.needs).toEqual(['reuse', 'windows-plan', 'windows-free-shard']);
|
||||
expect(aggregate.steps.find((step: any) => step.name === 'Require exact coverage and complete strict results').run).toContain('--windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results ');
|
||||
});
|
||||
|
||||
test('a Windows CI plan binds the curated file set, so it never verifies as the full Linux suite', () => {
|
||||
|
||||
Reference in new issue
Block a user