forgedock 1.19.0 → 1.21.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 (53) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/phases.mjs +4 -2
  3. package/bin/engine/telemetry-seam.mjs +1 -1
  4. package/bin/engine-cli.mjs +40 -1
  5. package/bin/engine.mjs +48 -3
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/lean/effects.mjs +51 -0
  8. package/bin/lean/lean.mjs +352 -0
  9. package/bin/lean/lean.test.mjs +525 -0
  10. package/bin/lean/prompts.mjs +62 -0
  11. package/bin/lean/risk.mjs +49 -0
  12. package/bin/runner.mjs +99 -6
  13. package/commands/orchestrate/phase-4-execution.md +1 -1
  14. package/commands/review-pr-agents/protocols.md +19 -0
  15. package/commands/review-pr.md +7 -6
  16. package/commands/work-on/build/architect.md +1 -1
  17. package/commands/work-on/build/implement.md +9 -1
  18. package/commands/work-on/build/validate.md +14 -7
  19. package/commands/work-on/build.md +118 -8
  20. package/commands/work-on/close.md +9 -4
  21. package/commands/work-on/remediate.md +11 -4
  22. package/commands/work-on/review.md +12 -4
  23. package/commands/work-on.md +4 -4
  24. package/docs/CONFIG.md +8 -1
  25. package/docs/RELEASE-NOTES.md +21 -0
  26. package/docs/TELEMETRY.md +2 -2
  27. package/docs/WORK-ON-RUNTIME.md +11 -3
  28. package/package.json +2 -2
  29. package/scripts/architect-resume-match.test.sh +12 -3
  30. package/scripts/build-knowledge-index.mjs +8 -5
  31. package/scripts/builder-resume-match.test.sh +12 -3
  32. package/scripts/calibration.mjs +5 -2
  33. package/scripts/check-spec-markers.sh +2 -0
  34. package/scripts/ci-local/lib.mjs +11 -3
  35. package/scripts/ci-local/run.mjs +155 -49
  36. package/scripts/ci-local/scoping.mjs +27 -4
  37. package/scripts/ci-local.sh +3 -2
  38. package/scripts/ci-local.test.sh +189 -0
  39. package/scripts/classify-finding.sh +1 -1
  40. package/scripts/file-deferred-followups.sh +91 -30
  41. package/scripts/file-deferred-followups.test.sh +101 -8
  42. package/scripts/lib/telemetry.sh +2 -0
  43. package/scripts/review-finalize.sh +101 -12
  44. package/scripts/review-finalize.test.sh +136 -5
  45. package/scripts/review-roster.sh +18 -5
  46. package/scripts/review-roster.test.sh +57 -1
  47. package/scripts/selfreview-plan.sh +86 -0
  48. package/scripts/selfreview-plan.test.sh +90 -0
  49. package/scripts/spawn-depth-check.sh +5 -4
  50. package/scripts/spawn-depth-check.test.sh +3 -0
  51. package/scripts/telemetry.test.sh +8 -0
  52. package/scripts/transition-label.sh +26 -0
  53. package/scripts/verify-phase-trail.test.sh +6 -0
@@ -50,7 +50,8 @@
50
50
  # Phase 7-9 subcommands: --context FILE is optional; without it pass --review-sha SHA, --review-mode FULL|DELTA|BASE_SYNC_ONLY, --issue N, --base B.
51
51
  # verdict: --merge-health S --merge-health-state S [--base B] [--head-branch H] [--is-milestone true|false] [--has-purpose-regression true|false]
52
52
  # [--purpose-findings-file F | --purpose-findings TEXT] [--result stale|clean|blocking] [--agents-file F | --agents TEXT] [--findings-count N] [--blocking-count N]
53
- # [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--dry-run]
53
+ # [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--other-blocking true|false] [--out-dir D] [--dry-run]
54
+ # (--other-blocking: the reviewer's criterion 1/2/3/5 result; resolved and persisted to OUT_DIR/other-blocking.txt, which merge-gate ORs in)
54
55
  # calibrate: [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--trust-needs-human B] [--dry-run]; the issue is context contract.issue or --issue N
55
56
  # merge-gate: --verdict "APPROVED|CHANGES REQUESTED" --has-purpose-regression B --calibration-needs-human B --trust-needs-human B
56
57
  # --base-conflict B --other-blocking B [--calibration-note-file F | --calibration-note T] [--table-cell-key K] [--base B] [--issue N]
