forgedock 1.16.2 → 1.18.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 (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,833 @@
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/review-finalize.sh (review-pr Phases 6-9: extract, classify, file-prepare, file-verify, verdict, calibrate, merge-gate, demilestone, integrity). A stubbed `gh` and a throwaway
6
+ # git repo stand in for GitHub and the PR checkout; the real classify-finding.sh, cap-findings.sh, trusted-comments.sh and
7
+ # check-contract-scope.sh are copied into a fake trusted install root so the moved behavior is exercised end to end.
8
+ # Run: FORGEDOCK_HOME=$PWD bash scripts/review-finalize.test.sh (bash 3.2 compatible)
9
+
10
+ set -u
11
+ DIR=$(cd "$(dirname "$0")" && pwd)
12
+ ROOT=$(cd "$DIR/.." && pwd)
13
+ pass=0; fail=0
14
+ ok() { pass=$((pass+1)); }
15
+ bad() { fail=$((fail+1)); echo "FAIL: $1"; }
16
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/review-finalize-test.XXXXXX")
17
+ trap 'rm -rf "$WORK"' EXIT
18
+ command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not installed"; exit 0; }
19
+
20
+ export HOME="$WORK/home"; mkdir -p "$HOME"
21
+ unset FORGE_ROOT FORGE_HOME CLAUDE_PLUGIN_ROOT FORGE_CONFIG FORGE_SCRATCHPAD FORGEDOCK_HOME
22
+ export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
23
+
24
+ # ---- stubbed gh: every answer comes from files under $FIX; mutating calls are logged ----
25
+ FIX="$WORK/fix"; mkdir -p "$FIX" "$WORK/bin"
26
+ cat > "$WORK/bin/gh" <<'STUB'
27
+ #!/usr/bin/env bash
28
+ a="$*"
29
+ case "$a" in
30
+ "issue view"*"labels,comments"*) cat "$FIX/escalated" 2>/dev/null || echo false ;;
31
+ "issue view"*"--json title"*) echo "finding title" ;;
32
+ "issue edit"*) printf 'ISSUE_EDIT %s\n' "$a" >> "$FIX/edits.log"; exit 0 ;;
33
+ "issue list"*"--json number,title,milestone"*) cat "$FIX/demile.txt" 2>/dev/null; exit 0 ;;
34
+ "pr review"*) { printf '%s\n' '=====REVIEW====='; for x in "$@"; do last="$x"; done; printf '%s\n' "$last"; } >> "$FIX/reviews.log"; exit 0 ;;
35
+ "pr merge"*) printf 'PR_MERGE %s\n' "$a" >> "$FIX/merge.log"; [ -f "$FIX/merge-fail" ] || echo MERGED > "$FIX/state"; exit 0 ;;
36
+ "pr view"*"--json state"*) cat "$FIX/state" 2>/dev/null || echo OPEN ;;
37
+ "pr view"*"mergeable,mergeStateStatus"*) cat "$FIX/premerge" 2>/dev/null || echo "MERGEABLE|CLEAN" ;;
38
+ "pr view"*"headRefName"*) echo feat/x ;;
39
+ "api --paginate repos/o/r/pulls/"*"/files"*) cat "$FIX/pr-files.txt" 2>/dev/null || exit 1 ;;
40
+ "api repos/o/r/pulls/"*"--jq .changed_files") cat "$FIX/changed-n" 2>/dev/null || exit 1 ;;
41
+ "api repos/o/r/pulls/"*"--jq .head.sha") cat "$FIX/head" ;;
42
+ "api repos/o/r/branches/"*"/protection"*) cat "$FIX/protection" 2>/dev/null || exit 1 ;;
43
+ "api repos/o/r/issues/"*"/comments --paginate --jq"*) cat "$FIX/route-bodies" 2>/dev/null; exit 0 ;;
44
+ "api repos/o/r/issues/"*"/comments --jq"*) cat "$FIX/investigator" 2>/dev/null; exit 0 ;;
45
+ "api repos/o/r/commits/"*) cat "$FIX/commit-sha" 2>/dev/null || exit 1 ;;
46
+ "api repos/o/r/compare/"*) cat "$FIX/compare-files" 2>/dev/null || exit 1 ;;
47
+ "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
+ "label create"*) exit 0 ;;
50
+ "pr view"*"headRefOid"*) cat "$FIX/head" ;;
51
+ "pr view"*"baseRefName"*) echo main ;;
52
+ "pr diff"*"--name-only"*) [ -f "$FIX/difffail" ] && exit 1; cat "$FIX/files" ;;
53
+ "api repos/o/r/pulls/"*"/files"*) cat "$FIX/pr-files.json" 2>/dev/null || echo '[]' ;;
54
+ "api -H"*) exit 1 ;;
55
+ "api --paginate repos/o/r/issues/"*"/comments")
56
+ n="${a#*issues/}"; n="${n%%/*}"
57
+ if [ -f "$FIX/comments-$n.json" ]; then cat "$FIX/comments-$n.json"; else cat "$FIX/comments.json" 2>/dev/null || exit 1; fi ;;
58
+ "api repos/o/r/issues/"*) n="${a##*/}"; cat "$FIX/issue-$n.json" 2>/dev/null || exit 1 ;;
59
+ "issue view"*"labels"*) cat "$FIX/labels" 2>/dev/null ;;
60
+ "issue list"*"--jq"*) exit 0 ;;
61
+ "issue list"*"--state open"*) cat "$FIX/issues-open.json" 2>/dev/null || echo '[]' ;;
62
+ "issue list"*"--state closed"*) cat "$FIX/issues-closed.json" 2>/dev/null || echo '[]' ;;
63
+ "issue list"*"--state all"*) cat "$FIX/issues-all.json" 2>/dev/null || echo '[]' ;;
64
+ *) exit 1 ;;
65
+ esac
66
+ STUB
67
+ chmod +x "$WORK/bin/gh"; export FIX PATH="$WORK/bin:$PATH"
68
+
69
+ # ---- fake trusted install roots ----
70
+ mkroot() { # mkroot <dir> [skip-script ...]
71
+ local r="$1" f; shift; mkdir -p "$r/scripts/lib"
72
+ cp "$ROOT/scripts/lib/forge-resolve.sh" "$ROOT/scripts/lib/review-delta-mode.sh" "$r/scripts/lib/"
73
+ [ -f "$ROOT/scripts/lib/telemetry.sh" ] && cp "$ROOT/scripts/lib/telemetry.sh" "$r/scripts/lib/"
74
+ for f in trusted-comments.sh classify-finding.sh cap-findings.sh check-contract-scope.sh issue-dedup.sh severity-to-priority.sh; do
75
+ case " $* " in *" $f "*) continue ;; esac
76
+ cp "$ROOT/scripts/$f" "$r/scripts/"
77
+ done
78
+ printf '#!/usr/bin/env bash\necho "Milestone One"\n' > "$r/scripts/derive-finding-milestone.sh"
79
+ }
80
+ GOOD="$WORK/root-good"; mkroot "$GOOD"
81
+ NOCLS="$WORK/root-nocls"; mkroot "$NOCLS" classify-finding.sh
82
+ NOCAP="$WORK/root-nocap"; mkroot "$NOCAP" cap-findings.sh
83
+ NOTRUST="$WORK/root-notrust"; mkroot "$NOTRUST" trusted-comments.sh
84
+
85
+ # ---- throwaway PR checkout: a.py has 20 lines at the reviewed head ----
86
+ REPO_DIR="$WORK/repo"; mkdir -p "$REPO_DIR"
87
+ git -C "$REPO_DIR" init -q -b main
88
+ for f in a b c d e f g; do seq 1 20 > "$REPO_DIR/$f.py"; done; git -C "$REPO_DIR" add -A; git -C "$REPO_DIR" commit -qm A
89
+ SHA=$(git -C "$REPO_DIR" rev-parse HEAD)
90
+ OLD_SHA=$(printf '%040d' 7)
91
+ export FORGE_SCRATCHPAD="$WORK/scratch"; mkdir -p "$FORGE_SCRATCHPAD"
92
+
93
+ CTXF="$WORK/ctx.json"
94
+ mkctx() { # mkctx [sv] [mode] [from] [issue]
95
+ jq -n --arg sha "${SHA_OVERRIDE:-$SHA}" --argjson sv "${1:-1}" --arg mode "${2:-FULL}" --arg from "${3:-}" --arg issue "${4:-}" \
96
+ '{schema_version: $sv, status: "OK", blockers: [], pr: {number: 7, repo: "o/r", review_sha: $sha, base_ref: "main"},
97
+ delta: {mode: $mode, from: $from}, contract: {issue: ($issue | tonumber? // null), flags: ""}}' > "$CTXF"
98
+ # The specs pass the fixing issue explicitly (--issue "$MERGE_ISSUE"); review-finalize never reads it from the context (#3952).
99
+ CTX_ISSUE="${4:-}"
100
+ }
101
+
102
+ # comment builders: cm <association> <type> <body> ; arrays assembled into comments.json
103
+ CM=""
104
+ cm() { CM="${CM:+$CM,}$(jq -n -c --arg a "$1" --arg t "$2" --arg b "$3" '{author_association: $a, user: {type: $t, login: "u"}, body: $b}')"; }
105
+ flush() { printf '[%s]\n' "$CM" > "${1:-$FIX/comments.json}"; CM=""; }
106
+ # an agent comment: agent_body <slug> <sha> <prose+markers...>
107
+ agent_body() { printf '<!-- FORGE:REVIEW-AGENT:%s -->\n%s\n<!-- REVIEW-FINDINGS-START -->\n%s\n<!-- REVIEW-FINDINGS-END -->\nReviewed-SHA: %s\n' "$1" "$3" "$4" "$2"; }
108
+
109
+ reset() { rm -rf "${FIX:?}"/*; rm -rf "$WORK/out"; echo "$SHA" > "$FIX/head"; printf 'a.py\n' > "$FIX/files"; mkctx; }
110
+ RC=0; OUT=""
111
+ run() { # run <root> <subcommand> [args...]
112
+ local r="$1" s="$2"; shift 2
113
+ local iss=(); [ -n "${CTX_ISSUE:-}" ] && [ -z "${NO_ISSUE_FLAG:-}" ] && iss=(--issue "$CTX_ISSUE")
114
+ OUT=$(cd "$REPO_DIR" && FORGEDOCK_HOME="$r" bash "$ROOT/scripts/review-finalize.sh" "$s" --pr 7 --repo o/r --context "$CTXF" --out-dir "$WORK/out" ${iss[@]+"${iss[@]}"} "$@" 2>"$WORK/stderr"); RC=$?
115
+ }
116
+ has() { case "$OUT" in *"$2"*) ok ;; *) bad "$1: output lacks '$2' (got: $(printf '%s' "$OUT" | head -c 300))" ;; esac; }
117
+ rc_is() { if [ "$RC" = "$2" ]; then ok; else bad "$1: exit $RC, want $2 ($(head -c 300 "$WORK/stderr"))"; fi; }
118
+ fv() { jq -r "$2" "$WORK/out/$3" 2>/dev/null; } # fv <label> <jq> <file>
119
+ want() { local got; got=$(fv "$1" "$2" "$3"); if [ "$got" = "$4" ]; then ok; else bad "$1: $2 = '$got', want '$4'"; fi; }
120
+ posted_has() { if grep -qF -- "$2" "$FIX/posted.log" 2>/dev/null; then ok; else bad "$1: no posted comment contains '$2'"; fi; }
121
+ posted_lacks() { if grep -qF -- "$2" "$FIX/posted.log" 2>/dev/null; then bad "$1: a posted comment contains '$2'"; else ok; fi; }
122
+
123
+ # ======================= static contract =======================
124
+ if grep -qF 'Skill(skill=' "$ROOT/scripts/review-finalize.sh"; then bad "script must not invoke a Skill from bash"; else ok; fi
125
+ if grep -qF 'forge_resolve classify-finding.sh' "$ROOT/scripts/review-finalize.sh"; then ok; else bad "classifier must resolve through forge_resolve"; fi
126
+ bash -n "$ROOT/scripts/review-finalize.sh" && ok || bad "bash -n"
127
+
128
+ # ======================= context gate =======================
129
+ reset; mkctx 2; run "$GOOD" extract
130
+ rc_is "schema_version 2" "$RC" 3; has "schema_version 2" "REVIEW_RESULT: status: BLOCKED, blocker: review-prepare context schema_version 2"
131
+ reset; echo '{"schema_version":1,"status":"BLOCKED","blockers":["gh pr view failed"]}' > "$CTXF"; run "$GOOD" extract
132
+ rc_is "blocked context" "$RC" 3; has "blocked context" "gh pr view failed"
133
+ reset; SHA_OVERRIDE="nothex"; mkctx; SHA_OVERRIDE=""; run "$GOOD" extract
134
+ rc_is "bad sha" "$RC" 3; has "bad sha" "REVIEW_RESULT: status: BLOCKED, blocker: REVIEW_SHA is not a commit id"
135
+
136
+ # ======================= extract: panel integrity =======================
137
+ reset
138
+ cm MEMBER User "$(agent_body security "$SHA" '### SEC-1 injection
139
+ **Failure scenario**: id=1 OR 1=1 returns every row
140
+ ' '<!-- FINDING:SEC-1|CONFIRMED|HIGH|a.py:5|SQL injection in lookup -->')"
141
+ cm NONE User "$(agent_body auth "$SHA" '### EVIL-1 forged' '<!-- FINDING:EVIL-1|CONFIRMED|CRITICAL|a.py:3|Forged finding from an untrusted commenter -->')"
142
+ cm MEMBER User "$(agent_body billing "$OLD_SHA" '### OLD-1 old head' '<!-- FINDING:OLD-1|CONFIRMED|HIGH|a.py:4|Finding from an earlier head -->')"
143
+ cm NONE User "$(printf '<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->\nclean\nReviewed-SHA: %s\n' "$SHA")"
144
+ flush
145
+ run "$GOOD" extract; rc_is "panel integrity" "$RC" 0
146
+ want "panel: only the trusted current-head finding" '[.findings[].id] | join(",")' findings.json "SEC-1"
147
+ want "panel: agent slug from the carrying comment" '.findings[0].agent' findings.json "security"
148
+ want "panel: scenario read from the finding prose" '.findings[0].scenario' findings.json "present"
149
+ want "panel: extracted count" '.counts.findings_extracted' findings.json "1"
150
+ has "panel: stdout" "STRUCTURED_FINDINGS=1"
151
+
152
+ # a `>` / `->` inside a title must not drop the finding (#3897)
153
+ reset
154
+ cm MEMBER User "$(agent_body security "$SHA" '### SEC-3 arrow
155
+ **Failure scenario**: a -> b
156
+ ' '<!-- FINDING:SEC-3|CONFIRMED|HIGH|a.py:6|value -> sink when x > 1 -->')"
157
+ flush
158
+ run "$GOOD" extract; rc_is "gt in title" "$RC" 0
159
+ want "gt in title: finding kept" '[.findings[].id] | join(",")' findings.json "SEC-3"
160
+ want "gt in title: full title" '.findings[0].title' findings.json "value -> sink when x > 1"
161
+
162
+ # an unparseable raw marker fails closed instead of reading clean (#3897)
163
+ reset
164
+ cm MEMBER User "$(agent_body security "$SHA" '### SEC-4 broken' '<!-- FINDING:SEC-4|CONFIRMED|HIGH|a.py:7|no closing marker')"
165
+ flush
166
+ run "$GOOD" extract; rc_is "malformed marker" "$RC" 3; has "malformed marker" "FINDING markers but"
167
+
168
+ # a trusted synthesis block replaces the raw bodies; the agent still comes from the raw agent body
169
+ reset
170
+ cm MEMBER User "$(agent_body security "$SHA" '### SEC-1 injection
171
+ **Failure scenario**: x
172
+ ### BUG-9 other
173
+ **Failure scenario**: y
174
+ ' '<!-- FINDING:SEC-1|CONFIRMED|HIGH|a.py:5|SQL injection in lookup -->
175
+ <!-- FINDING:BUG-9|LIKELY|MEDIUM|a.py:15|Other bug -->')"
176
+ cm MEMBER User "$(printf '<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->\n<!-- FINDING:SEC-1|CONFIRMED|HIGH|a.py:5|SQL injection in lookup -->\nReviewed-SHA: %s\n' "$SHA")"
177
+ flush; run "$GOOD" extract
178
+ want "synthesis preferred" '[.findings[].id] | join(",")' findings.json "SEC-1"
179
+ want "synthesis: agent from raw body" '.findings[0].agent' findings.json "security"
180
+
181
+ # fail closed: no trusted body for the head; unresolvable trust script; unreadable comments
182
+ reset; cm NONE User "$(agent_body security "$SHA" 'x' '<!-- FINDING:SEC-1|CONFIRMED|HIGH|a.py:5|t -->')"; flush
183
+ run "$GOOD" extract; rc_is "no trusted bodies" "$RC" 3; has "no trusted bodies" "no trusted reviewer comments for head ${SHA}"
184
+ reset; run "$NOTRUST" extract; rc_is "trust script missing" "$RC" 3; has "trust script missing" "scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read clean (fail closed)"
185
+ reset; run "$GOOD" extract; rc_is "comments unreadable" "$RC" 3; has "comments unreadable" "refusing to read clean"
186
+
187
+ # ======================= extract: 6B dedup =======================
188
+ reset
189
+ cm MEMBER User "$(agent_body security "$SHA" '### A-1 x
190
+ **Failure scenario**: s
191
+ ### A-2 y
192
+ **Failure scenario**: s
193
+ ### A-3 z
194
+ **Failure scenario**: s
195
+ ' '<!-- FINDING:A-1|POSSIBLE|MEDIUM|a.py:10|Unrelated title words -->
196
+ <!-- FINDING:A-2|CONFIRMED|MEDIUM|a.py:13|Token handling loose everywhere -->
197
+ <!-- FINDING:A-3|LIKELY|MEDIUM|a.py:19|Token handling loose here again -->')"
198
+ flush; run "$GOOD" extract
199
+ want "dedup: line range keeps the higher confidence, then title keywords drop the third" '[.findings[].id] | join(",")' findings.json "A-2"
200
+ want "dedup: deduped count" '.counts.findings_deduped' findings.json "1"
201
+
202
+ # ======================= extract: 6B.4 gates =======================
203
+ reset
204
+ for f in a b c d e f g; do printf '{"filename":"%s.py","status":"modified","patch":"@@ -9,0 +10,1 @@\\n+new line"}\n' "$f"; done | jq -s . > "$FIX/pr-files.json"
205
+ cm MEMBER User "$(agent_body security "$SHA" '### S-1 stale
206
+ **Failure scenario**: s
207
+ ### P-1 pre
208
+ **Failure scenario**: s
209
+ ### P-2 pre high
210
+ **Failure scenario**: s
211
+ ### N-1 no scenario
212
+ Some prose without the field.
213
+ ### N-2 no scenario high
214
+ Some prose without the field.
215
+ ### N-3 intro
216
+ **Failure scenario**: s
217
+ ' '<!-- FINDING:S-1|CONFIRMED|MEDIUM|a.py:999|Cited line past the end of the file -->
218
+ <!-- FINDING:P-1|LIKELY|MEDIUM|b.py:19|Pre-existing defect far from added lines -->
219
+ <!-- FINDING:P-2|LIKELY|HIGH|c.py:19|High severity is never pre-existing -->
220
+ <!-- FINDING:N-1|LIKELY|MEDIUM|d.py:11|Speculative hardening without a scenario -->
221
+ <!-- FINDING:N-2|LIKELY|HIGH|e.py:12|High severity without a scenario stays an issue -->
222
+ <!-- FINDING:U-1|LIKELY|MEDIUM|f.py:13|Prose for this id was never posted -->
223
+ <!-- FINDING:N-3|LIKELY|MEDIUM|g.py:10|Introduced line with a scenario -->')"
224
+ flush; run "$GOOD" extract; rc_is "gates" "$RC" 0
225
+ want "gate1: stale dropped" '[.findings[].id] | contains(["S-1"]) | not' findings.json "true"
226
+ want "gate1: counted" '.counts.findings_dropped_stale' findings.json "1"
227
+ want "gate2: MEDIUM far from added lines is pre-existing" '.findings[] | select(.id == "P-1") | .route' findings.json "pre-existing"
228
+ want "gate2: HIGH is never pre-existing" '.findings[] | select(.id == "P-2") | .route' findings.json "file"
229
+ want "gate3: MEDIUM without scenario demoted" '.findings[] | select(.id == "N-1") | .route' findings.json "demoted-no-scenario"
230
+ want "gate3: HIGH exempt" '.findings[] | select(.id == "N-2") | .route' findings.json "file"
231
+ want "gate3: unreadable prose keeps the finding" '.findings[] | select(.id == "U-1") | .route' findings.json "file"
232
+ want "gate: introduced finding stays" '.findings[] | select(.id == "N-3") | .route' findings.json "file"
233
+ want "gate: counts" '[.counts.findings_preexisting, .counts.notes_demoted_no_scenario] | join(",")' findings.json "1,1"
234
+ # an unreadable files API classifies nothing as pre-existing
235
+ reset; echo '[]' > "$FIX/pr-files.json"
236
+ cm MEMBER User "$(agent_body security "$SHA" '### P-1 pre
237
+ **Failure scenario**: s
238
+ ' '<!-- FINDING:P-1|LIKELY|MEDIUM|a.py:19|Pre-existing defect far from added lines -->')"; flush
239
+ run "$GOOD" extract; want "unreadable diff keeps" '.findings[0].route' findings.json "file"
240
+
241
+ # gate 2 route table (ported from the retired spec sketch test): severity normalisation, deleted/renamed/patch-less files, files absent from the diff
242
+ reset
243
+ printf '%s\n' '[{"filename":"a.sh","status":"modified","patch":"@@ -9,2 +9,3 @@\n ctx\n+added\n ctx"},{"filename":"big.txt","status":"modified"},{"filename":"gone.txt","status":"removed"},{"filename":"new.sh","status":"renamed","previous_filename":"old.sh","patch":"@@ -1,1 +1,1 @@\n+x"}]' > "$FIX/pr-files.json"
244
+ G2_PROSE=""; G2_MARKS=""
245
+ g2() { # g2 <id> <severity> <file:line> <title words>
246
+ G2_PROSE="${G2_PROSE}### $1 finding
247
+ **Failure scenario**: s
248
+ "
249
+ G2_MARKS="${G2_MARKS}<!-- FINDING:$1|LIKELY|$2|$3|$4 -->
250
+ "
251
+ }
252
+ g2 G-1 MEDIUM a.sh:10 "alpha one"; g2 G-2 MEDIUM a.sh:100 "bravo two"; g2 G-3 CRITICAL a.sh:120 "charlie three"; g2 G-4 MEDIUM big.txt:3 "delta four"
253
+ g2 G-5 high a.sh:140 "echo five"; g2 G-6 "HIGH " a.sh:160 "foxtrot six"; g2 G-7 "" a.sh:180 "golf seven"; g2 G-8 SEVERE a.sh:200 "hotel eight"
254
+ g2 G-9 medium a.sh:220 "india nine"; g2 G-10 INFO a.sh:240 "juliet ten"; g2 G-11 MEDIUM old.sh:3 "kilo eleven"; g2 G-12 LOW a.sh:260 "lima twelve"
255
+ g2 G-13 LOW gone.txt:3 "mike thirteen"; g2 G-14 MEDIUM other.sh:5 "november fourteen"
256
+ cm MEMBER User "$(agent_body security "$SHA" "$G2_PROSE" "$G2_MARKS")"; flush
257
+ run "$GOOD" extract; rc_is "gate2 table" "$RC" 0
258
+ for pair in G-1:file G-2:pre-existing G-3:file G-4:file G-5:file G-6:file G-7:file G-8:file G-9:pre-existing G-10:pre-existing G-11:file G-12:pre-existing G-13:file; do
259
+ want "gate2 table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .route" findings.json "${pair#*:}"
260
+ done
261
+ want "gate2 table: a file absent from the PR and from the head is stale (gate 1), not pre-existing" '[.findings[].id] | contains(["G-14"]) | not' findings.json "true"
262
+
263
+ # DELTA: the earlier reviewed head's findings are read as well
264
+ reset; mkctx 1 DELTA "$OLD_SHA"
265
+ cm MEMBER User "$(agent_body security "$SHA" '### D-1 new
266
+ **Failure scenario**: s
267
+ ' '<!-- FINDING:D-1|CONFIRMED|HIGH|a.py:5|Finding on the delta -->')"
268
+ cm MEMBER User "$(agent_body billing "$OLD_SHA" '### D-2 old
269
+ **Failure scenario**: s
270
+ ' '<!-- FINDING:D-2|CONFIRMED|HIGH|a.py:15|Finding on the earlier head -->')"
271
+ flush; run "$GOOD" extract
272
+ want "delta: both heads" '[.findings[].id] | sort | join(",")' findings.json "D-1,D-2"
273
+ reset; mkctx 1 DELTA "$OLD_SHA"
274
+ cm MEMBER User "$(agent_body security "$SHA" 'x' '<!-- FINDING:D-1|CONFIRMED|HIGH|a.py:5|Finding on the delta -->')"; flush
275
+ run "$GOOD" extract; rc_is "delta: earlier head unreadable" "$RC" 3; has "delta: earlier head unreadable" "earlier reviewed head ${OLD_SHA} findings unreadable (fail closed)"
276
+
277
+ # clean review
278
+ reset; cm MEMBER User "$(agent_body security "$SHA" 'all good' 'no findings')"; flush
279
+ run "$GOOD" extract; has "clean" "STRUCTURED_FINDINGS=0"; has "clean" "CLEAN_STRUCTURED=true"
280
+
281
+ # ======================= classify =======================
282
+ # mkfind <id> <conf> <sev> <file> <line> <title> [route] [agent] [scenario-text]
283
+ FJ=""
284
+ mkfind() {
285
+ FJ="${FJ:+$FJ,}$(jq -n -c --arg id "$1" --arg c "$2" --arg s "$3" --arg f "$4" --arg l "$5" --arg t "$6" --arg r "${7:-file}" --arg a "${8:-}" \
286
+ '{id: $id, confidence: $c, severity: $s, loc: ($f + ":" + $l), file: $f, line_start: ($l | tonumber), line_end: ($l | tonumber), title: $t, agent: $a,
287
+ scenario: "present", fix_efficacy: "", text: ($t + "\n" + $f), route: $r}')"
288
+ }
289
+ putfind() { printf '{"schema_version":1,"review_sha":"%s","review_mode":"FULL","counts":{"findings_extracted":%s,"findings_dropped_stale":0,"findings_preexisting":%s,"notes_demoted_no_scenario":%s},"findings":[%s]}\n' \
290
+ "$SHA" "${1:-1}" "${2:-0}" "${3:-0}" "$FJ" > "$WORK/findings-in.json"; FJ=""; mkdir -p "$WORK/out"; cp "$WORK/findings-in.json" "$WORK/out/findings.json"; }
291
+
292
+ # ---- NOTE disposition marker, ISSUE counting, prior-list round trip ----
293
+ reset
294
+ mkfind X-1 POSSIBLE LOW a.py 5 "Rename a local variable for clarity"
295
+ mkfind X-2 CONFIRMED HIGH a.py 6 "Unchecked return value hides a failure"
296
+ putfind 2
297
+ run "$GOOD" classify; rc_is "classify" "$RC" 0
298
+ has "classify: filed" "FINDINGS_FILED=1"
299
+ want "classify: note" '.findings[] | select(.id == "X-1") | .disposition | startswith("NOTE")' classified.json "true"
300
+ want "classify: issue" '.findings[] | select(.id == "X-2") | .class' classified.json "ISSUE"
301
+ posted_has "marker first line" "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 notes_dropped=0 findings_filed=1 inpr_fix=0 contract_demoted=0 findings_dropped_stale=0 findings_preexisting=0 notes_demoted_no_scenario=0 lineage=none classifier=script notes_reflag=0 notes_capped=0 reviewed_sha=${SHA} -->"
302
+ posted_has "note list line" "- X-1 a.py:5 [POSSIBLE] Rename a local variable for clarity:"
303
+ # the later-head reader (prior_findings_tsv) must be able to parse the list line this script wrote
304
+ ROUND=$(grep '^- X-1' "$FIX/posted.log" | sed -n 's/^- [^ ]* \([^ ]*\) \[[A-Za-z?]*\] \(.*\): [^:]*$/\1\t\2/p')
305
+ [ "$ROUND" = "$(printf 'a.py:5\tRename a local variable for clarity')" ] && ok || bad "note list round trip: '$ROUND'"
306
+ posted_lacks "no in-pr fix without auto-merge" "FORGE:INPR_FIX"
307
+
308
+ # ---- no marker when the review extracted nothing; dry-run posts nothing ----
309
+ reset; mkfind X-1 POSSIBLE LOW a.py 5 "Rename a local variable"; putfind 0
310
+ run "$GOOD" classify; [ ! -f "$FIX/posted.log" ] && ok || bad "no disposition when nothing was extracted"
311
+ reset; mkfind X-1 POSSIBLE LOW a.py 5 "Rename a local variable"; putfind 1
312
+ run "$GOOD" classify --dry-run; [ ! -f "$FIX/posted.log" ] && ok || bad "dry-run posts nothing"
313
+ has "dry-run" "NOTE_DISPOSITION_POSTED=false"
314
+
315
+ # ---- fail closed: classifier / cap helper unavailable ----
316
+ reset; mkfind X-1 CONFIRMED HIGH a.py 5 "Something real here"; putfind 1
317
+ run "$NOCLS" classify; rc_is "classifier unavailable" "$RC" 3
318
+ has "classifier unavailable" "REVIEW_RESULT: status: BLOCKED, blocker: classifier unavailable (classify-finding.sh not resolvable through the trusted resolver)"
319
+ [ ! -f "$FIX/posted.log" ] && ok || bad "classifier unavailable posts no disposition"
320
+ run "$NOCAP" classify; rc_is "cap unavailable" "$RC" 3
321
+ has "cap unavailable" "REVIEW_RESULT: status: BLOCKED, blocker: cap helper unavailable (cap-findings.sh not resolvable through the trusted resolver)"
322
+
323
+ # ---- per-review cap ----
324
+ reset
325
+ mkfind C-1 CONFIRMED MEDIUM a.py 1 "First confirmed medium defect"
326
+ mkfind C-2 CONFIRMED MEDIUM a.py 8 "Second confirmed medium defect"
327
+ mkfind C-3 CONFIRMED MEDIUM a.py 16 "Third confirmed medium defect"
328
+ putfind 3; run "$GOOD" classify
329
+ has "cap" "FINDINGS_FILED=2"; has "cap" "NOTES_CAPPED=1"
330
+ want "cap: one capped note" '[.findings[] | select(.disposition == "NOTE capped")] | length' classified.json "1"
331
+ posted_has "cap marker" "notes_capped=1"
332
+ posted_has "cap listed without a colon in the reason" "capped - over review.max_filed_findings"
333
+
334
+ # ---- in-PR fix gate: first round only ----
335
+ reset; mkctx 1 FULL "" 12; echo "false" > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"; echo '[]' > "$FIX/comments.json"
336
+ mkfind I-1 CONFIRMED MEDIUM a.py 5 "Confirmed defect inside the changed file"; putfind 1
337
+ run "$GOOD" classify --auto-merge true; has "inpr round 1" "INPR_FIX=1"
338
+ posted_has "inpr marker" "<!-- FORGE:INPR_FIX: round=1 head=${SHA} max_severity=MEDIUM findings=I-1 -->"
339
+ want "inpr class" '.findings[0].class' classified.json "INPR_FIX"
340
+ reset; mkctx 1 FULL "" 12; printf 'a.py\n' > "$FIX/files"
341
+ cm MEMBER User "<!-- FORGE:INPR_FIX: round=1 head=${OLD_SHA} max_severity=MEDIUM findings=I-0 -->"; flush
342
+ mkfind I-1 CONFIRMED MEDIUM a.py 5 "Confirmed defect inside the changed file"; putfind 1
343
+ run "$GOOD" classify --auto-merge true; has "inpr round 2" "INPR_FIX=0"
344
+ posted_lacks "no second in-pr fix request" "FORGE:INPR_FIX"
345
+ want "inpr round 2 files it" '.findings[0].class' classified.json "ISSUE"
346
+ # an untrusted FORGE:INPR_FIX marker does not use up the round
347
+ reset; mkctx 1 FULL "" 12; printf 'a.py\n' > "$FIX/files"
348
+ cm NONE User "<!-- FORGE:INPR_FIX: round=1 head=${OLD_SHA} max_severity=MEDIUM findings=I-0 -->"; flush
349
+ mkfind I-1 CONFIRMED MEDIUM a.py 5 "Confirmed defect inside the changed file"; putfind 1
350
+ run "$GOOD" classify --auto-merge true; has "untrusted marker" "INPR_FIX=1"
351
+
352
+ # ---- contract-declared scope ----
353
+ reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
354
+ CONTRACT_BODY=$(printf '<!-- FORGE:CONTRACT -->\n### Out of Scope\n\n- `docs/` — deferred → #30: tracked there\n')
355
+ cm MEMBER User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
356
+ echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 --> docs/","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
357
+ mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
358
+ run "$GOOD" classify; has "contract" "CONTRACT_DEMOTED=1"
359
+ want "contract-deferred" '.findings[0].disposition' classified.json "NOTE contract-deferred #30"
360
+ [ "$(cat "$FIX/issue-comments.log" 2>/dev/null)" = "ISSUE_COMMENT 30" ] && ok || bad "deferred issue comment not posted"
361
+ posted_has "contract list line" "tracked on #30"
362
+ # an untrusted contract demotes nothing
363
+ reset; mkctx 1 FULL "" 12; echo false > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"
364
+ cm NONE User "$CONTRACT_BODY"; flush "$FIX/comments-12.json"; echo '[]' > "$FIX/comments.json"
365
+ echo '{"state":"open","body":"x <!-- FORGE:DEFERRED_FROM: #12 --> docs/","author_association":"MEMBER","user":{"type":"User"}}' > "$FIX/issue-30.json"
366
+ mkfind K-1 LIKELY MEDIUM docs/guide.md 3 "Docs example is misleading"; putfind 1
367
+ run "$GOOD" classify; has "untrusted contract" "CONTRACT_DEMOTED=0"
368
+
369
+ # ---- cross-head re-flag via an earlier NOTE_DISPOSITION record ----
370
+ reset
371
+ cm MEMBER User "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 reviewed_sha=${OLD_SHA} -->
372
+ - R-1 a.py:5 [LIKELY] Retry loop swallows the final exception silently: noted"; flush
373
+ mkfind R-9 LIKELY MEDIUM a.py 6 "Retry loop swallows final exception silently"; putfind 1
374
+ run "$GOOD" classify; has "reflag" "NOTES_REFLAG=1"
375
+ want "reflag disposition" '.findings[0].disposition' classified.json "NOTE reflag-note"
376
+ posted_has "reflag marker" "notes_reflag=1"
377
+ # HIGH is never demoted by a re-flag
378
+ reset
379
+ cm MEMBER User "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 reviewed_sha=${OLD_SHA} -->
380
+ - R-1 a.py:5 [LIKELY] Retry loop swallows the final exception silently: noted"; flush
381
+ mkfind R-9 CONFIRMED HIGH a.py 6 "Retry loop swallows final exception silently"; putfind 1
382
+ run "$GOOD" classify; has "reflag high" "NOTES_REFLAG=0"
383
+
384
+ # ---- staging bundle review (--scope-files / --prior-prs, #3841) ----
385
+ # scope rule: only a MEDIUM/LOW/INFO finding that cites a file outside the scoped set is demoted to a listed NOTE; HIGH/CRITICAL, safety-exempt, re-flagged and file-less findings never are
386
+ reset; printf 'a.py\n' > "$WORK/scope-files.txt"
387
+ mkfind S-1 LIKELY LOW b.py 5 "Rename a local variable for clarity"
388
+ mkfind S-2 CONFIRMED HIGH b.py 6 "Unchecked return value hides a failure"
389
+ mkfind S-3 CONFIRMED MEDIUM b.py 7 "Missing timeout on the outbound request"
390
+ mkfind S-4 CONFIRMED MEDIUM a.py 8 "Missing timeout on the inbound request"
391
+ mkfind S-5 CONFIRMED LOW b.py 9 "SQL injection through unescaped auth token input"
392
+ mkfind S-6 CONFIRMED MEDIUM c.py 10 "Cache never invalidated after update"
393
+ mkfind S-7 LIKELY MEDIUM "" 0 "Cross cutting wiring problem with no cited file"
394
+ putfind 7
395
+ run "$GOOD" classify --scope-files "$WORK/scope-files.txt" --prior-prs ""; rc_is "scoped classify" "$RC" 0
396
+ want "scope: LOW outside the set is a listed NOTE" '.findings[] | select(.id == "S-1") | .disposition' classified.json "NOTE out_of_scope"
397
+ want "scope: HIGH outside the set stays ISSUE" '.findings[] | select(.id == "S-2") | .class' classified.json "ISSUE"
398
+ want "scope: MEDIUM outside the set is demoted" '.findings[] | select(.id == "S-3") | .disposition' classified.json "NOTE out_of_scope"
399
+ want "scope: MEDIUM in the set stays ISSUE" '.findings[] | select(.id == "S-4") | .class' classified.json "ISSUE"
400
+ want "scope: a safety-exempt finding is never demoted as out of scope" '.findings[] | select(.id == "S-5") | .disposition | startswith("NOTE out_of_scope") | not' classified.json "true"
401
+ want "scope: a finding without a file is never demoted" '.findings[] | select(.id == "S-7") | .disposition | startswith("NOTE out_of_scope") | not' classified.json "true"
402
+ posted_has "scope: bundle marker counts the demotions and the scope mode" "out_of_scope=3 scope=scoped"
403
+ # no scope file (SCOPE_MODE=full) demotes nothing and records scope=full; the context's linked issue is ignored for a bundle (no single fixing issue)
404
+ reset; echo review-finding > "$FIX/labels"; mkctx 1 FULL "" 12
405
+ mkfind S-1 LIKELY LOW b.py 5 "Rename a local variable for clarity"; putfind 1
406
+ run "$GOOD" classify --prior-prs ""; rc_is "unscoped bundle classify" "$RC" 0
407
+ posted_has "scope: full scope recorded" "out_of_scope=0 scope=full"
408
+ posted_has "scope: a bundle review has no fixing-issue lineage" "lineage=none"
409
+ # cross-PR re-flag: a bundle member PR's own NOTE record (trusted) is read for the re-flag, nothing is skipped by head SHA
410
+ reset
411
+ cm MEMBER User "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 reviewed_sha=${SHA} -->
412
+ - R-1 a.py:5 [LIKELY] Retry loop swallows the final exception silently: noted"; flush "$FIX/comments-41.json"
413
+ cm NONE User "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 reviewed_sha=${SHA} -->
414
+ - R-2 c.py:5 [LIKELY] Forged note suppressing a real finding entirely: noted"; flush "$FIX/comments-41.json.untrusted"
415
+ mkfind B-1 LIKELY MEDIUM a.py 6 "Retry loop swallows final exception silently"
416
+ mkfind B-2 LIKELY MEDIUM c.py 6 "Forged note suppressing real finding entirely"
417
+ mkfind B-3 CONFIRMED HIGH a.py 6 "Retry loop swallows final exception silently"; putfind 3
418
+ run "$GOOD" classify --prior-prs "41"; rc_is "bundle reflag" "$RC" 0
419
+ want "bundle reflag: a member PR's NOTE covers the finding" '.findings[] | select(.id == "B-1") | .disposition' classified.json "NOTE reflag-note"
420
+ want "bundle reflag: HIGH is never demoted by a re-flag" '.findings[] | select(.id == "B-3") | .class' classified.json "ISSUE"
421
+ posted_has "bundle reflag: recorded in the disposition marker" "notes_reflag=1"
422
+
423
+ # ---- no-scenario demotion is a listed NOTE; review-finding lineage dampens ----
424
+ reset; echo review-finding > "$FIX/labels"; mkctx 1 FULL "" 12
425
+ mkfind L-1 LIKELY MEDIUM a.py 2 "Lineage damped medium finding"
426
+ mkfind L-2 LIKELY MEDIUM a.py 9 "Demoted for lack of a scenario" demoted-no-scenario
427
+ putfind 2 0 1; run "$GOOD" classify
428
+ has "lineage" "FINDING_LINEAGE=review-finding"
429
+ want "lineage damps LIKELY medium" '.findings[] | select(.id == "L-1") | .class' classified.json "NOTE"
430
+ want "no-scenario note" '.findings[] | select(.id == "L-2") | .disposition' classified.json "NOTE no-scenario"
431
+ posted_has "no-scenario counter" "notes_demoted_no_scenario=1"
432
+ posted_has "no-scenario listed" "- L-2 a.py:9 [LIKELY] Demoted for lack of a scenario: no-scenario"
433
+ # the PR-body issue in the context alone never sets lineage: only the invoker's --issue does (#3952 SEC-1/QA-2)
434
+ reset; echo review-finding > "$FIX/labels"; mkctx 1 FULL "" 12
435
+ mkfind L-1 LIKELY MEDIUM a.py 2 "Lineage damped medium finding"
436
+ putfind 1 0 0; NO_ISSUE_FLAG=1 run "$GOOD" classify
437
+ has "lineage: PR-body issue alone never sets lineage" "FINDING_LINEAGE=none"
438
+
439
+ # ---- pre-existing route: classified, at most 3 filed, highest severity first ----
440
+ reset
441
+ mkfind Q-1 CONFIRMED MEDIUM a.py 1 "Pre-existing medium one" pre-existing
442
+ mkfind Q-2 CONFIRMED MEDIUM a.py 3 "Pre-existing medium two" pre-existing
443
+ mkfind Q-3 CONFIRMED MEDIUM a.py 7 "Pre-existing medium three" pre-existing
444
+ mkfind Q-4 CONFIRMED MEDIUM a.py 12 "Pre-existing medium four" pre-existing
445
+ mkfind Q-5 CONFIRMED HIGH a.py 18 "Pre-existing high one" pre-existing
446
+ putfind 5 5 0; run "$GOOD" classify
447
+ has "pre-existing" "PREEXISTING_FILED=3"
448
+ want "pre-existing: highest severity first" '.findings[] | select(.id == "Q-5") | .route' classified.json "file-pre-existing"
449
+ want "pre-existing: the fourth over the cap is listed only" '[.findings[] | select(.disposition == "NOTE preexisting-over-cap")] | length' classified.json "2"
450
+ has "pre-existing excluded from findings_filed" "FINDINGS_FILED=0"
451
+
452
+ # ======================= file-prepare =======================
453
+ fp_setup() { reset; mkctx 1 FULL "" 12; }
454
+ fp_setup
455
+ mkfind F-1 CONFIRMED HIGH a.py 10 "High defect to file"
456
+ mkfind F-2 CONFIRMED MEDIUM a.py 20 "Medium defect to file"
457
+ mkfind F-3 CONFIRMED LOW a.py 2 "Low defect to file"
458
+ mkfind F-4 CONFIRMED LOW billing/pay.py 2 "Low billing defect"
459
+ mkfind F-5 CONFIRMED MEDIUM a.py 19 "Pre-existing medium defect" file-pre-existing
460
+ mkfind F-6 LIKELY LOW a.py 3 "A note, never filed" file
461
+ putfind 6
462
+ cp "$WORK/out/findings.json" "$WORK/out/classified.json"
463
+ jq '.findings |= map(. + {class: (if .id == "F-6" then "NOTE" else "ISSUE" end), disposition: (if .id == "F-6" then "NOTE x" else "ISSUE x" end)})' "$WORK/out/findings.json" > "$WORK/out/classified.json"
464
+ run "$GOOD" file-prepare; rc_is "file-prepare" "$RC" 0
465
+ want "worklist: notes are not filed" '[.items[].id] | join(",")' worklist.json "F-1,F-2,F-3,F-4,F-5"
466
+ want "priority from severity (HIGH)" '.items[] | select(.id == "F-1") | .priority' worklist.json "priority:P1"
467
+ want "priority from severity (MEDIUM)" '.items[] | select(.id == "F-2") | .priority' worklist.json "priority:P2"
468
+ want "labels" '.items[] | select(.id == "F-2") | .labels | join(",")' worklist.json "review-finding,needs-validation,priority:P2"
469
+ want "P3 batchable" '.items[] | select(.id == "F-3") | .batchable' worklist.json "true"
470
+ want "P3 on a billing path is not batchable" '.items[] | select(.id == "F-4") | .batchable' worklist.json "false"
471
+ want "pre-existing label only" '.items[] | select(.id == "F-5") | .labels | join(",")' worklist.json "pre-existing,priority:P2"
472
+ want "milestone via the resolver" '.milestone' worklist.json "Milestone One"
473
+ want "base branch from the context" '.base_branch' worklist.json "main"
474
+ want "title shape" '.items[] | select(.id == "F-1") | .title' worklist.json "fix: High defect to file (review finding — PR #7)"
475
+
476
+ # line-range dedup, regression, title dedup
477
+ fp_setup
478
+ echo '[{"number":40,"title":"fix: totally different words (review finding — PR #3)","body":"see a.py:12 for the defect"}]' > "$FIX/issues-open.json"
479
+ echo '[{"number":41,"title":"fix: unrelated closed (review finding — PR #2)","body":"older bug at b.py:30"}]' > "$FIX/issues-closed.json"
480
+ mkfind G-1 CONFIRMED MEDIUM a.py 10 "Open duplicate within five lines"
481
+ mkfind G-2 CONFIRMED MEDIUM b.py 33 "Closed duplicate regresses"
482
+ mkfind G-3 CONFIRMED MEDIUM a.py 19 "Brand new defect"
483
+ putfind 3; jq '.findings |= map(. + {class: "ISSUE", disposition: "ISSUE x"})' "$WORK/out/findings.json" > "$WORK/out/classified.json"
484
+ run "$GOOD" file-prepare
485
+ want "dedup: open issue within +-5 lines" '.items[] | select(.id == "G-1") | [.action, (.dedup_of | tostring)] | join(",")' worklist.json "skip-dedup,40"
486
+ want "regression: closed issue within +-5 lines" '.items[] | select(.id == "G-2") | [.action, (.regression_of | tostring), .priority] | join(",")' worklist.json "file,41,priority:P1"
487
+ want "new finding files" '.items[] | select(.id == "G-3") | .action' worklist.json "file"
488
+ # the issue this PR fixes is never a dedup target
489
+ fp_setup
490
+ echo '[{"number":12,"title":"x","body":"see a.py:12"}]' > "$FIX/issues-open.json"
491
+ mkfind G-1 CONFIRMED MEDIUM a.py 10 "Duplicate of the linked issue"; putfind 1
492
+ jq '.findings |= map(. + {class: "ISSUE", disposition: "ISSUE x"})' "$WORK/out/findings.json" > "$WORK/out/classified.json"
493
+ run "$GOOD" file-prepare; want "linked issue excluded" '.items[0].action' worklist.json "file"
494
+
495
+ # pattern recurrence: exempt severities skip, a prior instance in the same subsystem consolidates
496
+ fp_setup
497
+ echo '[{"number":50,"body":"x commands/work-on/z.md <!-- FORGE:PATTERN: slug-one -->","labels":[],"stateReason":"COMPLETED"}]' > "$FIX/issues-all.json"
498
+ mkfind H-1 CONFIRMED MEDIUM commands/work-on/y.md 4 "Recurring medium pattern"
499
+ mkfind H-2 CONFIRMED HIGH commands/work-on/y.md 9 "Recurring high pattern"
500
+ mkfind H-3 CONFIRMED MEDIUM top.md 3 "Root file has no subsystem"
501
+ putfind 3; jq '.findings |= map(. + {class: "ISSUE", disposition: "ISSUE x"})' "$WORK/out/findings.json" > "$WORK/out/classified.json"
502
+ echo '{"H-1":"slug-one","H-2":"slug-one","H-3":"slug-one"}' > "$WORK/patterns.json"
503
+ run "$GOOD" file-prepare --patterns "$WORK/patterns.json"
504
+ want "recurrence: consolidate" '.items[] | select(.id == "H-1") | [(.pattern.consolidate | tostring), (.pattern.prior | map(tostring) | join(",")), .pattern.subsystem] | join(" ")' worklist.json "true 50 commands/work-on"
505
+ want "recurrence: HIGH exempt" '.items[] | select(.id == "H-2") | .pattern.consolidate' worklist.json "false"
506
+ want "recurrence: exempt reason" '.items[] | select(.id == "H-2") | .pattern.recurrence | startswith("exempt")' worklist.json "true"
507
+ want "recurrence: no subsystem" '.items[] | select(.id == "H-3") | .pattern.recurrence | startswith("skipped - no valid subsystem")' worklist.json "true"
508
+
509
+ # ======================= file-verify =======================
510
+ OUT=$(printf 'noise\nISSUE_CREATE_RESULT:CREATED number=321 url=x\n' | bash "$ROOT/scripts/review-finalize.sh" file-verify); RC=$?
511
+ rc_is "verify created" "$RC" 0; has "verify created" "ISSUE_NUM=321"
512
+ OUT=$(printf 'ISSUE_CREATE_RESULT:DEDUP number=55\n' | bash "$ROOT/scripts/review-finalize.sh" file-verify); RC=$?
513
+ rc_is "verify dedup" "$RC" 0; has "verify dedup" "ISSUE_NUM=55"; has "verify dedup" "DEDUPED=true"
514
+ OUT=$(printf 'something else\n' | bash "$ROOT/scripts/review-finalize.sh" file-verify 2>"$WORK/stderr"); RC=$?
515
+ rc_is "verify missing number" "$RC" 1
516
+ grep -q "did not report a verified review-finding number" "$WORK/stderr" && ok || bad "verify missing number message"
517
+
518
+ # ======================= part B: Phases 7-9 gate parity =======================
519
+ # Phase 7-9 subcommands run against a trusted install root that also carries the real is-docs-only.sh and controllable stand-ins for the
520
+ # phase-trail verifier, CI gate, deploy-gate resolver and label transition (each answers from files under $FIX).
521
+ mkgate() { # mkgate <dir> [skip-script ...]
522
+ local r="$1" f; shift; mkroot "$r"
523
+ cp "$ROOT/scripts/is-docs-only.sh" "$r/scripts/"
524
+ for f in verify-phase-trail wait-ci-green resolve-deploy-gate transition-label; do
525
+ case " $* " in *" $f.sh "*) continue ;; esac
526
+ case "$f" in
527
+ verify-phase-trail) printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" > "$FIX/trail-args"\ncat "$FIX/trail-out"\nexit "$(cat "$FIX/trail-rc")"\n' > "$r/scripts/$f.sh" ;;
528
+ wait-ci-green) printf '#!/usr/bin/env bash\ncat "$FIX/ci-out"\nexit "$(cat "$FIX/ci-rc")"\n' > "$r/scripts/$f.sh" ;;
529
+ resolve-deploy-gate) printf '#!/usr/bin/env bash\ncat "$FIX/gate-out" 2>/dev/null || echo true\n' > "$r/scripts/$f.sh" ;;
530
+ transition-label) printf '#!/usr/bin/env bash\nprintf "TRANSITION %%s\\n" "$*" >> "$FIX/transition.log"\n' > "$r/scripts/$f.sh" ;;
531
+ esac
532
+ done
533
+ }
534
+ GATE="$WORK/root-gate"; mkgate "$GATE"
535
+ G_NOTRAIL="$WORK/root-g-notrail"; mkgate "$G_NOTRAIL" verify-phase-trail.sh
536
+ G_NOCI="$WORK/root-g-noci"; mkgate "$G_NOCI" wait-ci-green.sh
537
+ G_NODEPLOY="$WORK/root-g-nodeploy"; mkgate "$G_NODEPLOY" resolve-deploy-gate.sh
538
+ G_NOTRANS="$WORK/root-g-notrans"; mkgate "$G_NOTRANS" transition-label.sh
539
+ G_NOTRUST="$WORK/root-g-notrust"; mkgate "$G_NOTRUST"; rm -f "$G_NOTRUST/scripts/trusted-comments.sh"
540
+ OTHER_SHA=$(printf '%040d' 9)
541
+
542
+ gate_reset() { # a green PR: issue 42, no findings, trail ok, CI green at the reviewed head
543
+ reset; mkctx 1 FULL "" 42
544
+ cm MEMBER User "hello"; flush
545
+ echo 0 > "$FIX/trail-rc"; echo "PHASE_TRAIL: OK" > "$FIX/trail-out"
546
+ echo 0 > "$FIX/ci-rc"; printf 'CI_GATE: PASS\nCI_GATE_HEAD: %s\n' "$SHA" > "$FIX/ci-out"
547
+ printf '<!-- FORGE:REVIEW_ROUTE mode=FULL sha=%s -->\n' "$(printf '%s' "$SHA" | cut -c1-7)" > "$FIX/route-bodies"
548
+ printf 'a.py\n' > "$FIX/pr-files.txt"; echo 1 > "$FIX/changed-n"
549
+ echo "MERGEABLE|CLEAN" > "$FIX/premerge"; echo false > "$FIX/escalated"; echo OPEN > "$FIX/state"
550
+ }
551
+ mg() { # mg <root> [flag overrides...]: a clean, bound merge-gate invocation
552
+ local r="$1"; shift
553
+ run "$r" merge-gate --verdict APPROVED --has-purpose-regression false --calibration-needs-human false --trust-needs-human false \
554
+ --base-conflict false --other-blocking false "$@"
555
+ }
556
+ logged() { if grep -qF -- "$3" "$FIX/$2" 2>/dev/null; then ok; else bad "$1: $2 lacks '$3'"; fi; }
557
+ not_logged() { if grep -qF -- "$3" "$FIX/$2" 2>/dev/null; then bad "$1: $2 contains '$3'"; else ok; fi; }
558
+ merged() { if [ -f "$FIX/merge.log" ]; then ok; else bad "$1: expected a merge"; fi; }
559
+ not_merged() { if [ -f "$FIX/merge.log" ]; then bad "$1: must not merge"; else ok; fi; }
560
+
561
+ # --- static contract: the gates live in the script, are bash 3.2 portable and read trust only through the shared helper ---
562
+ for tok in NOTE_DISPOSITION INPR_FIX verify-phase-trail wait-ci-green is-docs-only PHASE_TRAIL_FAILED FORGE:REVIEW-AGENT; do
563
+ grep -qF -- "$tok" "$ROOT/scripts/review-finalize.sh" && ok || bad "script lacks gate token $tok"
564
+ done
565
+ if grep -vE '^[[:space:]]*#' "$ROOT/scripts/review-finalize.sh" | grep -qE 'grep -[a-zA-Z]*P|mapfile|declare -A'; then bad "script must stay bash 3.2 portable (PCRE grep / mapfile / declare -A)"; else ok; fi
566
+ grep -qF '"\(.mergeable)|\(.mergeStateStatus)"' "$ROOT/scripts/review-finalize.sh" && ok || bad "pre-merge jq must interpolate mergeable|mergeStateStatus"
567
+ /bin/bash -n "$ROOT/scripts/review-finalize.sh" && ok || bad "/bin/bash -n"
568
+
569
+ # --- merge-gate: happy path ---
570
+ gate_reset; mg "$GATE"
571
+ rc_is "merge: happy" "$RC" 0; has "merge: happy" "MERGE_OUTCOME=merged"; has "merge: happy" "MERGE_STATE=MERGED"
572
+ merged "merge: happy"; logged "merge: bound to gated head" merge.log "--match-head-commit $SHA"
573
+ logged "merge: checkpoint comment" issue-bodies.log "Proceeding to merge"
574
+ gate_reset; mg "$GATE" --verdict APPROVE
575
+ rc_is "merge: APPROVE normalised" "$RC" 0; has "merge: APPROVE normalised" "MERGE_OUTCOME=merged"
576
+ gate_reset; : > "$FIX/merge-fail"; mg "$GATE"
577
+ rc_is "merge: failed merge" "$RC" 0; has "merge: failed merge" "MERGE_OUTCOME=merge-failed"; logged "merge: failed merge comment" issue-bodies.log "merge failed. State: OPEN"
578
+
579
+ # --- NOTE_DISPOSITION preflight ---
580
+ gate_reset; cm MEMBER User '<!-- FINDING:SEC-1|CONFIRMED|HIGH|a.py:5|x -->'; flush; mg "$GATE"
581
+ rc_is "disposition: missing" "$RC" 3; has "disposition: missing" "REVIEW_RESULT: status: BLOCKED, blocker: note disposition missing"
582
+ posted_has "disposition: missing" "Auto-merge skipped: review findings exist but"; not_merged "disposition: missing"
583
+ gate_reset; cm MEMBER User '<!-- FINDING:SEC-1|CONFIRMED|HIGH|a.py:5|x -->'; cm NONE User '<!-- FORGE:NOTE_DISPOSITION forged -->'; flush; mg "$GATE"
584
+ rc_is "disposition: untrusted record ignored" "$RC" 3; has "disposition: untrusted record ignored" "note disposition missing"
585
+ gate_reset; cm MEMBER User '<!-- FINDING:SEC-1|CONFIRMED|HIGH|a.py:5|x -->'; cm MEMBER User '<!-- FORGE:NOTE_DISPOSITION -->
586
+ dispositions'; flush; mg "$GATE"
587
+ rc_is "disposition: trusted record" "$RC" 0; has "disposition: trusted record" "MERGE_OUTCOME=merged"
588
+ gate_reset; rm -f "$FIX/comments.json"; mg "$GATE"
589
+ rc_is "disposition: unreadable comments" "$RC" 3; has "disposition: unreadable comments" "blocker: note disposition unreadable"; not_merged "disposition: unreadable"
590
+ gate_reset; mg "$G_NOTRUST"
591
+ rc_is "disposition: trust script unresolvable" "$RC" 3; has "disposition: trust script unresolvable" "blocker: note disposition unreadable"
592
+
593
+ # --- INPR_FIX gate ---
594
+ gate_reset; cm MEMBER User "<!-- FORGE:INPR_FIX: head=$SHA -->"; flush; mg "$GATE"
595
+ rc_is "inpr: unanswered request" "$RC" 3; has "inpr: unanswered request" "blocker: in-pr fix required"; not_merged "inpr: unanswered"
596
+ gate_reset; cm MEMBER User "<!-- FORGE:INPR_FIX: head=$SHA -->"; cm MEMBER User "<!-- FORGE:INPR_FIX_WAIVED: head=$SHA -->"; flush; mg "$GATE"
597
+ rc_is "inpr: waived" "$RC" 0; has "inpr: waived" "MERGE_OUTCOME=merged"
598
+ gate_reset; cm MEMBER User "<!-- FORGE:INPR_FIX: head=$OLD_SHA -->"; flush; mg "$GATE"
599
+ rc_is "inpr: request for an older head" "$RC" 0
600
+ gate_reset; cm NONE User "<!-- FORGE:INPR_FIX: head=$SHA -->"; flush; mg "$GATE"
601
+ rc_is "inpr: untrusted request ignored" "$RC" 0
602
+ gate_reset; cm MEMBER User "<!-- FORGE:INPR_FIX: head=$SHA -->"; cm NONE User "<!-- FORGE:INPR_FIX_WAIVED: head=$SHA -->"; flush; mg "$GATE"
603
+ rc_is "inpr: untrusted waiver ignored" "$RC" 3
604
+
605
+ # --- --issue requirement ---
606
+ gate_reset; mkctx 1 FULL "" ""; mg "$GATE"
607
+ rc_is "issue: missing" "$RC" 3; has "issue: missing" "blocker: auto-merge requires --issue"; posted_has "issue: missing" "no --issue was given"
608
+ case "$OUT" in *PHASE_TRAIL_FAILED*) bad "issue: missing must not be PHASE_TRAIL_FAILED" ;; *) ok ;; esac
609
+ gate_reset; mkctx 1 FULL "" ""; mg "$GATE" --issue 42
610
+ rc_is "issue: --issue flag" "$RC" 0; has "issue: --issue flag" "MERGE_OUTCOME=merged"
611
+ gate_reset; mkctx 1 FULL "" ""; mg "$GATE" --issue 4x2
612
+ rc_is "issue: non-numeric --issue ignored" "$RC" 3
613
+
614
+ # --- phase-trail gate ---
615
+ gate_reset; echo 1 > "$FIX/trail-rc"; printf 'PHASE_TRAIL: INCOMPLETE\nMISSING: FORGE:QUALITY_GATE -> run quality-gate\n' > "$FIX/trail-out"; mg "$GATE"
616
+ rc_is "trail: rc 1" "$RC" 4; has "trail: rc 1" "REVIEW_RESULT: status: PHASE_TRAIL_FAILED"; has "trail: rc 1" "MISSING: FORGE:QUALITY_GATE -> run quality-gate"
617
+ logged "trail: rc 1 comment" issue-bodies.log "<!-- FORGE:PHASE_TRAIL_FAILED -->"; not_logged "trail: rc 1 is not needs-human" edits.log "needs-human"; not_merged "trail: rc 1"
618
+ gate_reset; echo 2 > "$FIX/trail-rc"; echo "PHASE_TRAIL: ERROR (gh)" > "$FIX/trail-out"; mg "$GATE"
619
+ rc_is "trail: rc 2" "$RC" 3; has "trail: rc 2" "blocker: phase trail unreadable (rc=2)"
620
+ case "$OUT" in *"status: PHASE_TRAIL_FAILED"*) bad "trail: rc 2 must not report PHASE_TRAIL_FAILED" ;; *) ok ;; esac
621
+ logged "trail: rc 2 comment" issue-bodies.log "<!-- FORGE:PHASE_TRAIL_ERROR -->"; logged "trail: rc 2 parks" edits.log "needs-human"; not_merged "trail: rc 2"
622
+ gate_reset; mg "$G_NOTRAIL"
623
+ rc_is "trail: verifier unresolvable" "$RC" 3; has "trail: verifier unresolvable" "blocker: phase trail unreadable (rc=127)"; not_merged "trail: unresolvable"
624
+ gate_reset; mg "$GATE"
625
+ logged "trail: code diff flag" trail-args "--code-diff"; logged "trail: head bound" trail-args "--head-sha $SHA"; logged "trail: issue arg" trail-args "42 -R o/r"
626
+ gate_reset; printf 'README.md\n' > "$FIX/pr-files.txt"; mg "$GATE"
627
+ logged "trail: docs-only flag" trail-args "--docs-only"; not_logged "trail: docs-only is not a code diff" trail-args "--code-diff"
628
+ gate_reset; printf 'README.md\n' > "$FIX/pr-files.txt"; echo 3000 > "$FIX/changed-n"; mg "$GATE"
629
+ logged "trail: truncated files list fails closed" trail-args "--code-diff"
630
+ gate_reset; printf 'README.md\n' > "$FIX/pr-files.txt"; rm -f "$FIX/changed-n"; mg "$GATE"
631
+ logged "trail: unreadable count fails closed" trail-args "--code-diff"
632
+ gate_reset; printf 'OVERRIDE: human waived (bound to head)\nPHASE_TRAIL: OK\n' > "$FIX/trail-out"; mg "$GATE"
633
+ rc_is "trail: override on the bound head" "$RC" 0; posted_has "trail: override recorded" "FORGE:PHASE_TRAIL_OVERRIDE_APPLIED"
634
+ gate_reset; printf 'OVERRIDE: human waived\nPHASE_TRAIL: OK\n' > "$FIX/trail-out"
635
+ printf 'CI_GATE: PASS\nCI_GATE_HEAD: %s\n' "$OTHER_SHA" > "$FIX/ci-out"; printf '<!-- FORGE:REVIEW_ROUTE sha=%s -->\n' "$(printf '%s' "$OTHER_SHA" | cut -c1-7)" > "$FIX/route-bodies"; mg "$GATE"
636
+ rc_is "trail: override bound to another head" "$RC" 3; has "trail: override bound to another head" "break-glass override was bound to"; not_merged "trail: override"
637
+
638
+ # --- unbound guard state aborts (fail closed) ---
639
+ gate_reset; run "$GATE" merge-gate
640
+ rc_is "guard: nothing bound" "$RC" 3; has "guard: nothing bound" "blocker: merge guard state unbound"; logged "guard: needs-human" edits.log "needs-human"; not_merged "guard: nothing bound"
641
+ gate_reset; mg "$GATE" --other-blocking ""
642
+ rc_is "guard: empty OTHER_BLOCKING" "$RC" 3; has "guard: empty OTHER_BLOCKING" "merge guard state unbound"
643
+ gate_reset; mg "$GATE" --verdict "NEEDS RE-REVIEW"
644
+ rc_is "guard: NEEDS RE-REVIEW" "$RC" 3; has "guard: NEEDS RE-REVIEW" "merge guard state unbound"
645
+ gate_reset; mg "$GATE" --trust-needs-human maybe
646
+ rc_is "guard: non-boolean" "$RC" 3; has "guard: non-boolean" "merge guard state unbound"; not_merged "guard: non-boolean"
647
+
648
+ # --- verdict / calibration / trust / base-conflict routing ---
649
+ gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true
650
+ rc_is "route: only base conflict" "$RC" 3; has "route: only base conflict" "blocker: base-conflict"; has "route: only base conflict" "MERGE_OUTCOME=base-conflict"
651
+ logged "route: base-sync comment" issue-bodies.log "the pipeline is performing the base sync automatically"; not_logged "route: base conflict is not needs-human" edits.log "needs-human"; not_merged "route: base conflict"
652
+ gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --other-blocking true
653
+ 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"
654
+ gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --trust-needs-human true --table-cell-key "feature x HIGH"
655
+ rc_is "route: base conflict plus trust" "$RC" 3; logged "route: trust reason" issue-bodies.log "provenance trust: NOVEL_NEEDS_HUMAN tier for cell \`feature x HIGH\`"; logged "route: trust needs-human" edits.log "needs-human"
656
+ gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --calibration-needs-human true --calibration-note "overconfident cell"
657
+ rc_is "route: base conflict plus calibration" "$RC" 3; logged "route: calibration reason" issue-bodies.log "calibration threshold: overconfident cell"; logged "route: calibration needs-human" edits.log "needs-human"
658
+ gate_reset; mg "$GATE" --has-purpose-regression true
659
+ rc_is "route: purpose regression with APPROVED" "$RC" 3; logged "route: purpose reason" issue-bodies.log "purpose regression detected by Phase 7A"; logged "route: purpose needs-human" edits.log "needs-human"; not_merged "route: purpose"
660
+ gate_reset; mg "$GATE" --calibration-needs-human true --calibration-note "note"
661
+ rc_is "route: calibration alone blocks an APPROVED verdict" "$RC" 3; not_merged "route: calibration alone"
662
+ gate_reset; mg "$GATE" --trust-needs-human true
663
+ rc_is "route: trust alone blocks an APPROVED verdict" "$RC" 3; not_merged "route: trust alone"
664
+ gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED"
665
+ rc_is "route: CHANGES REQUESTED" "$RC" 3; logged "route: verdict reason" issue-bodies.log "review verdict is CHANGES REQUESTED"; not_merged "route: CHANGES REQUESTED"
666
+
667
+ # --- pre-merge mergeability guard ---
668
+ gate_reset; echo "CONFLICTING|DIRTY" > "$FIX/premerge"; mg "$GATE"
669
+ rc_is "premerge: conflicting" "$RC" 3; has "premerge: conflicting" "blocker: base-conflict"; not_logged "premerge: conflict is not needs-human" edits.log "needs-human"; not_merged "premerge: conflicting"
670
+ gate_reset; echo "MERGEABLE|BEHIND" > "$FIX/premerge"; echo true > "$FIX/protection"; mg "$GATE"
671
+ rc_is "premerge: behind under strict protection" "$RC" 3; has "premerge: behind under strict protection" "blocker: base-conflict"
672
+ gate_reset; echo "MERGEABLE|BEHIND" > "$FIX/premerge"; echo false > "$FIX/protection"; mg "$GATE"
673
+ rc_is "premerge: behind without strict protection" "$RC" 0; has "premerge: behind without strict protection" "MERGE_OUTCOME=merged"
674
+ gate_reset; echo "MERGEABLE|BEHIND" > "$FIX/premerge"; mg "$GATE"
675
+ rc_is "premerge: behind, unreadable protection" "$RC" 0
676
+ gate_reset; echo "MERGEABLE|BLOCKED" > "$FIX/premerge"; mg "$GATE"
677
+ rc_is "premerge: branch protection" "$RC" 3; has "premerge: branch protection" "blocker: branch protection not satisfied"
678
+ case "$OUT" in *base-conflict*) bad "premerge: protection must never read as base-conflict" ;; *) ok ;; esac
679
+ logged "premerge: protection needs-human" edits.log "needs-human"
680
+
681
+ # --- previously-escalated re-review guard ---
682
+ gate_reset; echo true > "$FIX/escalated"; echo false > "$FIX/gate-out"; mg "$GATE"
683
+ rc_is "escalated: non-default base" "$RC" 0; has "escalated: non-default base" "MERGE_OUTCOME=parked-awaiting-merge"
684
+ logged "escalated: non-default comment" issue-bodies.log "remediation auto-land bar"; logged "escalated: transition" transition.log "awaiting-merge"; not_merged "escalated: non-default"
685
+ gate_reset; echo true > "$FIX/escalated"; echo true > "$FIX/gate-out"; mg "$GATE"
686
+ rc_is "escalated: deploy gate" "$RC" 0; logged "escalated: deploy gate comment" issue-bodies.log "for a human merge decision"; not_merged "escalated: deploy gate"
687
+ gate_reset; echo true > "$FIX/escalated"; echo false > "$FIX/gate-out"; mg "$G_NODEPLOY"
688
+ rc_is "escalated: unresolved resolver" "$RC" 0; logged "escalated: unresolved resolver holds for a human" issue-bodies.log "for a human merge decision"; not_merged "escalated: unresolved resolver"
689
+ gate_reset; echo true > "$FIX/escalated"; mg "$G_NOTRANS"
690
+ rc_is "escalated: no transition script" "$RC" 0; logged "escalated: prose label fallback" edits.log "workflow:awaiting-merge"
691
+
692
+ # --- CI gate, reviewed-head guard ---
693
+ gate_reset; echo 1 > "$FIX/ci-rc"; printf 'CI_GATE: FAIL\nCI_GATE_HEAD: %s\nfailed: build\n' "$SHA" > "$FIX/ci-out"; mg "$GATE"
694
+ rc_is "ci: red" "$RC" 3; has "ci: red" "blocker: ci gate not green (rc=1)"; logged "ci: red comment" issue-bodies.log "CI is not green (ci gate rc=1)"; logged "ci: red needs-human" edits.log "needs-human"; not_merged "ci: red"
695
+ gate_reset; echo 3 > "$FIX/ci-rc"; printf 'CI_GATE: TIMEOUT\nCI_GATE_HEAD: %s\n' "$SHA" > "$FIX/ci-out"; mg "$GATE"
696
+ rc_is "ci: timeout" "$RC" 3; has "ci: timeout" "CI_GATE_TIMEOUT: CI still running"; has "ci: timeout" "blocker: checks still pending after the wait window"; case "$OUT" in *"blocker: ci gate"*) bad "ci: timeout blocker must not match the work-on \"ci gate\" remediation route" ;; *) ok ;; esac; has "ci: timeout outcome" "MERGE_OUTCOME=ci-pending"; not_logged "ci: timeout is not needs-human (#3919)" edits.log "needs-human"; not_merged "ci: timeout"
697
+ gate_reset; mg "$G_NOCI"
698
+ rc_is "ci: gate unresolvable" "$RC" 3; has "ci: gate unresolvable" "blocker: ci gate not green (rc=2)"; not_merged "ci: unresolvable"
699
+ gate_reset; echo 2 > "$FIX/ci-rc"; printf 'CI_GATE: ERROR\nCI_GATE_HEAD: %s\nPR head moved during the wait\n' "$SHA" > "$FIX/ci-out"; mg "$GATE"
700
+ rc_is "ci: head moved" "$RC" 3; has "ci: head moved" "blocker: stale review"; not_logged "ci: stale is not needs-human" edits.log "needs-human"; not_merged "ci: head moved"
701
+ gate_reset; rm -f "$FIX/route-bodies"; mg "$GATE"
702
+ rc_is "stale: no route marker" "$RC" 3; has "stale: no route marker" "cannot bind the merge to a reviewed commit"; not_merged "stale: no route"
703
+ gate_reset; printf 'CI_GATE: PASS\nCI_GATE_HEAD: %s\n' "$OTHER_SHA" > "$FIX/ci-out"; echo "$SHA" > "$FIX/commit-sha"; printf 'README.md\n' > "$FIX/compare-files"; mg "$GATE"
704
+ rc_is "stale: docs-only post-review delta" "$RC" 0; has "stale: docs-only post-review delta" "Post-review commits are docs-only"; logged "stale: merges the gated head" merge.log "--match-head-commit $OTHER_SHA"
705
+ gate_reset; printf 'CI_GATE: PASS\nCI_GATE_HEAD: %s\n' "$OTHER_SHA" > "$FIX/ci-out"; echo "$SHA" > "$FIX/commit-sha"; printf 'a.py\n' > "$FIX/compare-files"; mg "$GATE"
706
+ rc_is "stale: code post-review delta" "$RC" 3; has "stale: code post-review delta" "is not the reviewed commit"; not_merged "stale: code delta"
707
+ gate_reset; printf 'CI_GATE: PASS\nCI_GATE_HEAD: %s\n' "$OTHER_SHA" > "$FIX/ci-out"; rm -f "$FIX/compare-files"; mg "$GATE"
708
+ rc_is "stale: unreadable compare fails closed" "$RC" 3; not_merged "stale: unreadable compare"
709
+
710
+ # --- verdict (7A log, 7B base conflict and posting) ---
711
+ vd() { run "$GOOD" verdict --merge-health "${MH:-MERGEABLE}" --merge-health-state "${MHS:-CLEAN}" --base main --head-branch feat/x "$@"; }
712
+ reset; MH=MERGEABLE; MHS=CLEAN; vd
713
+ rc_is "verdict: clean state" "$RC" 0; has "verdict: clean state" "BASE_CONFLICT=false"; has "verdict: clean state" "HAS_MERGE_CONFLICT=false"
714
+ reset; MH=CONFLICTING; MHS=DIRTY; vd
715
+ has "verdict: conflicting" "BASE_CONFLICT=true"; has "verdict: conflicting message" "Merge \`origin/main\` into \`feat/x\` (merge only, no rebase, no force-push)"
716
+ reset; MH=MERGEABLE; MHS=DIRTY; vd; has "verdict: dirty alone" "BASE_CONFLICT=true"
717
+ reset; MH=MERGEABLE; MHS=BEHIND; echo true > "$FIX/protection"; vd; has "verdict: behind under strict protection" "BASE_CONFLICT=true"
718
+ reset; MH=MERGEABLE; MHS=BEHIND; echo false > "$FIX/protection"; vd; has "verdict: behind without strict protection" "BASE_CONFLICT=false"
719
+ reset; MH=MERGEABLE; MHS=BEHIND; vd; has "verdict: behind, unreadable protection" "BASE_CONFLICT=false"
720
+ reset; MH=MERGEABLE; MHS=BLOCKED; vd
721
+ has "verdict: blocked is not a conflict" "BASE_CONFLICT=false"; has "verdict: blocked warning" "MERGE_PROTECTION_WARNING=Merge state is BLOCKED"
722
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --result clean --agents "3 agents: a, b, c" --findings-count 2
723
+ rc_is "verdict: clean post" "$RC" 0; has "verdict: clean" "VERDICT=APPROVED"
724
+ logged "verdict: clean body" reviews.log "APPROVED: commit $(printf '%s' "$SHA" | cut -c1-7) after context-aware review (3 agents: a, b, c). 2 findings created as issues. Safe to merge."
725
+ logged "verdict: trust annotation defaults" reviews.log '**Intensity tier**: SHADOW | **Cell**: `(unavailable)` | **Shadow mode**: true'
726
+ reset; MH=UNKNOWN; MHS=UNKNOWN; vd --result clean; logged "verdict: unknown mergeability warning" reviews.log "GitHub is still computing merge state"
727
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --result clean --intensity-tier FAST --table-cell-key "bug HIGH" --trust-shadow-mode false
728
+ logged "verdict: trust annotation" reviews.log '**Intensity tier**: FAST | **Cell**: `bug HIGH` | **Shadow mode**: false'
729
+ reset; MH=CONFLICTING; MHS=DIRTY; vd --result blocking --blocking-count 1
730
+ has "verdict: blocking" "VERDICT=CHANGES REQUESTED"; logged "verdict: blocking body" reviews.log "CHANGES REQUESTED: commit"; logged "verdict: base conflict line" reviews.log "🔴 Base Conflict:"
731
+ reset; MH=CONFLICTING; MHS=DIRTY; vd --result clean
732
+ has "verdict: clean coerced over a base conflict" "VERDICT_COERCED=true"; has "verdict: coerced verdict" "VERDICT=CHANGES REQUESTED"; logged "verdict: coerced body" reviews.log "CHANGES REQUESTED: commit"
733
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --result clean --has-purpose-regression true --purpose-findings "F-1"
734
+ has "verdict: clean coerced over a purpose regression" "VERDICT_COERCED=true"; logged "verdict: purpose line" reviews.log "⚠ Purpose Regression:"; logged "verdict: purpose finding list" reviews.log "See: F-1"
735
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --result stale
736
+ has "verdict: stale" "VERDICT=STALE"; logged "verdict: stale body" reviews.log "is stale — PR HEAD changed. Re-run /review-pr."
737
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --result clean --dry-run
738
+ if [ -f "$FIX/reviews.log" ]; then bad "verdict: --dry-run must post nothing"; else ok; fi
739
+ # 7A: the purpose-regression log is milestone-only
740
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --is-milestone true --has-purpose-regression true --purpose-findings "F-1"
741
+ has "7A: blocked log" "PURPOSE REGRESSION GATE: BLOCKED"; has "7A: escalation" "Verdict escalated to CHANGES REQUESTED."
742
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --is-milestone true --has-purpose-regression false; has "7A: passed log" "PURPOSE REGRESSION GATE: PASSED"
743
+ reset; MH=MERGEABLE; MHS=CLEAN; vd --has-purpose-regression true
744
+ case "$OUT" in *"PURPOSE REGRESSION GATE"*) bad "7A: non-milestone PRs print no gate log" ;; *) ok ;; esac
745
+ reset; OUT=$(cd "$REPO_DIR" && FORGEDOCK_HOME="$GOOD" bash "$ROOT/scripts/review-finalize.sh" verdict --pr 7 --repo o/r --context "$CTXF" --merge-health 2>/dev/null); RC=$?
746
+ rc_is "verdict: flag without a value" "$RC" 2
747
+ reset; vd --result bogus; rc_is "verdict: invalid result" "$RC" 2
748
+
749
+ # --- calibrate (7B.5) ---
750
+ CAL_ROW() { printf '{"rows":[{"taskType":"%s","confidence":"%s","trusted":%s,"survivalRate":0.5,"sampleCount":20,"flag":"%s"}]}' "$1" "$2" "$3" "$4"; }
751
+ setcal() { # setcal <json>: publish calibration/table.json as origin/forge-knowledge in the throwaway checkout
752
+ git -C "$REPO_DIR" checkout -q -b kn-tmp; mkdir -p "$REPO_DIR/calibration"; printf '%s' "$1" > "$REPO_DIR/calibration/table.json"
753
+ git -C "$REPO_DIR" add -A; git -C "$REPO_DIR" commit -qm cal
754
+ git -C "$REPO_DIR" update-ref refs/remotes/origin/forge-knowledge HEAD
755
+ git -C "$REPO_DIR" checkout -q main; git -C "$REPO_DIR" branch -q -D kn-tmp; rm -rf "$REPO_DIR/calibration"
756
+ }
757
+ clrcal() { git -C "$REPO_DIR" update-ref -d refs/remotes/origin/forge-knowledge 2>/dev/null || true; }
758
+ # investigator comments: inv_comments <association> <type> <body> -> $FIX/comments-42.json (read by calibrate via --paginate)
759
+ inv_comments() { jq -n -c --arg a "$1" --arg t "$2" --arg b "$3" '[{author_association: $a, user: {type: $t, login: "u"}, body: $b}]' > "$FIX/comments-42.json"; }
760
+ cal() { reset; mkctx 1 FULL "" 42; inv_comments NONE Bot "$(printf '<!-- FORGE:INVESTIGATOR -->\n## Investigation\n**Task Type**: Refactor\n**Confidence**: HIGH\n')"; run "$GOOD" calibrate "$@"; }
761
+ setcal "$(CAL_ROW Refactor HIGH true overconfidence)"
762
+ cal; rc_is "calibrate: overconfidence" "$RC" 0; has "calibrate: overconfidence" "CALIBRATION_NEEDS_HUMAN=true"; has "calibrate: cell" "CALIBRATION_CELL=Refactor × HIGH: survival=0.5 (n=20)"
763
+ has "calibrate: note" "Routing to needs-human per forge#1741 policy."; logged "calibrate: trajectory record" issue-bodies.log "<!-- FORGE:CALIBRATION_CHECK -->"
764
+ cal --intensity-tier FAST --table-cell-key "k" --trust-shadow-mode false --trust-needs-human true
765
+ logged "calibrate: provenance record" issue-bodies.log '**TRUST_NEEDS_HUMAN**: true'
766
+ cal --dry-run; has "calibrate: dry run" "CALIBRATION_NEEDS_HUMAN=true"; if [ -f "$FIX/issue-bodies.log" ]; then bad "calibrate: --dry-run must post nothing"; else ok; fi
767
+ clrcal; setcal "$(CAL_ROW Refactor HIGH true null)"
768
+ cal; has "calibrate: healthy cell" "CALIBRATION_NEEDS_HUMAN=false"; has "calibrate: healthy cell note" "within acceptable threshold"
769
+ clrcal; setcal "$(CAL_ROW Refactor HIGH false overconfidence)"
770
+ cal; has "calibrate: untrusted cell never tightens" "CALIBRATION_NEEDS_HUMAN=false"; has "calibrate: untrusted cell note" "absent or untrusted"
771
+ clrcal; setcal "$(CAL_ROW Refactor MEDIUM true overconfidence)"
772
+ cal; has "calibrate: only HIGH confidence tightens" "CALIBRATION_NEEDS_HUMAN=false"
773
+ clrcal; setcal "not json"
774
+ cal; has "calibrate: malformed table fails safe" "CALIBRATION_NEEDS_HUMAN=false"
775
+ clrcal
776
+ cal; has "calibrate: missing table fails safe" "CALIBRATION_NEEDS_HUMAN=false"; has "calibrate: missing table note" "calibration table unavailable"
777
+ # untrusted / unanchored INVESTIGATOR text never feeds the note, and the note never carries shell metacharacters (BUG-3, #3952)
778
+ reset; mkctx 1 FULL "" 42; inv_comments NONE User "$(printf '<!-- FORGE:INVESTIGATOR -->\n**Task Type**: X\x27;touch${IFS}/tmp/pwn;\x27\n**Confidence**: HIGH\n')"; run "$GOOD" calibrate
779
+ has "calibrate: untrusted investigator ignored" "could not resolve task type/confidence"
780
+ reset; mkctx 1 FULL "" 42; inv_comments MEMBER User "$(printf 'see <!-- FORGE:INVESTIGATOR -->\n**Task Type**: Refactor\n**Confidence**: HIGH\n')"; run "$GOOD" calibrate
781
+ has "calibrate: unanchored marker ignored" "could not resolve task type/confidence"
782
+ reset; mkctx 1 FULL "" 42; inv_comments MEMBER User "$(printf '<!-- FORGE:INVESTIGATOR -->\n**Task Type**: Re\x27fa$(id)ctor\n**Confidence**: HIGH\n')"; run "$GOOD" calibrate
783
+ note=$(printf '%s\n' "$OUT" | sed -n 's/^CALIBRATION_NOTE=//p'); case "$note" in *"'"*|*'$'*|*'`'*) bad "calibrate: note carries a shell metacharacter: $note" ;; *) ok ;; esac
784
+ setcal "$(CAL_ROW Refactor HIGH true overconfidence)"
785
+ reset; mkctx 1 FULL "" 42; run "$GOOD" calibrate; has "calibrate: no investigator comment fails safe" "CALIBRATION_NEEDS_HUMAN=false"; has "calibrate: unresolved note" "task type or confidence not resolved"
786
+ reset; mkctx 1 FULL "" ""; run "$GOOD" calibrate; has "calibrate: no issue fails safe" "CALIBRATION_NEEDS_HUMAN=false"
787
+ clrcal
788
+
789
+ # --- demilestone (8B / 9A) ---
790
+ printf '11\n12\n' > "$WORK/demile.seed"
791
+ dm() { reset; cp "$WORK/demile.seed" "$FIX/demile.txt"; run "$GOOD" demilestone "$@"; }
792
+ dm --is-milestone false --merge-state MERGED; has "demilestone: non-milestone PR" "DEMILESTONED=0"; not_logged "demilestone: non-milestone edits" edits.log "--milestone"
793
+ dm --is-milestone true --merge-state OPEN; has "demilestone: not merged" "DEMILESTONED=0"; not_logged "demilestone: unmerged edits" edits.log "--milestone"
794
+ dm --is-milestone true --merge-state MERGED
795
+ rc_is "demilestone: 8B" "$RC" 0; has "demilestone: 8B count" "DEMILESTONED=2"; logged "demilestone: first finding" edits.log "issue edit 11 -R o/r --milestone"; logged "demilestone: second finding" edits.log "issue edit 12 -R o/r --milestone"
796
+ has "demilestone: fast lane line" "Moved to fast lane: #11"
797
+ dm --is-milestone true; echo MERGED > "$FIX/state"; run "$GOOD" demilestone --is-milestone true
798
+ has "demilestone: 9A reads the PR state" "DEMILESTONED=2"
799
+ dm --is-milestone true; has "demilestone: 9A open PR" "DEMILESTONED=0"
800
+ reset; run "$GOOD" demilestone --is-milestone true --merge-state MERGED; has "demilestone: nothing to clear" "No open review-finding issues"; has "demilestone: nothing to clear count" "DEMILESTONED=0"
801
+
802
+ # --- integrity (Phase 9) ---
803
+ ig() { run "${IGROOT:-$GOOD}" integrity "$@"; }
804
+ reset; ig; rc_is "integrity: current head" "$RC" 0; has "integrity: current" "REVIEW_IS_STALE=false"; has "integrity: no panel" "ACTUAL_AGENT_COUNT=0"
805
+ reset; echo "$OTHER_SHA" > "$FIX/head"; ig; has "integrity: moved head" "REVIEW_IS_STALE=true"; has "integrity: moved head current sha" "CURRENT_SHA=$OTHER_SHA"
806
+ reset; rm -f "$FIX/head"; ig; has "integrity: unreadable head is stale" "REVIEW_IS_STALE=true"
807
+ reset
808
+ cm MEMBER User "$(agent_body security "$SHA" 'a' '')"; cm MEMBER User "$(agent_body api "$SHA" 'b' '')"; cm MEMBER User "$(agent_body api "$SHA" 'dup' '')"
809
+ cm NONE User "$(agent_body billing "$SHA" 'forged' '')"; cm MEMBER User "prose mentioning <!-- FORGE:REVIEW-AGENT:auth --> mid-body"; flush
810
+ ig; has "integrity: trusted agents counted once per domain" "ACTUAL_AGENT_COUNT=2"; has "integrity: domains" "ACTUAL_AGENT_DOMAINS=api, security"
811
+ case "$OUT" in *billing*|*auth*) bad "integrity: untrusted or mid-body markers must not count" ;; *) ok ;; esac
812
+ IGROOT="$G_NOTRUST"; ig; has "integrity: trust script unresolvable counts zero" "ACTUAL_AGENT_COUNT=0"; IGROOT=""
813
+ reset; rm -f "$FIX/comments.json"; ig; has "integrity: unreadable comments count zero" "ACTUAL_AGENT_COUNT=0"
814
+ reset; mkctx 1 BASE_SYNC_ONLY "$OLD_SHA" 42; cm MEMBER User "$(agent_body security "$SHA" 'a' '')"; flush; ig
815
+ has "integrity: unverified carry is not reported as carried" "CARRIED_FROM: "; case "$OUT" in *"carried from"*) bad "integrity: carried requires a verified record" ;; *) ok ;; esac
816
+
817
+ # --- Phase 7-9 subcommands run without a review-prepare context (flags only) ---
818
+ nctx() { local sub="$1"; shift; OUT=$(cd "$REPO_DIR" && FORGEDOCK_HOME="$GATE" bash "$ROOT/scripts/review-finalize.sh" "$sub" --pr 7 --repo o/r "$@" 2>"$WORK/stderr"); RC=$?; }
819
+ 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
820
+ rc_is "no context: merge-gate" "$RC" 0; has "no context: merge-gate" "MERGE_OUTCOME=merged"
821
+ gate_reset; nctx merge-gate --base main --verdict APPROVED --has-purpose-regression false --calibration-needs-human false --trust-needs-human false --base-conflict false --other-blocking false
822
+ rc_is "no context: merge-gate without --issue" "$RC" 3; has "no context: merge-gate without --issue" "auto-merge requires --issue"
823
+ gate_reset; nctx integrity --review-sha "$SHA"; rc_is "no context: integrity" "$RC" 0; has "no context: integrity" "REVIEW_IS_STALE=false"
824
+ gate_reset; nctx integrity; rc_is "no context: integrity without a sha" "$RC" 3; has "no context: integrity without a sha" "REVIEW_SHA is not a commit id"
825
+ gate_reset; nctx extract --out-dir "$WORK/out"; rc_is "no context: extract still needs one" "$RC" 2
826
+
827
+ # ======================= usage errors =======================
828
+ OUT=$(bash "$ROOT/scripts/review-finalize.sh" bogus 2>/dev/null); RC=$?; rc_is "unknown subcommand" "$RC" 2
829
+ OUT=$(bash "$ROOT/scripts/review-finalize.sh" extract --pr x --repo o/r --context "$CTXF" 2>/dev/null); RC=$?; rc_is "non-numeric pr" "$RC" 2
830
+ OUT=$(bash "$ROOT/scripts/review-finalize.sh" extract --pr 7 --repo o/r 2>/dev/null); RC=$?; rc_is "missing context" "$RC" 2
831
+
832
+ echo "review-finalize tests: $pass passed, $fail failed"
833
+ [ "$fail" -eq 0 ]