forgedock 1.18.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 (116) hide show
  1. package/bin/engine/outcome-codes.mjs +13 -0
  2. package/bin/engine/phases.mjs +54 -4
  3. package/bin/engine/runlog.mjs +10 -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 +32 -2
  7. package/bin/engine.mjs +139 -7
  8. package/bin/runner.mjs +69 -9
  9. package/bin/stats/aggregate.mjs +7 -3
  10. package/bin/stats/cache.mjs +7 -3
  11. package/bin/stats/github-source.mjs +136 -28
  12. package/bin/stats/index.mjs +46 -9
  13. package/bin/stats/render.mjs +1 -1
  14. package/bin/stats/stats.test.mjs +201 -40
  15. package/commands/review-pr-staging.md +4 -4
  16. package/commands/review-pr.md +25 -15
  17. package/commands/work-on/build/architect.md +53 -0
  18. package/commands/work-on/build/implement.md +19 -0
  19. package/commands/work-on/build/validate.md +320 -28
  20. package/commands/work-on/build.md +66 -9
  21. package/commands/work-on/investigate.md +7 -0
  22. package/commands/work-on/remediate.md +32 -13
  23. package/commands/work-on/review.md +32 -19
  24. package/docs/CONFIG.md +89 -2
  25. package/docs/OUTCOME-CODES.md +5 -1
  26. package/docs/STATS.md +11 -4
  27. package/docs/TELEMETRY.md +5 -2
  28. package/docs/WORK-ON-RUNTIME.md +18 -0
  29. package/package.json +2 -2
  30. package/packages/protocol/src/types.js +1 -1
  31. package/scripts/acceptance-red-green.sh +90 -0
  32. package/scripts/acceptance-red-green.test.sh +74 -0
  33. package/scripts/cap-findings.test.sh +2 -0
  34. package/scripts/check-contract-scope.test.sh +2 -0
  35. package/scripts/check-edge-cases.sh +143 -0
  36. package/scripts/check-edge-cases.test.sh +175 -0
  37. package/scripts/check-spec-markers.sh +2 -0
  38. package/scripts/check-test-tmp-cleanup.sh +56 -0
  39. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  40. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  41. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  42. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  43. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  44. package/scripts/ci-local/adapters/common.mjs +115 -0
  45. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  46. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  47. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  48. package/scripts/ci-local/adapters/portability.mjs +104 -0
  49. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  50. package/scripts/ci-local/cli.mjs +50 -0
  51. package/scripts/ci-local/lib.mjs +371 -0
  52. package/scripts/ci-local/plan.mjs +186 -0
  53. package/scripts/ci-local/run.mjs +345 -0
  54. package/scripts/ci-local/scoping.mjs +508 -0
  55. package/scripts/ci-local.sh +97 -0
  56. package/scripts/ci-local.test.sh +919 -0
  57. package/scripts/classify-finding.sh +32 -6
  58. package/scripts/classify-finding.test.sh +23 -0
  59. package/scripts/classify-test-run.sh +69 -0
  60. package/scripts/classify-test-run.test.sh +90 -0
  61. package/scripts/close-parent-c3.test.sh +2 -0
  62. package/scripts/consumer-sweep.sh +569 -0
  63. package/scripts/consumer-sweep.test.sh +357 -0
  64. package/scripts/detect-append-registries.sh +106 -0
  65. package/scripts/detect-append-registries.test.sh +64 -0
  66. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  67. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  68. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  69. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  70. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  71. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  72. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  73. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  74. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  75. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  76. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  77. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  78. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  79. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  80. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  81. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  82. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  83. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  84. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  85. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  86. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  87. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  88. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  89. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  90. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  91. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  92. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  93. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  94. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  95. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  96. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  97. package/scripts/forge-root.test.sh +1 -1
  98. package/scripts/lib/telemetry.sh +1 -0
  99. package/scripts/remediate-approved-head.test.sh +2 -0
  100. package/scripts/remediate-basesync.test.sh +1 -1
  101. package/scripts/review-acceptance-gate.sh +2 -2
  102. package/scripts/review-acceptance-gate.test.sh +20 -0
  103. package/scripts/review-delta.sh +8 -1
  104. package/scripts/review-delta.test.sh +19 -0
  105. package/scripts/review-finalize.sh +193 -41
  106. package/scripts/review-finalize.test.sh +211 -4
  107. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  108. package/scripts/review-prepare.sh +5 -3
  109. package/scripts/review-prepare.test.sh +18 -2
  110. package/scripts/run-shell-tests.sh +5 -0
  111. package/scripts/telemetry.test.sh +2 -0
  112. package/scripts/trusted-comments.test.sh +2 -1
  113. package/scripts/verify-phase-trail.test.sh +8 -0
  114. package/scripts/wait-ci-green.sh +61 -9
  115. package/scripts/wait-ci-green.test.sh +41 -3
  116. package/scripts/with-run-tmpdir.sh +20 -0
@@ -184,6 +184,52 @@ run "$GOOD" extract; rc_is "no trusted bodies" "$RC" 3; has "no trusted bodies"
184
184
  reset; run "$NOTRUST" extract; rc_is "trust script missing" "$RC" 3; has "trust script missing" "scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read clean (fail closed)"
185
185
  reset; run "$GOOD" extract; rc_is "comments unreadable" "$RC" 3; has "comments unreadable" "refusing to read clean"
186
186
 
