forgedock 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/bin/engine/outcome-codes.mjs +37 -2
  2. package/bin/engine/phases.mjs +207 -106
  3. package/bin/engine/runlog.mjs +17 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +37 -2
  7. package/bin/engine.mjs +221 -15
  8. package/bin/forgedock.mjs +1 -1
  9. package/bin/hooks/pre-tool-use.mjs +65 -32
  10. package/bin/runner.mjs +125 -6
  11. package/bin/stats/aggregate.mjs +7 -3
  12. package/bin/stats/cache.mjs +7 -3
  13. package/bin/stats/github-source.mjs +203 -24
  14. package/bin/stats/index.mjs +46 -9
  15. package/bin/stats/render.mjs +1 -1
  16. package/bin/stats/stats.test.mjs +264 -15
  17. package/commands/cleanup.md +10 -3
  18. package/commands/deploy-pr.md +64 -0
  19. package/commands/fix-ci.md +20 -6
  20. package/commands/milestone.md +34 -17
  21. package/commands/orchestrate/phase-4-execution.md +11 -4
  22. package/commands/review-pr-staging.md +145 -522
  23. package/commands/review-pr.md +496 -3124
  24. package/commands/test-gate.md +25 -10
  25. package/commands/work-on/build/architect.md +66 -1
  26. package/commands/work-on/build/implement.md +21 -1
  27. package/commands/work-on/build/validate.md +322 -28
  28. package/commands/work-on/build.md +173 -95
  29. package/commands/work-on/close.md +50 -15
  30. package/commands/work-on/decompose.md +72 -34
  31. package/commands/work-on/investigate.md +16 -12
  32. package/commands/work-on/remediate.md +58 -25
  33. package/commands/work-on/review.md +36 -21
  34. package/commands/work-on.md +1 -2
  35. package/docs/CONFIG.md +132 -26
  36. package/docs/OUTCOME-CODES.md +12 -3
  37. package/docs/RELEASE-NOTES.md +12 -0
  38. package/docs/STATS.md +13 -5
  39. package/docs/TELEMETRY.md +7 -4
  40. package/docs/WORK-ON-RUNTIME.md +24 -0
  41. package/docs/spec/forge-protocol-v1.md +2 -2
  42. package/package.json +2 -2
  43. package/packages/protocol/src/phases.js +5 -5
  44. package/packages/protocol/src/types.js +1 -1
  45. package/scripts/acceptance-red-green.sh +90 -0
  46. package/scripts/acceptance-red-green.test.sh +74 -0
  47. package/scripts/cap-findings.sh +1 -1
  48. package/scripts/cap-findings.test.sh +3 -1
  49. package/scripts/check-contract-scope.test.sh +2 -0
  50. package/scripts/check-edge-cases.sh +143 -0
  51. package/scripts/check-edge-cases.test.sh +175 -0
  52. package/scripts/check-spec-bash.sh +5 -1
  53. package/scripts/check-spec-bash.test.sh +20 -0
  54. package/scripts/check-spec-markers.sh +2 -0
  55. package/scripts/check-test-tmp-cleanup.sh +56 -0
  56. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  57. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  58. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  59. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  60. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  61. package/scripts/ci-local/adapters/common.mjs +115 -0
  62. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  63. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  64. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  65. package/scripts/ci-local/adapters/portability.mjs +104 -0
  66. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  67. package/scripts/ci-local/cli.mjs +50 -0
  68. package/scripts/ci-local/lib.mjs +371 -0
  69. package/scripts/ci-local/plan.mjs +186 -0
  70. package/scripts/ci-local/run.mjs +345 -0
  71. package/scripts/ci-local/scoping.mjs +508 -0
  72. package/scripts/ci-local.sh +97 -0
  73. package/scripts/ci-local.test.sh +919 -0
  74. package/scripts/classify-finding.sh +32 -6
  75. package/scripts/classify-finding.test.sh +23 -0
  76. package/scripts/classify-test-run.sh +69 -0
  77. package/scripts/classify-test-run.test.sh +90 -0
  78. package/scripts/close-parent-c3.test.sh +71 -0
  79. package/scripts/consumer-sweep.sh +569 -0
  80. package/scripts/consumer-sweep.test.sh +357 -0
  81. package/scripts/detect-append-registries.sh +106 -0
  82. package/scripts/detect-append-registries.test.sh +64 -0
  83. package/scripts/diff-size.sh +93 -4
  84. package/scripts/diff-size.test.sh +59 -2
  85. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  86. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  87. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  88. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  89. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  90. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  91. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  92. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  93. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  94. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  95. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  96. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  97. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  98. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  99. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  100. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  101. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  102. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  103. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  104. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  105. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  106. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  107. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  108. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  109. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  110. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  111. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  112. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  113. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  114. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  115. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  116. package/scripts/forge-root.test.sh +14 -12
  117. package/scripts/lib/telemetry.sh +1 -0
  118. package/scripts/publish-staging-sync.test.sh +48 -0
  119. package/scripts/remediate-approved-head.test.sh +2 -0
  120. package/scripts/remediate-basesync.test.sh +1 -1
  121. package/scripts/resolve-deploy-gate.sh +32 -6
  122. package/scripts/resolve-deploy-gate.test.sh +42 -1
  123. package/scripts/review-acceptance-gate.sh +2 -2
  124. package/scripts/review-acceptance-gate.test.sh +20 -0
  125. package/scripts/review-delta.sh +8 -1
  126. package/scripts/review-delta.test.sh +19 -0
  127. package/scripts/review-finalize.sh +1579 -0
  128. package/scripts/review-finalize.test.sh +1040 -0
  129. package/scripts/review-pr-delta-wiring.test.sh +32 -72
  130. package/scripts/review-pr-domain-map.test.sh +18 -13
  131. package/scripts/review-pr-gate2.test.sh +9 -65
  132. package/scripts/review-pr-staging-scope.test.sh +14 -98
  133. package/scripts/review-prepare.sh +53 -15
  134. package/scripts/review-prepare.test.sh +37 -3
  135. package/scripts/review-roster.sh +16 -2
  136. package/scripts/review-roster.test.sh +22 -0
  137. package/scripts/run-shell-tests.sh +5 -0
  138. package/scripts/telemetry.test.sh +2 -0
  139. package/scripts/trusted-comments.test.sh +11 -3
  140. package/scripts/verify-phase-trail.test.sh +8 -0
  141. package/scripts/wait-ci-green.sh +61 -9
  142. package/scripts/wait-ci-green.test.sh +44 -6
  143. package/scripts/with-run-tmpdir.sh +20 -0
@@ -9,13 +9,19 @@
9
9
  # the PR checkout, a SPEC_CLI persona file, forge.yaml paths.root) as data.
10
10
  #
11
11
  # Usage: review-prepare.sh --pr N --repo owner/name [--merge-issue N] [--thorough true|false] [--out FILE] [--out-dir DIR]
12
- # [--checkout-pr]
12
+ # [--checkout-pr] [--no-delta] [--build-gate block|advisory]
13
13
  # --pr PR number (numeric, required) --repo owner/name (required)
14
14
  # --merge-issue issue the PR closes, from --issue (numeric; non-numeric is ignored)
15
15
  # --thorough true restores the full-union roster --out write the context JSON here (default: stdout)
16
- # --out-dir directory for per-domain diff slices (default: ${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-prepare-<pr>)
16
+ # --out-dir directory for per-domain diff slices, named slice-<sha7>-<domain>.diff (default: ${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-prepare-<pr>)
17
17
  # --checkout-pr `git checkout -f --detach <review sha>` in the CURRENT repo (must be a throwaway review checkout, never the
18
18
  # main checkout); the recorded original ref is restored by restore_ref on every exit path
19
+ # --build-gate block (default): an empty trusted typescript.build / python.format on a full-build lane BLOCKs.
20
+ # advisory: record it as a SKIPPED Phase 2 finding instead (review-pr-staging: the deploy review only ever
21
+ # reported "SKIPPED — typescript.build not configured", and blocking it would stop every deploy of a repo
22
+ # with plain .js files and no build command, ForgeDock itself included).
23
+ # --no-delta skip the FULL/DELTA/BASE_SYNC_ONLY resolver: the review mode is always FULL (a staging bundle review always reviews the
24
+ # whole bundle), without widening the roster to the full union the way --thorough does
19
25
  # Env: FORGE_CONFIG (forge.yaml path; default: the main checkout's forge.yaml, then the git top-level one),
