forgedock 1.18.0 → 1.20.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 (135) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/outcome-codes.mjs +13 -0
  3. package/bin/engine/phases.mjs +54 -4
  4. package/bin/engine/runlog.mjs +10 -2
  5. package/bin/engine/telemetry-seam.mjs +3 -0
  6. package/bin/engine/telemetry.test.mjs +3 -2
  7. package/bin/engine-cli.mjs +32 -2
  8. package/bin/engine.mjs +187 -10
  9. package/bin/runner.mjs +89 -13
  10. package/bin/stats/aggregate.mjs +7 -3
  11. package/bin/stats/cache.mjs +7 -3
  12. package/bin/stats/github-source.mjs +136 -28
  13. package/bin/stats/index.mjs +46 -9
  14. package/bin/stats/render.mjs +1 -1
  15. package/bin/stats/stats.test.mjs +201 -40
  16. package/commands/orchestrate/phase-4-execution.md +1 -1
  17. package/commands/review-pr-agents/protocols.md +19 -0
  18. package/commands/review-pr-staging.md +4 -4
  19. package/commands/review-pr.md +31 -20
  20. package/commands/work-on/build/architect.md +54 -1
  21. package/commands/work-on/build/implement.md +28 -1
  22. package/commands/work-on/build/validate.md +321 -29
  23. package/commands/work-on/build.md +184 -17
  24. package/commands/work-on/close.md +9 -4
  25. package/commands/work-on/investigate.md +7 -0
  26. package/commands/work-on/remediate.md +43 -17
  27. package/commands/work-on/review.md +43 -22
  28. package/commands/work-on.md +4 -4
  29. package/docs/CONFIG.md +93 -2
  30. package/docs/OUTCOME-CODES.md +5 -1
  31. package/docs/RELEASE-NOTES.md +15 -0
  32. package/docs/STATS.md +11 -4
  33. package/docs/TELEMETRY.md +5 -2
  34. package/docs/WORK-ON-RUNTIME.md +29 -3
  35. package/package.json +2 -2
  36. package/packages/protocol/src/types.js +1 -1
  37. package/scripts/acceptance-red-green.sh +90 -0
  38. package/scripts/acceptance-red-green.test.sh +74 -0
  39. package/scripts/architect-resume-match.test.sh +12 -3
  40. package/scripts/build-knowledge-index.mjs +8 -5
  41. package/scripts/builder-resume-match.test.sh +12 -3
  42. package/scripts/calibration.mjs +5 -2
  43. package/scripts/cap-findings.test.sh +2 -0
  44. package/scripts/check-contract-scope.test.sh +2 -0
  45. package/scripts/check-edge-cases.sh +143 -0
  46. package/scripts/check-edge-cases.test.sh +175 -0
  47. package/scripts/check-spec-markers.sh +4 -0
  48. package/scripts/check-test-tmp-cleanup.sh +56 -0
  49. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  50. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  51. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  52. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  53. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  54. package/scripts/ci-local/adapters/common.mjs +115 -0
  55. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  56. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  57. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  58. package/scripts/ci-local/adapters/portability.mjs +104 -0
  59. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  60. package/scripts/ci-local/cli.mjs +50 -0
  61. package/scripts/ci-local/lib.mjs +379 -0
  62. package/scripts/ci-local/plan.mjs +186 -0
  63. package/scripts/ci-local/run.mjs +451 -0
  64. package/scripts/ci-local/scoping.mjs +531 -0
  65. package/scripts/ci-local.sh +98 -0
  66. package/scripts/ci-local.test.sh +1108 -0
  67. package/scripts/classify-finding.sh +33 -7
  68. package/scripts/classify-finding.test.sh +23 -0
  69. package/scripts/classify-test-run.sh +69 -0
  70. package/scripts/classify-test-run.test.sh +90 -0
  71. package/scripts/close-parent-c3.test.sh +2 -0
  72. package/scripts/consumer-sweep.sh +569 -0
  73. package/scripts/consumer-sweep.test.sh +357 -0
  74. package/scripts/detect-append-registries.sh +106 -0
  75. package/scripts/detect-append-registries.test.sh +64 -0
  76. package/scripts/file-deferred-followups.sh +91 -30
  77. package/scripts/file-deferred-followups.test.sh +101 -8
  78. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  79. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  80. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  81. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  82. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  83. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  84. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  85. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  86. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  87. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  88. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  89. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  90. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  91. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  92. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  93. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  94. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  95. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  96. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  97. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  98. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  99. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  100. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  101. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  102. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  103. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  104. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  105. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  106. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  107. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  108. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  109. package/scripts/forge-root.test.sh +1 -1
  110. package/scripts/lib/telemetry.sh +3 -0
  111. package/scripts/remediate-approved-head.test.sh +2 -0
  112. package/scripts/remediate-basesync.test.sh +1 -1
  113. package/scripts/review-acceptance-gate.sh +2 -2
  114. package/scripts/review-acceptance-gate.test.sh +20 -0
  115. package/scripts/review-delta.sh +8 -1
  116. package/scripts/review-delta.test.sh +19 -0
  117. package/scripts/review-finalize.sh +294 -53
  118. package/scripts/review-finalize.test.sh +347 -9
  119. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  120. package/scripts/review-prepare.sh +5 -3
  121. package/scripts/review-prepare.test.sh +18 -2
  122. package/scripts/review-roster.sh +18 -5
  123. package/scripts/review-roster.test.sh +57 -1
  124. package/scripts/run-shell-tests.sh +5 -0
  125. package/scripts/selfreview-plan.sh +86 -0
  126. package/scripts/selfreview-plan.test.sh +90 -0
  127. package/scripts/spawn-depth-check.sh +5 -4
  128. package/scripts/spawn-depth-check.test.sh +3 -0
  129. package/scripts/telemetry.test.sh +10 -0
  130. package/scripts/transition-label.sh +26 -0
  131. package/scripts/trusted-comments.test.sh +2 -1
  132. package/scripts/verify-phase-trail.test.sh +14 -0
  133. package/scripts/wait-ci-green.sh +61 -9
  134. package/scripts/wait-ci-green.test.sh +41 -3
  135. package/scripts/with-run-tmpdir.sh +20 -0
@@ -39,13 +39,15 @@ case "$a" in
39
39
  "api --paginate repos/o/r/pulls/"*"/files"*) cat "$FIX/pr-files.txt" 2>/dev/null || exit 1 ;;
40
40
  "api repos/o/r/pulls/"*"--jq .changed_files") cat "$FIX/changed-n" 2>/dev/null || exit 1 ;;
41
41
  "api repos/o/r/pulls/"*"--jq .head.sha") cat "$FIX/head" ;;
42
+ "api repos/o/r/pulls/"*"--jq .base.sha") cat "$FIX/base-sha" 2>/dev/null || exit 1 ;;
42
43
  "api repos/o/r/branches/"*"/protection"*) cat "$FIX/protection" 2>/dev/null || exit 1 ;;