187
+ # a findings summary list above the per-finding sections must not hide the scenario (summary bullets never start the section)
188
+ reset
189
+ cm MEMBER User "$(agent_body bug-hunter "$SHA" 'Findings summary:
190
+ - BUG-1: first problem
191
+ - BUG-2: second problem
192
+ - BUG-3: third problem
193
+
194
+ ### BUG-1 first problem
195
+ **Failure scenario**: one breaks
196
+ ### BUG-2 second problem
197
+ **Failure scenario**: two breaks
198
+ ### BUG-3 third problem
199
+ **Fix efficacy**: n/a
200
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|First problem alpha -->
201
+ <!-- FINDING:BUG-2|CONFIRMED|MEDIUM|b.py:10|Second problem bravo -->
202
+ <!-- FINDING:BUG-3|CONFIRMED|MEDIUM|c.py:18|Third problem charlie -->')"
203
+ flush; run "$GOOD" extract; rc_is "summary list" "$RC" 0
204
+ want "summary: first id keeps its scenario" '[.findings[] | select(.id == "BUG-1") | .scenario] | join(",")' findings.json "present"
205
+ want "summary: middle id keeps its scenario" '[.findings[] | select(.id == "BUG-2") | .scenario] | join(",")' findings.json "present"
206
+ want "summary: a section without a scenario stays absent" '[.findings[] | select(.id == "BUG-3") | .scenario] | join(",")' findings.json "absent"
207
+
208
+ # the same id repeated across two agents binds each finding to its own body: agent and scenario
209
+ reset
210
+ cm MEMBER User "$(agent_body security "$SHA" '### BUG-1 alpha
211
+ **Failure scenario**: alpha scenario
212
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Alpha problem in security -->')"
213
+ cm MEMBER User "$(agent_body billing "$SHA" '### BUG-1 omega
214
+ no scenario written here
215
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|d.py:15|Omega problem in billing -->')"
216
+ flush; run "$GOOD" extract; rc_is "repeated id" "$RC" 0
217
+ want "repeated id: first binds its own agent" '[.findings[] | select(.title | test("Alpha")) | .agent] | join(",")' findings.json "security"
218
+ want "repeated id: first keeps its scenario" '[.findings[] | select(.title | test("Alpha")) | .scenario] | join(",")' findings.json "present"
219
+ want "repeated id: second binds its own agent" '[.findings[] | select(.title | test("Omega")) | .agent] | join(",")' findings.json "billing"
220
+ want "repeated id: second does not borrow the first scenario" '[.findings[] | select(.title | test("Omega")) | .scenario] | join(",")' findings.json "absent"
221
+
222
+ # the identical marker line repeated across two heads (DELTA) binds each occurrence to its own head's body
223
+ reset; mkctx 1 DELTA "$OLD_SHA"
224
+ cm MEMBER User "$(agent_body security "$SHA" '### BUG-1 same
225
+ no scenario at the current head
226
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Same marker both heads -->')"
227
+ cm MEMBER User "$(agent_body billing "$OLD_SHA" '### BUG-1 same
228
+ **Failure scenario**: earlier head scenario
229
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Same marker both heads -->')"
230
+ flush; run "$GOOD" extract
231
+ want "repeated across heads: both occurrences extracted" '.counts.findings_extracted' findings.json "2"
232
+
187
233
  # ======================= extract: 6B dedup =======================
188
234
  reset
189
235
  cm MEMBER User "$(agent_body security "$SHA" '### A-1 x
@@ -231,12 +277,20 @@ want "gate3: HIGH exempt" '.findings[] | select(.id == "N-2") | .route' findings
231
277
  want "gate3: unreadable prose keeps the finding" '.findings[] | select(.id == "U-1") | .route' findings.json "file"
232
278
  want "gate: introduced finding stays" '.findings[] | select(.id == "N-3") | .route' findings.json "file"
233
279
  want "gate: counts" '[.counts.findings_preexisting, .counts.notes_demoted_no_scenario] | join(",")' findings.json "1,1"
280
+ # scope (#3975): recorded for every finding, any severity, computed from the added-line map
281
+ want "scope: MEDIUM far from added lines" '.findings[] | select(.id == "P-1") | .scope' findings.json "pre-existing"
282
+ want "scope: HIGH is recorded although never routed pre-existing" '.findings[] | select(.id == "P-2") | .scope' findings.json "pre-existing"
283
+ want "scope: HIGH near an added line" '.findings[] | select(.id == "N-2") | .scope' findings.json "introduced"
284
+ want "scope: MEDIUM near an added line" '.findings[] | select(.id == "N-3") | .scope' findings.json "introduced"
285
+ want "scope: every finding carries a valid scope" '[.findings[].scope | select(. == "introduced" or . == "pre-existing" or . == "unknown")] | length' findings.json "$(jq '.findings | length' "$WORK/out/findings.json")"
286
+ want "scope: introduced count" '.counts.findings_introduced' findings.json "$(jq '[.findings[] | select(.scope == "introduced")] | length' "$WORK/out/findings.json")"
234
287
  # an unreadable files API classifies nothing as pre-existing
235
288
  reset; echo '[]' > "$FIX/pr-files.json"
236
289
  cm MEMBER User "$(agent_body security "$SHA" '### P-1 pre
237
290
  **Failure scenario**: s
238
291
  ' '<!-- FINDING:P-1|LIKELY|MEDIUM|a.py:19|Pre-existing defect far from added lines -->')"; flush
239
292
  run "$GOOD" extract; want "unreadable diff keeps" '.findings[0].route' findings.json "file"
293
+ want "unreadable diff: scope unknown" '.findings[0].scope' findings.json "unknown"
240
294
 
