forgedock 1.19.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 (44) hide show
  1. package/README.md +2 -0
  2. package/bin/engine.mjs +48 -3
  3. package/bin/runner.mjs +20 -4
  4. package/commands/orchestrate/phase-4-execution.md +1 -1
  5. package/commands/review-pr-agents/protocols.md +19 -0
  6. package/commands/review-pr.md +7 -6
  7. package/commands/work-on/build/architect.md +1 -1
  8. package/commands/work-on/build/implement.md +9 -1
  9. package/commands/work-on/build/validate.md +1 -1
  10. package/commands/work-on/build.md +118 -8
  11. package/commands/work-on/close.md +9 -4
  12. package/commands/work-on/remediate.md +11 -4
  13. package/commands/work-on/review.md +11 -3
  14. package/commands/work-on.md +4 -4
  15. package/docs/CONFIG.md +5 -1
  16. package/docs/RELEASE-NOTES.md +15 -0
  17. package/docs/TELEMETRY.md +2 -2
  18. package/docs/WORK-ON-RUNTIME.md +11 -3
  19. package/package.json +1 -1
  20. package/scripts/architect-resume-match.test.sh +12 -3
  21. package/scripts/build-knowledge-index.mjs +8 -5
  22. package/scripts/builder-resume-match.test.sh +12 -3
  23. package/scripts/calibration.mjs +5 -2
  24. package/scripts/check-spec-markers.sh +2 -0
  25. package/scripts/ci-local/lib.mjs +11 -3
  26. package/scripts/ci-local/run.mjs +155 -49
  27. package/scripts/ci-local/scoping.mjs +27 -4
  28. package/scripts/ci-local.sh +3 -2
  29. package/scripts/ci-local.test.sh +189 -0
  30. package/scripts/classify-finding.sh +1 -1
  31. package/scripts/file-deferred-followups.sh +91 -30
  32. package/scripts/file-deferred-followups.test.sh +101 -8
  33. package/scripts/lib/telemetry.sh +2 -0
  34. package/scripts/review-finalize.sh +101 -12
  35. package/scripts/review-finalize.test.sh +136 -5
  36. package/scripts/review-roster.sh +18 -5
  37. package/scripts/review-roster.test.sh +57 -1
  38. package/scripts/selfreview-plan.sh +86 -0
  39. package/scripts/selfreview-plan.test.sh +90 -0
  40. package/scripts/spawn-depth-check.sh +5 -4
  41. package/scripts/spawn-depth-check.test.sh +3 -0
  42. package/scripts/telemetry.test.sh +8 -0
  43. package/scripts/transition-label.sh +26 -0
  44. package/scripts/verify-phase-trail.test.sh +6 -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 ;;
@@ -317,6 +319,55 @@ for pair in G-1:introduced G-2:pre-existing G-3:pre-existing G-4:unknown G-7:pre
317
319
  done
318
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"
319
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
+
320
371
  # DELTA: the earlier reviewed head's findings are read as well
321
372
  reset; mkctx 1 DELTA "$OLD_SHA"
322
373
  cm MEMBER User "$(agent_body security "$SHA" '### D-1 new
@@ -462,16 +513,49 @@ run "$GOOD" classify --auto-merge true; has "untrusted marker" "INPR_FIX=1"
462
513
  reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
463
514
  CONTRACT_BODY=$(printf '<!-- FORGE:CONTRACT -->\n### Out of Scope\n\n- `docs/` — deferred → #30: tracked there\n')
464
515
  cm MEMBER User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
465
- 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"
466
517
  mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
467
518
  run "$GOOD" classify; has "contract" "CONTRACT_DEMOTED=1"
468
519
  want "contract-deferred" '.findings[0].disposition' classified.json "NOTE contract-deferred #30"
469
520
  [ "$(cat "$FIX/issue-comments.log" 2>/dev/null)" = "ISSUE_COMMENT 30" ] && ok || bad "deferred issue comment not posted"
470
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"
471
555
  # an untrusted contract demotes nothing
472
556
  reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
473
557
  cm NONE User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
474
- 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"
475
559
  mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
476
560
  run "$GOOD" classify; has "untrusted contract" "CONTRACT_DEMOTED=0"
477
561
 
@@ -809,9 +893,25 @@ rc_is "guard: non-boolean" "$RC" 3; has "guard: non-boolean" "merge guard state
809
893
  # --- verdict / calibration / trust / base-conflict routing ---
810
894
  gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true
811
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"
812
- 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"
813
897
  gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --other-blocking true
814
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"
815
915
  gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --trust-needs-human true --table-cell-key "feature x HIGH"