@@ -407,13 +408,15 @@ EOF
407
408
  gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate > "$FILES_JSON" 2>/dev/null || : > "$FILES_JSON"
408
409
  # "path:line" for every added line; an empty file means the diff was unreadable (gates then keep every finding).
409
410
  ADDED_LINES_FILE="$WORKDIR/added-lines"
410
- jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk '
411
+ # Second stream from the same pass: "path<TAB>text" per added line, for the Gate 2 symbol fallback.
412
+ ADDED_TEXT_FILE="$WORKDIR/added-text"; : > "$ADDED_TEXT_FILE"
413
+ jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk -v tf="$ADDED_TEXT_FILE" '
411
414
  /^FILE\t/{f=substr($(0),6);next}
412
415
  /^@@/{s=$(0);sub(/^@@ -[0-9,]+ \+/,"",s);sub(/[ ,].*/,"",s);n=s+0;next}
413
416
  /^\\/{next}
414
- /^\+/{print f":"n;n++;next}
417
+ /^\+/{print f":"n; print f "\t" substr($(0),2) > tf; n++;next}
415
418
  /^-/{next}
416
- {n++}' > "$ADDED_LINES_FILE" || : > "$ADDED_LINES_FILE"
419
+ {n++}' > "$ADDED_LINES_FILE" || { : > "$ADDED_LINES_FILE"; : > "$ADDED_TEXT_FILE"; }
417
420
  # Files the PR deleted or renamed (the old name too): findings on them always intersect.
418
421
  GONE_FILES_FILE="$WORKDIR/gone-files"
419
422
  jq -r '.[] | select(.status=="removed" or .status=="renamed") | .filename, (.previous_filename // empty)' "$FILES_JSON" > "$GONE_FILES_FILE" 2>/dev/null || : > "$GONE_FILES_FILE"
@@ -435,6 +438,52 @@ EOF
435
438
  if gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${REVIEW_SHA}" > "$t" 2>/dev/null; then awk 'END{print NR}' "$t"; else echo UNKNOWN; fi
436
439
  }
437
440
 
441
+ # Gate 2 symbol fallback: 0 when an identifier the finding names occurs (whole word, fixed string) in the added lines of FILE
442
+ # and nowhere in the base version of FILE (a symbol the PR introduced).
443
+ # Candidates: backticked tokens, plus identifier-shaped tokens on "Root cause" lines; each must match an anchored identifier
444
+ # regex of 4+ chars and not be a common word. Reviewer text is never used in arithmetic, a glob or a regex. Any failure returns 1.
445
+ # A symbol counts only when it is NEW: present in the added lines AND absent from the base version of the file (#4068 review
446
+ # DSEC-1/DBH-1: `self`, `return`, `result` in an added line must not re-scope a pre-existing finding). The base text comes from
447
+ # `git show <base>:<file>` (local commit) or the raw contents API; a file the PR adds has an empty base; an unreadable base
448
+ # disables the fallback (the line-window result stands).
449
+ SYM_BASE_SHA=""
450
+ base_text_for() { # base_text_for <file> <out>; 0 = out holds the base text (possibly empty), 1 = unknown
451
+ local f="$1" o="$2"
452
+ if jq -e --arg f "$f" 'any(.[]; .filename == $f and .status == "added")' "$FILES_JSON" >/dev/null 2>&1; then : > "$o"; return 0; fi
453
+ [ -n "$SYM_BASE_SHA" ] || SYM_BASE_SHA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .base.sha 2>/dev/null || true)
454
+ case "$SYM_BASE_SHA" in *[!0-9a-f]*|"") SYM_BASE_SHA="-"; return 1 ;; -) return 1 ;; esac
455
+ if git rev-parse -q --verify "${SYM_BASE_SHA}^{commit}" >/dev/null 2>&1; then
456
+ git show "${SYM_BASE_SHA}:${f}" > "$o" 2>/dev/null && return 0
457
+ git cat-file -e "${SYM_BASE_SHA}:${f}" 2>/dev/null && return 1
458
+ : > "$o"; return 0
459
+ fi
460
+ case "$f" in *[!A-Za-z0-9._/@+-]*|"") return 1 ;; esac
461
+ gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${SYM_BASE_SHA}" > "$o" 2>/dev/null && return 0
462
+ return 1
463
+ }
464
+ symbol_in_added() {
465
+ local f="$1" text="$2" t="$WORKDIR/sym-added" b="$WORKDIR/sym-base" c n=0
466
+ [ -s "$ADDED_TEXT_FILE" ] || return 1
467
+ awk -v f="$f" 'index($0, f "\t") == 1 { print substr($0, length(f) + 2) }' "$ADDED_TEXT_FILE" > "$t" 2>/dev/null || return 1
468
+ [ -s "$t" ] || return 1
469
+ base_text_for "$f" "$b" || return 1
470
+ while IFS= read -r c; do
471
+ case "$c" in ""|[!A-Za-z_]*) continue ;; esac
472
+ [ "${#c}" -ge 4 ] || continue
473
+ case "$(printf '%s' "$c" | tr '[:upper:]' '[:lower:]')" in
474
+ this|that|with|from|true|false|null|none|file|line|code|test|case|value|root|cause|when|then|which|where|there|their|error|data|name|path|type|call|only|also|into|have|does|used|uses) continue ;;
475
+ esac
476
+ n=$((n + 1)); [ "$n" -le 8 ] || break
477
+ if grep -qwF -- "$c" "$t" 2>/dev/null && ! grep -qwF -- "$c" "$b" 2>/dev/null; then return 0; fi
478
+ done <<SYMEOF
479
+ $({
480
+ printf '%s\n' "$text" | grep -oE '`[^`]+`' 2>/dev/null | tr -c 'A-Za-z0-9_\n' '\n'
481
+ printf '%s\n' "$text" | grep -iE 'root cause' 2>/dev/null | grep -oE '[A-Za-z_][A-Za-z0-9_]*' | grep -E '_|[a-z][A-Z]|[0-9]'
482
+ } 2>/dev/null | grep -E '^[A-Za-z_][A-Za-z0-9_]{3,}$' | awk '!seen[$0]++')
483
+ SYMEOF
484
+ return 1
485
+ }
486
+
438
487
  FINAL="$WORKDIR/final.jsonl"; : > "$FINAL"