241
295
  # gate 2 route table (ported from the retired spec sketch test): severity normalisation, deleted/renamed/patch-less files, files absent from the diff
242
296
  reset
@@ -258,6 +312,9 @@ run "$GOOD" extract; rc_is "gate2 table" "$RC" 0
258
312
  for pair in G-1:file G-2:pre-existing G-3:file G-4:file G-5:file G-6:file G-7:file G-8:file G-9:pre-existing G-10:pre-existing G-11:file G-12:pre-existing G-13:file; do
259
313
  want "gate2 table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .route" findings.json "${pair#*:}"
260
314
  done
315
+ for pair in G-1:introduced G-2:pre-existing G-3:pre-existing G-4:unknown G-7:pre-existing G-13:introduced G-12:pre-existing; do
316
+ want "scope table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .scope" findings.json "${pair#*:}"
317
+ done
261
318
  want "gate2 table: a file absent from the PR and from the head is stale (gate 1), not pre-existing" '[.findings[].id] | contains(["G-14"]) | not' findings.json "true"
262
319
 
263
320
  # DELTA: the earlier reviewed head's findings are read as well
@@ -277,14 +334,53 @@ run "$GOOD" extract; rc_is "delta: earlier head unreadable" "$RC" 3; has "delta:
277
334
  # clean review
278
335
  reset; cm MEMBER User "$(agent_body security "$SHA" 'all good' 'no findings')"; flush
279
336
  run "$GOOD" extract; has "clean" "STRUCTURED_FINDINGS=0"; has "clean" "CLEAN_STRUCTURED=true"
337
+ has "clean: no undelivered agent" "AGENTS_WITHOUT_BLOCK=
338
+ "
339
+ lacks() { case "$OUT" in *"$2"*) bad "$1: output has '$2'" ;; *) ok ;; esac; }
340
+ nob() { printf '<!-- FORGE:REVIEW-AGENT:%s -->\n**Verdict: APPROVE - no findings.**\nReviewed-SHA: %s\n' "$1" "$2"; }
341
+
342
+ # verdict-only agent comment (no findings block): undelivered, never a finding, never clean
343
+ reset; cm MEMBER User "$(nob security "$SHA")"; flush
344
+ run "$GOOD" extract; rc_is "no-block agent" "$RC" 0
345
+ has "no-block agent: reported undelivered" "AGENTS_WITHOUT_BLOCK=security"
346
+ has "no-block agent: no finding created" "STRUCTURED_FINDINGS=0"
347
+ lacks "no-block agent: not clean" "CLEAN_STRUCTURED=true"
348
+ # mix: one clean (empty block) agent and one block-less agent
349
+ reset; cm MEMBER User "$(agent_body auth "$SHA" 'all good' '')"; cm MEMBER User "$(nob security "$SHA")"; flush
350
+ run "$GOOD" extract
351
+ has "mixed: block-less agent listed" "AGENTS_WITHOUT_BLOCK=security"
352
+ lacks "mixed: block-delivering agent not listed" "AGENTS_WITHOUT_BLOCK=auth"
353
+ lacks "mixed: block-delivering agent not listed (joined)" "security auth"
354
+ lacks "mixed: not clean" "CLEAN_STRUCTURED=true"
355
+ # SEC-1: a re-dispatched agent's later complete block clears its earlier block-less comment
356
+ reset; cm MEMBER User "$(nob security "$SHA")"; cm MEMBER User "$(agent_body security "$SHA" 'all good' '')"; flush
357
+ run "$GOOD" extract
358
+ lacks "redispatch: delivered domain not listed" "AGENTS_WITHOUT_BLOCK=security"
359
+ has "redispatch: clean" "CLEAN_STRUCTURED=true"
360
+ # SEC-2: START without END (truncated) is not delivered
361
+ reset; cm MEMBER User "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n<!-- REVIEW-FINDINGS-START -->\n' "$SHA")"; flush
362
+ run "$GOOD" extract
363
+ has "START without END: undelivered" "AGENTS_WITHOUT_BLOCK=security"
364
+ lacks "START without END: not clean" "CLEAN_STRUCTURED=true"
365
+ # a synthesized block must not mask a block-less raw agent comment
366
+ reset; cm MEMBER User "$(nob security "$SHA")"
367
+ cm MEMBER User "$(printf '<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->\nclean\nReviewed-SHA: %s\n' "$SHA")"; flush
368
+ run "$GOOD" extract
369
+ has "synth: block-less agent still reported" "AGENTS_WITHOUT_BLOCK=security"
370
+ lacks "synth: not clean" "CLEAN_STRUCTURED=true"
371
+ # an untrusted block-less comment is ignored (cannot park a review)
372
+ reset; cm MEMBER User "$(agent_body auth "$SHA" 'ok' '')"; cm NONE User "$(nob security "$SHA")"; flush
373
+ run "$GOOD" extract
374
+ lacks "untrusted block-less ignored" "AGENTS_WITHOUT_BLOCK=security"
375
+ has "untrusted block-less ignored: clean" "CLEAN_STRUCTURED=true"
280
376
 
281
377
  # ======================= classify =======================
282
- # mkfind <id> <conf> <sev> <file> <line> <title> [route] [agent] [scenario-text]
378
+ # mkfind <id> <conf> <sev> <file> <line> <title> [route] [agent] [scenario-text] [scope]
283
379
  FJ=""