816
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"
817
917
  gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --calibration-needs-human true --calibration-note "overconfident cell"
@@ -834,7 +934,7 @@ rc_is "route: CHANGES REQUESTED" "$RC" 3; logged "route: verdict reason" issue-b
834
934
 
835
935
  # --- pre-merge mergeability guard ---
836
936
  gate_reset; echo "CONFLICTING|DIRTY" > "$FIX/premerge"; mg "$GATE"
837
- 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"
838
938
  gate_reset; echo "MERGEABLE|BEHIND" > "$FIX/premerge"; echo true > "$FIX/protection"; mg "$GATE"
839
939
  rc_is "premerge: behind under strict protection" "$RC" 3; has "premerge: behind under strict protection" "blocker: base-conflict"
840
940
  gate_reset; echo "MERGEABLE|BEHIND" > "$FIX/premerge"; echo false > "$FIX/protection"; mg "$GATE"
@@ -896,6 +996,22 @@ reset; MH=MERGEABLE; MHS=CLEAN; vd --result clean --intensity-tier FAST --table-
896
996
  logged "verdict: trust annotation" reviews.log '**Intensity tier**: FAST | **Cell**: `bug HIGH` | **Shadow mode**: false'
897
997
  reset; MH=CONFLICTING; MHS=DIRTY; vd --result blocking --blocking-count 1
898
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"
899
1015
  reset; MH=CONFLICTING; MHS=DIRTY; vd --result clean
900
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"
901
1017
  reset; MH=MERGEABLE; MHS=CLEAN; vd --result clean --has-purpose-regression true --purpose-findings "F-1"
@@ -1007,6 +1123,21 @@ reset; rm -f "$FIX/comments.json"; ig; has "integrity: unreadable comments count
1007
1123
  reset; mkctx 1 BASE_SYNC_ONLY "$OLD_SHA" 42; cm MEMBER User "$(agent_body security "$SHA" 'a' '')"; flush; ig
1008
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
1009
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
+
1010
1141
  # --- Phase 7-9 subcommands run without a review-prepare context (flags only) ---
1011
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=$?; }
1012
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
@@ -17,7 +17,8 @@
17
17
  #
18
18
  # Tiers, evaluated in this order:
19
19
  # full THOROUGH or milestone->staging: Security plus every domain with a score > 0.
20
- # fix-pr the PR closes a `review-finding` issue: Security, plus the source finding's domain when it was HIGH+.
20
+ # fix-pr the PR closes a `review-finding` issue AND has <= 150 changed lines with a known size: Security, plus the
21
+ # source finding's domain when it was HIGH+. Larger or unknown-size fix PRs fall to the size ladder below.
21
22
  # small <= 60 changed lines, or one file of <= 150 lines: Security only.
22
23
  # medium 61-150 lines: Security plus the score-escalation table (no top-2 baseline).
23
24
  # standard > 150 lines, or an unknown size (fail toward more agents): top-2 domains by score plus the table.
@@ -27,7 +28,7 @@
27
28
  # exact value EXTENDED is STANDARD. An extended roster prints an extra DEPTH=extended line.
28
29
  # Path triggers escalate in EVERY tier except full (which already runs the union): CONTRACT HIGH_RISK, cross-critical
29
30
  # pairs, churn hot-spot, danger zone, bin/engine* (Concurrency + spec-cli), bin/ (spec-cli), auth/session/trust-filter
30
- # files (Auth), CI/docker/deploy/infra files (Infra), migration/.sql files (Database), SDK/OpenAPI files (API).
31
+ # files (Auth: auth*, not author*; oauth, jwt, login, password, permission, rbac, acl, token(s), api_key(s), credential, deps.py), CI/docker/deploy/infra files (Infra), migration/.sql files (Database), SDK/OpenAPI files (API).
31
32
  # Auth and Infra are path-gated: a diff-text score alone never selects them. Security is a hard floor in every tier.
32
33
  # spec-cli is added only with --spec-cli true (the persona file exists in the repo under review); in the standard tier it
33
34
  # also covers commands/**. Bash 3.2 portable (no mapfile, associative arrays, grep -P).
@@ -114,7 +115,10 @@ fi
114
115
  HIGH_RISK=false
115
116
  case " $CFLAGS " in *" HIGH_RISK "*) HIGH_RISK=true ;; esac
116
117
 
117
- if [ "$FIX_PR" = "true" ]; then
118
+ # The fix-pr tier is author-controlled (Closes #N), so it is capped by size: a larger or unknown-size fix PR follows the size ladder.
119
+ FIX_PR_MAX_LINES=150
120
+ FIX_PR_CAPPED=false
121
+ if [ "$FIX_PR" = "true" ] && [ "$LINES_KNOWN" = "true" ] && [ "$LINES" -le "$FIX_PR_MAX_LINES" ]; then
118
122
  TIER=fix-pr; REASON="fix PR for a review-finding issue"