439
488
  N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_INTRODUCED=0; N_KEPT=0
440
489
  I=0; TOTAL=$(jq 'length' "$DEDUPED")
@@ -472,6 +521,8 @@ EOF
472
521
  if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
473
522
  L=$((L + 1))
474
523
  done
524
+ # The cited line is the reviewer's estimate (stale or at the call site): symbol fallback before routing pre-existing.
525
+ if [ "$INTRODUCED" -eq 0 ] && symbol_in_added "$FILE" "$(printf '%s' "$F" | jq -r '.text // ""')"; then INTRODUCED=1; fi
475
526
  if [ "$INTRODUCED" -eq 1 ]; then SCOPE="introduced"; else
476
527
  SCOPE="pre-existing"
477
528
  case "$SEV" in MEDIUM|LOW|INFO) ROUTE="pre-existing" ;; esac
@@ -561,7 +612,7 @@ do_classify() {
561
612
  && [ -s "$CONTRACT_SCOPE_FILE" ] \
562
613
  && gh pr diff "$PR_NUMBER" -R "$REPO" --name-only > "$PR_FILES_FILE" 2>/dev/null; then
563
614
  # A deferred row counts only if issue N is OPEN, is an issue (not a PR), is not MERGE_ISSUE, and its body
564
- # carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus the row's path, and is authored by a trusted author
615
+ # carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus a checklist line for the row's exact path (the list script strips a trailing slash, so `p` and `p/` both match; `- [ ] \`path\``, [xX] accepted, the producer format of file-deferred-followups.sh; a body that only mentions the path does not count), and is authored by a trusted author
565
616
  # (OWNER/MEMBER/COLLABORATOR or Bot; same predicate as trusted-comments.sh; missing fields fail closed).
566
617
  # Unverified rows are dropped.
567
618
  _verified="$_scratch/contract-verified"; : > "$_verified"
@@ -571,7 +622,7 @@ do_classify() {
571
622
  [ "$_n" = "${MERGE_ISSUE#\#}" ] && continue
572
623
  gh api "repos/${REPO}/issues/$_n" 2>/dev/null \
573
624
  | jq -e --arg m "FORGE:DEFERRED_FROM: #${MERGE_ISSUE#\#} -->" --arg p "$_ipath" \
574
- '.state == "open" and .pull_request == null and ((.body // "") | contains($m) and contains($p)) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
625
+ '.state == "open" and .pull_request == null and ((.body // "") | (contains($m) and (split("\n") | any(rtrimstr("\r") as $l | ("- [ ] `","- [x] `","- [X] `") as $pre | ($p, $p + "/") as $q | ($pre + $q + "`") as $w | $l == $w or ($l | startswith($w + " ")))))) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
575
626
  || continue
576
627
  grep -Fxq -- "$_n" "$CONTRACT_OPEN_FILE" || echo "$_n" >> "$CONTRACT_OPEN_FILE"
577
628
  fi
@@ -696,11 +747,32 @@ do_classify() {
696
747
  fi
697
748
  case "$DISPOSITION" in
698
749
  "NOTE contract-deferred #"*)
699
- _dn="${DISPOSITION##*#}"
700
- case "$_dn" in ''|*[!0-9]*) ;; *)
701
- [ "$DRY_RUN" = "true" ] || gh issue comment "$_dn" -R "$REPO" --body "Review of #${PR_NUMBER} also found this on a path declared deferred here: ${F_LOC} ${F_TITLE}" >/dev/null 2>&1 || true # allowlist:check-command-side-effects
750
+ # The demotion counts only after the comment on #N succeeded and #N is still OPEN; any failure files the finding (fails toward filing).
751
+ _dn="${DISPOSITION##*#}"; _dok=1
752
+ case "$_dn" in ''|*[!0-9]*) _dok=0 ;; *)
753
+ if [ "$DRY_RUN" != "true" ]; then
754
+ _dst=$(gh issue view "$_dn" -R "$REPO" --json state --jq .state 2>/dev/null) || _dst=""
755
+ if [ "$_dst" != "OPEN" ]; then _dok=0
756
+ elif ! gh issue comment "$_dn" -R "$REPO" --body "Review of #${PR_NUMBER} also found this on a path declared deferred here: ${F_LOC} ${F_TITLE}" >/dev/null 2>&1; then _dok=0 # allowlist:check-command-side-effects
757
+ fi
758
+ fi
702
759
  ;; esac