284
380
  mkfind() {
285
- FJ="${FJ:+$FJ,}$(jq -n -c --arg id "$1" --arg c "$2" --arg s "$3" --arg f "$4" --arg l "$5" --arg t "$6" --arg r "${7:-file}" --arg a "${8:-}" \
381
+ FJ="${FJ:+$FJ,}$(jq -n -c --arg id "$1" --arg c "$2" --arg s "$3" --arg f "$4" --arg l "$5" --arg t "$6" --arg r "${7:-file}" --arg a "${8:-}" --arg sc "${10:-}" \
286
382
  '{id: $id, confidence: $c, severity: $s, loc: ($f + ":" + $l), file: $f, line_start: ($l | tonumber), line_end: ($l | tonumber), title: $t, agent: $a,
287
- scenario: "present", fix_efficacy: "", text: ($t + "\n" + $f), route: $r}')"
383
+ scenario: "present", fix_efficacy: "", text: ($t + "\n" + $f), route: $r} + (if $sc == "" then {} else {scope: $sc} end)')"
288
384
  }
289
385
  putfind() { printf '{"schema_version":1,"review_sha":"%s","review_mode":"FULL","counts":{"findings_extracted":%s,"findings_dropped_stale":0,"findings_preexisting":%s,"notes_demoted_no_scenario":%s},"findings":[%s]}\n' \
290
386
  "$SHA" "${1:-1}" "${2:-0}" "${3:-0}" "$FJ" > "$WORK/findings-in.json"; FJ=""; mkdir -p "$WORK/out"; cp "$WORK/findings-in.json" "$WORK/out/findings.json"; }
@@ -298,7 +394,7 @@ run "$GOOD" classify; rc_is "classify" "$RC" 0
298
394
  has "classify: filed" "FINDINGS_FILED=1"
299
395
  want "classify: note" '.findings[] | select(.id == "X-1") | .disposition | startswith("NOTE")' classified.json "true"
300
396
  want "classify: issue" '.findings[] | select(.id == "X-2") | .class' classified.json "ISSUE"
301
- posted_has "marker first line" "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 notes_dropped=0 findings_filed=1 inpr_fix=0 contract_demoted=0 findings_dropped_stale=0 findings_preexisting=0 notes_demoted_no_scenario=0 lineage=none classifier=script notes_reflag=0 notes_capped=0 reviewed_sha=${SHA} -->"
397
+ posted_has "marker first line" "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 notes_dropped=0 findings_filed=1 inpr_fix=0 contract_demoted=0 findings_dropped_stale=0 findings_preexisting=0 introduced=0 notes_demoted_no_scenario=0 lineage=none classifier=script notes_reflag=0 notes_capped=0 reviewed_sha=${SHA} -->"
302
398
  posted_has "note list line" "- X-1 a.py:5 [POSSIBLE] Rename a local variable for clarity:"
303
399
  # the later-head reader (prior_findings_tsv) must be able to parse the list line this script wrote
304
400
  ROUND=$(grep '^- X-1' "$FIX/posted.log" | sed -n 's/^- [^ ]* \([^ ]*\) \[[A-Za-z?]*\] \(.*\): [^:]*$/\1\t\2/p')
@@ -343,6 +439,19 @@ mkfind I-1 CONFIRMED MEDIUM a.py 5 "Confirmed defect inside the changed file"; p
343
439
  run "$GOOD" classify --auto-merge true; has "inpr round 2" "INPR_FIX=0"
344
440
  posted_lacks "no second in-pr fix request" "FORGE:INPR_FIX"
345
441
  want "inpr round 2 files it" '.findings[0].class' classified.json "ISSUE"
442
+ # scope (#3975): an introduced MEDIUM LIKELY finding in the diff is an in-PR fix; other scopes keep filing it
443
+ reset; mkctx 1 FULL "" 12; echo "false" > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"; echo '[]' > "$FIX/comments.json"
444
+ mkfind S-1 LIKELY MEDIUM a.py 5 "Introduced likely defect in new code" file "" "" introduced
445
+ mkfind S-2 LIKELY MEDIUM a.py 6 "Pre-existing likely defect in old code" file "" "" pre-existing
446
+ mkfind S-3 LIKELY MEDIUM a.py 7 "Likely defect with unknown provenance" file "" "" unknown
447
+ mkfind S-4 LIKELY MEDIUM a.py 8 "Likely defect with no scope recorded"; putfind 4
448
+ run "$GOOD" classify --auto-merge true; has "scope inpr" "INPR_FIX=1"
449
+ want "scope: introduced LIKELY is INPR_FIX" '.findings[] | select(.id == "S-1") | .class' classified.json "INPR_FIX"
450
+ want "scope: pre-existing LIKELY is ISSUE" '.findings[] | select(.id == "S-2") | .class' classified.json "ISSUE"
451
+ want "scope: unknown LIKELY is ISSUE" '.findings[] | select(.id == "S-3") | .class' classified.json "ISSUE"
452
+ want "scope: absent LIKELY is never INPR_FIX" '.findings[] | select(.id == "S-4") | .class != "INPR_FIX"' classified.json "true"
453
+ want "scope: carried into classified.json" '.findings[] | select(.id == "S-1") | .scope' classified.json "introduced"
454
+ posted_has "NOTE_DISPOSITION records introduced count" " introduced=1 "
346
455
  # an untrusted FORGE:INPR_FIX marker does not use up the round
347
456
  reset; mkctx 1 FULL "" 12; printf 'a.py\n' > "$FIX/files"
348
457
  cm NONE User "<!-- FORGE:INPR_FIX: round=1 head=${OLD_SHA} max_severity=MEDIUM findings=I-0 -->"; flush