20
26
  # REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5).
21
27
  # Exit: 0 context emitted (status OK) | 3 context emitted with status BLOCKED (the caller must stop, never skip) | 2 usage error
@@ -52,7 +58,7 @@
52
58
 
53
59
  set -u
54
60
 
55
- PR_NUMBER=""; REPO=""; MERGE_ISSUE=""; THOROUGH="false"; OUT=""; OUT_DIR=""; CHECKOUT_PR="false"
61
+ PR_NUMBER=""; REPO=""; MERGE_ISSUE=""; THOROUGH="false"; OUT=""; OUT_DIR=""; CHECKOUT_PR="false"; NO_DELTA="false"; BUILD_GATE="block"
56
62
  while [ $# -gt 0 ]; do
57
63
  case "$1" in
58
64
  --pr|--repo|--merge-issue|--thorough|--out|--out-dir)
@@ -66,6 +72,8 @@ while [ $# -gt 0 ]; do
66
72
  --out) OUT="${2:-}"; shift 2 ;;
67
73
  --out-dir) OUT_DIR="${2:-}"; shift 2 ;;
68
74
  --checkout-pr) CHECKOUT_PR="true"; shift ;;
75
+ --no-delta) NO_DELTA="true"; shift ;;
76
+ --build-gate) case "${2:-}" in block|advisory) BUILD_GATE="$2" ;; *) echo "review-prepare: --build-gate must be block or advisory" >&2; exit 2 ;; esac; shift 2 ;;
69
77
  *) echo "review-prepare: unknown argument: $1" >&2; exit 2 ;;
70
78
  esac
71
79
  done
@@ -104,7 +112,7 @@ cleanup() { restore_ref; rm -rf "$TMP_DIR"; }
104
112
  trap cleanup EXIT
105
113
 
106
114
  # --- Phase 1A: fetch ---
107
- PR_INFO=$(gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,author,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles 2>/dev/null) || PR_INFO=""
115
+ PR_INFO=$(gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,author,baseRefName,headRefName,headRefOid,isCrossRepository,additions,deletions,changedFiles 2>/dev/null) || PR_INFO=""
108
116
  printf '%s' "$PR_INFO" | jq -e . >/dev/null 2>&1 || PR_INFO=""
109
117
  if [ -z "$PR_INFO" ]; then block "gh pr view failed for #${PR_NUMBER}"; fi
110
118
  pj() { printf '%s' "$PR_INFO" | jq -r "$1 // empty" 2>/dev/null; }
@@ -118,8 +126,18 @@ LINES=$((ADDITIONS + DELETIONS))
118
126
 
119
127
  FILES=""; DIFF=""
120
128
  if [ -z "$BLOCKERS" ]; then
121
- FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only 2>/dev/null) || block "gh pr diff --name-only failed"
122
- DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" 2>/dev/null) || block "gh pr diff failed"
129
+ FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only 2>/dev/null) || FILES="__FAILED__"
130
+ [ "$FILES" = "__FAILED__" ] || DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" 2>/dev/null) || FILES="__FAILED__"
131
+ if [ "$FILES" = "__FAILED__" ]; then
132
+ # A staging bundle can exceed the API's diff size limit: read it from the long-lived branches instead (--no-delta only; a per-PR review blocks).
133
+ FILES=""; DIFF=""
134
+ # Pinned (forge#3935): same-repo PRs only (a fork's branch name resolves to origin's own branch), and the fetched tip must equal REVIEW_SHA; the diff is taken against REVIEW_SHA, never the branch name.
135
+ if [ "$NO_DELTA" = "true" ] && [ -n "$BASE" ] && [ -n "$HEAD" ] && [ "$(printf '%s' "$PR_INFO" | jq -r .isCrossRepository 2>/dev/null)" = "false" ] && printf '%s' "$REVIEW_SHA" | grep -qE '^[0-9a-f]{40}$' \
136
+ && git fetch -q origin "$BASE" "$HEAD" 2>/dev/null && [ "$(git rev-parse -q --verify "refs/remotes/origin/${HEAD}^{commit}" 2>/dev/null)" = "$REVIEW_SHA" ]; then
137
+ FILES=$(git diff --name-only "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) && DIFF=$(git diff "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) || { FILES=""; DIFF=""; }
138
+ fi
139
+ [ -n "$FILES" ] || block "gh pr diff failed"
140
+ fi
123
141
  [ -n "$FILES" ] || [ -n "$BLOCKERS" ] || block "PR #${PR_NUMBER} has an empty file list"
124
142
  fi
125
143
 
@@ -201,8 +219,13 @@ if [ "$REQUIRES_FULL_BUILD" = "true" ]; then
201
219
  if [ "$CONFIG_READABLE" != "true" ]; then
202
220
  BUILD_CONFIG_STATUS="blocked"; block "full-build lane but no readable trusted forge.yaml (checked FORGE_CONFIG, the main checkout and the git top-level)"
203
221
  else