43
44
  "api repos/o/r/issues/"*"/comments --paginate --jq"*) cat "$FIX/route-bodies" 2>/dev/null; exit 0 ;;
44
45
  "api repos/o/r/issues/"*"/comments --jq"*) cat "$FIX/investigator" 2>/dev/null; exit 0 ;;
45
46
  "api repos/o/r/commits/"*) cat "$FIX/commit-sha" 2>/dev/null || exit 1 ;;
46
47
  "api repos/o/r/compare/"*) cat "$FIX/compare-files" 2>/dev/null || exit 1 ;;
47
48
  "pr comment"*) { printf '%s\n' '=====COMMENT====='; for x in "$@"; do last="$x"; done; printf '%s\n' "$last"; } >> "$FIX/posted.log"; exit 0 ;;
48
- "issue comment"*) printf 'ISSUE_COMMENT %s\n' "$3" >> "$FIX/issue-comments.log"; { for x in "$@"; do last="$x"; done; printf '%s\n' "$last"; } >> "$FIX/issue-bodies.log"; exit 0 ;;
49
+ "issue view"*"--json state"*) [ -f "$FIX/issue-closed" ] && echo CLOSED || echo OPEN; exit 0 ;;
50
+ "issue comment"*) [ -f "$FIX/issue-comment-fail" ] && exit 1; printf 'ISSUE_COMMENT %s\n' "$3" >> "$FIX/issue-comments.log"; { for x in "$@"; do last="$x"; done; printf '%s\n' "$last"; } >> "$FIX/issue-bodies.log"; exit 0 ;;
49
51
  "label create"*) exit 0 ;;
50
52
  "pr view"*"headRefOid"*) cat "$FIX/head" ;;
51
53
  "pr view"*"baseRefName"*) echo main ;;
@@ -184,6 +186,52 @@ run "$GOOD" extract; rc_is "no trusted bodies" "$RC" 3; has "no trusted bodies"
184
186
  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
187
  reset; run "$GOOD" extract; rc_is "comments unreadable" "$RC" 3; has "comments unreadable" "refusing to read clean"
186
188
 
189
+ # a findings summary list above the per-finding sections must not hide the scenario (summary bullets never start the section)
190
+ reset
191
+ cm MEMBER User "$(agent_body bug-hunter "$SHA" 'Findings summary:
192
+ - BUG-1: first problem
193
+ - BUG-2: second problem
194
+ - BUG-3: third problem
195
+
196
+ ### BUG-1 first problem
197
+ **Failure scenario**: one breaks
198
+ ### BUG-2 second problem
199
+ **Failure scenario**: two breaks
200
+ ### BUG-3 third problem
201
+ **Fix efficacy**: n/a
202
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|First problem alpha -->
203
+ <!-- FINDING:BUG-2|CONFIRMED|MEDIUM|b.py:10|Second problem bravo -->
204
+ <!-- FINDING:BUG-3|CONFIRMED|MEDIUM|c.py:18|Third problem charlie -->')"
205
+ flush; run "$GOOD" extract; rc_is "summary list" "$RC" 0
206
+ want "summary: first id keeps its scenario" '[.findings[] | select(.id == "BUG-1") | .scenario] | join(",")' findings.json "present"
207
+ want "summary: middle id keeps its scenario" '[.findings[] | select(.id == "BUG-2") | .scenario] | join(",")' findings.json "present"
208
+ want "summary: a section without a scenario stays absent" '[.findings[] | select(.id == "BUG-3") | .scenario] | join(",")' findings.json "absent"
209
+
210
+ # the same id repeated across two agents binds each finding to its own body: agent and scenario
211
+ reset
212
+ cm MEMBER User "$(agent_body security "$SHA" '### BUG-1 alpha
213
+ **Failure scenario**: alpha scenario
214
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Alpha problem in security -->')"
215
+ cm MEMBER User "$(agent_body billing "$SHA" '### BUG-1 omega
216
+ no scenario written here
217
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|d.py:15|Omega problem in billing -->')"
218
+ flush; run "$GOOD" extract; rc_is "repeated id" "$RC" 0
219
+ want "repeated id: first binds its own agent" '[.findings[] | select(.title | test("Alpha")) | .agent] | join(",")' findings.json "security"
220
+ want "repeated id: first keeps its scenario" '[.findings[] | select(.title | test("Alpha")) | .scenario] | join(",")' findings.json "present"
221
+ want "repeated id: second binds its own agent" '[.findings[] | select(.title | test("Omega")) | .agent] | join(",")' findings.json "billing"
222
+ want "repeated id: second does not borrow the first scenario" '[.findings[] | select(.title | test("Omega")) | .scenario] | join(",")' findings.json "absent"
223
+
224
+ # the identical marker line repeated across two heads (DELTA) binds each occurrence to its own head's body
225
+ reset; mkctx 1 DELTA "$OLD_SHA"
226
+ cm MEMBER User "$(agent_body security "$SHA" '### BUG-1 same
227
+ no scenario at the current head
228
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Same marker both heads -->')"
229
+ cm MEMBER User "$(agent_body billing "$OLD_SHA" '### BUG-1 same
230
+ **Failure scenario**: earlier head scenario
231
+ ' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Same marker both heads -->')"
232
+ flush; run "$GOOD" extract
233
+ want "repeated across heads: both occurrences extracted" '.counts.findings_extracted' findings.json "2"
234
+
187
235
  # ======================= extract: 6B dedup =======================
188
236
  reset
189
237
  cm MEMBER User "$(agent_body security "$SHA" '### A-1 x
@@ -231,12 +279,20 @@ want "gate3: HIGH exempt" '.findings[] | select(.id == "N-2") | .route' findings
231
279
  want "gate3: unreadable prose keeps the finding" '.findings[] | select(.id == "U-1") | .route' findings.json "file"
232
280
  want "gate: introduced finding stays" '.findings[] | select(.id == "N-3") | .route' findings.json "file"
233
281
  want "gate: counts" '[.counts.findings_preexisting, .counts.notes_demoted_no_scenario] | join(",")' findings.json "1,1"
282
+ # scope (#3975): recorded for every finding, any severity, computed from the added-line map
283
+ want "scope: MEDIUM far from added lines" '.findings[] | select(.id == "P-1") | .scope' findings.json "pre-existing"
284
+ want "scope: HIGH is recorded although never routed pre-existing" '.findings[] | select(.id == "P-2") | .scope' findings.json "pre-existing"
285
+ want "scope: HIGH near an added line" '.findings[] | select(.id == "N-2") | .scope' findings.json "introduced"
286
+ want "scope: MEDIUM near an added line" '.findings[] | select(.id == "N-3") | .scope' findings.json "introduced"
287
+ 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")"
288
+ want "scope: introduced count" '.counts.findings_introduced' findings.json "$(jq '[.findings[] | select(.scope == "introduced")] | length' "$WORK/out/findings.json")"
234
289
  # an unreadable files API classifies nothing as pre-existing
235
290
  reset; echo '[]' > "$FIX/pr-files.json"