@@ -458,6 +567,7 @@ mkfind F-3 CONFIRMED LOW a.py 2 "Low defect to file"
458
567
  mkfind F-4 CONFIRMED LOW billing/pay.py 2 "Low billing defect"
459
568
  mkfind F-5 CONFIRMED MEDIUM a.py 19 "Pre-existing medium defect" file-pre-existing
460
569
  mkfind F-6 LIKELY LOW a.py 3 "A note, never filed" file
570
+ FJ=$(printf '%s' "[$FJ]" | jq -c 'map(if .id == "F-2" then . + {scope: "introduced"} elif .id == "F-5" then . + {scope: "pre-existing"} else . end) | .[]' | paste -sd, -)
461
571
  putfind 6
462
572
  cp "$WORK/out/findings.json" "$WORK/out/classified.json"
463
573
  jq '.findings |= map(. + {class: (if .id == "F-6" then "NOTE" else "ISSUE" end), disposition: (if .id == "F-6" then "NOTE x" else "ISSUE x" end)})' "$WORK/out/findings.json" > "$WORK/out/classified.json"
@@ -466,6 +576,9 @@ want "worklist: notes are not filed" '[.items[].id] | join(",")' worklist.json "
466
576
  want "priority from severity (HIGH)" '.items[] | select(.id == "F-1") | .priority' worklist.json "priority:P1"
467
577
  want "priority from severity (MEDIUM)" '.items[] | select(.id == "F-2") | .priority' worklist.json "priority:P2"
468
578
  want "labels" '.items[] | select(.id == "F-2") | .labels | join(",")' worklist.json "review-finding,needs-validation,priority:P2"
579
+ want "worklist item carries scope" '.items[] | select(.id == "F-2") | .scope' worklist.json "introduced"
580
+ want "worklist item scope pre-existing" '.items[] | select(.id == "F-5") | .scope' worklist.json "pre-existing"
581
+ want "worklist item without scope is unknown" '.items[] | select(.id == "F-1") | .scope' worklist.json "unknown"
469
582
  want "P3 batchable" '.items[] | select(.id == "F-3") | .batchable' worklist.json "true"
470
583
  want "P3 on a billing path is not batchable" '.items[] | select(.id == "F-4") | .batchable' worklist.json "false"
471
584
  want "pre-existing label only" '.items[] | select(.id == "F-5") | .labels | join(",")' worklist.json "pre-existing,priority:P2"
@@ -602,6 +715,54 @@ rc_is "inpr: untrusted request ignored" "$RC" 0
602
715
  gate_reset; cm MEMBER User "<!-- FORGE:INPR_FIX: head=$SHA -->"; cm NONE User "<!-- FORGE:INPR_FIX_WAIVED: head=$SHA -->"; flush; mg "$GATE"
603
716
  rc_is "inpr: untrusted waiver ignored" "$RC" 3
604
717
 