703
- CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
760
+ if [ "$_dok" = "1" ]; then CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)); else
761
+ # Fall back to what the finding would have been absent the contract (a LOW/POSSIBLE or lineage-damped finding stays a NOTE),
762
+ # not a blanket ISSUE: re-run the classifier without the contract flags. An INPR_FIX or unusable result files (fails toward filing).
763
+ DISPOSITION="ISSUE contract-comment-failed"
764
+ _n=$#; _skip=0
765
+ while [ "$_n" -gt 0 ]; do
766
+ _a="$1"; shift; _n=$((_n - 1))
767
+ if [ "$_skip" = "1" ]; then _skip=0; continue; fi
768
+ case "$_a" in --contract-scope|--contract-open|--pr-files) _skip=1 ;; *) set -- "$@" "$_a" ;; esac
769
+ done
770
+ _fb=$(bash "$CLASSIFY_SCRIPT" "$@" 2>/dev/null | head -n 1) || _fb=""
771
+ case "$_fb" in
772
+ "NOTE reflag"*) DISPOSITION="$_fb"; NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
773
+ NOTE*|ISSUE*) DISPOSITION="$_fb" ;;
774
+ esac
775
+ fi ;;
704
776
  "NOTE contract-accepted-risk"*) CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
705
777
  "NOTE reflag-"*) NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
706
778
  INPR_FIX*)
@@ -1021,6 +1093,20 @@ do_verdict() {
1021
1093
  if [ "$RESULT" = "clean" ] && { [ "$BASE_CONFLICT" = "true" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ]; }; then
1022
1094
  RESULT="blocking"; VERDICT_COERCED=true
1023
1095
  fi
1096
+ # OTHER_BLOCKING (criteria 1, 2, 3 and 5) is reviewer-judged, so it is resolved here and persisted for merge-gate to OR in: a blocking
1097
+ # result that BASE_CONFLICT and HAS_PURPOSE_REGRESSION do not explain was caused by another criterion, whatever the reviewer passed.
1098
+ OTHER_BLOCKING_RESOLVED=""
1099
+ case "$OTHER_BLOCKING" in true|false) OTHER_BLOCKING_RESOLVED="$OTHER_BLOCKING" ;; esac
1100
+ if [ "$RESULT" = "blocking" ] && [ "$BASE_CONFLICT" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ]; then
1101
+ OTHER_BLOCKING_RESOLVED=true
1102
+ elif [ "$RESULT" = "clean" ]; then
1103
+ OTHER_BLOCKING_RESOLVED=false
1104
+ fi
1105
+ # Rewritten (or removed) on every run so a stale value from a prior head can never survive a re-review.
1106
+ rm -f "$OUT_DIR/other-blocking.txt" 2>/dev/null || true
1107
+ if { [ "$RESULT" = "clean" ] || [ "$RESULT" = "blocking" ]; } && [ -n "$OTHER_BLOCKING_RESOLVED" ] && [ "$DRY_RUN" != "true" ]; then
1108
+ printf '%s\n' "$OTHER_BLOCKING_RESOLVED" > "$OUT_DIR/other-blocking.txt" 2>/dev/null || true
1109
+ fi
1024
1110
  VERDICT_OUT=""