236
291
  cm MEMBER User "$(agent_body security "$SHA" '### P-1 pre
237
292
  **Failure scenario**: s
238
293
  ' '<!-- FINDING:P-1|LIKELY|MEDIUM|a.py:19|Pre-existing defect far from added lines -->')"; flush
239
294
  run "$GOOD" extract; want "unreadable diff keeps" '.findings[0].route' findings.json "file"
295
+ want "unreadable diff: scope unknown" '.findings[0].scope' findings.json "unknown"
240
296
 
241
297
  # gate 2 route table (ported from the retired spec sketch test): severity normalisation, deleted/renamed/patch-less files, files absent from the diff
242
298
  reset
@@ -258,8 +314,60 @@ run "$GOOD" extract; rc_is "gate2 table" "$RC" 0
258
314
  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
315
  want "gate2 table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .route" findings.json "${pair#*:}"
260
316
  done
317
+ 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
318
+ want "scope table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .scope" findings.json "${pair#*:}"
319
+ done
261
320
  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
321
 
322
+ # symbol fallback (#4061): a stale or call-site cited line still scopes introduced when a named identifier is in the added lines
323
+ reset
324
+ printf '%s\n' '[{"filename":"s.sh","status":"modified","patch":"@@ -9,2 +9,3 @@\n ctx baseOnlyName\n+stepKey=1 stepKeyed=2 a*b $(x) self return result computeLaneBudget\n ctx"}]' > "$FIX/pr-files.json"
325
+ # The base version of s.sh (a dangling commit, HEAD untouched): base-present words never count as new symbols.
326
+ _bb=$(printf 'ctx baseOnlyName\nctx\nself return result\n' | git -C "$REPO_DIR" hash-object -w --stdin)
327
+ _bt=$(printf '100644 blob %s\ts.sh\n' "$_bb" | git -C "$REPO_DIR" mktree)
328
+ git -C "$REPO_DIR" commit-tree "$_bt" -m base-s > "$FIX/base-sha"
329
+ SY_PROSE=""; SY_MARKS=""; SY_N=0
330
+ sy() { # sy <id> <title> <extra prose line>
331
+ SY_PROSE="${SY_PROSE}### $1 finding
332
+ **Failure scenario**: s
333
+ $3
334
+ "
335
+ SY_N=$((SY_N + 1))
336
+ SY_MARKS="${SY_MARKS}<!-- FINDING:$1|LIKELY|MEDIUM|s.sh:$((300 + SY_N * 20))|$2 -->
337
+ "
338
+ }
339
+ sy Y-1 "alpha stale" 'The dedup key `stepKey` is wrong'
340
+ sy Y-2 "bravo base" 'Calls `baseOnlyName` badly'
341
+ sy Y-3 "charlie generic" 'Uses `this` and `file` and `abc` here'
342
+ sy Y-4 "delta substring" 'Wrong `stepKe` or `step` usage'
343
+ sy Y-5 "echo plain" 'No backticks and no cause line at all'
344
+ sy Y-6 "foxtrot meta" 'Odd `a*b` and `$(x)` and `[` tokens'
345
+ sy Y-7 "golf rootcause" 'Root cause: stepKey is built too late'
346
+ sy Y-8 "hotel self" 'The `self` handle leaks'
347
+ sy Y-9 "india return" 'Missing `return` on the error path'
348
+ sy Y-10 "juliet result" 'The `result` is discarded'
349
+ sy Y-11 "kilo newfn" 'The new `computeLaneBudget` overflows'
350
+ cm MEMBER User "$(agent_body security "$SHA" "$SY_PROSE" "$SY_MARKS")"; flush
351
+ run "$GOOD" extract; rc_is "symbol fallback" "$RC" 0
352
+ # stale cited line (outside every added hunk) naming an added identifier: Y-1, Y-7 introduced
353
+ # generic words (this, file) and identifiers under 4 characters (abc): Y-3 never triggers the fallback
354
+ for pair in Y-1:introduced Y-2:pre-existing Y-3:pre-existing Y-4:pre-existing Y-5:pre-existing Y-6:pre-existing Y-7:introduced Y-8:pre-existing Y-9:pre-existing Y-10:pre-existing Y-11:introduced; do
355
+ want "symbol fallback: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .scope" findings.json "${pair#*:}"
356
+ done
357
+ want "symbol fallback: stale cited line naming an added identifier is introduced" '[.findings[] | select(.id == "Y-1" or .id == "Y-7") | .scope] | join(",")' findings.json "introduced,introduced"
358
+ want "symbol fallback: generic words and short identifiers never trigger the fallback" '.findings[] | select(.id == "Y-3") | .scope' findings.json "pre-existing"
359
+ want "symbol fallback: introduced routes file" '.findings[] | select(.id == "Y-1") | .route' findings.json "file"
360
+ want "symbol fallback: base-only stays pre-existing route" '.findings[] | select(.id == "Y-2") | .route' findings.json "pre-existing"
361
+ want "symbol fallback: self/return/result present in base stay pre-existing (DSEC-1/DBH-1)" '[.findings[] | select(.id == "Y-8" or .id == "Y-9" or .id == "Y-10") | .scope] | join(",")' findings.json "pre-existing,pre-existing,pre-existing"
362
+ want "symbol fallback: a brand-new function name is introduced" '.findings[] | select(.id == "Y-11") | .scope' findings.json "introduced"
363
+ # unreadable added text (extraction failure) keeps the line-window result
364
+ reset; echo '[]' > "$FIX/pr-files.json"
365
+ cm MEMBER User "$(agent_body security "$SHA" '### Y-1 f
366
+ **Failure scenario**: s
367
+ `stepKey`
368
+ ' '<!-- FINDING:Y-1|LIKELY|MEDIUM|s.sh:300|alpha stale -->')"; flush
369
+ run "$GOOD" extract; want "symbol fallback: no diff keeps" '.findings[0].scope' findings.json "unknown"
370
+
263
371
  # DELTA: the earlier reviewed head's findings are read as well
264
372
  reset; mkctx 1 DELTA "$OLD_SHA"
265
373
  cm MEMBER User "$(agent_body security "$SHA" '### D-1 new
@@ -277,14 +385,53 @@ run "$GOOD" extract; rc_is "delta: earlier head unreadable" "$RC" 3; has "delta:
277
385
  # clean review
278
386
  reset; cm MEMBER User "$(agent_body security "$SHA" 'all good' 'no findings')"; flush
279
387
  run "$GOOD" extract; has "clean" "STRUCTURED_FINDINGS=0"; has "clean" "CLEAN_STRUCTURED=true"