718
+ # --- degraded-gate gate (forge#3628): issue 42's newest trusted QUALITY_GATE / ACCEPTANCE_GATE name the degraded checks; PR 7's comments must cover each ---
719
+ QG_DEG=$'<!-- FORGE:QUALITY_GATE -->\n## Quality Gate Result\n\n**Result**: PASS\n**Degraded**: python.test — test run exercised nothing; blast-radius — check did not complete'
720
+ QG_OK=$'<!-- FORGE:QUALITY_GATE -->\n## Quality Gate Result\n\n**Result**: PASS\n**Degraded**: none'
721
+ AG_DEG=$'<!-- FORGE:ACCEPTANCE_GATE -->\n## Acceptance Gate — DEGRADED\n\n**Degraded checks (verified nothing)**: python.test — test run exercised nothing\n\n<!-- FORGE:ACCEPTANCE_GATE:DEGRADED -->'
722
+ AGM='<!-- FORGE:REVIEW-AGENT:security -->'
723
+ SEC_FULL="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n- blast-radius: callers checked manually\n\n### Other\nx'
724
+ SEC_PART="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n\n### Other\nblast-radius is mentioned only outside the section'
725
+ SEC_GENERIC="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\nAll degraded paths were reviewed.\n'
726
+ SEC_STALE="$AGM"$'\nReviewed-SHA: '"$OLD_SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n- blast-radius: callers checked manually\n'
727
+ SEC_NEG="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: NOT reviewed\n- blast-radius: callers checked manually\n'
728
+ SEC_PLAIN=$'### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n- blast-radius: callers checked manually\n'
729
+ SEC_AG="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: covered by hand\n'
730
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
731
+ rc_is "degraded: no section" "$RC" 3; has "degraded: no section" "REVIEW_RESULT: status: BLOCKED, blocker: degraded-gate-unreviewed"
732
+ logged "degraded: no section needs-human" edits.log "needs-human"; logged "degraded: no section comment" issue-bodies.log "FORGE:DEGRADED_GATE_UNREVIEWED"; not_merged "degraded: no section"
733
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_PART"; flush; mg "$GATE"
734
+ rc_is "degraded: partial coverage" "$RC" 3; has "degraded: partial coverage" "blocker: degraded-gate-unreviewed"; logged "degraded: partial names the gap" issue-bodies.log "not covered: blast-radius"; not_merged "degraded: partial"
735
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_GENERIC"; flush; mg "$GATE"
736
+ rc_is "degraded: generic section" "$RC" 3; has "degraded: generic section" "blocker: degraded-gate-unreviewed"; not_merged "degraded: generic"
737
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm NONE User "$SEC_FULL"; flush; mg "$GATE"
738
+ rc_is "degraded: untrusted section ignored" "$RC" 3; has "degraded: untrusted section ignored" "blocker: degraded-gate-unreviewed"
739
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_STALE"; flush; mg "$GATE"
740
+ rc_is "degraded: stale-head section ignored" "$RC" 3; has "degraded: stale-head section ignored" "blocker: degraded-gate-unreviewed"
741
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_NEG"; flush; mg "$GATE"
742
+ rc_is "degraded: negated item is not coverage" "$RC" 3; logged "degraded: negated item names the gap" issue-bodies.log "not covered: python.test"
743
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_PLAIN"; flush; mg "$GATE"
744
+ rc_is "degraded: plain comment without review marker ignored" "$RC" 3
745
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_FULL"; flush; mg "$GATE"
746
+ rc_is "degraded: full per-check section" "$RC" 0; has "degraded: full per-check section" "MERGE_OUTCOME=merged"; merged "degraded: full"
747
+ gate_reset; cm Bot Bot "$QG_DEG"; flush "$FIX/comments-42.json"; cm NONE Bot "$SEC_FULL"; flush; mg "$GATE"
748
+ rc_is "degraded: bot-authored gate and section" "$RC" 0
749
+ gate_reset; cm MEMBER User "$AG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
750
+ rc_is "degraded: ACCEPTANCE_GATE:DEGRADED unreviewed" "$RC" 3; has "degraded: ACCEPTANCE_GATE:DEGRADED unreviewed" "blocker: degraded-gate-unreviewed"
751
+ gate_reset; cm MEMBER User "$AG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_AG"; flush; mg "$GATE"
752
+ rc_is "degraded: ACCEPTANCE_GATE:DEGRADED reviewed" "$RC" 0
753
+ gate_reset; cm MEMBER User "$QG_OK"; flush "$FIX/comments-42.json"; mg "$GATE"
754
+ rc_is "degraded: Degraded none is unaffected" "$RC" 0; has "degraded: Degraded none" "MERGE_OUTCOME=merged"
755
+ gate_reset; cm MEMBER User "$QG_DEG"; cm MEMBER User "$QG_OK"; flush "$FIX/comments-42.json"; mg "$GATE"
756
+ rc_is "degraded: a newer clean gate clears it" "$RC" 0
757
+ gate_reset; cm MEMBER User "$QG_OK"; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
758
+ rc_is "degraded: a newer degraded gate wins" "$RC" 3
759
+ gate_reset; cm NONE User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
760
+ rc_is "degraded: untrusted DEGRADED marker cannot block" "$RC" 0
761
+ gate_reset; mg "$GATE"
762
+ rc_is "degraded: no gate comments is unaffected" "$RC" 0
763
+ gate_reset; rm -f "$FIX/comments.json"; mg "$GATE"
764
+ rc_is "degraded: unreadable comments fail closed" "$RC" 3; not_merged "degraded: unreadable"
765
+
605
766
  # --- --issue requirement ---
606
767
  gate_reset; mkctx 1 FULL "" ""; mg "$GATE"
607
768
  rc_is "issue: missing" "$RC" 3; has "issue: missing" "blocker: auto-merge requires --issue"; posted_has "issue: missing" "no --issue was given"
@@ -657,6 +818,13 @@ gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --cali
657
818
  rc_is "route: base conflict plus calibration" "$RC" 3; logged "route: calibration reason" issue-bodies.log "calibration threshold: overconfident cell"; logged "route: calibration needs-human" edits.log "needs-human"
658
819
  gate_reset; mg "$GATE" --has-purpose-regression true
659
820
  rc_is "route: purpose regression with APPROVED" "$RC" 3; logged "route: purpose reason" issue-bodies.log "purpose regression detected by Phase 7A"; logged "route: purpose needs-human" edits.log "needs-human"; not_merged "route: purpose"
821
+ for payload in "it's cal" "\$(touch $FIX/SENTINEL)" "\`touch $FIX/SENTINEL\`" "'; touch $FIX/SENTINEL; '"; do
822
+ gate_reset; rm -f "$FIX/SENTINEL"; printf '%s' "$payload" > "$FIX/cn.txt"
823
+ mg "$GATE" --calibration-needs-human true --calibration-note-file "$FIX/cn.txt"
824
+ rc_is "calibration note file: gate blocks" "$RC" 3
825
+ if [ -e "$FIX/SENTINEL" ]; then bad "calibration note file: payload executed ($payload)"; else ok; fi
826
+ logged "calibration note file: rendered as data" issue-bodies.log "calibration threshold: $payload"
827
+ done
660
828
  gate_reset; mg "$GATE" --calibration-needs-human true --calibration-note "note"
661
829
  rc_is "route: calibration alone blocks an APPROVED verdict" "$RC" 3; not_merged "route: calibration alone"
662
830
  gate_reset; mg "$GATE" --trust-needs-human true
@@ -742,6 +910,30 @@ has "7A: blocked log" "PURPOSE REGRESSION GATE: BLOCKED"; has "7A: escalation" "
742
910
  reset; MH=MERGEABLE; MHS=CLEAN; vd --is-milestone true --has-purpose-regression false; has "7A: passed log" "PURPOSE REGRESSION GATE: PASSED"
743
911
  reset; MH=MERGEABLE; MHS=CLEAN; vd --has-purpose-regression true
744
912
  case "$OUT" in *"PURPOSE REGRESSION GATE"*) bad "7A: non-milestone PRs print no gate log" ;; *) ok ;; esac