119
123
  elif [ "$LINES_KNOWN" = "true" ] && { [ "$LINES" -le 60 ] || { [ "$NFILES" -eq 1 ] && [ "$LINES" -le 150 ]; }; }; then
120
124
  TIER=small; REASON="lines=$LINES files=$NFILES"
@@ -124,6 +128,9 @@ else
124
128
  TIER=standard
125
129
  if [ "$LINES_KNOWN" = "true" ]; then REASON="lines=$LINES"; else REASON="size unknown"; fi
126
130
  fi
131
+ if [ "$FIX_PR" = "true" ] && [ "$TIER" != "fix-pr" ]; then
132
+ FIX_PR_CAPPED=true; REASON="$REASON; fix-pr tier capped by size (max $FIX_PR_MAX_LINES lines, known size)"
133
+ fi
127
134
  # CONTRACT HIGH_RISK is never narrowed: a small, medium or fix-PR roster is lifted to the standard roster.
128
135
  BASE_TIER="$TIER"
129
136
  if [ "$HIGH_RISK" = "true" ] && [ "$TIER" != "standard" ]; then TIER=standard; REASON="$REASON; CONTRACT HIGH_RISK lifts $BASE_TIER to standard"; fi
@@ -147,6 +154,12 @@ case "$TIER" in
147
154
  esac ;;
148
155
  standard) for d in $(top_domains "$TOP_N"); do add_agent "$d"; done ;;
149
156
  esac
157
+ # A size-capped fix PR keeps the source finding's domain (size only ever ADDS depth).
158
+ if [ "$FIX_PR_CAPPED" = "true" ] && [ -n "$SRC_DOM" ]; then
159
+ case "$(printf '%s' "$SRC_SEV" | tr '[:lower:]' '[:upper:]')" in
160
+ HIGH|CRITICAL|BLOCKER) add_agent "$SRC_DOM"; REASON="$REASON; source finding ${SRC_SEV} adds its domain" ;;
161
+ esac
162
+ fi
150
163
 
151
164
  # Score escalation table (medium and standard only; diff-text signals, so never in the small or fix-pr tiers)
152
165
  if [ "$TIER" = "medium" ] || [ "$TIER" = "standard" ]; then
@@ -170,8 +183,8 @@ if [ "$CHURN" = "true" ] || [ "$DANGER" = "true" ]; then
170
183
  for d in $(top_domains 1); do add_agent "$d"; trig churn-or-danger-zone; done
171
184
  fi
172
185
  hit() { printf '%s\n' "$FILES" | grep -qiE "$1"; }
173
- hit '(^|[/_.-])(auth|authn|authz|session|sessions|jwt|oauth|login|logout|password|trust[-_]?filter|trusted[-_a-z]*)([/_.-]|$)' && { add_agent Auth; trig auth-path; }
174
- hit '^\.github/|(^|/)Dockerfile|docker-compose|(^|/)(deploy|infra|traefik|nginx|k8s|helm|terraform)/|\.tf$' && { add_agent Infra; trig infra-path; }
186
+ hit '(^|[/_.-])(auth([^o]|o[^r]|$)|authori[sz]|oauth|session|jwt|login|logout|passw(or)?d|permission|rbac|acl([/_.-]|$)|tokens?([/_.-]|$)|api[-_]?keys?|credential|trust[-_]?filter|trusted[-_a-z]*)|(^|/)deps\.py$' && { add_agent Auth; trig auth-path; }
187
+ hit '^\.github/|(^|/)Dockerfile|docker-compose|(^|/)(docker-)?compose[^/]*\.ya?ml$|(^|/)deploy[^/]*\.sh$|(^|/)docker-entrypoint[^/]*$|(^|/)(deploy|infra|traefik|nginx|k8s|helm|terraform)/|\.tf$' && { add_agent Infra; trig infra-path; }
175
188
  hit 'migration|\.sql$' && { add_agent Database; trig migration-path; }
176
189
  hit '^sdk/|openapi.*\.json$|openapi-versions/' && { add_agent API; trig sdk-path; }
177
190
  hit '^bin/engine' && { add_agent Concurrency; trig bin-engine; }
@@ -58,9 +58,26 @@ expect_has "consumer repo bin/engine" concurrency; expect_not "consumer repo bin
58
58
 
59
59
  # Case 3: a fix PR gives Security only
60
60
  run "commands/orchestrate/phase-4-execution.md