388
+ has "clean: no undelivered agent" "AGENTS_WITHOUT_BLOCK=
389
+ "
390
+ lacks() { case "$OUT" in *"$2"*) bad "$1: output has '$2'" ;; *) ok ;; esac; }
391
+ nob() { printf '<!-- FORGE:REVIEW-AGENT:%s -->\n**Verdict: APPROVE - no findings.**\nReviewed-SHA: %s\n' "$1" "$2"; }
392
+
393
+ # verdict-only agent comment (no findings block): undelivered, never a finding, never clean
394
+ reset; cm MEMBER User "$(nob security "$SHA")"; flush
395
+ run "$GOOD" extract; rc_is "no-block agent" "$RC" 0
396
+ has "no-block agent: reported undelivered" "AGENTS_WITHOUT_BLOCK=security"
397
+ has "no-block agent: no finding created" "STRUCTURED_FINDINGS=0"
398
+ lacks "no-block agent: not clean" "CLEAN_STRUCTURED=true"
399
+ # mix: one clean (empty block) agent and one block-less agent
400
+ reset; cm MEMBER User "$(agent_body auth "$SHA" 'all good' '')"; cm MEMBER User "$(nob security "$SHA")"; flush
401
+ run "$GOOD" extract
402
+ has "mixed: block-less agent listed" "AGENTS_WITHOUT_BLOCK=security"
403
+ lacks "mixed: block-delivering agent not listed" "AGENTS_WITHOUT_BLOCK=auth"
404
+ lacks "mixed: block-delivering agent not listed (joined)" "security auth"
405
+ lacks "mixed: not clean" "CLEAN_STRUCTURED=true"
406
+ # SEC-1: a re-dispatched agent's later complete block clears its earlier block-less comment
407
+ reset; cm MEMBER User "$(nob security "$SHA")"; cm MEMBER User "$(agent_body security "$SHA" 'all good' '')"; flush
408
+ run "$GOOD" extract
409
+ lacks "redispatch: delivered domain not listed" "AGENTS_WITHOUT_BLOCK=security"
410
+ has "redispatch: clean" "CLEAN_STRUCTURED=true"
411
+ # SEC-2: START without END (truncated) is not delivered
412
+ reset; cm MEMBER User "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n<!-- REVIEW-FINDINGS-START -->\n' "$SHA")"; flush
413
+ run "$GOOD" extract
414
+ has "START without END: undelivered" "AGENTS_WITHOUT_BLOCK=security"
415
+ lacks "START without END: not clean" "CLEAN_STRUCTURED=true"
416
+ # a synthesized block must not mask a block-less raw agent comment
417
+ reset; cm MEMBER User "$(nob security "$SHA")"
418
+ cm MEMBER User "$(printf '<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->\nclean\nReviewed-SHA: %s\n' "$SHA")"; flush
419
+ run "$GOOD" extract
420
+ has "synth: block-less agent still reported" "AGENTS_WITHOUT_BLOCK=security"
421
+ lacks "synth: not clean" "CLEAN_STRUCTURED=true"
422
+ # an untrusted block-less comment is ignored (cannot park a review)
423
+ reset; cm MEMBER User "$(agent_body auth "$SHA" 'ok' '')"; cm NONE User "$(nob security "$SHA")"; flush
424
+ run "$GOOD" extract
425
+ lacks "untrusted block-less ignored" "AGENTS_WITHOUT_BLOCK=security"
426
+ has "untrusted block-less ignored: clean" "CLEAN_STRUCTURED=true"
280
427
 
281
428
  # ======================= classify =======================
282
- # mkfind <id> <conf> <sev> <file> <line> <title> [route] [agent] [scenario-text]
429
+ # mkfind <id> <conf> <sev> <file> <line> <title> [route] [agent] [scenario-text] [scope]
283
430
  FJ=""
284
431
  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:-}" \
432
+ 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
433
  '{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}')"
434
+ scenario: "present", fix_efficacy: "", text: ($t + "\n" + $f), route: $r} + (if $sc == "" then {} else {scope: $sc} end)')"
288
435
  }
289
436
  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
437
  "$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 +445,7 @@ run "$GOOD" classify; rc_is "classify" "$RC" 0
298
445
  has "classify: filed" "FINDINGS_FILED=1"
299
446
  want "classify: note" '.findings[] | select(.id == "X-1") | .disposition | startswith("NOTE")' classified.json "true"
300
447
  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} -->"
448
+ 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
449
  posted_has "note list line" "- X-1 a.py:5 [POSSIBLE] Rename a local variable for clarity:"
303
450
  # the later-head reader (prior_findings_tsv) must be able to parse the list line this script wrote
304
451
  ROUND=$(grep '^- X-1' "$FIX/posted.log" | sed -n 's/^- [^ ]* \([^ ]*\) \[[A-Za-z?]*\] \(.*\): [^:]*$/\1\t\2/p')
@@ -343,6 +490,19 @@ mkfind I-1 CONFIRMED MEDIUM a.py 5 "Confirmed defect inside the changed file"; p
343
490
  run "$GOOD" classify --auto-merge true; has "inpr round 2" "INPR_FIX=0"
344
491
  posted_lacks "no second in-pr fix request" "FORGE:INPR_FIX"
345
492
  want "inpr round 2 files it" '.findings[0].class' classified.json "ISSUE"
493
+ # scope (#3975): an introduced MEDIUM LIKELY finding in the diff is an in-PR fix; other scopes keep filing it
494
+ reset; mkctx 1 FULL "" 12; echo "false" > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"; echo '[]' > "$FIX/comments.json"
495
+ mkfind S-1 LIKELY MEDIUM a.py 5 "Introduced likely defect in new code" file "" "" introduced
496
+ mkfind S-2 LIKELY MEDIUM a.py 6 "Pre-existing likely defect in old code" file "" "" pre-existing
497
+ mkfind S-3 LIKELY MEDIUM a.py 7 "Likely defect with unknown provenance" file "" "" unknown
498
+ mkfind S-4 LIKELY MEDIUM a.py 8 "Likely defect with no scope recorded"; putfind 4
499
+ run "$GOOD" classify --auto-merge true; has "scope inpr" "INPR_FIX=1"
500
+ want "scope: introduced LIKELY is INPR_FIX" '.findings[] | select(.id == "S-1") | .class' classified.json "INPR_FIX"
501
+ want "scope: pre-existing LIKELY is ISSUE" '.findings[] | select(.id == "S-2") | .class' classified.json "ISSUE"
502
+ want "scope: unknown LIKELY is ISSUE" '.findings[] | select(.id == "S-3") | .class' classified.json "ISSUE"
503
+ want "scope: absent LIKELY is never INPR_FIX" '.findings[] | select(.id == "S-4") | .class != "INPR_FIX"' classified.json "true"
504
+ want "scope: carried into classified.json" '.findings[] | select(.id == "S-1") | .scope' classified.json "introduced"
505
+ posted_has "NOTE_DISPOSITION records introduced count" " introduced=1 "
346
506
  # an untrusted FORGE:INPR_FIX marker does not use up the round
347
507
  reset; mkctx 1 FULL "" 12; printf 'a.py\n' > "$FIX/files"
348
508
  cm NONE User "<!-- FORGE:INPR_FIX: round=1 head=${OLD_SHA} max_severity=MEDIUM findings=I-0 -->"; flush
@@ -353,16 +513,49 @@ run "$GOOD" classify --auto-merge true; has "untrusted marker" "INPR_FIX=1"
353
513
  reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