913
+ # free text arrives through *-file flags and is data, never code (injection payload classes: apostrophe, command substitution, backtick, quote breakout, newline)
914
+ SENT="$FIX/SENTINEL"
915
+ for payload in "it's a F-1" "\$(touch $SENT)" "\`touch $SENT\`" "'; touch $SENT; '" "\"; touch $SENT; \"" "F-1
916
+ touch $SENT"; do
917
+ reset; rm -f "$SENT"; MH=MERGEABLE; MHS=CLEAN
918
+ printf '%s' "$payload" > "$FIX/pf.txt"; printf '%s' "$payload" > "$FIX/ag.txt"
919
+ vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt" --agents-file "$FIX/ag.txt"
920
+ rc_is "payload file: verdict runs" "$RC" 0
921
+ if [ -e "$SENT" ]; then bad "payload file: payload executed ($payload)"; else ok; fi
922
+ logged "payload file: purpose text kept as data" reviews.log "See: $(printf '%s' "$payload" | tr '\n' ' ')"
923
+ done
924
+ reset; MH=MERGEABLE; MHS=CLEAN; printf 'a\001b\033c\nd' > "$FIX/pf.txt"
925
+ vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt"
926
+ logged "payload file: control characters stripped, newline folded" reviews.log "See: abc d"
927
+ reset; MH=MERGEABLE; MHS=CLEAN; head -c 5000 /dev/zero | tr '\0' 'x' > "$FIX/pf.txt"
928
+ vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt"
929
+ if grep -qF "$(head -c 2001 /dev/zero | tr '\0' 'x')" "$FIX/reviews.log" 2>/dev/null; then bad "payload file: text must be capped at 2000 bytes"; else ok; fi
930
+ logged "payload file: capped text still rendered" reviews.log "See: $(head -c 2000 /dev/zero | tr '\0' 'x')"
931
+ reset; MH=MERGEABLE; MHS=CLEAN
932
+ vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/does-not-exist.txt"
933
+ rc_is "payload file: missing file tolerated" "$RC" 0
934
+ reset; MH=MERGEABLE; MHS=CLEAN; printf 'from-file' > "$FIX/pf.txt"
935
+ vd --result clean --has-purpose-regression true --purpose-findings "from-flag" --purpose-findings-file "$FIX/pf.txt"
936
+ logged "payload file: file wins over inline flag" reviews.log "See: from-file"; not_logged "payload file: inline flag overridden" reviews.log "from-flag"
745
937
  reset; OUT=$(cd "$REPO_DIR" && FORGEDOCK_HOME="$GOOD" bash "$ROOT/scripts/review-finalize.sh" verdict --pr 7 --repo o/r --context "$CTXF" --merge-health 2>/dev/null); RC=$?
746
938
  rc_is "verdict: flag without a value" "$RC" 2
747
939
  reset; vd --result bogus; rc_is "verdict: invalid result" "$RC" 2
@@ -780,6 +972,7 @@ has "calibrate: untrusted investigator ignored" "could not resolve task type/con
780
972
  reset; mkctx 1 FULL "" 42; inv_comments MEMBER User "$(printf 'see <!-- FORGE:INVESTIGATOR -->\n**Task Type**: Refactor\n**Confidence**: HIGH\n')"; run "$GOOD" calibrate
781
973
  has "calibrate: unanchored marker ignored" "could not resolve task type/confidence"
782
974
  reset; mkctx 1 FULL "" 42; inv_comments MEMBER User "$(printf '<!-- FORGE:INVESTIGATOR -->\n**Task Type**: Re\x27fa$(id)ctor\n**Confidence**: HIGH\n')"; run "$GOOD" calibrate
975
+ if [ -s "$(dirname "$CTXF")/review-finalize-7/calibration-note.txt" ] || find "$FIX" "$(dirname "$CTXF")" -name calibration-note.txt 2>/dev/null | grep -q .; then ok; else bad "calibrate: must write calibration-note.txt under the out dir"; fi
783
976
  note=$(printf '%s\n' "$OUT" | sed -n 's/^CALIBRATION_NOTE=//p'); case "$note" in *"'"*|*'$'*|*'`'*) bad "calibrate: note carries a shell metacharacter: $note" ;; *) ok ;; esac
784
977
  setcal "$(CAL_ROW Refactor HIGH true overconfidence)"
785
978
  reset; mkctx 1 FULL "" 42; run "$GOOD" calibrate; has "calibrate: no investigator comment fails safe" "CALIBRATION_NEEDS_HUMAN=false"; has "calibrate: unresolved note" "task type or confidence not resolved"
@@ -829,5 +1022,19 @@ OUT=$(bash "$ROOT/scripts/review-finalize.sh" bogus 2>/dev/null); RC=$?; rc_is "
829
1022
  OUT=$(bash "$ROOT/scripts/review-finalize.sh" extract --pr x --repo o/r --context "$CTXF" 2>/dev/null); RC=$?; rc_is "non-numeric pr" "$RC" 2
830
1023
  OUT=$(bash "$ROOT/scripts/review-finalize.sh" extract --pr 7 --repo o/r 2>/dev/null); RC=$?; rc_is "missing context" "$RC" 2
831
1024
 