61
- commands/work-on.md" --scores "$SC_NOISY" --changed-lines 200 --changed-files 2 --fix-pr true --source-severity MEDIUM
61
+ commands/work-on.md" --scores "$SC_NOISY" --changed-lines 120 --changed-files 2 --fix-pr true --source-severity MEDIUM
62
62
  expect_sel "fix PR" "security"; expect_tier "fix PR" fix-pr
63
63
 
64
+ # Case 3b (forge#3848): the fix-pr tier is capped at 150 known lines; larger or unknown-size fix PRs follow the size ladder
65
+ SC_MULTI="AUTH=0 BILLING=5 CONCURRENCY=0 DATABASE=4 INFRA=0 FRONTEND=3 API=0"
66
+ run "svc/x.py" --scores "$SC_MULTI" --changed-lines 900 --changed-files 30 --fix-pr true --source-severity MEDIUM
67
+ expect_tier "fix PR 900 lines" standard; expect_has "fix PR 900 lines" billing; expect_has "fix PR 900 lines" database
68
+ run "svc/x.py" --scores "$SC_QUIET" --changed-lines 150 --changed-files 2 --fix-pr true
69
+ expect_tier "fix-PR-at-cap" fix-pr
70
+ run "svc/x.py" --scores "$SC_QUIET" --changed-lines 151 --changed-files 2 --fix-pr true
71
+ expect_tier "fix PR over cap" standard
72
+ run "svc/x.py" --scores "$SC_MULTI" --fix-pr true
73
+ expect_tier "fix-PR-unknown-size" standard
74
+ run "svc/x.py" --scores "$SC_MULTI" --changed-lines abc --fix-pr true
75
+ expect_tier "fix PR non-numeric size" standard
76
+ run "svc/x.py" --scores "$SC_QUIET" --changed-lines 400 --changed-files 5 --fix-pr true --source-severity HIGH --source-domain Concurrency
77
+ expect_has "capped-fix-PR,-HIGH-source" concurrency; expect_tier "capped-fix-PR,-HIGH-source" standard
78
+ run "svc/x.py" --scores "$SC_QUIET" --changed-lines 400 --changed-files 5 --fix-pr true --contract-flags "HIGH_RISK" --review-depth EXTENDED
79
+ expect_tier "capped-fix-PR-with-HIGH_RISK-and-EXTENDED" standard
80
+
64
81
  # Case 4: a fix PR whose source finding was HIGH adds the domain agent
65
82
  run "commands/work-on.md" --scores "AUTH=0 BILLING=0 CONCURRENCY=2 DATABASE=0 INFRA=0 FRONTEND=0 API=0" --changed-lines 40 --fix-pr true --source-severity HIGH --source-domain Concurrency
66
83
  expect_sel "fix PR, HIGH source finding" "security concurrency"
@@ -89,12 +106,51 @@ run "svc/app/routers/session.py" --scores "$SC_QUIET" --changed-lines 20 --chang
89
106
  expect_has "auth path" auth
90
107
  run "scripts/trusted-comments.sh" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
91
108
  expect_has "trust-filter path" auth
109
+ run "svc/authorization.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
110
+ expect_has "auth path authorization.py" auth
111
+ run "svc/authMiddleware.ts" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
112
+ expect_has "auth-path-authMiddleware.ts" auth
113
+ run "svc/oauth2.ts" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
114
+ expect_has "auth path oauth2.ts" auth
115
+ run "svc/permissions.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
116
+ expect_has "auth path permissions.py" auth
117
+ run "svc/rbac.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
118
+ expect_has "auth path rbac.py" auth
119
+ run "svc/deps.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
120
+ expect_has "auth path deps.py" auth
121
+ run "svc/tokens.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
122
+ expect_has "auth path tokens.py" auth
123
+ run "svc/api_keys.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
124
+ expect_has "auth path api_keys.py" auth
125
+ run "svc/auth_utils.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
126
+ expect_has "auth-path-auth_utils.py" auth
127
+ run "AUTHORS.md" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
128
+ expect_not "no-auth-path-AUTHORS.md" auth
129
+ run "docs/author.md" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
130
+ expect_not "no auth path docs/author.md" auth
131
+ run "svc/tokenizer.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
132
+ expect_not "no-auth-path-svc/tokenizer.py" auth
133
+ run "commands/review-pr.md" --scores "AUTH=3 INFRA=0 DATABASE=0 BILLING=0 CONCURRENCY=0" --changed-lines 20 --changed-files 1
134
+ expect_not "auth-from-prose-score-alone" auth
92
135
  run "commands/review-pr.md" --scores "AUTH=3 INFRA=3 DATABASE=0 BILLING=0 CONCURRENCY=0" --changed-lines 120