354
514
  CONTRACT_BODY=$(printf '<!-- FORGE:CONTRACT -->\n### Out of Scope\n\n- `docs/` — deferred → #30: tracked there\n')
355
515
  cm MEMBER User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
356
- echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 --> docs/","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
516
+ echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 -->\n- [ ] `docs/` — why","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
357
517
  mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
358
518
  run "$GOOD" classify; has "contract" "CONTRACT_DEMOTED=1"
359
519
  want "contract-deferred" '.findings[0].disposition' classified.json "NOTE contract-deferred #30"
360
520
  [ "$(cat "$FIX/issue-comments.log" 2>/dev/null)" = "ISSUE_COMMENT 30" ] && ok || bad "deferred issue comment not posted"
361
521
  posted_has "contract list line" "tracked on #30"
522
+ # substring-only / longer-path / legacy / empty bodies must not verify (e1, e3, e4); [x] and regex-metachar paths do (e2, e3)
523
+ for B in 'x <!-- FORGE:DEFERRED_FROM: #12 --> docs/' 'x <!-- FORGE:DEFERRED_FROM: #12 -->\n- [ ] `docs/guide.md.bak` — why' 'x <!-- FORGE:DEFERRED_FROM: #12 -->\n- [ ] `docs/x`' 'x <!-- FORGE:DEFERRED_FROM: #12 -->\n- [ ] `docs/`x' ''; do
524
+ reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
525
+ cm MEMBER User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
526
+ echo "{\"state\":\"open\",\"body\":\"$B\",\"author_association\":\"MEMBER\",\"user\":{\"type\":\"User\"}}" > "$FIX/issue-30.json"
527
+ mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
528
+ run "$GOOD" classify; has "unverified-body '$B'" "CONTRACT_DEMOTED=0"
529
+ done
530
+ reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
531
+ cm MEMBER User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
532
+ echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 -->\n- [x] `docs/`","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
533
+ mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
534
+ run "$GOOD" classify; has "checked-item-verifies" "CONTRACT_DEMOTED=1"
535
+ # a failing comment on the deferred issue, or a no-longer-open one, files the finding instead of demoting it
536
+ for _mode in issue-comment-fail issue-closed; do
537
+ reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
538
+ cm MEMBER User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
539
+ echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 -->\n- [ ] `docs/` — why","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
540
+ mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
541
+ : > "$FIX/$_mode"
542
+ run "$GOOD" classify; has "$_mode not counted" "CONTRACT_DEMOTED=0"
543
+ want "$_mode falls back to the no-contract disposition" '.findings[0].disposition' classified.json "ISSUE MEDIUM-LIKELY"
544
+ rm -f "$FIX/$_mode"
545
+ done
546
+ # a failing comment on a LOW/POSSIBLE finding falls back to its no-contract disposition (a NOTE), not a blanket ISSUE (DB-1)
547
+ reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
548
+ cm MEMBER User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
549
+ echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 -->\n- [ ] `docs/` — why","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
550
+ mkfind K-1 POSSIBLE LOW docs/guide.md 3 "Docs example is misleading"; putfind 1
551
+ : > "$FIX/issue-comment-fail"
552
+ run "$GOOD" classify; has "low-fallback not counted" "CONTRACT_DEMOTED=0"
553
+ want "low finding falls back to its NOTE" '.findings[0].class' classified.json "NOTE"
554
+ rm -f "$FIX/issue-comment-fail"
362
555
  # an untrusted contract demotes nothing
363
556
  reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
364
557
  cm NONE User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
365
- echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 --> docs/","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
558
+ echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 -->\n- [ ] `docs/` — why","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
366
559
  mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
367
560
  run "$GOOD" classify; has "untrusted contract" "CONTRACT_DEMOTED=0"
368
561
 
@@ -458,6 +651,7 @@ mkfind F-3 CONFIRMED LOW a.py 2 "Low defect to file"
458
651
  mkfind F-4 CONFIRMED LOW billing/pay.py 2 "Low billing defect"
459
652
  mkfind F-5 CONFIRMED MEDIUM a.py 19 "Pre-existing medium defect" file-pre-existing
460
653
  mkfind F-6 LIKELY LOW a.py 3 "A note, never filed" file
654
+ 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
655
  putfind 6
462
656
  cp "$WORK/out/findings.json" "$WORK/out/classified.json"
463
657
  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 +660,9 @@ want "worklist: notes are not filed" '[.items[].id] | join(",")' worklist.json "
466
660
  want "priority from severity (HIGH)" '.items[] | select(.id == "F-1") | .priority' worklist.json "priority:P1"
467
661
  want "priority from severity (MEDIUM)" '.items[] | select(.id == "F-2") | .priority' worklist.json "priority:P2"
468
662
  want "labels" '.items[] | select(.id == "F-2") | .labels | join(",")' worklist.json "review-finding,needs-validation,priority:P2"
663
+ want "worklist item carries scope" '.items[] | select(.id == "F-2") | .scope' worklist.json "introduced"
664
+ want "worklist item scope pre-existing" '.items[] | select(.id == "F-5") | .scope' worklist.json "pre-existing"
665
+ want "worklist item without scope is unknown" '.items[] | select(.id == "F-1") | .scope' worklist.json "unknown"
469
666
  want "P3 batchable" '.items[] | select(.id == "F-3") | .batchable' worklist.json "true"
470
667
  want "P3 on a billing path is not batchable" '.items[] | select(.id == "F-4") | .batchable' worklist.json "false"
471
668
  want "pre-existing label only" '.items[] | select(.id == "F-5") | .labels | join(",")' worklist.json "pre-existing,priority:P2"
@@ -602,6 +799,54 @@ rc_is "inpr: untrusted request ignored" "$RC" 0
602
799
  gate_reset; cm MEMBER User "<!-- FORGE:INPR_FIX: head=$SHA -->"; cm NONE User "<!-- FORGE:INPR_FIX_WAIVED: head=$SHA -->"; flush; mg "$GATE"
603
800
  rc_is "inpr: untrusted waiver ignored" "$RC" 3
604
801
 