1025
+ # ======================= forge#4037: build.md red-green DEGRADED entry survives the real name splitter =======================
1026
+ RG_LINE=$(grep -F 'RG_DEGRADED_LIST="${RG_DEGRADED_LIST}' "$ROOT/commands/work-on/build.md" | sed -n '1p' | sed 's/^[[:space:]]*\*) //; s/ ;;.*$//')
1027
+ SPLIT_LINE=$(grep -F 'DEG_NAME_LIST=$(printf' "$ROOT/scripts/review-finalize.sh" | sed -n '1p' | sed 's/^[[:space:]]*//')
1028
+ if [ -n "$RG_LINE" ] && [ -n "$SPLIT_LINE" ]; then
1029
+ RG_DEGRADED_LIST=""; ID="AC2"; RG_BASE=UNRUNNABLE; RG_HEAD=RAN
1030
+ eval "$RG_LINE"
1031
+ RG_DEGRADED_LIST="${RG_DEGRADED_LIST}; AC3 — base run cannot execute (base=UNRUNNABLE head=RAN), so the test is unproven"
1032
+ DEG_NAMES="$RG_DEGRADED_LIST"
1033
+ eval "$SPLIT_LINE"
1034
+ [ "$DEG_NAME_LIST" = "$(printf 'AC2\nAC3')" ] && ok || bad "red-green DEGRADED entries split into exactly their check names (got: $DEG_NAME_LIST)"
1035
+ else
1036
+ bad "could not extract the build.md DEGRADED entry or the review-finalize splitter"
1037
+ fi
1038
+
832
1039
  echo "review-finalize tests: $pass passed, $fail failed"
833
1040
  [ "$fail" -eq 0 ]
@@ -232,6 +232,19 @@ case "$(p4 DELTA 'security auth billing')" in "PANEL_STATUS: SHORTFALL"*) ok ;;
232
232
  { cm "$(body_agent security "$HEAD")" User NONE; cm "$(body_agent auth "$HEAD")" User NONE; } | jq -s '.' > "$FAKE_COMMENTS"
233
233
  case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL"*) ok ;; *) bad "untrusted reviewer markers must not satisfy the panel: $(p4 FULL 'security auth')" ;; esac
234
234
 
235
+ # a verdict without a findings block is undelivered (#3976): not complete, never counted
236
+ { cm "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n\nVerdict: APPROVE, no findings.' "$HEAD")" User MEMBER; cm "$(body_agent auth "$HEAD")" User MEMBER; } | jq -s '.' > "$FAKE_COMMENTS"
237
+ case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL (ACTUAL_AGENT_COUNT=1)"*) ok ;; *) bad "a block-less reviewer comment must not satisfy Phase 4: $(p4 FULL 'security auth')" ;; esac
238
+ # the 3C pending-agent count and the staging 6.75 delivery count carry the same block lookahead as Phase 4
239
+ STAGING="$ROOT/commands/review-pr-staging.md"
240
+ for spec in "$SPEC" "$STAGING"; do
241
+ n=$(grep -F 'count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->' "$spec" | wc -l)
242
+ want=2; [ "$spec" = "$STAGING" ] && want=1
243
+ [ "$n" -eq "$want" ] && ok || bad "$(basename "$spec"): $n delivery counts carry the findings-block lookahead, want $want"
244
+ if grep -E 'count "\^<!-- FORGE:REVIEW-AGENT:\$\{AGENT_DOMAIN\}' "$spec" >/dev/null; then bad "$(basename "$spec"): header-only delivery regex remains"; else ok; fi
245
+ if grep -qE 'scan the trusted (agent comments|reviewer bodies) for unstructured|no unstructured finding' "$spec"; then bad "$(basename "$spec"): free-text finding scan remains"; else ok; fi
246
+ done
247
+
235
248
  # ---- 6. The 6A read of the carried / earlier head's findings is scripts/review-finalize.sh extract (covered by scripts/review-finalize.test.sh).
236
249
 
237
250
  echo "review-pr-delta-wiring tests: pass=$PASS fail=$FAILN"
@@ -13,7 +13,7 @@
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
19
  # --build-gate block (default): an empty trusted typescript.build / python.format on a full-build lane BLOCKs.
@@ -482,7 +482,7 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
482
482
  SLICE_FILES_SRC="$FILES"; [ "$REVIEW_MODE" = "DELTA" ] && SLICE_FILES_SRC="$DELTA_FILES"
483
483
  # slice <domain> <diff-header regex|ALL> <file-path regex|ALL>
484
484
  slice() {
485
- 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"
486
486
  if [ "$dre" = "ALL" ]; then body="$FULL_DIFF"
487
487
  else
488
488
  body=$(printf '%s\n' "$FULL_DIFF" | awk -v re="$dre" '/^diff --git/ { in_block=0 } $0 ~ re { in_block=1 } in_block { print }')
@@ -492,7 +492,9 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
492
492
  else paths=$(printf '%s\n' "$SLICE_FILES_SRC" | grep -iE "$fre" || echo ""); [ -n "$paths" ] || paths="$SLICE_FILES_SRC"; fi
493
493
  # 100K-char cap, same as the runner's tool-result cap
494
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
495
- 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
496
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}}')
497
499
  }
498
500
  slice security ALL ALL
@@ -125,8 +125,24 @@ eq "auth score" '.scores.AUTH > 0' true; eq "billing score" '.scores.BILLING > 0
125
125
  eq "security baseline" .scores.SECURITY 1
126
126
  eq "risk FINANCIAL" '.risk_signals | index("FINANCIAL") != null' true
127
127
  eq "slice auth file exists" '.slices.auth.diff_file | length > 0' true
128
- 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
129
- 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"
130
146
  eq "auth slice paths" '.slices.auth.paths | join(" ")' "services/api/auth.py"
131
147
  eq "security slice is the full diff" '.slices.security.paths | length' 2
132
148
 
@@ -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 -->"
@@ -57,6 +57,7 @@ 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
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
@@ -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