204
- if [ -n "$HAS_TS" ] && [ -z "$TS_BUILD" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: typescript.build is empty in verification.commands on a full-build lane (typecheck alone misses prerender failures)"; fi
205
- if [ -n "$HAS_PY" ] && [ -z "$PY_FORMAT" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: python.format is empty in verification.commands on a full-build lane"; fi
222
+ if [ "$BUILD_GATE" = "advisory" ]; then
223
+ if [ -n "$HAS_TS" ] && [ -z "$TS_BUILD" ]; then BUILD_CONFIG_STATUS="skipped"; p2 "1B: SKIPPED — typescript.build not configured in verification.commands (build not run)"; fi
224
+ if [ -n "$HAS_PY" ] && [ -z "$PY_FORMAT" ]; then BUILD_CONFIG_STATUS="skipped"; p2 "1A: SKIPPED — python.format not configured in verification.commands"; fi
225
+ else
226
+ if [ -n "$HAS_TS" ] && [ -z "$TS_BUILD" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: typescript.build is empty in verification.commands on a full-build lane (typecheck alone misses prerender failures)"; fi
227
+ if [ -n "$HAS_PY" ] && [ -z "$PY_FORMAT" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: python.format is empty in verification.commands on a full-build lane"; fi
228
+ fi
206
229
  fi
207
230
  fi
208
231
  # 2D env vars, 2E secrets (CRITICAL), 2F SQL, over the diff text
@@ -346,6 +369,13 @@ CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS# }"
346
369
  # --- Phase 3B step 4: roster (review-roster.sh; unresolved or failing means the full union) ---
347
370
  agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]'); case "$l" in infrastructure) l=infra ;; scraping) l=scraper ;; web) l=frontend ;; esac; printf '%s' "$l"; }
348
371
  FIX_PR=false; SRC_SEV=""; SRC_DOM=""
372
+ # Review depth (forge#3862): latest TRUSTED FORGE:DIFF_SIZE on the merge issue; unreadable or absent stays STANDARD (extended only adds reviewers).
373
+ REVIEW_DEPTH=STANDARD; REVIEW_WEIGHT=""
374
+ if [ -n "$MERGE_ISSUE" ]; then
375
+ _ds=$(gh api --paginate "repos/$REPO/issues/$MERGE_ISSUE/comments" --jq '[.[] | select(.body | startswith("<!-- FORGE:DIFF_SIZE")) | select((.author_association | IN("OWNER","MEMBER","COLLABORATOR")) or (.user.type == "Bot"))] | last | .body // ""' 2>/dev/null) || _ds=""
376
+ printf '%s\n' "$_ds" | grep -qE '^review_depth: *EXTENDED[[:space:]]*$' && REVIEW_DEPTH=EXTENDED
377
+ REVIEW_WEIGHT=$(printf '%s\n' "$_ds" | sed -n 's/^review_weight: *\([0-9][0-9]*\).*/\1/p' | head -n 1)
378
+ fi
349
379
  if [ -n "$MERGE_ISSUE" ]; then
350
380
  gh issue view "$MERGE_ISSUE" -R "$REPO" --json labels --jq '[.labels[].name] | any(. == "review-finding")' 2>/dev/null | grep -qx true && FIX_PR=true
351
381
  if [ "$FIX_PR" = "true" ]; then
@@ -361,7 +391,7 @@ DBC=false; printf '%s\n' "$DIFF" | grep -qE "docker-compose.*postgres|docker-com
361
391
  ROSTER_OUT=""
362
392
  [ -n "$ROSTER_SCRIPT" ] && ROSTER_OUT=$(printf '%s\n' "$FILES" | bash "$ROSTER_SCRIPT" --files-file - --scores "$SC" --changed-lines "$LINES" --changed-files "$NFILES" \
363
393
  --fix-pr "$FIX_PR" --source-severity "$SRC_SEV" --source-domain "$SRC_DOM" --contract-flags "$CONTRACT_RISK_FLAGS" --churn "$CHURN_ESCALATION" --danger-zone "$DANGER" \
364
- --thorough "$THOROUGH" --milestone "$IS_MILESTONE_TO_STAGING" --scraping-configured "$SCRAPING_ON" --database-container "$DBC" --spec-cli "$SPEC_CLI" 2>/dev/null)
394
+ --thorough "$THOROUGH" --milestone "$IS_MILESTONE_TO_STAGING" --scraping-configured "$SCRAPING_ON" --database-container "$DBC" --spec-cli "$SPEC_CLI" --review-depth "$REVIEW_DEPTH" 2>/dev/null)
365
395
  SELECTED_AGENTS=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^SELECTED_AGENTS=//p')
366
396
  ROSTER_TIER=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^TIER=//p')
367
397
  ROSTER_REASON=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^REASON=//p')
@@ -405,7 +435,7 @@ delta_roster() {
405
435
  printf '%s' "$out"
406
436
  }
407
437
  REVIEW_MODE="FULL"; DELTA_FROM=""; CARRIED_FROM=""; FULL_ROUNDS=0; DELTA_FILES=""
408
- if [ "$THOROUGH" != "true" ] && [ "$IS_MILESTONE_TO_STAGING" != "true" ]; then
438
+ if [ "$THOROUGH" != "true" ] && [ "$IS_MILESTONE_TO_STAGING" != "true" ] && [ "$NO_DELTA" != "true" ]; then
409
439
  read -r RD_KIND RD_ARG RD_ROUNDS <<< "$(review_delta_mode)"
410
440
  FULL_ROUNDS="${RD_ROUNDS:-0}"
411
441
  case "$RD_KIND" in
@@ -426,7 +456,13 @@ IDX_AUTH=""; IDX_BILLING=""; IDX_DATABASE=""; IDX_API=""; IDX_FRONTEND=""; IDX_I
426
456
  SLICES_JSON="{}"
427
457
  if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
428
458
  review_template_resolve "$REPO_PATH"
429
- [ "$TEMPLATE_SOURCE" = "none" ] && block "no review-pr-agents template source resolved: run \`npx forgedock update\`; NEVER review inline"
459
+ if [ "$TEMPLATE_SOURCE" = "none" ]; then
460
+ block "no review-pr-agents template source resolved: run \`npx forgedock update\`; NEVER review inline"
461
+ # forge#3936: keep the setup-error trace and the needs-human alert the old spec bash posted before returning BLOCKED.
462
+ _msg="Review of PR #${PR_NUMBER} could not start: no review-pr-agents template source resolved (broken install). Run \`npx forgedock update\`, then re-run the review. Reviews are never done inline."
463
+ gh pr comment "$PR_NUMBER" -R "$REPO" --body "$_msg" >/dev/null 2>&1 || true # allowlist:check-command-side-effects
464
+ [ -z "$MERGE_ISSUE" ] || gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label needs-human >/dev/null 2>&1 || true # allowlist:check-command-side-effects
465
+ fi
430
466
  if [ -n "$CODE_INDEX_SCRIPT" ]; then bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1 || true; fi
431
467
  build_index_slice() {
432
468
  local domain="${1}" files
@@ -446,7 +482,7 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
446
482
  SLICE_FILES_SRC="$FILES"; [ "$REVIEW_MODE" = "DELTA" ] && SLICE_FILES_SRC="$DELTA_FILES"
447
483
  # slice <domain> <diff-header regex|ALL> <file-path regex|ALL>
448
484
  slice() {
449
- local dom="$1" dre="$2" fre="$3" body paths df="$OUT_DIR/slice-$1.diff"
485
+ local dom="$1" dre="$2" fre="$3" body paths tmp df="$OUT_DIR/slice-${REVIEW_SHA_SHORT:-unresolved}-$1.diff"
450
486
  if [ "$dre" = "ALL" ]; then body="$FULL_DIFF"
451
487
  else
452
488
  body=$(printf '%s\n' "$FULL_DIFF" | awk -v re="$dre" '/^diff --git/ { in_block=0 } $0 ~ re { in_block=1 } in_block { print }')
@@ -456,7 +492,9 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
456
492
  else paths=$(printf '%s\n' "$SLICE_FILES_SRC" | grep -iE "$fre" || echo ""); [ -n "$paths" ] || paths="$SLICE_FILES_SRC"; fi
457
493
  # 100K-char cap, same as the runner's tool-result cap
458
494
  if [ "${#body}" -gt 102400 ]; then body="$(printf '%s' "$body" | head -c 102400)"$'\n'"... [TRUNCATED — diff exceeded 100K chars; focus on the files listed]"; fi
459
- printf '%s\n' "$body" > "$df"
495
+ # Publish atomically: temp file in $OUT_DIR (same filesystem) then rename, so a concurrent reader sees nothing or the whole file.
496
+ tmp=$(mktemp "$OUT_DIR/.slice-XXXXXX") || { echo "review-prepare: cannot create temp slice in $OUT_DIR" >&2; exit 2; }
497
+ if printf '%s\n' "$body" > "$tmp" && chmod 644 "$tmp"; then mv -f "$tmp" "$df"; else rm -f "$tmp"; echo "review-prepare: cannot write slice $df" >&2; exit 2; fi
460
498
  SLICES_JSON=$(printf '%s' "$SLICES_JSON" | jq -c --arg d "$dom" --arg df "$df" --arg p "$paths" '. + {($d): {paths: ($p | split("\n") | map(select(length > 0))), diff_file: $df}}')
461
499
  }
462
500
  slice security ALL ALL
@@ -487,7 +525,7 @@ CTX=$(jq -n \
487
525
  --argjson sa "$SCORE_AUTH" --argjson sb "$SCORE_BILLING" --argjson sc "$SCORE_CONCURRENCY" --argjson sd "$SCORE_DATABASE" --argjson si "$SCORE_INFRA" \
488
526
  --argjson ss "$SCORE_SECURITY" --argjson ssc "$SCORE_SCRAPING" --argjson sf "$SCORE_FRONTEND" --argjson sap "$SCORE_API" \
489
527
  --arg cissue "$ISSUE_NUM" --arg cflags "$CONTRACT_RISK_FLAGS" \
490
- --arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
528
+ --arg rdepth "$REVIEW_DEPTH" --arg rweight "$REVIEW_WEIGHT" --arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
491
529
  --arg dmode "$REVIEW_MODE" --arg dfrom "$DELTA_FROM" --arg dcarried "$CARRIED_FROM" --argjson drounds "$FULL_ROUNDS" --argjson dfiles "$(j_lines "$DELTA_FILES")" \
492
530
  --arg pname "$PROJECT_NAME" --arg pctx "$PROJECT_CONTEXT" --arg scraping_on "$SCRAPING_ON" --arg rpath "$REPO_PATH" \
493
531
  --arg d_auth "$DOMAIN_CONTEXT_AUTH" --arg d_billing "$DOMAIN_CONTEXT_BILLING" --arg d_conc "$DOMAIN_CONTEXT_CONCURRENCY" --arg d_infra "$DOMAIN_CONTEXT_INFRA" \
@@ -505,7 +543,7 @@ CTX=$(jq -n \
505
543
  broken_assumptions: $asm, risk_signals: $risk, churn_context: $churn, churn_escalation: ($churn_esc == "true"), danger_zone: ($danger == "true"),
506
544
  scores: {AUTH: $sa, BILLING: $sb, CONCURRENCY: $sc, DATABASE: $sd, INFRA: $si, SECURITY: $ss, SCRAPING: $ssc, FRONTEND: $sf, API: $sap},
507
545
  contract: {issue: ($cissue | tonumber? // null), flags: $cflags},
508
- roster: {tier: $rtier, reason: $rreason, agents: $agents},
546
+ roster: {tier: $rtier, review_depth: $rdepth, review_weight: $rweight, reason: $rreason, agents: $agents},
509
547
  delta: {mode: $dmode, from: $dfrom, carried_from: $dcarried, full_rounds: $drounds, files: $dfiles},
510
548
  config: {project_name: $pname, project_context: $pctx, scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
511
549
  domains: {auth: $d_auth, billing: $d_billing, concurrency: $d_conc, infra: $d_infra, database: $d_db, frontend: $d_fe, security: $d_sec, api: $d_api, scraping: $d_scr}},
@@ -68,7 +68,7 @@ printf 'project:\n name: Demo\nverification:\n commands:\n typescript:\n
68
68
  # setup <head> <base> <files> <diff> [sha]
69
69
  setup() {
70
70
  rm -f "${FIX:?}"/*; printf 'MERGEABLE|CLEAN\n' > "$FIX/merge.seq"; : > "$FIX/body.txt"
71
- printf '{"number":7,"title":"t","author":{"login":"dev"},"baseRefName":"%s","headRefName":"%s","headRefOid":"%s","additions":5,"deletions":2,"changedFiles":1}\n' "$2" "$1" "${5:-$SHA_B}" > "$FIX/prinfo.json"
71
+ printf '{"number":7,"title":"t","author":{"login":"dev"},"baseRefName":"%s","headRefName":"%s","headRefOid":"%s","isCrossRepository":%s,"additions":5,"deletions":2,"changedFiles":1}\n' "$2" "$1" "${5:-$SHA_B}" "${CROSS_REPO:-false}" > "$FIX/prinfo.json"
72
72
  printf '%s\n' "$3" > "$FIX/files"; printf '%s\n' "$4" > "$FIX/diff"
73
73
  }
74
74
  CTX=""; RC=0
@@ -94,6 +94,8 @@ prep "$ROOT"
94
94
  rc_is "happy path" 0
95
95
  eq "schema" .schema_version 1; eq "status" .status OK; eq "pr number" .pr.number 7; eq "review sha" .pr.review_sha "$SHA_B"
96
96
  eq "sha7" .pr.review_sha_short "$(printf '%s' "$SHA_B" | cut -c1-7)"
97
+ eq "review depth defaults to STANDARD" .roster.review_depth STANDARD
98
+ eq "review weight key present in context" '.roster | has("review_weight")' true
97
99
  eq "small tier" .roster.tier small; eq "small roster" '.roster.agents | join(" ")' security
98
100
  eq "lines" '.pr.additions + .pr.deletions' 7
99
101
  eq "first-time false" .pr.first_time_contributor false
@@ -123,8 +125,24 @@ eq "auth score" '.scores.AUTH > 0' true; eq "billing score" '.scores.BILLING > 0
123
125
  eq "security baseline" .scores.SECURITY 1
124
126
  eq "risk FINANCIAL" '.risk_signals | index("FINANCIAL") != null' true
125
127
  eq "slice auth file exists" '.slices.auth.diff_file | length > 0' true
126
- if grep -q "auth.py" "$WORK/out/slice-auth.diff" 2>/dev/null; then ok; else bad "auth slice should hold the auth.py diff"; fi
127
- if grep -q "billing.py" "$WORK/out/slice-auth.diff" 2>/dev/null; then bad "auth slice leaked the billing diff"; else ok; fi
128
+ AUTH_SLICE=$(jqv '.slices.auth.diff_file')
129
+ eq "auth slice name is keyed by head SHA" '.slices.auth.diff_file | endswith("/slice-'"$(printf '%s' "$SHA_B" | cut -c1-7)"'-auth.diff")' true
130
+ if grep -q "auth.py" "$AUTH_SLICE" 2>/dev/null; then ok; else bad "auth slice should hold the auth.py diff"; fi
131
+ if grep -q "billing.py" "$AUTH_SLICE" 2>/dev/null; then bad "auth slice leaked the billing diff"; else ok; fi
132
+ if [ -z "$(ls -A "$WORK"/out/.slice-* 2>/dev/null)" ]; then ok; else bad "temp slice files left behind"; fi
133
+ # SHA isolation: a re-run at a different head SHA must not overwrite the earlier head's slice
134
+ FIRST_SUM=$(cksum < "$AUTH_SLICE")
135
+ setup feat/x staging "services/api/auth.py
136
+ services/api/billing.py" "$DIFF_AUTH_BILL
137
+ +changed-for-head-A" "$SHA_A"
138
+ prep "$ROOT"
139
+ AUTH_SLICE_A=$(jqv '.slices.auth.diff_file')
140
+ if [ -n "$AUTH_SLICE_A" ] && [ "$AUTH_SLICE_A" != "$AUTH_SLICE" ] && [ -f "$AUTH_SLICE" ] && [ -f "$AUTH_SLICE_A" ]; then ok; else bad "different head SHA must produce a distinct slice file ($AUTH_SLICE_A vs $AUTH_SLICE)"; fi
141
+ if [ "$(cksum < "$AUTH_SLICE")" = "$FIRST_SUM" ]; then ok; else bad "earlier head's slice was overwritten by a re-review at another SHA"; fi
142
+ if [ -z "$(ls -A "$WORK"/out/.slice-* 2>/dev/null)" ]; then ok; else bad "temp slice files left behind after re-run"; fi
143
+ setup feat/x staging "services/api/auth.py
144
+ services/api/billing.py" "$DIFF_AUTH_BILL"
145
+ prep "$ROOT"
128
146
  eq "auth slice paths" '.slices.auth.paths | join(" ")' "services/api/auth.py"
129
147
  eq "security slice is the full diff" '.slices.security.paths | length' 2
130
148
 
@@ -189,6 +207,11 @@ rc_is "empty ts build on a milestone lane" 3; eq "BLOCKED status" .status BLOCKE
189
207
  eq "blocker names typescript.build" '.blockers | join(" ") | test("typescript.build")' true
190
208
  eq "config status blocked" .phase2.build_config.status blocked; eq "lane flag" .lane.requires_full_build true
191
209
  prep "$ROOT"; rc_is "configured ts build passes" 0; eq "status OK" .status OK
210
+ # --build-gate advisory (review-pr-staging): an unconfigured build is a SKIPPED Phase 2 finding, never a block (#3952 QA-1)
211
+ CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT" --build-gate advisory
212
+ rc_is "advisory build gate does not block" 0; eq "advisory status OK" .status OK
213
+ eq "advisory config status skipped" .phase2.build_config.status skipped
214
+ eq "advisory records SKIPPED finding" '.phase2.findings | join(" ") | test("SKIPPED — typescript.build")' true
192
215
  PY_DIFF='diff --git a/app/x.py b/app/x.py
193
216
  +x'
194
217
  printf 'project:\n name: Demo\n' > "$WORK/empty.yaml"
@@ -253,6 +276,17 @@ if grep -q 'checkout -f "\$ORIG_REF"' "$ROOT/scripts/review-prepare.sh"; then ok
253
276
  # --- 10. fail closed on gh and bad arguments ---
254
277
  setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
255
278
  prep "$ROOT"; rc_is "gh pr diff failure" 3; eq "blocked on gh failure" .status BLOCKED
279
+ # a staging bundle review (--no-delta) reads an API-oversized diff from the long-lived branches; a per-PR review still blocks
280
+ setup pr-branch main "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
281
+ prep "$ROOT" --no-delta; rc_is "--no-delta falls back to git when the API diff fails" 0; eq "fallback status" .status OK
282
+ eq "fallback file list comes from git" '.full_files | index("services/api/auth.py") != null' true; eq "--no-delta mode is FULL" .delta.mode FULL
283
+ # forge#3935: the fallback is pinned to the review SHA and refuses fork PRs
284
+ setup pr-branch main "README.md" "$DIFF_PLAIN" "0000000000000000000000000000000000000000"; : > "$FIX/difffail"
285
+ prep "$ROOT" --no-delta; rc_is "--no-delta fallback refuses a branch tip that is not the review SHA" 3
286
+ CROSS_REPO=true setup pr-branch main "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
287
+ prep "$ROOT" --no-delta; rc_is "--no-delta fallback refuses a cross-repository PR" 3
288
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
289
+ prep "$ROOT" --no-delta; rc_is "--no-delta with unfetchable branches still fails closed" 3; eq "unfetchable blocked" .status BLOCKED
256
290
  setup feat/x staging "README.md" "$DIFF_PLAIN"
257
291
  for bad_args in "--pr abc --repo o/r" "--pr 7 --repo noslash" "--pr 7 --repo o/r;x/y" "--pr 7 --repo o/r --bogus" "--repo o/r --pr"; do
258
292
  # shellcheck disable=SC2086
@@ -11,6 +11,7 @@
11
11
  # [--source-severity SEV] [--source-domain DOMAIN] [--contract-flags "HIGH_RISK CONTRACT_AUTH ..."]
12
12
  # [--churn true|false] [--danger-zone true|false] [--thorough true|false] [--milestone true|false]
13
13
  # [--scraping-configured true|false] [--database-container true|false] [--spec-cli true|false]
14
+ # [--review-depth STANDARD|EXTENDED]
14
15
  # Output (stdout): SELECTED_AGENTS=<canonical marker domains, space separated> TIER=<tier> REASON=<text>
15
16
  # Exit: 0 always for a parseable call; 2 on an unknown flag (callers fall back to the full union, never a narrower roster).
16
17
  #
@@ -20,6 +21,10 @@
20
21
  # small <= 60 changed lines, or one file of <= 150 lines: Security only.
21
22
  # medium 61-150 lines: Security plus the score-escalation table (no top-2 baseline).
22
23
  # standard > 150 lines, or an unknown size (fail toward more agents): top-2 domains by score plus the table.
24
+ # --review-depth EXTENDED (forge#3862: the build's FORGE:DIFF_SIZE review_weight or out-of-plan scope flagged extended
25
+ # review) only ever ADDS reviewers: a small, medium or fix-pr roster is lifted to standard and the standard baseline takes
26
+ # the top-3 domains instead of top-2. It never narrows a roster and never touches the Security floor. Anything but the
27
+ # exact value EXTENDED is STANDARD. An extended roster prints an extra DEPTH=extended line.
23
28
  # Path triggers escalate in EVERY tier except full (which already runs the union): CONTRACT HIGH_RISK, cross-critical
24
29
  # pairs, churn hot-spot, danger zone, bin/engine* (Concurrency + spec-cli), bin/ (spec-cli), auth/session/trust-filter
25
30
  # files (Auth), CI/docker/deploy/infra files (Infra), migration/.sql files (Database), SDK/OpenAPI files (API).
@@ -30,7 +35,7 @@ set -u
30
35
 
31
36
  S_AUTH=0; S_BILLING=0; S_CONCURRENCY=0; S_DATABASE=0; S_INFRA=0; S_SCRAPING=0; S_FRONTEND=0; S_API=0
32
37
  LINES=""; NFILES=""; FILES_FILE=""; FIX_PR=false; SRC_SEV=""; SRC_DOM=""; CFLAGS=""
33
- CHURN=false; DANGER=false; THOROUGH=false; MILESTONE=false; SCRAPING_ON=false; DB_CONTAINER=false; SPEC_CLI=false
38
+ DEPTH=STANDARD; CHURN=false; DANGER=false; THOROUGH=false; MILESTONE=false; SCRAPING_ON=false; DB_CONTAINER=false; SPEC_CLI=false
34
39
 
35
40
  num() { case "${1:-}" in ''|*[!0-9]*) printf '0' ;; *) printf '%s' "$1" ;; esac; }
36
41
  agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]'); case "$l" in infrastructure) l=infra ;; scraping) l=scraper ;; web) l=frontend ;; esac; printf '%s' "$l"; }
@@ -67,6 +72,7 @@ while [ $# -gt 0 ]; do
67
72
  --scraping-configured) SCRAPING_ON="${2:-false}"; shift 2 ;;
68
73
  --database-container) DB_CONTAINER="${2:-false}"; shift 2 ;;
69
74
  --spec-cli) SPEC_CLI="${2:-false}"; shift 2 ;;
75
+ --review-depth) [ "${2:-}" = "EXTENDED" ] && DEPTH=EXTENDED; shift 2 ;;
70
76
  *) echo "review-roster: unknown argument: $1" >&2; exit 2 ;;
71
77
  esac
72
78
  done
@@ -121,6 +127,12 @@ fi
121
127
  # CONTRACT HIGH_RISK is never narrowed: a small, medium or fix-PR roster is lifted to the standard roster.
122
128
  BASE_TIER="$TIER"
123
129
  if [ "$HIGH_RISK" = "true" ] && [ "$TIER" != "standard" ]; then TIER=standard; REASON="$REASON; CONTRACT HIGH_RISK lifts $BASE_TIER to standard"; fi
130
+ # Extended review depth (forge#3862) is only ever additive: lift to standard, widen the standard baseline to top-3.
131
+ TOP_N=2
132
+ if [ "$DEPTH" = "EXTENDED" ]; then
133
+ TOP_N=3
134
+ if [ "$TIER" != "standard" ]; then REASON="$REASON; extended review depth lifts $TIER to standard"; TIER=standard; else REASON="$REASON; extended review depth"; fi
135
+ fi
124
136
 
125
137
  TRIG=""
126
138
  trig() { case " $TRIG " in *" $1 "*) ;; *) TRIG="$TRIG $1" ;; esac; }
@@ -133,7 +145,7 @@ case "$TIER" in
133
145
  if [ -n "$SRC_DOM" ]; then add_agent "$SRC_DOM"; else for d in $(top_domains 1); do add_agent "$d"; done; fi
134
146
  REASON="$REASON; source finding ${SRC_SEV} adds its domain" ;;
135
147
  esac ;;
136
- standard) for d in $(top_domains 2); do add_agent "$d"; done ;;
148
+ standard) for d in $(top_domains "$TOP_N"); do add_agent "$d"; done ;;
137
149
  esac
138
150
 
139
151
  # Score escalation table (medium and standard only; diff-text signals, so never in the small or fix-pr tiers)
@@ -170,3 +182,5 @@ fi
170
182
 
171
183
  [ -n "$TRIG" ] && REASON="$REASON; triggers:$TRIG"
172
184
  printf 'SELECTED_AGENTS=%s\nTIER=%s\nREASON=%s\n' "${SELECTED_AGENTS# }" "$TIER" "$REASON"
185
+ [ "$DEPTH" = "EXTENDED" ] && printf 'DEPTH=extended\n'
186
+ exit 0
@@ -159,5 +159,27 @@ hold 3520 4977 9 false "concurrency" "concurrency" "bin/engine-cli.mjs bin/engin
159
159
  hold 3524 156 5 false "spec-cli" "spec-cli" "bin/engine.mjs bin/engine/phases.mjs bin/runner.mjs bin/tests/engine-phases.test.mjs bin/tests/engine.test.mjs"
160
160
  hold 3555 137 8 false "security" "" "bin/engine/phases.mjs bin/tests/engine-phases.test.mjs commands/work-on/build.md commands/work-on/build/implement.md commands/work-on/decompose.md commands/work-on/investigate.md packages/protocol/CHANGELOG.md packages/protocol/src/phases.js"
161
161
 
162
+ # forge#3862: extended review depth (from the build's review_weight / scope flag) only ever adds reviewers
163
+ SC_TOP="AUTH=0 BILLING=1 CONCURRENCY=0 DATABASE=1 INFRA=0 SECURITY=1 SCRAPING=0 FRONTEND=2 API=2"
164
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1
165
+ expect_tier "no depth flag stays small" small; expect_sel "no depth flag stays security-only" "security"
166
+ case "$out" in *DEPTH=*) bad "standard roster prints no DEPTH line" ;; *) ok ;; esac
167
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth STANDARD
168
+ expect_tier "STANDARD depth stays small" small
169
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth extended
170
+ expect_tier "lowercase depth is not EXTENDED" small
171
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth EXTENDED
172
+ expect_tier "EXTENDED lifts small to standard" standard; expect_has "EXTENDED keeps security" security
173
+ expect_has "EXTENDED small adds top domains" frontend; expect_has "EXTENDED small adds top domains" api
174
+ case "$out" in *DEPTH=extended*) ok ;; *) bad "extended roster prints DEPTH=extended" ;; esac
175
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --changed-files 6
176
+ expect_not "standard top-2 omits the third domain" database
177
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --changed-files 6 --review-depth EXTENDED
178
+ expect_has "EXTENDED standard takes a third domain" database; expect_has "EXTENDED standard keeps security" security
179
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 30 --fix-pr true --review-depth EXTENDED
180
+ expect_tier "EXTENDED lifts fix-pr to standard" standard
181
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --thorough true --review-depth EXTENDED
182
+ expect_tier "full stays full" full
183
+
162
184
  echo "review-roster: $pass passed, $fail failed"
163
185
  [ "$fail" -eq 0 ]
@@ -22,6 +22,11 @@ cd "$REPO_ROOT" || exit 1
22
22
  # Gate scripts emit best-effort telemetry (forge#3834); suites run with it off so stubbed gh calls never buffer into ~/.forge.
23
23
  # scripts/telemetry.test.sh sets its own environment.
24
24
  export FORGE_TELEMETRY=off
25
+ # Per-run TMPDIR (forge#4016): every suite and child process inherits it and the whole dir is removed at exit,
26
+ # so a temp path a suite forgets to clean never outlives the run (tmpfs quotas fail with EDQUOT otherwise).
27
+ RUN_TMP=$(mktemp -d) || { echo "run-shell-tests: cannot create per-run TMPDIR" >&2; exit 1; }
28
+ trap 'rm -rf "$RUN_TMP"' EXIT
29
+ export TMPDIR="$RUN_TMP"
25
30
 
26
31
  TIMEOUT_SECS="${SHELL_TEST_TIMEOUT:-300}"
27
32
  # WIRE:PROVEN — manual: SHELL_TEST_TIMEOUT=abc and SHELL_TEST_TIMEOUT=0 both print the error and exit 2
@@ -55,6 +55,8 @@ for fx in "acceptance_gate phase=review verified=3 failed=0 malformed=1 deferred
55
55
  check "fixture $ev event" "$(grep '^{' "$TD/posted" | jq -r '.event')" "$ev"
56
56
  check "fixture $ev key $key" "$(grep '^{' "$TD/posted" | jq -r --arg k "$key" 'has($k)')" "true"
57
57
  done
58
+ reset; FORGE_PR=9 forge_emit finding_disposition severity=MEDIUM scope=introduced disposition=INPR_FIX
59
+ check "finding_disposition carries scope" "$(grep '^{' "$TD/posted" | jq -r '.scope')" "introduced"
58
60
  # review-side events go to the PR, others to the issue
59
61
  reset; FORGE_PR=9 forge_emit review_panel mode=FULL; grep -q 'issues/9/comments' "$TD/calls" && ok || bad "review_panel targets PR"
60
62
  reset; FORGE_PR=9 forge_emit diff_size lines=1; grep -q 'issues/5/comments' "$TD/calls" && ok || bad "diff_size targets issue"
@@ -47,7 +47,7 @@ eq "bodies with no match prints nothing" "$(printf '[]' | bash "$TC" bodies x)"
47
47
  SHA=0123456789abcdef0123456789abcdef01234567
48
48
  OTHER=fedcba9876543210fedcba9876543210fedcba98
49
49
  AGENT_RE="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
50
- LOOK_RE="^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
50
+ LOOK_RE="^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
51
51
  SYNTH_RE="^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
52
52
  NL=$'\n'
53
53
  AG_BODY="<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}${NL}<!-- REVIEW-FINDINGS-START -->${NL}<!-- FINDING:real-1 -->${NL}<!-- REVIEW-FINDINGS-END -->"
@@ -55,8 +55,9 @@ FAKE_AG="<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}${NL}<!--
55
55
  FAKE_SYN="<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->${NL}Reviewed-SHA: ${SHA}${NL}"
56
56
  AGJ="[$(c NONE Bot b "$AG_BODY"),$(c NONE User h "$FAKE_AG")]"
57
57
  eq "agent regex counts only the trusted body" "$(printf '%s' "$AGJ" | bash "$TC" count "$AGENT_RE")" 1
58
- eq "forged FINDING id never extracted" "$(printf '%s' "$AGJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]')" "real-1"
58
+ eq "forged FINDING id never extracted" "$(printf '%s' "$AGJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]')" "real-1"
59
59
  eq "lookahead agent count (REVIEW-FINDINGS-START inside body)" "$(printf '%s' "$AGJ" | bash "$TC" count "$LOOK_RE")" 1
60
+ eq "lookahead rejects START without END (truncated)" "$(printf '%s' "[$(c NONE Bot b "$FAKE_AG")]" | bash "$TC" count "$LOOK_RE")" 0
60
61
  eq "lookahead rejects body without REVIEW-FINDINGS-START" "$(printf '%s' "[$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:api -->${NL}Reviewed-SHA: ${SHA}")]" | bash "$TC" count "$LOOK_RE")" 0
61
62
  eq "agent regex rejects a different head SHA" "$(printf '%s' "[$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:api -->${NL}Reviewed-SHA: ${OTHER}")]" | bash "$TC" count "$AGENT_RE")" 0
62
63
  eq "agent regex rejects SHA line mid-line" "$(printf '%s' "[$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:api -->${NL}note Reviewed-SHA: ${SHA}")]" | bash "$TC" count "$AGENT_RE")" 0
@@ -74,7 +75,14 @@ STG_FORGED="<!-- FORGE:REVIEW-AGENT:bug-hunter-api -->${NL}Reviewed-SHA: ${SHA}$
74
75
  STGJ="[$(c NONE Bot b "$STG_TRUE"),$(c NONE Bot b "$STG_TRUE"),$(c NONE User h "$STG_FORGED"),$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}"),$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:code-quality -->${NL}Reviewed-SHA: ${OTHER}")]"
75
76
  eq "staging per-domain count ignores forged and duplicate-free of untrusted" "$(printf '%s' "$STGJ" | bash "$TC" count "$STG_DOM_RE")" 2
76
77
  eq "staging unique trusted current-head domains (hyphen, wrong head excluded)" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("^<!-- FORGE:REVIEW-AGENT:([a-z0-9-]+) -->") | .[0]' | sort -u | grep -c .)" 2
77
- eq "staging forged FINDING never extracted" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]' | sort -u)" "real-2"
78
+ eq "staging forged FINDING never extracted" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' | sort -u)" "real-2"
79
+ # Marker titles containing '>' must be extracted in full (the old [^>]+ capture dropped them); two markers on one line still split
80
+ GT_BODY="<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}${NL}<!-- FINDING:SEC-9|CONFIRMED|HIGH|a.sh:3|eval of x -> y reaches shell -->${NL}<!-- FINDING:SEC-10|LIKELY|LOW|b.sh:1|a > b --> <!-- FINDING:SEC-11|LIKELY|LOW|c.sh:2|plain -->"
81
+ GTJ="[$(c NONE Bot b "$GT_BODY")]"
82
+ GT_OUT=$(printf '%s' "$GTJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]')
83
+ eq "marker with -> in title extracted in full" "$(printf '%s\n' "$GT_OUT" | grep -c 'eval of x -> y reaches shell')" 1
84
+ eq "markers with > and two on one line all extracted" "$(printf '%s\n' "$GT_OUT" | grep -c .)" 3
85
+ eq "raw marker count equals extracted count" "$(printf '%s' "$GTJ" | bash "$TC" bodies "$AGENT_RE" | grep -o '<!-- FINDING:' | wc -l | tr -d ' ')" 3
78
86
  eq "staging regex rejects forged-only comment from NONE user" "$(printf '[%s]' "$(c NONE User h "$STG_FORGED")" | bash "$TC" count "$STG_RE")" 0
79
87
 
80
88
  # fail closed
@@ -52,6 +52,7 @@ mk() {
52
52
  CONTEXT) items+=('{"body":"<!-- FORGE:CONTEXT -->\n## Context"}') ;;
53
53
  ARCH) items+=('{"body":"<!-- FORGE:ARCHITECT -->\n## Plan"}') ;;
54
54
  QG_PASS) items+=('{"body":"<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS\n"}') ;;
55
+ QG_DEGRADED) items+=('{"body":"<!-- FORGE:QUALITY_GATE -->\n## Quality Gate Result\n\n**Result**: PASS\n**Tree**: abc\n**Degraded**: python.test — test run exercised nothing; blast-radius — check did not complete\n"}') ;;
55
56
  QG_FAIL) items+=('{"body":"<!-- FORGE:QUALITY_GATE -->\n**Result**: FAIL\n"}') ;;
56
57
  FP_NOBAND) items+=('{"body":"<!-- FORGE:FAST_PATH -->\nnothing"}') ;;
57
58
  FP_*) items+=("{\"body\":\"<!-- FORGE:FAST_PATH -->\\n**COMPLEXITY_BAND**: ${k#FP_}\"}") ;;
@@ -90,6 +91,7 @@ expect_fail "missing FAST_PATH" "$(mk f INV CONTRACT CONTEXT ARCH QG_PASS)" FAST
90
91
  expect_fail "FAST_PATH without band value fails" "$(mk g INV CONTRACT FP_NOBAND CONTEXT ARCH QG_PASS)" FAST_PATH
91
92
  expect_fail "missing QUALITY_GATE" "$(mk h INV CONTRACT FP_COMPLEX CONTEXT ARCH)" QUALITY_GATE
92
93
  expect_fail "QUALITY_GATE FAIL result does not count" "$(mk i INV CONTRACT FP_COMPLEX CONTEXT ARCH QG_FAIL)" QUALITY_GATE
94
+ expect_pass "QUALITY_GATE PASS with a **Degraded**: line still counts (forge#3628)" "$(mk i2 INV CONTRACT FP_COMPLEX CONTEXT ARCH QG_DEGRADED)"
93
95
  expect_fail "TRIVIAL still needs CONTRACT" "$(mk j INV FP_TRIVIAL QG_PASS)" CONTRACT
94
96
  expect_fail "prose mentions do not satisfy markers" "$(mk k INV CONTRACT FP_COMPLEX PROSE)" CONTEXT
95
97
 
@@ -263,6 +265,12 @@ expect_pass "head-tree matches the PASS marker's Tree" "$(ht h1 "$QG_T1")" --hea
263
265
  expect_fail "PASS recorded for an earlier tree does not satisfy the gate" "$(ht h2 "$QG_T2")" QUALITY_GATE --head-tree "$T1"
264
266
  expect_fail "PASS without a Tree line does not satisfy a bound gate" "$(ht h3 "$QG_NOTREE")" QUALITY_GATE --head-tree "$T1"
265
267
  expect_pass "a later PASS for the head tree wins over an earlier-tree PASS" "$(ht h4 "$QG_T2" "$QG_T1")" --head-tree "$T1"
268
+ # Post-merge re-gate (#3983): T1 = pre-sync build tree, T2 = merged tree after the base sync
269
+ expect_fail "stale pre-merge PASS does not satisfy the merged head" "$(ht m1 "$QG_T1")" QUALITY_GATE --head-tree "$T2"
270
+ expect_pass "post-merge re-gate marker satisfies the merged head" "$(ht m2 "$QG_T1" "$QG_T2")" --head-tree "$T2"
271
+ expect_pass "pre-merge PASS still satisfies the pre-merge tree" "$(ht m3 "$QG_T1" "$QG_T2")" --head-tree "$T1"
272
+ QG_T2_FAIL=$'<!-- FORGE:QUALITY_GATE -->\n**Result**: FAIL\n**Tree**: '"$T2"
273
+ expect_fail "a FAIL re-gate on the merged tree is not a PASS" "$(ht m4 "$QG_T2_FAIL")" QUALITY_GATE --head-tree "$T2"
266
274
  expect_pass "no --head-tree keeps the unbound behaviour" "$(ht h5 "$QG_NOTREE")"
267
275
  expect_pass "uppercase head-tree is normalised" "$(ht h6 "$QG_T1")" --head-tree "$(echo "$T1" | tr a-f A-F)"
268
276
  expect_pass "docs-only still waives the bound gate" "$(ht h7)" --docs-only --head-tree "$T1"
@@ -18,6 +18,9 @@
18
18
  # 2 ERROR bad usage, gh unreadable for too long, the PR head moved during the wait, or
19
19
  # FORGE_CI_REQUIRE_CHECKS=1 and no checks were reported
20
20
  # 3 TIMEOUT checks still pending when the timeout expired (listed)
21
+ # 4 RATE_LIMITED the window ended while GitHub only answered with rate-limit (403/429,
22
+ # x-ratelimit-remaining: 0, secondary-limit text) or transient 5xx/network errors. Retryable,
23
+ # never a CI verdict: callers treat it like TIMEOUT (wait and re-run), and it is never a merge.
21
24
  #
22
25
  # Every result also prints "CI_GATE_HEAD: <sha>" — the exact PR head the decision applies to. Callers
23
26
  # MUST merge with `--match-head-commit <that sha>` so a push after the gate cannot land unchecked.
@@ -26,7 +29,10 @@
26
29
  # caller re-runs the gate, it does not raise the timeout), FORGE_CI_INTERVAL (default 20),
27
30
  # FORGE_CI_NO_CHECKS_GRACE (default 120), FORGE_CI_SETTLE (default 45 — never PASS earlier than
28
31
  # this after the wait starts, so workflows that register late are not missed),
29
- # FORGE_CI_REQUIRE_CHECKS (default 0).
32
+ # FORGE_CI_REQUIRE_CHECKS (default 0), FORGE_CI_JITTER_PCT (default 20 — each sleep varies by +/- this
33
+ # percent so many waiting lanes do not poll in lockstep).
34
+ # Rate-limit and transient gh failures are retried with a growing, jittered sleep and never count toward
35
+ # the 5-error ERROR limit.
30
36
  # "No checks" passes only when GitHub Actions also reports no workflow runs for the head commit;
31
37
  # if runs exist but their checks have not appeared yet, the gate keeps waiting.
32
38
  set -uo pipefail
@@ -44,7 +50,7 @@ while [ $# -gt 0 ]; do
44
50
  -R*) REPO="${1#-R}"; REPO="${REPO# }"; shift ;;
45
51
  --timeout) [ $# -ge 2 ] || usage; TIMEOUT="$2"; shift 2 ;;
46
52
  --interval) [ $# -ge 2 ] || usage; INTERVAL="$2"; shift 2 ;;
47
- -h|--help) sed -n '5,25p' "$0"; exit 0 ;;
53
+ -h|--help) sed -n '5,32p' "$0"; exit 0 ;;
48
54
  *) [ -z "$PR" ] || usage; PR="$1"; shift ;;
49
55
  esac
50
56
  done
@@ -52,17 +58,50 @@ case "$PR" in ''|*[!0-9]*) usage ;; esac
52
58
  case "$REPO" in */*) ;; *) usage ;; esac
53
59
  for v in "$TIMEOUT" "$INTERVAL" "$GRACE" "$SETTLE"; do case "$v" in ''|*[!0-9]*) usage ;; esac; done
54
60
  [ "$INTERVAL" -ge 1 ] || INTERVAL=1
61
+ JITTER_PCT="${FORGE_CI_JITTER_PCT:-20}"; case "$JITTER_PCT" in ''|*[!0-9]*) JITTER_PCT=20 ;; esac
62
+ [ "$JITTER_PCT" -le 90 ] || JITTER_PCT=90
63
+ # jitter <seconds> -> seconds varied by +/- JITTER_PCT percent (never below 1).
64
+ jitter() { awk -v s="$1" -v p="$JITTER_PCT" -v r="$RANDOM" 'BEGIN{ v=s*(1-p/100+2*(p/100)*(r/32767)); if (v<1) v=1; printf "%.2f", v }'; }
65
+
66
+ # classify_gh <text> -> prints one class, or nothing when the text is unrecognised:
67
+ # limited real rate-limit evidence (x-ratelimit-remaining: 0, HTTP 429, "API rate limit exceeded" or GraphQL "API rate limit already exceeded", secondary rate limit,
68
+ # abuse detection, too many requests): a wait, bounded by the whole window
69
+ # auth a bare 401/403 (bad credentials, missing scope): permanent, never waited on
70
+ # transient 5xx / gateway / network failures: a small bounded retry (FORGE_CI_TRANSIENT_RETRIES), never the long wait
71
+ classify_gh() {
72
+ if printf '%s' "$1" | grep -Eqi 'api rate limit (already )?exceeded|secondary (rate )?limit|abuse detection|x-ratelimit-remaining: *0|HTTP 429|status 429|too many requests'; then echo limited
73
+ elif printf '%s' "$1" | grep -Eqi 'HTTP (401|403)|status (401|403)|bad credentials|requires authentication'; then echo auth
74
+ elif printf '%s' "$1" | grep -Eqi 'HTTP 5[0-9][0-9]|status 5[0-9][0-9]|bad gateway|service unavailable|gateway time-?out|timed? ?out|i/o timeout|connection (reset|refused)|could not resolve host|unexpected EOF|network is unreachable|TLS handshake'; then echo transient
75
+ fi
76
+ }
55
77
 
56
78
  emit() {
57
- case "$1" in PASS) _rc=0 ;; FAIL) _rc=1 ;; TIMEOUT) _rc=3 ;; *) _rc=2 ;; esac
79
+ case "$1" in PASS) _rc=0 ;; FAIL) _rc=1 ;; TIMEOUT) _rc=3 ;; RATE_LIMITED) _rc=4 ;; *) _rc=2 ;; esac
58
80
  FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit ci_gate result="$1" rc="$_rc" checks="${total:-0}"