802
+ # --- 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 ---
803
+ 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'
804
+ QG_OK=$'<!-- FORGE:QUALITY_GATE -->\n## Quality Gate Result\n\n**Result**: PASS\n**Degraded**: none'
805
+ 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 -->'
806
+ AGM='<!-- FORGE:REVIEW-AGENT:security -->'
807
+ 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'
808
+ 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'
809
+ SEC_GENERIC="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\nAll degraded paths were reviewed.\n'
810
+ 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'
811
+ SEC_NEG="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: NOT reviewed\n- blast-radius: callers checked manually\n'
812
+ SEC_PLAIN=$'### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n- blast-radius: callers checked manually\n'
813
+ SEC_AG="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: covered by hand\n'
814
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
815
+ rc_is "degraded: no section" "$RC" 3; has "degraded: no section" "REVIEW_RESULT: status: BLOCKED, blocker: degraded-gate-unreviewed"
816
+ 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"
817
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_PART"; flush; mg "$GATE"
818
+ 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"
819
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_GENERIC"; flush; mg "$GATE"
820
+ rc_is "degraded: generic section" "$RC" 3; has "degraded: generic section" "blocker: degraded-gate-unreviewed"; not_merged "degraded: generic"
821
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm NONE User "$SEC_FULL"; flush; mg "$GATE"
822
+ rc_is "degraded: untrusted section ignored" "$RC" 3; has "degraded: untrusted section ignored" "blocker: degraded-gate-unreviewed"
823
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_STALE"; flush; mg "$GATE"
824
+ rc_is "degraded: stale-head section ignored" "$RC" 3; has "degraded: stale-head section ignored" "blocker: degraded-gate-unreviewed"
825
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_NEG"; flush; mg "$GATE"
826
+ rc_is "degraded: negated item is not coverage" "$RC" 3; logged "degraded: negated item names the gap" issue-bodies.log "not covered: python.test"
827
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_PLAIN"; flush; mg "$GATE"
828
+ rc_is "degraded: plain comment without review marker ignored" "$RC" 3
829
+ gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_FULL"; flush; mg "$GATE"
830
+ rc_is "degraded: full per-check section" "$RC" 0; has "degraded: full per-check section" "MERGE_OUTCOME=merged"; merged "degraded: full"
831
+ gate_reset; cm Bot Bot "$QG_DEG"; flush "$FIX/comments-42.json"; cm NONE Bot "$SEC_FULL"; flush; mg "$GATE"
832
+ rc_is "degraded: bot-authored gate and section" "$RC" 0
833
+ gate_reset; cm MEMBER User "$AG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
834
+ rc_is "degraded: ACCEPTANCE_GATE:DEGRADED unreviewed" "$RC" 3; has "degraded: ACCEPTANCE_GATE:DEGRADED unreviewed" "blocker: degraded-gate-unreviewed"
835
+ gate_reset; cm MEMBER User "$AG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_AG"; flush; mg "$GATE"
836
+ rc_is "degraded: ACCEPTANCE_GATE:DEGRADED reviewed" "$RC" 0
837
+ gate_reset; cm MEMBER User "$QG_OK"; flush "$FIX/comments-42.json"; mg "$GATE"
838
+ rc_is "degraded: Degraded none is unaffected" "$RC" 0; has "degraded: Degraded none" "MERGE_OUTCOME=merged"
839
+ gate_reset; cm MEMBER User "$QG_DEG"; cm MEMBER User "$QG_OK"; flush "$FIX/comments-42.json"; mg "$GATE"
840
+ rc_is "degraded: a newer clean gate clears it" "$RC" 0
841
+ gate_reset; cm MEMBER User "$QG_OK"; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
842
+ rc_is "degraded: a newer degraded gate wins" "$RC" 3
843
+ gate_reset; cm NONE User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
844
+ rc_is "degraded: untrusted DEGRADED marker cannot block" "$RC" 0
845
+ gate_reset; mg "$GATE"
846
+ rc_is "degraded: no gate comments is unaffected" "$RC" 0
847
+ gate_reset; rm -f "$FIX/comments.json"; mg "$GATE"
848
+ rc_is "degraded: unreadable comments fail closed" "$RC" 3; not_merged "degraded: unreadable"
849
+
605
850
  # --- --issue requirement ---
606
851
  gate_reset; mkctx 1 FULL "" ""; mg "$GATE"
607
852
  rc_is "issue: missing" "$RC" 3; has "issue: missing" "blocker: auto-merge requires --issue"; posted_has "issue: missing" "no --issue was given"
@@ -648,15 +893,38 @@ rc_is "guard: non-boolean" "$RC" 3; has "guard: non-boolean" "merge guard state
648
893
  # --- verdict / calibration / trust / base-conflict routing ---
649
894
  gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true
650
895
  rc_is "route: only base conflict" "$RC" 3; has "route: only base conflict" "blocker: base-conflict"; has "route: only base conflict" "MERGE_OUTCOME=base-conflict"
651
- logged "route: base-sync comment" issue-bodies.log "the pipeline is performing the base sync automatically"; not_logged "route: base conflict is not needs-human" edits.log "needs-human"; not_merged "route: base conflict"
896
+ logged "route: base-sync comment" issue-bodies.log "the work-on pipeline performs the base sync"; logged "route: standalone remediate action" issue-bodies.log "--remediate"; not_logged "route: base conflict is not needs-human" edits.log "needs-human"; not_merged "route: base conflict"
652
897
  gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --other-blocking true
653
898
  rc_is "route: base conflict plus blocker" "$RC" 3; has "route: base conflict plus blocker" "MERGE_OUTCOME=needs-human"; logged "route: needs-human" edits.log "needs-human"
899
+ # other-blocking-derived: a persisted true (written by verdict) is ORed in; a passed false never lowers it
900
+ ob() { mkdir -p "$WORK/out"; printf '%s\n' "$1" > "$WORK/out/other-blocking.txt"; }
901
+ gate_reset; ob true; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --other-blocking false
902
+ rc_is "other-blocking-derived: persisted true beats passed false" "$RC" 3; has "other-blocking-derived: persisted true beats passed false" "MERGE_OUTCOME=needs-human"
903
+ not_has() { case "$OUT" in *"$2"*) bad "$1: output has '$2'" ;; *) ok ;; esac; }
904
+ not_has "other-blocking-derived: not base-conflict" "MERGE_OUTCOME=base-conflict"; logged "other-blocking-derived: needs-human" edits.log "needs-human"
905
+ gate_reset; ob false; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true
906
+ rc_is "other-blocking-derived: persisted false keeps base-conflict route" "$RC" 3; has "other-blocking-derived: persisted false" "MERGE_OUTCOME=base-conflict"
907
+ gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true
908
+ has "other-blocking-derived: missing file uses passed flag" "MERGE_OUTCOME=base-conflict"
909
+ for junk in "" "maybe" "TRUE" "yes"; do
910
+ gate_reset; ob "$junk"; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true
911
+ has "other-blocking-derived: garbage file '$junk' ignored" "MERGE_OUTCOME=base-conflict"
912
+ done
913
+ gate_reset; ob true; mg "$GATE" --other-blocking ""
914
+ rc_is "other-blocking-derived: unbound passed flag still fails closed" "$RC" 3; has "other-blocking-derived: unbound still fails closed" "merge guard state unbound"
654
915
  gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --trust-needs-human true --table-cell-key "feature x HIGH"
655
916
  rc_is "route: base conflict plus trust" "$RC" 3; logged "route: trust reason" issue-bodies.log "provenance trust: NOVEL_NEEDS_HUMAN tier for cell \`feature x HIGH\`"; logged "route: trust needs-human" edits.log "needs-human"
656
917
  gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --calibration-needs-human true --calibration-note "overconfident cell"
657
918
  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
919
  gate_reset; mg "$GATE" --has-purpose-regression true
659
920
  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"