93
136
  expect_not "auth from prose score alone" auth; expect_not "infra from prose score alone" infra
94
137
  run ".github/workflows/ci.yml" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
95
138
  expect_has "infra path" infra
96
139
  run "docker-compose.prod.yml" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
97
140
  expect_has "docker path" infra
141
+ # Infra path gate covers deploy scripts, container entrypoints and Compose v2 names (basename-anchored)
142
+ for f in scripts/deploy.sh scripts/deploy-prod.sh docker-entrypoint.sh svc/docker-entrypoint.sh compose.yaml compose.yml; do
143
+ run "$f" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
144
+ expect_has "infra path: $f" infra
145
+ done
146
+ for f in scripts/resolve-deploy-gate.sh commands/deploy-pr.md; do
147
+ run "$f" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
148
+ expect_not "no-infra-path-$f" infra
149
+ done
150
+ run "$(printf 'README.md\nscripts/deploy.sh')" --scores "$SC_QUIET" --changed-lines 20 --changed-files 2
151
+ expect_has "infra-path-multi-file-list" infra
152
+ run "Scripts/Deploy.SH" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
153
+ expect_has "infra-path-case-insensitive" infra
98
154
  run "db/migrations/0042_add.sql" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
99
155
  expect_has "migration path" database
100
156
  run "sdk/node/src/index.ts" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # selfreview-plan.sh — build-time self-review plan: run or skip, and which roster (forge#3971).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Pure decision script: flags in, key=value lines out. No network, no git, no eval; every flag value is data.