1025
1111
  case "$RESULT" in
1026
1112
  stale) VERDICT_OUT="STALE" ;;
@@ -1055,6 +1141,7 @@ ${TRUST_ANNOTATION}${CONFLICT_LINE}${PROTECTION_LINE}${PURPOSE_LINE}${ATTRIBUTIO
1055
1141
  echo "HAS_PURPOSE_REGRESSION=${HAS_PURPOSE_REGRESSION}"
1056
1142
  echo "HAS_MERGE_CONFLICT=${HAS_MERGE_CONFLICT}"
1057
1143
  echo "BASE_CONFLICT=${BASE_CONFLICT}"
1144
+ echo "OTHER_BLOCKING=${OTHER_BLOCKING_RESOLVED}"
1058
1145
  echo "MERGE_CONFLICT_MSG=${MERGE_CONFLICT_MSG}"
1059
1146
  echo "MERGE_PROTECTION_WARNING=${MERGE_PROTECTION_WARNING}"
1060
1147
  exit 0
@@ -1347,6 +1434,8 @@ The review must add a \`### Degraded Paths Reviewed\` section that names each de
1347
1434
  gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
1348
1435
  blocked "merge guard state unbound"
1349
1436
  fi
1437
+ # OR in the value `verdict` persisted: a passed `false` never overrides a persisted `true`. Anything else in the file is ignored.
1438
+ if [ "$(head -n 1 "$OUT_DIR/other-blocking.txt" 2>/dev/null | tr -d '[:space:]')" = "true" ]; then OTHER_BLOCKING=true; fi
1350
1439
  # ONLY_BASE_CONFLICT: the base conflict is the ONLY reason for CHANGES REQUESTED, evaluated after every human gate is set.
1351
1440
  ONLY_BASE_CONFLICT=false
1352
1441
  if [ "$BASE_CONFLICT" = "true" ] && [ "$OTHER_BLOCKING" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ] && [ "$CALIBRATION_NEEDS_HUMAN" != "true" ] && [ "$TRUST_NEEDS_HUMAN" != "true" ]; then
@@ -1354,7 +1443,7 @@ The review must add a \`### Degraded Paths Reviewed\` section that names each de
1354
1443
  fi
1355
1444
  if [ "$ONLY_BASE_CONFLICT" = "true" ] && [ "$VERDICT" = "CHANGES REQUESTED" ]; then
1356
1445
  # Pure base conflict: route to the base-sync path, NOT needs-human. A standalone /review-pr caller just sees the blocker.
1357
- gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push), re-running the quality gate, then re-reviewing the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1446
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). Base sync needed: the work-on pipeline performs the base sync (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push); a standalone \`/review-pr --auto-merge\` caller with no work-on router must run \`/work-on ${MERGE_ISSUE} --remediate\` to do it. The sync re-runs the quality gate and then re-reviews the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1358
1447
  echo "MERGE_OUTCOME=base-conflict"
1359
1448
  blocked "base-conflict"
1360
1449
  elif [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "$TRUST_NEEDS_HUMAN" = "true" ]; then
@@ -1382,7 +1471,7 @@ The review must add a \`### Degraded Paths Reviewed\` section that names each de
1382
1471
  fi
1383
1472
  if [ "$PRE_MERGE_BASE_CONFLICT" = "true" ]; then
1384
1473
  # Base moved during review: route to the base-sync path, NOT needs-human.
1385
- gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). No operator action is needed: the pipeline is performing the base sync automatically (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push) and then re-reviewing. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1474
+ gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). Base sync needed: the work-on pipeline performs the base sync (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push); a standalone \`/review-pr --auto-merge\` caller with no work-on router must run \`/work-on ${MERGE_ISSUE} --remediate\` to do it. The sync is followed by a re-review. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
1386
1475
  echo "MERGE_OUTCOME=base-conflict"
1387
1476
  blocked "base-conflict"
1388
1477
  elif [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
@@ -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