59
81
  echo "CI_GATE: $1"; echo "CI_GATE_HEAD: ${START_SHA:-unknown}"
60
82
  }
61
83
  head_sha() { gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null; }
62
- START_SHA="$(head_sha)"
63
- if [ -z "$START_SHA" ]; then START_SHA=""; emit ERROR; echo "cannot read PR #$PR head in $REPO"; exit 2; fi
84
+ # forge#4037: the initial head read goes through the same classification as the checks poll: a rate limit is a wait
85
+ # (rc 4 once the window is spent), a bare 401/403 is a fast permanent error, anything else a small bounded retry.
86
+ START_SHA=""; _hstart=$(date +%s); _hlim=0; _hfail=0; _herr="$(mktemp)"; trap 'rm -f "$_herr"' EXIT
87
+ while :; do
88
+ START_SHA="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>"$_herr")" && [ -n "$START_SHA" ] && break
89
+ START_SHA=""; _hc="$(classify_gh "$(cat "$_herr" 2>/dev/null)")"
90
+ case "$_hc" in
91
+ auth) emit ERROR; echo "cannot read PR #$PR head in $REPO (HTTP 401/403 without rate-limit evidence)"; exit 2 ;;
92
+ limited)
93
+ _hlim=$((_hlim + 1))
94
+ if [ $(( $(date +%s) - _hstart )) -ge "$TIMEOUT" ]; then emit RATE_LIMITED; echo "PR #$PR: GitHub rate-limited for the whole ${TIMEOUT}s window reading the PR head; retry later"; exit 4; fi
95
+ _m=$((_hlim > 4 ? 4 : _hlim)); sleep "$(jitter $((INTERVAL * (1 << (_m - 1)))))" ;;
96
+ *)
97
+ _hfail=$((_hfail + 1))
98
+ if [ "$_hfail" -gt 2 ]; then emit ERROR; echo "cannot read PR #$PR head in $REPO"; exit 2; fi
99
+ sleep "$(jitter "$INTERVAL")" ;;
100
+ esac
101
+ done
64
102
 