7
+ # It calls scripts/review-roster.sh (resolved through forge_resolve, never from the repo under build) on the staged
8
+ # diff's file list so the self-review roster equals the roster /review-pr would pick for the same diff.
9
+ #
10
+ # Usage: selfreview-plan.sh --files-file PATH|- [--changed-lines N] [--changed-files N] [--contract-flags "HIGH_RISK ..."]
11
+ # [--spec-cli true|false] [--review-depth STANDARD|EXTENDED] [--scores "AUTH=n ..."]
12
+ # [--agent-available true|false] [--tree HASH] [--done-trees "HASH HASH ..."]
13
+ # Env: FORGE_SELFREVIEW=off|0|false|disabled operator off switch
14
+ # Output (stdout, always all four lines):
15
+ # SELFREVIEW=run|skip SKIP_REASON=<reason or empty> TIER=<roster tier or empty> SELECTED_AGENTS=<domains>
16
+ # Skip reasons (first match wins): disabled, empty-diff, docs-only, no-agent-tool, already-done, roster-unavailable,
17
+ # small-security-only (small tier, Security-only roster, no path or contract trigger fired).
18
+ # Exit: 0 for every parseable call, including every degrade-to-skip path (self-review never blocks build); 2 on an unknown flag.
19
+ # Bash 3.2 portable (no mapfile, associative arrays, grep -P).
20
+ set -u
21
+
22
+ FILES_FILE=""; LINES=""; NFILES=""; CFLAGS=""; SPEC_CLI=false; DEPTH=STANDARD; SCORES=""
23
+ AGENT_AVAILABLE=true; TREE=""; DONE_TREES=""
24
+
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ --files-file) FILES_FILE="${2:-}"; shift 2 ;;
28
+ --changed-lines) LINES="${2:-}"; shift 2 ;;
29
+ --changed-files) NFILES="${2:-}"; shift 2 ;;
30
+ --contract-flags) CFLAGS="${2:-}"; shift 2 ;;
31
+ --spec-cli) SPEC_CLI="${2:-false}"; shift 2 ;;
32
+ --review-depth) DEPTH="${2:-STANDARD}"; shift 2 ;;
33
+ --scores) SCORES="${2:-}"; shift 2 ;;
34
+ --agent-available) AGENT_AVAILABLE="${2:-true}"; shift 2 ;;
35
+ --tree) TREE="${2:-}"; shift 2 ;;
36
+ --done-trees) DONE_TREES="${2:-}"; shift 2 ;;
37
+ *) echo "selfreview-plan: unknown argument: $1" >&2; exit 2 ;;
38
+ esac
39
+ done
40
+
41
+ emit() { # <run|skip> <skip reason> <tier> <agents>
42
+ printf 'SELFREVIEW=%s\nSKIP_REASON=%s\nTIER=%s\nSELECTED_AGENTS=%s\n' "$1" "$2" "$3" "$4"
43
+ exit 0
44
+ }
45
+
46
+ case "$(printf '%s' "${FORGE_SELFREVIEW:-}" | tr '[:upper:]' '[:lower:]')" in
47
+ off|0|false|disabled|no) emit skip disabled "" "" ;;
48
+ esac
49
+
50
+ FILES=""
51
+ if [ "$FILES_FILE" = "-" ]; then FILES=$(cat)
52
+ elif [ -n "$FILES_FILE" ] && [ -f "$FILES_FILE" ]; then FILES=$(cat "$FILES_FILE"); fi
53
+ FILES=$(printf '%s\n' "$FILES" | grep -v '^[[:space:]]*$' || true)
54
+
55
+ [ -n "$FILES" ] || emit skip empty-diff "" ""
56
+
57
+ # Docs-only: every file is documentation. commands/**, .agents/** and skills/** are executable spec, never docs.
58
+ CODE_FILES=$(printf '%s\n' "$FILES" | grep -vE '^(docs/|README|CHANGELOG|LICENSE|NOTICE)|\.(md|txt|rst)$' || true)
59
+ SPEC_FILES=$(printf '%s\n' "$FILES" | grep -E '^(commands|\.agents|skills)/.*\.md$' || true)
60
+ [ -n "$CODE_FILES" ] || [ -n "$SPEC_FILES" ] || emit skip docs-only "" ""
61
+
62
+ [ "$AGENT_AVAILABLE" = "true" ] || emit skip no-agent-tool "" ""
63
+
64
+ if [ -n "$TREE" ]; then
65
+ for _t in $DONE_TREES; do
66
+ [ "$_t" = "$TREE" ] && emit skip already-done "" ""
67
+ done
68
+ fi
69
+
70
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/* "$_SELF_ROOT"; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; } # allowlist:portability — canonical resolver block; readlink -f is guarded (2>/dev/null) and optional
71
+ ROSTER_SCRIPT=$(forge_resolve review-roster.sh)
72
+ [ -n "$ROSTER_SCRIPT" ] && [ -f "$ROSTER_SCRIPT" ] || emit skip roster-unavailable "" ""
73
+
74
+ set -- --files-file - --contract-flags "$CFLAGS" --spec-cli "$SPEC_CLI" --review-depth "$DEPTH"
75
+ [ -n "$LINES" ] && set -- "$@" --changed-lines "$LINES"
76
+ [ -n "$NFILES" ] && set -- "$@" --changed-files "$NFILES"
77
+ [ -n "$SCORES" ] && set -- "$@" --scores "$SCORES"
78
+ OUT=$(printf '%s\n' "$FILES" | bash "$ROSTER_SCRIPT" "$@" 2>/dev/null) || emit skip roster-unavailable "" ""
79
+
80
+ SEL=$(printf '%s\n' "$OUT" | sed -n 's/^SELECTED_AGENTS=//p' | head -n 1)
81
+ TIER=$(printf '%s\n' "$OUT" | sed -n 's/^TIER=//p' | head -n 1)
82
+ [ -n "$SEL" ] && [ -n "$TIER" ] || emit skip roster-unavailable "" ""
83
+
84
+ # Cost guard: the small tier runs only when a path or contract trigger widened the roster past Security.
85
+ if [ "$TIER" = "small" ] && [ "$SEL" = "security" ]; then emit skip small-security-only "$TIER" "$SEL"; fi
86
+ emit run "" "$TIER" "$SEL"
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Tests for scripts/selfreview-plan.sh (build-time self-review plan, forge#3971). Run: bash scripts/selfreview-plan.test.sh
6
+ # (bash 3.2 compatible)
7
+ set -u
8
+ DIR=$(cd "$(dirname "$0")" && pwd)
9
+ ROOT=$(cd "$DIR/.." && pwd)
10
+ SCRIPT="$DIR/selfreview-plan.sh"
11
+ pass=0; fail=0
12
+ ok() { pass=$((pass+1)); }
13
+ bad() { fail=$((fail+1)); echo "FAIL: $1"; }
14
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/selfreview-plan.XXXXXX")
15
+ trap 'rm -rf "$WORK"' EXIT
16
+ export FORGEDOCK_HOME="$ROOT" # the resolver must not pick up an ambient install
17
+ unset FORGE_SELFREVIEW
18
+
19
+ PLAN=""; RC=0
20
+ # run <files, newline separated> <flags...>
21
+ run() {
22
+ printf '%s\n' "$1" > "$WORK/files"; shift
23
+ PLAN=$(bash "$SCRIPT" --files-file "$WORK/files" "$@" 2>/dev/null); RC=$?
24
+ }
25
+ field() { printf '%s\n' "$PLAN" | sed -n "s/^$1=//p"; }
26
+ expect() { # name key want
27
+ local got; got=$(field "$2")
28
+ if [ "$got" = "$3" ]; then ok; else bad "$1: $2='$got', want '$3'"; fi
29
+ }
30
+ has_agent() { case " $(field SELECTED_AGENTS) " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
31
+
32
+ # e1: docs-only and empty diffs skip with a recorded reason
33
+ run "docs/GUIDE.md
34
+ README.md" --changed-lines 40
35
+ expect "docs-only skips" SELFREVIEW skip; expect "docs-only reason" SKIP_REASON docs-only
36
+ run "" --changed-lines 0
37
+ expect "empty diff skips" SELFREVIEW skip; expect "empty reason" SKIP_REASON empty-diff
38
+ run "commands/work-on/build.md" --changed-lines 40 --changed-files 1
39
+ expect "spec markdown is not docs" SELFREVIEW skip
40
+ if [ "$(field SKIP_REASON)" = "docs-only" ]; then bad "commands/*.md treated as docs"; else ok; fi
41
+
42
+ # e2: small tier, Security-only roster, no trigger: skipped; the same size with an auth path runs
43
+ run "scripts/foo.sh" --changed-lines 30 --changed-files 1
44
+ expect "small security-only skips" SELFREVIEW skip; expect "small reason" SKIP_REASON small-security-only; expect "small tier" TIER small
45
+ run "src/auth/session.py
46
+ src/util.py" --changed-lines 30 --changed-files 2
47
+ expect "small + auth path runs" SELFREVIEW run; expect "small + auth tier" TIER small
48
+ has_agent auth && ok || bad "auth path did not add auth"
49
+ has_agent security && ok || bad "security floor missing"
50
+ run "scripts/foo.sh
51
+ scripts/bar.sh" --changed-lines 30 --changed-files 2 --contract-flags "HIGH_RISK"
52
+ expect "small + HIGH_RISK runs" SELFREVIEW run; expect "HIGH_RISK lifts tier" TIER standard
53
+
54
+ # standard tier runs
55
+ run "scripts/foo.sh
56
+ scripts/bar.sh" --changed-lines 400 --changed-files 2
57
+ expect "standard runs" SELFREVIEW run; expect "standard tier" TIER standard; expect "standard no reason" SKIP_REASON ""
58
+ has_agent security && ok || bad "standard roster lacks security"
59
+
60
+ # EXTENDED depth lifts a small roster
61
+ run "scripts/foo.sh" --changed-lines 20 --changed-files 1 --review-depth EXTENDED
62
+ expect "extended depth runs" SELFREVIEW run; expect "extended tier" TIER standard
63
+
64
+ # e3: off switch, no Agent tool, unresolvable roster degrade to skip and exit 0
65
+ FORGE_SELFREVIEW=off run "scripts/a.sh
66
+ scripts/b.sh" --changed-lines 400
67
+ [ "$RC" = 0 ] && ok || bad "off exit code $RC"
68
+ expect "off skips" SELFREVIEW skip; expect "off reason" SKIP_REASON disabled
69
+ run "scripts/a.sh
70
+ scripts/b.sh" --changed-lines 400 --agent-available false
71
+ [ "$RC" = 0 ] && ok || bad "no-agent exit code $RC"
72
+ expect "no agent skips" SELFREVIEW skip; expect "no agent reason" SKIP_REASON no-agent-tool
73
+ FORGEDOCK_HOME=/nonexistent-forge-home run "scripts/a.sh
74
+ scripts/b.sh" --changed-lines 400
75
+ [ "$RC" = 0 ] && ok || bad "unresolvable exit code $RC"
76
+ expect "unresolvable roster skips" SELFREVIEW skip; expect "unresolvable reason" SKIP_REASON roster-unavailable
77
+
78
+ # e4: a tree that already has a completed self-review is not dispatched again
79
+ run "scripts/a.sh
80
+ scripts/b.sh" --changed-lines 400 --tree aaa --done-trees "bbb aaa"
81
+ expect "same tree skips" SELFREVIEW skip; expect "same tree reason" SKIP_REASON already-done
82
+ run "scripts/a.sh
83
+ scripts/b.sh" --changed-lines 400 --tree ccc --done-trees "bbb aaa"
84
+ expect "new tree runs" SELFREVIEW run
85
+
86
+ # usage: unknown flag exits 2
87
+ bash "$SCRIPT" --nope >/dev/null 2>&1; [ "$?" = 2 ] && ok || bad "unknown flag should exit 2"
88
+
89
+ echo "selfreview-plan: ${pass} passed, ${fail} failed"
90
+ [ "$fail" -eq 0 ]
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env bash
2
- # spawn-depth-check.sh — will a /work-on dispatching phase (review, remediate) have the Agent tool?
2
+ # spawn-depth-check.sh — will a /work-on dispatching phase (build self-review, review, remediate) have the Agent tool?
3
3
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
4
  # SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  #
6
6
  # Claude Code grants the Agent tool to sub-agents down to CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH layers
7
7
  # below the main conversation (default 3 since 2.1.219; 5 for 2.1.172-2.1.216; 1 for 2.1.217-2.1.218).
8
- # A sub-agent at layer L can spawn only when L < depth. The router invokes review/remediate at
8
+ # A sub-agent at layer L can spawn only when L < depth. The router invokes build/review/remediate at
9
9
  # router layer + 1, so they can spawn reviewers only when router layer + 2 <= depth (forge#3398,
10
- # docs/WORK-ON-RUNTIME.md).
10
+ # docs/WORK-ON-RUNTIME.md). build (B5.6 self-review, forge#3971) uses the same arithmetic but degrades to a recorded
11
+ # skip on FAIL instead of stopping the run.
11
12
  #
12
13
  # Usage: spawn-depth-check.sh [--router-layer N] (N = 0 solo, 1 under /orchestrate; default 0)
13
14
  # Env: CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH explicit limit (wins over the version default)
@@ -69,5 +70,5 @@ if [ "$DEPTH" -ge "$REQUIRED" ]; then
69
70
  echo "SPAWN_DEPTH: OK depth=${DEPTH} required=${REQUIRED} (${SOURCE})"
70
71
  exit 0
71
72
  fi
72
- echo "SPAWN_DEPTH: FAIL depth=${DEPTH} required=${REQUIRED} (${SOURCE}) — review/remediate would run without the Agent tool and could not spawn /review-pr reviewers. Set CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH>=${REQUIRED}."
73
+ echo "SPAWN_DEPTH: FAIL depth=${DEPTH} required=${REQUIRED} (${SOURCE}) — build/review/remediate would run without the Agent tool and could not spawn self-review or /review-pr reviewers. Set CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH>=${REQUIRED}."
73
74
  exit 1
@@ -43,5 +43,8 @@ t "env depth 09 decimal" 0 "SPAWN_DEPTH: OK" CLAUDE_CODE_MAX_SUBA
43
43
  # Usage errors.
44
44
  t "bad router layer" 2 "spawn-depth-check:" -- --router-layer x
45
45
  t "unknown flag" 2 "usage:" -- --nope
46
+ # forge#3971: build is a dispatching phase too, and the FAIL text names it (exit codes unchanged)
47
+ out=$(env -u CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH -u FORGE_RUNTIME FORGE_CLAUDE_VERSION=2.1.217 bash "$S" 2>&1)
48
+ case "$out" in *build/review/remediate*) PASS=$((PASS+1)) ;; *) FAILN=$((FAILN+1)); echo "FAIL: FAIL text does not name build ($out)" ;; esac
46
49
  echo "spawn-depth-check: ${PASS} passed, ${FAILN} failed"
47
50
  [ "$FAILN" -eq 0 ]
@@ -61,6 +61,14 @@ check "finding_disposition carries scope" "$(grep '^{' "$TD/posted" | jq -r '.sc
61
61
  reset; FORGE_PR=9 forge_emit review_panel mode=FULL; grep -q 'issues/9/comments' "$TD/calls" && ok || bad "review_panel targets PR"
62
62
  reset; FORGE_PR=9 forge_emit diff_size lines=1; grep -q 'issues/5/comments' "$TD/calls" && ok || bad "diff_size targets issue"
63
63
 
64
+ # build_selfreview (forge#3971) is a build-side event: posted to the issue even when a PR number is set; ids and counts only
65
+ reset; FORGE_PR=9 forge_emit build_selfreview tier=standard agents=3 findings=4 fixed=2 declined=1 skipped=
66
+ grep -q 'issues/5/comments' "$TD/calls" && ok || bad "build_selfreview targets issue"
67
+ REC=$(grep '^{' "$TD/posted")
68
+ check "build_selfreview event" "$(printf '%s' "$REC" | jq -r '.event')" "build_selfreview"
69
+ check "build_selfreview counts typed" "$(printf '%s' "$REC" | jq -r '.agents + .findings + .fixed + .declined')" "10"
70
+ check "build_selfreview skip reason" "$(reset; forge_emit build_selfreview skipped=small-security-only tier=small; grep '^{' "$TD/posted" | jq -r '.skipped')" "small-security-only"
71
+
64
72
  # 2. Sanitising: reserved keys, bad keys, backticks and newlines never reach the record.
65
73
  reset
66
74
  forge_emit x repo=evil 'a b=1' ok='a`b