921
+ for payload in "it's cal" "\$(touch $FIX/SENTINEL)" "\`touch $FIX/SENTINEL\`" "'; touch $FIX/SENTINEL; '"; do
922
+ gate_reset; rm -f "$FIX/SENTINEL"; printf '%s' "$payload" > "$FIX/cn.txt"
923
+ mg "$GATE" --calibration-needs-human true --calibration-note-file "$FIX/cn.txt"
924
+ rc_is "calibration note file: gate blocks" "$RC" 3
925
+ if [ -e "$FIX/SENTINEL" ]; then bad "calibration note file: payload executed ($payload)"; else ok; fi
926
+ logged "calibration note file: rendered as data" issue-bodies.log "calibration threshold: $payload"
927
+ done
660
928
  gate_reset; mg "$GATE" --calibration-needs-human true --calibration-note "note"
661
929
  rc_is "route: calibration alone blocks an APPROVED verdict" "$RC" 3; not_merged "route: calibration alone"
662
930
  gate_reset; mg "$GATE" --trust-needs-human true
@@ -666,7 +934,7 @@ rc_is "route: CHANGES REQUESTED" "$RC" 3; logged "route: verdict reason" issue-b
666
934
 
667
935
  # --- pre-merge mergeability guard ---
668
936
  gate_reset; echo "CONFLICTING|DIRTY" > "$FIX/premerge"; mg "$GATE"
669
- rc_is "premerge: conflicting" "$RC" 3; has "premerge: conflicting" "blocker: base-conflict"; not_logged "premerge: conflict is not needs-human" edits.log "needs-human"; not_merged "premerge: conflicting"
937
+ rc_is "premerge: conflicting" "$RC" 3; has "premerge: conflicting" "blocker: base-conflict"; not_logged "premerge: conflict is not needs-human" edits.log "needs-human"; logged "premerge: standalone base-sync wording" issue-bodies.log "/work-on 42 --remediate"; not_merged "premerge: conflicting"
670
938
  gate_reset; echo "MERGEABLE|BEHIND" > "$FIX/premerge"; echo true > "$FIX/protection"; mg "$GATE"
671
939
  rc_is "premerge: behind under strict protection" "$RC" 3; has "premerge: behind under strict protection" "blocker: base-conflict"
672
940
  gate_reset; echo "MERGEABLE|BEHIND" > "$FIX/premerge"; echo false > "$FIX/protection"; mg "$GATE"
@@ -728,6 +996,22 @@ reset; MH=MERGEABLE; MHS=CLEAN; vd --result clean --intensity-tier FAST --table-
728
996
  logged "verdict: trust annotation" reviews.log '**Intensity tier**: FAST | **Cell**: `bug HIGH` | **Shadow mode**: false'
729
997
  reset; MH=CONFLICTING; MHS=DIRTY; vd --result blocking --blocking-count 1
730
998
  has "verdict: blocking" "VERDICT=CHANGES REQUESTED"; logged "verdict: blocking body" reviews.log "CHANGES REQUESTED: commit"; logged "verdict: base conflict line" reviews.log "🔴 Base Conflict:"
999
+ # other-blocking-derived: verdict resolves and persists OTHER_BLOCKING
1000
+ OBF="$WORK/out/other-blocking.txt"
1001
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --result blocking --blocking-count 1 --other-blocking false
1002
+ has "other-blocking-derived: unexplained blocking forces true" "OTHER_BLOCKING=true"; [ "$(cat "$OBF" 2>/dev/null)" = "true" ] && ok || bad "other-blocking-derived: file lacks true"
1003
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --result blocking --blocking-count 1
1004
+ has "other-blocking-derived: no flag still derives true" "OTHER_BLOCKING=true"
1005
+ reset; MH=CONFLICTING; MHS=DIRTY; vd --result blocking --blocking-count 1 --other-blocking false
1006
+ has "other-blocking-derived: base conflict explains the block" "OTHER_BLOCKING=false"; [ "$(cat "$OBF" 2>/dev/null)" = "false" ] && ok || bad "other-blocking-derived: file lacks false"
1007
+ reset; MH=CONFLICTING; MHS=DIRTY; vd --result blocking --blocking-count 1 --other-blocking true
1008
+ has "other-blocking-derived: reviewer true is kept" "OTHER_BLOCKING=true"
1009
+ reset; MH=MERGEABLE; MHS=CLEAN; mkdir -p "$WORK/out"; echo true > "$OBF"; vd --result clean --other-blocking true
1010
+ has "other-blocking-derived: clean result resets to false" "OTHER_BLOCKING=false"; [ "$(cat "$OBF" 2>/dev/null)" = "false" ] && ok || bad "other-blocking-derived: stale true survived a clean re-review"
1011
+ reset; MH=MERGEABLE; MHS=CLEAN; mkdir -p "$WORK/out"; echo true > "$OBF"; vd --result stale
1012
+ [ -e "$OBF" ] && bad "other-blocking-derived: stale result must remove the file" || ok
1013
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --result blocking --other-blocking maybe --blocking-count 1
1014
+ has "other-blocking-derived: garbage flag, unexplained block" "OTHER_BLOCKING=true"
731
1015
  reset; MH=CONFLICTING; MHS=DIRTY; vd --result clean
732
1016
  has "verdict: clean coerced over a base conflict" "VERDICT_COERCED=true"; has "verdict: coerced verdict" "VERDICT=CHANGES REQUESTED"; logged "verdict: coerced body" reviews.log "CHANGES REQUESTED: commit"
733
1017
  reset; MH=MERGEABLE; MHS=CLEAN; vd --result clean --has-purpose-regression true --purpose-findings "F-1"
@@ -742,6 +1026,30 @@ has "7A: blocked log" "PURPOSE REGRESSION GATE: BLOCKED"; has "7A: escalation" "
742
1026
  reset; MH=MERGEABLE; MHS=CLEAN; vd --is-milestone true --has-purpose-regression false; has "7A: passed log" "PURPOSE REGRESSION GATE: PASSED"
743
1027
  reset; MH=MERGEABLE; MHS=CLEAN; vd --has-purpose-regression true
744
1028
  case "$OUT" in *"PURPOSE REGRESSION GATE"*) bad "7A: non-milestone PRs print no gate log" ;; *) ok ;; esac