65
- start=$(date +%s); errors=0
103
+ start=$(date +%s); errors=0; limited=0; transient=0
104
+ TRANSIENT_MAX="${FORGE_CI_TRANSIENT_RETRIES:-4}"; case "$TRANSIENT_MAX" in ''|*[!0-9]*) TRANSIENT_MAX=4 ;; esac
66
105
  while :; do
67
106
  now=$(date +%s); elapsed=$((now - start))
68
107
  if ! JSON="$(gh pr checks "$PR" -R "$REPO" --json name,bucket 2>/dev/null)" || ! printf '%s' "$JSON" | jq -e 'type=="array"' >/dev/null 2>&1; then
@@ -72,14 +111,27 @@ while :; do
72
111
  # does precisely in the "no checks reported" case this branch exists to detect.
73
112
  NC_OUT="$(gh pr checks "$PR" -R "$REPO" 2>&1 || true)"
74
113
  if printf '%s' "$NC_OUT" | grep -qi 'no checks reported'; then JSON='[]'
114
+ elif GH_CLASS="$(classify_gh "$NC_OUT")"; [ "$GH_CLASS" = auth ]; then
115
+ # A bare 401/403 is an auth fault that never clears: fail fast instead of waiting out the window (forge#4037).
116
+ emit ERROR; echo "gh pr checks refused (HTTP 401/403 without rate-limit evidence); fix the token or its scopes"; exit 2
117
+ elif [ "$GH_CLASS" = transient ]; then
118
+ # Network timeout / 5xx: a small bounded retry, never the long rate-limit wait.
119
+ transient=$((transient + 1))
120
+ if [ "$transient" -gt "$TRANSIENT_MAX" ]; then emit ERROR; echo "gh pr checks unavailable (transient error) ${transient} times in a row"; exit 2; fi
121
+ sleep "$(jitter "$INTERVAL")"; continue
122
+ elif [ "$GH_CLASS" = limited ]; then
123
+ # Real rate limit: a wait, not a verdict. Retry with a growing jittered sleep; never counts toward ERROR.
124
+ limited=$((limited + 1)); now=$(date +%s)
125
+ if [ $((now - start)) -ge "$TIMEOUT" ]; then emit RATE_LIMITED; echo "PR #$PR: GitHub rate-limited for the whole ${TIMEOUT}s window (${limited} retries); retry later"; exit 4; fi
126
+ _m=$((limited > 4 ? 4 : limited)); sleep "$(jitter $((INTERVAL * (1 << (_m - 1)))))"; continue
75
127
  else