1029
+ # free text arrives through *-file flags and is data, never code (injection payload classes: apostrophe, command substitution, backtick, quote breakout, newline)
1030
+ SENT="$FIX/SENTINEL"
1031
+ for payload in "it's a F-1" "\$(touch $SENT)" "\`touch $SENT\`" "'; touch $SENT; '" "\"; touch $SENT; \"" "F-1
1032
+ touch $SENT"; do
1033
+ reset; rm -f "$SENT"; MH=MERGEABLE; MHS=CLEAN
1034
+ printf '%s' "$payload" > "$FIX/pf.txt"; printf '%s' "$payload" > "$FIX/ag.txt"
1035
+ vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt" --agents-file "$FIX/ag.txt"
1036
+ rc_is "payload file: verdict runs" "$RC" 0
1037
+ if [ -e "$SENT" ]; then bad "payload file: payload executed ($payload)"; else ok; fi
1038
+ logged "payload file: purpose text kept as data" reviews.log "See: $(printf '%s' "$payload" | tr '\n' ' ')"
1039
+ done
1040
+ reset; MH=MERGEABLE; MHS=CLEAN; printf 'a\001b\033c\nd' > "$FIX/pf.txt"
1041
+ vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt"
1042
+ logged "payload file: control characters stripped, newline folded" reviews.log "See: abc d"
1043
+ reset; MH=MERGEABLE; MHS=CLEAN; head -c 5000 /dev/zero | tr '\0' 'x' > "$FIX/pf.txt"
1044
+ vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt"
1045
+ 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
1046
+ logged "payload file: capped text still rendered" reviews.log "See: $(head -c 2000 /dev/zero | tr '\0' 'x')"
1047
+ reset; MH=MERGEABLE; MHS=CLEAN
1048
+ vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/does-not-exist.txt"
1049
+ rc_is "payload file: missing file tolerated" "$RC" 0
1050
+ reset; MH=MERGEABLE; MHS=CLEAN; printf 'from-file' > "$FIX/pf.txt"
1051
+ vd --result clean --has-purpose-regression true --purpose-findings "from-flag" --purpose-findings-file "$FIX/pf.txt"
1052
+ 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
1053
  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
1054
  rc_is "verdict: flag without a value" "$RC" 2
747
1055
  reset; vd --result bogus; rc_is "verdict: invalid result" "$RC" 2
@@ -780,6 +1088,7 @@ has "calibrate: untrusted investigator ignored" "could not resolve task type/con
780
1088
  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
1089
  has "calibrate: unanchored marker ignored" "could not resolve task type/confidence"
782
1090
  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
1091
+ 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
1092
  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
1093
  setcal "$(CAL_ROW Refactor HIGH true overconfidence)"
785
1094
  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"
@@ -814,6 +1123,21 @@ reset; rm -f "$FIX/comments.json"; ig; has "integrity: unreadable comments count
814
1123
  reset; mkctx 1 BASE_SYNC_ONLY "$OLD_SHA" 42; cm MEMBER User "$(agent_body security "$SHA" 'a' '')"; flush; ig
815
1124
  has "integrity: unverified carry is not reported as carried" "CARRIED_FROM: "; case "$OUT" in *"carried from"*) bad "integrity: carried requires a verified record" ;; *) ok ;; esac
816
1125
 
1126
+ # forge#3971: a build-time FORGE:SELFREVIEW-AGENT comment (even a trusted one pinned to the current head) is never a panel member
1127
+ sr_body() { printf '<!-- FORGE:SELFREVIEW-AGENT:%s -->\n%s\n<!-- REVIEW-FINDINGS-START -->\n%s\n<!-- REVIEW-FINDINGS-END -->\nReviewed-SHA: %s\nTree: abc123\n' "$1" "$3" "$4" "$2"; }
1128
+ reset
1129
+ cm MEMBER User "$(sr_body security "$SHA" 'self' '<!-- FINDING:SEC-9|CONFIRMED|HIGH|a.py:9|Self-review finding -->')"; cm MEMBER Bot "<!-- FORGE:SELFREVIEW -->
1130
+ Tree: abc123"; flush
1131
+ ig; has "integrity: self-review comments never count as panel agents" "ACTUAL_AGENT_COUNT=0"
1132
+ case "$OUT" in *security*) bad "integrity: SELFREVIEW-AGENT domain leaked into the panel" ;; *) ok ;; esac
1133
+ reset
1134
+ cm MEMBER User "$(sr_body security "$SHA" 'self' '<!-- FINDING:SEC-9|CONFIRMED|HIGH|a.py:9|Self-review finding -->')"; flush
1135
+ run "$GOOD" extract
1136
+ has "extract: a self-review comment alone is no review (fail closed)" "no trusted reviewer comments"
1137
+ reset
1138
+ cm MEMBER User "$(sr_body security "$SHA" 'self' '')"; cm MEMBER User "$(agent_body api "$SHA" 'real' '')"; flush
1139
+ ig; has "integrity: only the REVIEW-AGENT counts beside a self-review" "ACTUAL_AGENT_COUNT=1"; has "integrity: only the real domain" "ACTUAL_AGENT_DOMAINS=api"
1140
+
817
1141
  # --- Phase 7-9 subcommands run without a review-prepare context (flags only) ---
818
1142
  nctx() { local sub="$1"; shift; OUT=$(cd "$REPO_DIR" && FORGEDOCK_HOME="$GATE" bash "$ROOT/scripts/review-finalize.sh" "$sub" --pr 7 --repo o/r "$@" 2>"$WORK/stderr"); RC=$?; }
819
1143
  gate_reset; nctx merge-gate --issue 42 --base main --verdict APPROVED --has-purpose-regression false --calibration-needs-human false --trust-needs-human false --base-conflict false --other-blocking false
@@ -829,5 +1153,19 @@ OUT=$(bash "$ROOT/scripts/review-finalize.sh" bogus 2>/dev/null); RC=$?; rc_is "
829
1153
  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
1154
  OUT=$(bash "$ROOT/scripts/review-finalize.sh" extract --pr 7 --repo o/r 2>/dev/null); RC=$?; rc_is "missing context" "$RC" 2
831
1155
 
1156
+ # ======================= forge#4037: build.md red-green DEGRADED entry survives the real name splitter =======================
1157
+ RG_LINE=$(grep -F 'RG_DEGRADED_LIST="${RG_DEGRADED_LIST}' "$ROOT/commands/work-on/build.md" | sed -n '1p' | sed 's/^[[:space:]]*\*) //; s/ ;;.*$//')
1158
+ SPLIT_LINE=$(grep -F 'DEG_NAME_LIST=$(printf' "$ROOT/scripts/review-finalize.sh" | sed -n '1p' | sed 's/^[[:space:]]*//')
1159
+ if [ -n "$RG_LINE" ] && [ -n "$SPLIT_LINE" ]; then
1160
+ RG_DEGRADED_LIST=""; ID="AC2"; RG_BASE=UNRUNNABLE; RG_HEAD=RAN
1161
+ eval "$RG_LINE"
1162
+ RG_DEGRADED_LIST="${RG_DEGRADED_LIST}; AC3 — base run cannot execute (base=UNRUNNABLE head=RAN), so the test is unproven"
1163
+ DEG_NAMES="$RG_DEGRADED_LIST"
1164
+ eval "$SPLIT_LINE"
1165
+ [ "$DEG_NAME_LIST" = "$(printf 'AC2\nAC3')" ] && ok || bad "red-green DEGRADED entries split into exactly their check names (got: $DEG_NAME_LIST)"
1166
+ else
1167
+ bad "could not extract the build.md DEGRADED entry or the review-finalize splitter"
1168
+ fi
1169
+
832
1170
  echo "review-finalize tests: $pass passed, $fail failed"
833
1171
  [ "$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