76
128
  errors=$((errors + 1))
77
129
  if [ "$errors" -ge 5 ]; then emit ERROR; echo "gh pr checks unreadable 5 times in a row"; exit 2; fi
78
- sleep "$INTERVAL"; continue
130
+ sleep "$(jitter "$INTERVAL")"; continue
79
131
  fi
80
132
  fi
81
133
  fi
82
- errors=0
134
+ errors=0; limited=0; transient=0
83
135
  cur="$(head_sha)"
84
136
  if [ -n "$cur" ] && [ "$cur" != "$START_SHA" ]; then emit ERROR; echo "PR head moved during the wait ($START_SHA -> $cur); re-review the new head"; exit 2; fi
85
137
  total=$(printf '%s' "$JSON" | jq 'length')
@@ -101,5 +153,5 @@ while :; do
101
153
  emit PASS; echo "PR #$PR head ${START_SHA:0:7}: $total checks passed or skipped"; exit 0
102
154
  fi
103
155
  if [ "$elapsed" -ge "$TIMEOUT" ]; then emit TIMEOUT; echo "PR #$PR: still pending after ${TIMEOUT}s:"; echo "$pending"; exit 3; fi
104
- sleep "$INTERVAL"
156
+ sleep "$(jitter "$INTERVAL")"
105
157
  done