forgedock 1.12.0 → 1.13.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 (76) hide show
  1. package/bin/engine/phases-args.test.mjs +2 -1
  2. package/bin/engine/phases.mjs +349 -90
  3. package/bin/engine/runlog.mjs +25 -2
  4. package/bin/engine-cli.mjs +4 -1
  5. package/bin/engine.mjs +72 -19
  6. package/bin/hooks/interactive-engine.mjs +135 -209
  7. package/bin/hooks/pre-tool-use.mjs +16 -4
  8. package/bin/labels.json +10 -0
  9. package/bin/opencode-adapter.mjs +1 -1
  10. package/bin/orchestrate-preflight.mjs +1 -1
  11. package/bin/runner.mjs +258 -23
  12. package/bin/watch-utils.mjs +1 -0
  13. package/commands/autopilot.md +6 -6
  14. package/commands/cleanup.md +8 -5
  15. package/commands/orchestrate/phase-1-resolve.md +1 -1
  16. package/commands/orchestrate/phase-3-dependency.md +14 -19
  17. package/commands/orchestrate/phase-4-execution.md +80 -53
  18. package/commands/orchestrate/phase-6-report.md +6 -0
  19. package/commands/orchestrate/safety.md +1 -1
  20. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  21. package/commands/pipeline-health/phase-5-report.md +1 -0
  22. package/commands/pipeline-resume.md +1 -1
  23. package/commands/pipeline-status.md +2 -2
  24. package/commands/recover-orphans.md +5 -5
  25. package/commands/review-pr-agents/api.md +1 -1
  26. package/commands/review-pr-agents/auth.md +1 -1
  27. package/commands/review-pr-agents/infra.md +3 -3
  28. package/commands/review-pr-agents/protocols.md +15 -6
  29. package/commands/review-pr-agents/scraper.md +1 -1
  30. package/commands/review-pr-agents/security.md +5 -5
  31. package/commands/review-pr-agents.md +12 -12
  32. package/commands/review-pr-staging.md +226 -8
  33. package/commands/review-pr.md +394 -53
  34. package/commands/work-on/build/architect.md +36 -0
  35. package/commands/work-on/build/implement.md +1 -1
  36. package/commands/work-on/build/validate.md +88 -4
  37. package/commands/work-on/build.md +106 -7
  38. package/commands/work-on/decompose.md +2 -2
  39. package/commands/work-on/investigate.md +14 -4
  40. package/commands/work-on/remediate.md +137 -25
  41. package/commands/work-on/review.md +25 -9
  42. package/commands/work-on.md +15 -15
  43. package/docs/CONFIG.md +29 -0
  44. package/docs/RELEASE-NOTES.md +45 -0
  45. package/docs/spec/forge-protocol-v1.md +22 -1
  46. package/docs/spec/label-state-machine.md +6 -1
  47. package/docs/spec/review-protocol.md +20 -5
  48. package/package.json +2 -1
  49. package/packages/protocol/src/phases.js +21 -0
  50. package/scripts/check-blast-radius.sh +164 -0
  51. package/scripts/check-blast-radius.test.sh +183 -0
  52. package/scripts/check-contract-scope.sh +137 -0
  53. package/scripts/check-contract-scope.test.sh +83 -0
  54. package/scripts/check-spec-bash.sh +60 -3
  55. package/scripts/check-spec-bash.test.sh +56 -0
  56. package/scripts/check-spec-markers.sh +29 -0
  57. package/scripts/check-test-wiring.sh +57 -0
  58. package/scripts/check-test-wiring.test.sh +70 -0
  59. package/scripts/classify-finding.sh +72 -8
  60. package/scripts/classify-finding.test.sh +77 -8
  61. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  62. package/scripts/diff-size.test.sh +3 -2
  63. package/scripts/doctor-pipeline-state.sh +35 -0
  64. package/scripts/forge-annotation.sh +3 -0
  65. package/scripts/forge-root.test.sh +103 -13
  66. package/scripts/forge-run.sh +2 -2
  67. package/scripts/recovery-claim-live.test.sh +10 -7
  68. package/scripts/remediate-basesync.test.sh +103 -0
  69. package/scripts/review-delta.sh +206 -0
  70. package/scripts/review-delta.test.sh +213 -0
  71. package/scripts/review-pr-domain-map.test.sh +87 -0
  72. package/scripts/review-pr-gate2.test.sh +84 -0
  73. package/scripts/review-pr-staging-scope.test.sh +99 -0
  74. package/scripts/run-shell-tests.sh +115 -0
  75. package/scripts/select-fix-targets.sh +17 -5
  76. package/scripts/transition-label.sh +3 -1
@@ -229,20 +229,110 @@ if command -v node >/dev/null 2>&1; then
229
229
  done
230
230
  fi
231
231
 
232
- # Guard (#3400): gate-helper (trusted-comments.sh) resolution loops must never list "$PWD" — the repo
233
- # under review is author-controlled. Exact copy counts + normalized identity prevent drift.
234
- TC_LOOPS="commands/review-pr.md:7 commands/work-on/review.md:1"
235
- : > "$T/tc_all"
236
- for e in $TC_LOOPS; do
237
- f="${e%%:*}"; want="${e##*:}"
238
- n=$(grep -c 'trusted-comments.sh" \]' "$ROOT/$f"); expect "trusted-comments.sh loop count in $f" "$want" "$n"
239
- grep -n 'for _c in' "$ROOT/$f" | while IFS=: read -r ln _; do
240
- sed -n "$((ln+1))p" "$ROOT/$f" | grep -q 'trusted-comments.sh' && sed -n "${ln}p" "$ROOT/$f" | sed 's/^[ ]*//' >> "$T/tc_all"
241
- done
232
+ # Guard (#3400, #3454): every gate-helper (trusted-comments.sh) resolver block under commands/ must be
233
+ # byte-identical, must scan the plugin cache (the plugin-root placeholder is not always substituted in
234
+ # forked runs, so without the scan a cache-only install never resolves the script), and must never list the
235
+ # working directory (the repo under review is author-controlled). Blocks are discovered, not hard-coded, so a
236
+ # new copy cannot drift unnoticed. A block runs from its '# TRUSTED_SCRIPT resolver' header to 'done <<< "$_tc"'.
237
+ : > "$T/tc_all"; TC_BLOCKS=0; TC_STRAY=0
238
+ while IFS= read -r f; do
239
+ awk -v out="$T/tc_all" '
240
+ /^[ ]*# TRUSTED_SCRIPT resolver/ {inb=1; line=""}
241
+ inb { sub(/^[ ]+/, ""); line = line $0 "\\n" }
242
+ inb && /^done <<< "\$_tc"$/ { print line >> out; inb=0; n++ }
243
+ END { print n+0 }' "$f" > "$T/tc_n"
244
+ TC_BLOCKS=$((TC_BLOCKS + $(cat "$T/tc_n")))
245
+ # every trusted-comments.sh resolution case-arm must belong to a block (no stray short copies)
246
+ arms=$(grep -c 'trusted-comments.sh" \]' "$f" || true)
247
+ [ "$arms" = "$(cat "$T/tc_n")" ] || { TC_STRAY=$((TC_STRAY + 1)); echo " stray resolver in $f: $arms arms vs $(cat "$T/tc_n") blocks"; }
248
+ done < <(grep -rl 'trusted-comments.sh" \]' "$ROOT/commands")
249
+ expect "trusted-comments.sh resolver blocks discovered (review-pr 8 + review + remediate + phase-4 + review-pr-staging)" 12 "$TC_BLOCKS"
250
+ expect "trusted-comments.sh resolvers without a canonical block" 0 "$TC_STRAY"
251
+ expect "trusted-comments.sh resolvers byte-identical" 1 "$(sort -u "$T/tc_all" | wc -l | tr -d ' ')"
252
+ grep -qF 'plugins/cache/forgedock/forgedock' "$T/tc_all" && ok || bad 'trusted-comments.sh resolver lacks the plugin-cache scan'
253
+ grep -qF 'CLAUDE_CONFIG_DIR' "$T/tc_all" && ok || bad 'trusted-comments.sh resolver lacks CLAUDE_CONFIG_DIR'
254
+ grep -qF '"$PWD"' "$T/tc_all" && bad 'trusted-comments.sh resolver lists "$PWD"' || ok
255
+
256
+ # Behavior (#3454): a cache-only install with an unsubstituted plugin-root placeholder must resolve the script.
257
+ # Run the real block from review.md against a fake HOME that has only plugins/cache/forgedock/forgedock/<semver>.
258
+ awk '/^[ ]*# TRUSTED_SCRIPT resolver/ {inb=1} inb {sub(/^[ ]+/, ""); print} inb && /^done <<< "\$_tc"$/ {exit}' "$ROOT/commands/work-on/review.md" > "$T/tc_block.sh"
259
+ mkdir -p "$T/tchome/.claude/plugins/cache/forgedock/forgedock/1.9.0/scripts" "$T/tchome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts" "$T/tcwork"
260
+ : > "$T/tchome/.claude/plugins/cache/forgedock/forgedock/1.9.0/scripts/trusted-comments.sh"
261
+ : > "$T/tchome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/trusted-comments.sh"
262
+ mkdir -p "$T/tcwork/scripts"; : > "$T/tcwork/scripts/trusted-comments.sh" # author-controlled cwd copy must NOT win
263
+ out=$(cd "$T/tcwork" && env -i PATH="$PATH" HOME="$T/tchome" bash -c 'source "$1"; printf %s "$TRUSTED_SCRIPT"' _ "$T/tc_block.sh" 2>&1)
264
+ expect "cache-only install resolves newest cached trusted-comments.sh" "$T/tchome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/trusted-comments.sh" "$out"
265
+ out=$(cd "$T/tcwork" && env -i PATH="$PATH" HOME="$T/nohome" bash -c 'source "$1"; printf %s "$TRUSTED_SCRIPT"' _ "$T/tc_block.sh" 2>&1)
266
+ expect "no install resolves empty (cwd copy never used)" "" "$out"
267
+
268
+ # Guard (#3483): the other executable-helper resolvers (classify-finding.sh, amplification-breaker.sh,
269
+ # extract-affected-files.sh) must use the same trusted-install tiers: no "$PWD", no "$REPO_PATH", plugin-cache
270
+ # scan present. Blocks are extracted from the real specs (header '# HELPER_SCRIPT resolver' to 'done <<< "$_tc"',
271
+ # or the resolve_extract_affected_files function) so a drifting copy cannot pass on a hand-written stand-in.
272
+ extract_helper_block() { # file var -> block on stdout
273
+ awk -v var="$2" '/^[ ]*# HELPER_SCRIPT resolver \(/ && index($0, "(" var ";") {inb=1} inb {sub(/^[ ]+/, ""); print} inb && /^done <<< "\$_tc"$/ {exit}' "$1"
274
+ }
275
+ extract_helper_fn() { # file -> function on stdout
276
+ awk '/^resolve_extract_affected_files\(\) \{/ {inb=1} inb {print} inb && /^\}$/ {exit}' "$1"
277
+ }
278
+ HR_CLASSIFY="$ROOT/commands/review-pr.md"
279
+ HR_AMP="$ROOT/commands/orchestrate/phase-4-execution.md"
280
+ HR_EX3="$ROOT/commands/orchestrate/phase-3-dependency.md"
281
+ extract_helper_block "$HR_CLASSIFY" CLASSIFY_SCRIPT > "$T/hr_classify.sh"
282
+ extract_helper_block "$HR_AMP" AMP_SCRIPT > "$T/hr_amp.sh"
283
+ extract_helper_fn "$HR_AMP" > "$T/hr_ex4.sh"
284
+ extract_helper_fn "$HR_EX3" > "$T/hr_ex3.sh"
285
+ expect "CLASSIFY_SCRIPT resolver block found" 1 "$(grep -c '^CLASSIFY_SCRIPT=""' "$T/hr_classify.sh")"
286
+ expect "AMP_SCRIPT resolver block found" 1 "$(grep -c '^AMP_SCRIPT=""' "$T/hr_amp.sh")"
287
+ expect "extract-affected-files resolver found in phase-4" 1 "$(grep -c 'resolve_extract_affected_files() {' "$T/hr_ex4.sh")"
288
+ expect "extract-affected-files resolver found in phase-3" 1 "$(grep -c 'resolve_extract_affected_files() {' "$T/hr_ex3.sh")"
289
+ cmp -s "$T/hr_ex3.sh" "$T/hr_ex4.sh" && ok || bad 'resolve_extract_affected_files copies differ between phase-3 and phase-4'
290
+ for hr in classify amp ex3 ex4; do
291
+ grep -qF 'plugins/cache/forgedock/forgedock' "$T/hr_$hr.sh" && ok || bad "$hr helper resolver lacks the plugin-cache scan"
292
+ grep -qF 'CLAUDE_CONFIG_DIR' "$T/hr_$hr.sh" && ok || bad "$hr helper resolver lacks CLAUDE_CONFIG_DIR"
293
+ grep -qE '\$PWD|\$REPO_PATH|\$\{PWD|\$\{REPO_PATH' "$T/hr_$hr.sh" && bad "$hr helper resolver lists the working directory or repo path" || ok
242
294
  done
243
- expect "trusted-comments.sh loops total" 8 "$(wc -l < "$T/tc_all" | tr -d ' ')"
244
- expect "trusted-comments.sh loops byte-identical" 1 "$(sort -u "$T/tc_all" | wc -l | tr -d ' ')"
245
- grep -qF '"$PWD"' "$T/tc_all" && bad 'trusted-comments.sh loop lists "$PWD"' || ok
295
+ # no leftover cwd-derived helper tier anywhere under commands/
296
+ if grep -rnE 'PWD/scripts/|^\$PWD$|REPO_PATH/scripts/extract' "$ROOT/commands" >/dev/null 2>&1; then bad 'commands/ still lists a working-directory helper tier'; else ok; fi
297
+
298
+ # Behavior (#3483): a planted scripts/<helper>.sh in the cwd must never be selected; a cache-only install must resolve.
299
+ mkdir -p "$T/hrwork/scripts" "$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts"
300
+ for h in classify-finding amplification-breaker extract-affected-files; do
301
+ : > "$T/hrwork/scripts/$h.sh"; : > "$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/$h.sh"
302
+ done
303
+ CACHE_SCRIPTS="$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts"
304
+ hr_run() { # home blockfile expr
305
+ (cd "$T/hrwork" && env -i PATH="$PATH" HOME="$1" REPO_PATH="$T/hrwork" bash -c 'source "$1"; '"$3" _ "$2" 2>/dev/null)
306
+ }
307
+ expect "CLASSIFY_SCRIPT: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/classify-finding.sh" "$(hr_run "$T/hrhome" "$T/hr_classify.sh" 'printf %s "$CLASSIFY_SCRIPT"')"
308
+ expect "CLASSIFY_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_classify.sh" 'printf %s "$CLASSIFY_SCRIPT"')"
309
+ expect "AMP_SCRIPT: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/amplification-breaker.sh" "$(hr_run "$T/hrhome" "$T/hr_amp.sh" 'printf %s "$AMP_SCRIPT"')"
310
+ expect "AMP_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_amp.sh" 'printf %s "$AMP_SCRIPT"')"
311
+ expect "extract-affected-files: cache-only install resolves (cwd and REPO_PATH copies ignored)" "$CACHE_SCRIPTS/extract-affected-files.sh" "$(hr_run "$T/hrhome" "$T/hr_ex4.sh" 'resolve_extract_affected_files')"
312
+ expect "extract-affected-files: no install resolves nothing (cwd and REPO_PATH copies ignored)" "" "$(hr_run "$T/nohome" "$T/hr_ex4.sh" 'resolve_extract_affected_files')"
313
+
314
+ # Guard (#3636): review-delta.sh takes its trust script ONLY from an explicit absolute --trusted-script. Any
315
+ # commands/ block that resolves review-delta.sh (added by the specs that consume it) must use the trusted-install
316
+ # tiers: plugin-cache scan and CLAUDE_CONFIG_DIR present, no "$PWD"/"$REPO_PATH" tier. Passes with 0 blocks today.
317
+ RD_BLOCKS=0
318
+ while IFS= read -r f; do
319
+ awk '/^[ ]*# HELPER_SCRIPT resolver \(/ && index($0, "(REVIEW_DELTA_SCRIPT;") {inb=1} inb {sub(/^[ ]+/, ""); print} inb && /^done <<< "\$_tc"$/ {exit}' "$f" > "$T/rd_block.sh"
320
+ [ -s "$T/rd_block.sh" ] || { bad "review-delta.sh referenced in $f without a canonical REVIEW_DELTA_SCRIPT resolver block"; continue; }
321
+ RD_BLOCKS=$((RD_BLOCKS + 1))
322
+ grep -qF 'plugins/cache/forgedock/forgedock' "$T/rd_block.sh" && ok || bad "review-delta resolver in $f lacks the plugin-cache scan"
323
+ grep -qF 'CLAUDE_CONFIG_DIR' "$T/rd_block.sh" && ok || bad "review-delta resolver in $f lacks CLAUDE_CONFIG_DIR"
324
+ grep -qE '\$PWD|\$REPO_PATH|\$\{PWD|\$\{REPO_PATH' "$T/rd_block.sh" && bad "review-delta resolver in $f lists the working directory or repo path" || ok
325
+ done < <(grep -rl 'review-delta\.sh' "$ROOT/commands" 2>/dev/null)
326
+
327
+ # Behavior (#3636): a planted scripts/trusted-comments.sh in the cwd must never be used. Without --trusted-script the
328
+ # resolver fails closed (FULL); a stub that would trust everything cannot change that.
329
+ mkdir -p "$T/rdwork/scripts"
330
+ printf '#!/usr/bin/env bash\ncat >/dev/null\nprintf "%%s\\n" "\\"<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->\\""\n' > "$T/rdwork/scripts/trusted-comments.sh"
331
+ echo '[]' > "$T/rdwork/comments.json"
332
+ out=$(cd "$T/rdwork" && bash "$ROOT/scripts/review-delta.sh" --pr 1 --head 0123456789abcdef0123456789abcdef01234567 --base main --comments-file comments.json --labels "" 2>&1 | sed -n 1p)
333
+ expect "review-delta.sh without --trusted-script prints FULL (cwd trust script ignored)" "FULL" "$out"
334
+ out=$(cd "$T/rdwork" && bash "$ROOT/scripts/review-delta.sh" --pr 1 --head 0123456789abcdef0123456789abcdef01234567 --base main --trusted-script scripts/trusted-comments.sh --comments-file comments.json --labels "" 2>&1 | sed -n 1p)
335
+ expect "review-delta.sh with a relative cwd --trusted-script prints FULL" "FULL" "$out"
246
336
 
247
337
  echo "forge-root tests: pass=$PASS fail=$FAILN skipped=$SKIPPED"
248
338
  [ "$FAILN" -eq 0 ]
@@ -285,7 +285,7 @@ fi
285
285
 
286
286
  # Read current workflow labels
287
287
  WORKFLOW_LABEL=""
288
- for label in "workflow:in-review" "workflow:building" "workflow:ready-to-build" "workflow:investigating"; do
288
+ for label in "workflow:in-review" "workflow:remediating" "workflow:building" "workflow:ready-to-build" "workflow:investigating"; do
289
289
  if printf '%s' "$LABELS_JSON" | jq -e --arg l "$label" 'contains([$l])' > /dev/null 2>&1; then
290
290
  WORKFLOW_LABEL="$label"
291
291
  break
@@ -299,7 +299,7 @@ ACTION=""
299
299
  if [ "$REVIEW_STARTED" = "true" ]; then
300
300
  PHASE="in-review"
301
301
  ACTION="Review in progress — /review-pr handles merge. Check PR state."
302
- elif [ "$BUILDER_COMPLETE" = "true" ] && [ "$WORKFLOW_LABEL" != "workflow:in-review" ]; then
302
+ elif [ "$BUILDER_COMPLETE" = "true" ] && [ "$WORKFLOW_LABEL" != "workflow:in-review" ] && [ "$WORKFLOW_LABEL" != "workflow:remediating" ]; then
303
303
  PHASE="awaiting-pr"
304
304
  ACTION="Builder complete. Create PR targeting the classified lane branch."
305
305
  elif [ "$BUILDER_PRESENT" = "true" ] && [ "$BUILDER_COMPLETE" = "false" ]; then
@@ -32,10 +32,13 @@ old_iso() { date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u
32
32
  fx() { echo "$2" > "$TMP_FX/$1.json"; echo "$TMP_FX/$1.json"; }
33
33
  claim() { jq -cn --arg id "$1" --arg t "$2" '{body:("<!-- FORGE:RECOVERY_CLAIM -->\n**Sweep: "+$id+"**\nHolder: /recover-orphans"), updated_at:$t}'; }
34
34
  release() { jq -cn --arg id "$1" --arg t "$2" '{body:("<!-- FORGE:RECOVERY_CLAIM_RELEASED -->\n**Sweep: "+$id+"**\nReleased"), updated_at:$t}'; }
35
+ # comment <marker> <second-line> <updated_at>: built by jq, never hand-escaped. macOS /bin/bash 3.2 mis-parses
36
+ # escaped double quotes inside "$(fx name "...")", which turned literal JSON fixtures into invalid JSON there.
37
+ comment() { jq -cn --arg m "$1" --arg l "$2" --arg t "$3" '{body:("<!-- "+$m+" -->\n"+$l), updated_at:$t}'; }
35
38
 
36
39
  run() { local f="$1"; shift; OUT=$(MOCK_GH_JSON="$f" bash "$CHECK" 7 -R o/r "$@" 2>/dev/null); RC=$?; }
37
40
  expect() { # name rc needle
38
- [ "$RC" -eq "$2" ] && echo "$OUT" | grep -q "$3" && ok "$1" || bad "$1 (rc=$RC out=$OUT)"
41
+ [ "$RC" -eq "$2" ] && grep -q "$3" <<< "$OUT" && ok "$1" || bad "$1 (rc=$RC out=$OUT)"
39
42
  }
40
43
 
41
44
  N=$(now_iso); O=$(old_iso)
@@ -44,16 +47,16 @@ run "$(fx live "[$(claim sweep-A "$N")]")"; expect "fresh c
44
47
  run "$(fx exp "[$(claim sweep-A "$O")]")"; expect "expired claim is FREE" 0 "CLAIM: FREE"
45
48
  run "$(fx rel "[$(claim sweep-A "$N"), $(release sweep-A "$N")]")"; expect "released claim is FREE" 0 "CLAIM: FREE"
46
49
  run "$(fx relother "[$(claim sweep-A "$N"), $(release sweep-B "$N")]")"; expect "release of another sweep does not free the claim" 1 "CLAIM: LIVE sweep-A"
47
- run "$(fx unkrel "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}, {\"body\":\"<!-- FORGE:RECOVERY_CLAIM_RELEASED -->\\nno sweep line\",\"updated_at\":\"$N\"}]")"; expect "release with no sweep id does not free an id-less claim" 1 "CLAIM: LIVE unknown"
50
+ run "$(fx unkrel "[$(comment FORGE:RECOVERY_CLAIM "no sweep line" "$N"), $(comment FORGE:RECOVERY_CLAIM_RELEASED "no sweep line" "$N")]")"; expect "release with no sweep id does not free an id-less claim" 1 "CLAIM: LIVE unknown"
48
51
  run "$(fx own "[$(claim sweep-A "$N")]")" --exempt-sweep sweep-A; expect "own sweep id is exempt" 0 "CLAIM: FREE"
49
52
  run "$(fx other "[$(claim sweep-A "$N")]")" --exempt-sweep sweep-B; expect "other sweep id is not exempt" 1 "CLAIM: LIVE sweep-A"
50
53
  run "$(fx two "[$(claim sweep-A "$N"), $(claim sweep-B "$N")]")" --exempt-sweep sweep-A; expect "exempting A still sees live B" 1 "CLAIM: LIVE sweep-B"
51
- run "$(fx unk "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")" --exempt-sweep sweep-A; expect "claim with no sweep id is never exempt" 1 "CLAIM: LIVE"
52
- run "$(fx unk2 "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")" --exempt-sweep unknown; expect "--exempt-sweep unknown is rejected" 2 "CLAIM: ERROR"
54
+ run "$(fx unk "[$(comment FORGE:RECOVERY_CLAIM "no sweep line" "$N")]")" --exempt-sweep sweep-A; expect "claim with no sweep id is never exempt" 1 "CLAIM: LIVE"
55
+ run "$(fx unk2 "[$(comment FORGE:RECOVERY_CLAIM "no sweep line" "$N")]")" --exempt-sweep unknown; expect "--exempt-sweep unknown is rejected" 2 "CLAIM: ERROR"
53
56
  run "$(fx none '[]')"; expect "no comments is FREE" 0 "CLAIM: FREE"
54
- run "$(fx hb "[{\"body\":\"<!-- FORGE:HEARTBEAT -->\\nPhase 3\",\"updated_at\":\"$N\"}]")"; expect "heartbeat alone is not a recovery claim" 0 "CLAIM: FREE"
55
- run "$(fx bad "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")"; expect "malformed claim fails closed (LIVE)" 1 "CLAIM: LIVE"
56
- run "$(fx nodate "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\n**Sweep: sweep-A**\",\"updated_at\":\"garbage\"}]")"; expect "unparsable updated_at fails closed (LIVE)" 1 "CLAIM: LIVE"
57
+ run "$(fx hb "[$(comment FORGE:HEARTBEAT "Phase 3" "$N")]")"; expect "heartbeat alone is not a recovery claim" 0 "CLAIM: FREE"
58
+ run "$(fx bad "[$(comment FORGE:RECOVERY_CLAIM "no sweep line" "$N")]")"; expect "malformed claim fails closed (LIVE)" 1 "CLAIM: LIVE"
59
+ run "$(fx nodate "[$(comment FORGE:RECOVERY_CLAIM "**Sweep: sweep-A**" garbage)]")"; expect "unparsable updated_at fails closed (LIVE)" 1 "CLAIM: LIVE"
57
60
 
58
61
  OUT=$(MOCK_GH_FAIL=1 MOCK_GH_JSON=/dev/null bash "$CHECK" 7 -R o/r 2>/dev/null); RC=$?
59
62
  expect "gh outage is ERROR (exit 2)" 2 "CLAIM: ERROR"
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # remediate-basesync.test.sh — spec guard for the Phase M3 base-sync block in commands/work-on/remediate.md (forge#3515)
3
+ # Asserts rc capture, MERGE_HEAD classification/guard, distinct refused blocker, sync-before-edits ordering,
4
+ # clean-tree precondition, and a scratch-repo check of the git behaviour the spec relies on. No network.
5
+ # Usage: bash scripts/remediate-basesync.test.sh
6
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
7
+ # SPDX-License-Identifier: AGPL-3.0-or-later
8
+
9
+ set -uo pipefail
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
12
+ SPEC="$ROOT/commands/work-on/remediate.md"
13
+
14
+ PASS=0; FAILN=0
15
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
16
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
17
+ eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
18
+ has() { if grep -qE -- "$2" "$SPEC"; then ok "$1"; else bad "$1"; fi; }
19
+
20
+ has "merge rc captured into MERGE_RC" 'MERGE_RC=\$\?'
21
+ has "MERGE_HEAD guard present" 'git rev-parse -q --verify MERGE_HEAD'
22
+ has "distinct refused-merge blocker" 'base-sync: merge refused'
23
+ has "ordering: sync before any other FIXABLE edit" 'before any (other )?(FIXABLE|fix) edit'
24
+ has "clean-tree precondition" 'git diff --quiet'
25
+ has "empty CONFLICT_FILES fallback" 'CONFLICT_FILES="\(no conflicted files'
26
+ has "unresolvable blocker unchanged" 'base-sync: unresolvable conflicts'
27
+ has "FORGE:BASESYNC_FAILED marker kept" 'FORGE:BASESYNC_FAILED'
28
+ if grep -nE '^[[:space:]]*git merge --abort' "$SPEC" >/dev/null; then bad "no bare git merge --abort line"; else ok "no bare git merge --abort line"; fi
29
+ if grep -nE 'git merge --abort' "$SPEC" | grep -vq 'MERGE_HEAD'; then bad "every git merge --abort is MERGE_HEAD-guarded"; else ok "every git merge --abort is MERGE_HEAD-guarded"; fi
30
+ # Unannotated side-effect lines would trip the #3541 gate
31
+ if grep -E 'git merge (origin|--abort)' "$SPEC" | grep -v 'allowlist:check-command-side-effects' | grep -vq '^[[:space:]]*$'; then
32
+ grep -E 'git merge (origin|--abort)' "$SPEC" | grep -v 'allowlist:check-command-side-effects' | grep -qE '^[[:space:]]*(MERGE_OUT=|git merge|if git rev-parse)' && bad "side-effect lines annotated" || ok "side-effect lines annotated"
33
+ else ok "side-effect lines annotated"; fi
34
+
35
+ has "M3 records merged base head" 'BASESYNC_BASE_SHA=\$\(git rev-parse origin/'
36
+ has "re-sync rounds hard-capped" 'BASESYNC_MAX_ROUNDS'
37
+ has "M6 distinguishes advanced base" 'Base advanced'
38
+ has "M8 trail persists base sha" 'Base sync\*\*: ran \(base='
39
+ has "rounds derived from pushed sync merges" 'BASESYNC_ROUNDS=\$\(git rev-list --first-parent --merges --count origin/\{PR_BASE\}\.\.origin/\{HEAD_BRANCH\}'
40
+ has "unreadable round count treated as the cap" '\|\| BASESYNC_ROUNDS=\$BASESYNC_MAX_ROUNDS'
41
+ if grep -qF 'BASESYNC_ROUNDS=$(( ${BASESYNC_ROUNDS:-0}' "$SPEC"; then bad "no shell-state round counter"; else ok "no shell-state round counter"; fi
42
+ if grep -qF '[ -n "${BASESYNC_BASE_SHA:-}" ]' "$SPEC"; then bad "freshness pass not gated on a shell var"; else ok "freshness pass not gated on a shell var"; fi
43
+ has "freshness push rc checked" 'if git push origin HEAD:\{HEAD_BRANCH\}; then'
44
+ has "freshness merge re-gated before push" 'Run the Phase M4 pre-push ancestry guard'
45
+ if grep -qF 'do NOT sync a second time' "$SPEC"; then bad "unconditional second-sync refusal removed"; else ok "unconditional second-sync refusal removed"; fi
46
+
47
+ # Behavioural: dirty tree + base change -> refused (rc!=0, no MERGE_HEAD, empty U-list); real conflict -> MERGE_HEAD present.
48
+ T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
49
+ (
50
+ cd "$T" && git init -q -b main . && git config user.email t@t && git config user.name t
51
+ echo a > f && git add f && git commit -qm base
52
+ git checkout -qb feat && git checkout -q main && echo b > f && git commit -qam basechange
53
+ git checkout -q feat && echo local > f
54
+ out=$(git merge main --no-edit 2>&1); echo "$? $(git rev-parse -q --verify MERGE_HEAD >/dev/null && echo head || echo nohead) [$(git diff --name-only --diff-filter=U)]" > "$T/refused.out"
55
+ git checkout -q -- f && echo c > f && git commit -qam featchange
56
+ git merge main --no-edit >/dev/null 2>&1; echo "$? $(git rev-parse -q --verify MERGE_HEAD >/dev/null && echo head || echo nohead)" > "$T/conflict.out"
57
+ )
58
+ eq "refused merge: rc=1, no MERGE_HEAD, empty conflict list" "$(cat "$T/refused.out")" "1 nohead []"
59
+ eq "real conflict: rc=1 with MERGE_HEAD" "$(cat "$T/conflict.out")" "1 head"
60
+
61
+ # Advanced base: merge main at T0, main advances, head differs and a second merge is clean; unchanged base keeps the same SHA.
62
+ (
63
+ R="$T/adv"; mkdir "$R" && cd "$R" && git init -q -b main . && git config user.email t@t && git config user.name t
64
+ echo a > f && git add f && git commit -qm base
65
+ git checkout -qb feat && echo x > g && git add g && git commit -qm feat
66
+ git checkout -q main && echo b > f && git commit -qam m1 && git checkout -q feat
67
+ git update-ref refs/remotes/origin/main main
68
+ sha0=$(git rev-parse origin/main); git merge origin/main --no-edit >/dev/null 2>&1
69
+ same=$(git rev-parse origin/main)
70
+ git checkout -q main && echo c > h && git add h && git commit -qm sibling && git checkout -q feat
71
+ git update-ref refs/remotes/origin/main main
72
+ sha1=$(git rev-parse origin/main)
73
+ git merge origin/main --no-edit >/dev/null 2>&1; rc=$?
74
+ echo "$([ "$sha0" = "$same" ] && echo unchanged-same || echo unchanged-differs) $([ "$sha0" != "$sha1" ] && echo advanced || echo notadvanced) $rc" > "$T/adv.out"
75
+ )
76
+ eq "advanced base: SHA differs, unchanged base same, second merge clean" "$(cat "$T/adv.out")" "unchanged-same advanced 0"
77
+
78
+ # Durable derivation (forge#3517): the M3/M6 lines, run in a fresh shell against a scratch repo, recover rounds and the
79
+ # recorded base head from the pushed branch; an unpushed merge does not count.
80
+ DERIVE="$(grep -m1 -E '^BASESYNC_ROUNDS=\$\(git rev-list' "$SPEC")"$'\n'"$(grep -m1 -E '^_lm=\$\(git rev-list' "$SPEC")"$'\n'"$(grep -m1 -E '^\[ -n "\$_lm" \]' "$SPEC")"
81
+ DERIVE="${DERIVE//\{PR_BASE\}/main}"; DERIVE="${DERIVE//\{HEAD_BRANCH\}/feat}"
82
+ (
83
+ R="$T/derive"; mkdir "$R" && cd "$R" && git init -q -b main . && git config user.email t@t && git config user.name t
84
+ derive() { BASESYNC_MAX_ROUNDS=2; eval "$DERIVE"; echo "$BASESYNC_ROUNDS ${_p2:-none}"; }
85
+ echo a > f && git add f && git commit -qm base
86
+ git checkout -qb feat && echo x > g && git add g && git commit -qm feat
87
+ git update-ref refs/remotes/origin/main main; git update-ref refs/remotes/origin/feat feat
88
+ r0=$(derive)
89
+ git checkout -q main && echo b > f && git commit -qam m1 && git checkout -q feat
90
+ git update-ref refs/remotes/origin/main main; b1=$(git rev-parse main)
91
+ git merge origin/main --no-edit >/dev/null 2>&1
92
+ r_unpushed=$(derive)
93
+ git update-ref refs/remotes/origin/feat feat
94
+ r1=$(derive)
95
+ echo "$r0|$r_unpushed|$r1|$b1" > "$T/derive.out"
96
+ )
97
+ IFS='|' read -r D0 DU D1 B1 < "$T/derive.out"
98
+ eq "derive: no sync merge -> 0 rounds, no recorded head" "$D0" "0 none"
99
+ eq "derive: unpushed merge does not count" "$DU" "0 none"
100
+ eq "derive: pushed sync merge -> 1 round, recorded head = merged base" "$D1" "1 $B1"
101
+
102
+ echo "passed=$PASS failed=$FAILN"
103
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env bash
2
+ # review-delta.sh — fail-closed reviewed-SHA delta resolver for /review-pr.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Classifies how the PR head moved since the last FULLY reviewed head, so callers can skip or shrink the
7
+ # domain panel. Pure bash + git + jq, no LLM. Standalone so the engine and the specs can both call it.
8
+ #
9
+ # Usage:
10
+ # review-delta.sh --pr <n> --head <40|64-hex> --base <branch> --trusted-script <abs path>
11
+ # [--comments-file <json>] [--repo <owner/repo>] [--labels <csv>] [--repo-path <dir>]
12
+ #
13
+ # --trusted-script REQUIRED absolute path to an existing trusted-comments.sh. The caller passes the path it
14
+ # resolved from the trusted install; this script never derives it from the working
15
+ # directory, the repo under review or its own location (a PR-controlled copy would let the
16
+ # PR author decide which comments count).
17
+ # --comments-file JSON of `gh api --paginate repos/<repo>/issues/<pr>/comments` (one array or several
18
+ # concatenated arrays). When absent the comments are fetched (needs --repo).
19
+ # --labels comma-separated PR labels. When absent they are fetched (needs --repo). A `review-degraded`
20
+ # label always yields FULL. `--labels ""` means "no labels" and is allowed.
21
+ # --repo-path git checkout to inspect (default: the current directory). Needs origin/<base> fetched.
22
+ #
23
+ # Output (always exit 0; two lines):
24
+ # line 1 FULL | BASE_SYNC_ONLY <reviewed> | DELTA <reviewed>..<head>
25
+ # line 2 FULL_ROUNDS=<n> number of distinct trusted full-panel SHAs (0 when none or on error)
26
+ #
27
+ # Contract notes for callers (#3637/#3638):
28
+ # - Every error path prints FULL. FULL is always safe (a full review), never a skipped one.
29
+ # - head == reviewed prints `DELTA <sha>..<sha>` (empty delta). The caller's same-head re-entry handles that
30
+ # case before calling; this classifier does not special-case it.
31
+ # - Checkpoint = newest full 40/64-hex `Reviewed-SHA:` on a trusted, complete-shape FORGE:REVIEW-AGENT body that
32
+ # also has a post-panel-guard proof (a synthesized body naming the SHA, or a `# PR Review Summary` whose
33
+ # **Reviewed commit** is the SHA and whose **Agents** count equals the distinct domains that posted). The
34
+ # FORGE:REVIEW_ROUTE sha= value is 7 chars and is never a checkpoint. A trusted review-panel-integrity gate
35
+ # failure / REVIEW_DEGRADED / REVIEW_BLOCKED marker posted after a SHA's first agent body disqualifies it.
36
+ # - FULL also when forge.yaml, the review specs, this script or the trust script changed in reviewed..head.
37
+ # - BASE_SYNC_ONLY also needs the per-path diff shape (modes, status, binary blob ids) of both 3-dot diffs
38
+ # to match: the +/- line comparison cannot see binary content or mode changes.
39
+ #
40
+ # bash 3.2 portable. No `set -e`: every read is captured and status-tested, never behind a pipe.
41
+
42
+ set -uo pipefail
43
+
44
+ ROUNDS=0
45
+ full() { echo "FULL"; echo "FULL_ROUNDS=${ROUNDS}"; exit 0; }
46
+
47
+ PR=""; HEAD_SHA=""; BASE=""; TRUSTED=""; COMMENTS_FILE=""; REPO=""; LABELS=""; LABELS_SET=0; REPO_PATH="."
48
+ while [ $# -gt 0 ]; do
49
+ case "$1" in
50
+ --pr) [ $# -ge 2 ] || full; PR="$2"; shift 2 ;;
51
+ --head) [ $# -ge 2 ] || full; HEAD_SHA="$2"; shift 2 ;;
52
+ --base) [ $# -ge 2 ] || full; BASE="$2"; shift 2 ;;
53
+ --trusted-script) [ $# -ge 2 ] || full; TRUSTED="$2"; shift 2 ;;
54
+ --comments-file) [ $# -ge 2 ] || full; COMMENTS_FILE="$2"; shift 2 ;;
55
+ --repo) [ $# -ge 2 ] || full; REPO="$2"; shift 2 ;;
56
+ --labels) [ $# -ge 2 ] || full; LABELS="$2"; LABELS_SET=1; shift 2 ;;
57
+ --repo-path) [ $# -ge 2 ] || full; REPO_PATH="$2"; shift 2 ;;
58
+ *) full ;;
59
+ esac
60
+ done
61
+
62
+ valid_sha() { # 40 or 64 lowercase hex
63
+ case "$1" in *[!0-9a-f]*|"") return 1 ;; esac
64
+ [ "${#1}" -eq 40 ] || [ "${#1}" -eq 64 ]
65
+ }
66
+
67
+ case "$PR" in ''|*[!0-9]*) full ;; esac
68
+ valid_sha "$HEAD_SHA" || full
69
+ case "$BASE" in ''|-*|*[!A-Za-z0-9._/-]*|*..*) full ;; esac
70
+ case "$TRUSTED" in /*) ;; *) full ;; esac
71
+ [ -f "$TRUSTED" ] || full
72
+ case "$REPO" in *[!A-Za-z0-9._/-]*) full ;; esac
73
+ [ -d "$REPO_PATH" ] || full
74
+ command -v jq >/dev/null 2>&1 || full
75
+
76
+ G() { git -C "$REPO_PATH" -c core.quotepath=off "$@"; }
77
+
78
+ # ---- inputs: comments + labels --------------------------------------------------------------------------------
79
+ if [ -n "$COMMENTS_FILE" ]; then
80
+ [ -f "$COMMENTS_FILE" ] && [ -r "$COMMENTS_FILE" ] || full
81
+ COMMENTS_JSON=$(cat "$COMMENTS_FILE" 2>/dev/null) || full
82
+ else
83
+ [ -n "$REPO" ] || full
84
+ COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR}/comments" 2>/dev/null) || full
85
+ fi
86
+ [ -n "$COMMENTS_JSON" ] || full
87
+
88
+ if [ "$LABELS_SET" -eq 0 ]; then
89
+ [ -n "$REPO" ] || full
90
+ LABELS=$(gh pr view "$PR" -R "$REPO" --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null) || full
91
+ fi
92
+ case ",${LABELS}," in *,review-degraded,*) full ;; esac
93
+
94
+ # ---- checkpoint: newest SHA with a trusted, complete, proven panel ---------------------------------------------
95
+ RE='^(?=[\s\S]*<!-- REVIEW-FINDINGS-START -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->|^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->|# PR Review Summary|<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity|<!-- FORGE:REVIEW_DEGRADED|<!-- FORGE:REVIEW_BLOCKED'
96
+ BODIES=$(bash "$TRUSTED" bodies "$RE" 2>/dev/null <<< "$COMMENTS_JSON"); rc=$?
97
+ [ "$rc" -eq 0 ] || full
98
+
99
+ # Bodies arrive in comment order, one JSON string per line. Everything is decided in one jq pass over that order
100
+ # (the degraded markers carry no SHA and no timestamp, so relative order is the only signal).
101
+ JQ_PROG='
102
+ def shas($t; $re): [ $t | scan($re) | .[0] ];
103
+ def one($a): if ($a | length) == 1 then $a[0] else null end;
104
+ to_entries as $E
105
+ | ($E | map(select(.value | test("<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity|<!-- FORGE:REVIEW_DEGRADED|<!-- FORGE:REVIEW_BLOCKED")) | .key)) as $DEG
106
+ | ([ $E[] | .key as $i | .value as $t
107
+ | select($t | test("^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->"))
108
+ | one(shas($t; "(?:^|\\n)Reviewed-SHA: ([0-9a-f]{40}|[0-9a-f]{64})\\r?(?:\\n|$)")) as $s
109
+ | select($s != null)
110
+ | {i: $i, sha: $s, d: ($t | capture("^<!-- FORGE:REVIEW-AGENT:(?<d>[a-z-]+) -->").d)} ]) as $AG
111
+ | ([ $E[] | .key as $i | .value as $t
112
+ | select($t | startswith("<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->"))
113
+ | one(shas($t; "(?:^|\\n)Reviewed-SHA: ([0-9a-f]{40}|[0-9a-f]{64})\\r?(?:\\n|$)")) as $s
114
+ | select($s != null)
115
+ | {i: $i, sha: $s} ]) as $SY
116
+ | ([ $E[] | .key as $i | .value as $t
117
+ | select($t | test("# PR Review Summary"))
118
+ | one(shas($t; "\\*\\*Reviewed commit\\*\\*: `?([0-9a-f]{40}|[0-9a-f]{64})`?(?:[ |\\r\\n]|$)")) as $s
119
+ | one(shas($t; "\\*\\*Agents\\*\\*: ([0-9]+)")) as $n
120
+ | select($s != null and $n != null)
121
+ | {i: $i, sha: $s, n: ($n | tonumber)} ]) as $SU
122
+ | ([ $AG | group_by(.sha)[]
123
+ | {sha: .[0].sha, first: (map(.i) | min), domains: (map(.d) | unique | length)}
124
+ | . as $g
125
+ | select(
126
+ (($SY | map(select(.sha == $g.sha)) | length) > 0
127
+ or ($SU | map(select(.sha == $g.sha and .n == $g.domains)) | length) > 0)
128
+ and (($DEG | map(select(. > $g.first)) | length) == 0))
129
+ | . + {last: ([ ($AG[] | select(.sha == $g.sha) | .i), ($SY[] | select(.sha == $g.sha) | .i),
130
+ ($SU[] | select(.sha == $g.sha) | .i) ] | max)} ]) as $OK
131
+ | (if ($OK | length) == 0 then "-" else ($OK | max_by(.last) | .sha) end), ($OK | length)'
132
+ PARSED=$(jq -s -r "$JQ_PROG" 2>/dev/null <<< "$BODIES"); rc=$?
133
+ [ "$rc" -eq 0 ] || full
134
+ REVIEWED=$(printf '%s\n' "$PARSED" | sed -n '1p'); N=$(printf '%s\n' "$PARSED" | sed -n '2p')
135
+ case "$N" in ''|*[!0-9]*) full ;; esac
136
+ ROUNDS="$N"
137
+ [ "$REVIEWED" = "-" ] && full
138
+ valid_sha "$REVIEWED" || full
139
+
140
+ # ---- classification --------------------------------------------------------------------------------------------
141
+ G cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null || full
142
+ G cat-file -e "${REVIEWED}^{commit}" 2>/dev/null || full
143
+ G merge-base --is-ancestor "$REVIEWED" "$HEAD_SHA" 2>/dev/null; rc=$?
144
+ [ "$rc" -eq 0 ] || full # 1 = force-push / rebase, >1 = git error: both FULL
145
+
146
+ NAMES=$(G diff --name-only --no-renames "$REVIEWED" "$HEAD_SHA" 2>/dev/null); rc=$?
147
+ [ "$rc" -eq 0 ] || full
148
+ while IFS= read -r f; do
149
+ case "$f" in
150
+ forge.yaml|commands/review-pr*.md|commands/review-pr-agents/*|scripts/review-delta.sh|scripts/trusted-comments.sh) full ;;
151
+ esac
152
+ done <<< "$NAMES"
153
+
154
+ DELTA_OUT="DELTA ${REVIEWED}..${HEAD_SHA}"
155
+
156
+ G rev-parse --verify --quiet "origin/${BASE}^{commit}" >/dev/null 2>&1 || full
157
+ CHAIN=$(G rev-list --first-parent "${REVIEWED}..${HEAD_SHA}" 2>/dev/null); rc=$?
158
+ [ "$rc" -eq 0 ] || full
159
+ if [ -z "$CHAIN" ]; then echo "$DELTA_OUT"; echo "FULL_ROUNDS=${ROUNDS}"; exit 0; fi
160
+
161
+ # BASE_SYNC_ONLY needs every first-parent commit to be a clean merge of origin/<base>
162
+ SYNC=1
163
+ while IFS= read -r c; do
164
+ [ -n "$c" ] || continue
165
+ PARENTS=$(G rev-list --parents -n 1 "$c" 2>/dev/null); rc=$?
166
+ [ "$rc" -eq 0 ] || full
167
+ set -- $PARENTS
168
+ if [ $# -ne 3 ]; then SYNC=0; break; fi # not a plain two-parent merge
169
+ G merge-base --is-ancestor "$3" "origin/${BASE}" 2>/dev/null; rc=$?
170
+ [ "$rc" -le 1 ] || full
171
+ if [ "$rc" -ne 0 ]; then SYNC=0; break; fi # second parent is not base history
172
+ CC=$(G diff-tree --cc --no-commit-id -r "$c" 2>/dev/null); rc=$?
173
+ [ "$rc" -eq 0 ] || full
174
+ if [ -n "$CC" ]; then SYNC=0; break; fi # conflict-resolution content in the merge
175
+ done <<< "$CHAIN"
176
+
177
+ # per-path shape of a diff: modes, status and path for every file, plus both blob ids for binary files
178
+ # (whose content the +/- line comparison cannot see). Text blob ids are left out: they shift when base moves.
179
+ diff_shape() {
180
+ local raw bin
181
+ raw=$(G diff --raw --no-abbrev --no-renames "$1" 2>/dev/null) || return 1
182
+ bin=$(G diff --numstat --no-renames "$1" 2>/dev/null) || return 1
183
+ { printf '%s\n' "$bin"; printf '%s\n' '--RAW--'; printf '%s\n' "$raw"; } | awk -F'\t' '
184
+ $0 == "--RAW--" { r = 1; next }
185
+ !r { if ($1 == "-" && $2 == "-") b[$3] = 1; next }
186
+ $0 == "" { next }
187
+ { split($1, m, " "); line = m[1] " " m[2] " " m[5] "\t" $2; if ($2 in b) line = line " " m[3] " " m[4]; print line }'
188
+ }
189
+
190
+ if [ "$SYNC" -eq 1 ]; then
191
+ # the PR's own change must be unchanged: compare the +/- lines of both 3-dot diffs (index/hunk numbers shift
192
+ # when base moves, content lines do not)
193
+ D_OLD=$(G diff --no-color --no-ext-diff --no-renames -U0 "origin/${BASE}...${REVIEWED}" 2>/dev/null); rc=$?
194
+ [ "$rc" -eq 0 ] || full
195
+ D_NEW=$(G diff --no-color --no-ext-diff --no-renames -U0 "origin/${BASE}...${HEAD_SHA}" 2>/dev/null); rc=$?
196
+ [ "$rc" -eq 0 ] || full
197
+ L_OLD=$(printf '%s\n' "$D_OLD" | sed -n '/^[-+]/p'); L_NEW=$(printf '%s\n' "$D_NEW" | sed -n '/^[-+]/p')
198
+ # +/- lines miss binary content and mode changes, so the per-path shape must match too
199
+ S_OLD=$(diff_shape "origin/${BASE}...${REVIEWED}") || full
200
+ S_NEW=$(diff_shape "origin/${BASE}...${HEAD_SHA}") || full
201
+ if [ "$L_OLD" = "$L_NEW" ] && [ "$S_OLD" = "$S_NEW" ]; then
202
+ echo "BASE_SYNC_ONLY ${REVIEWED}"; echo "FULL_ROUNDS=${ROUNDS}"; exit 0
203
+ fi
204
+ fi
205
+
206
+ echo "$DELTA_OUT"; echo "FULL_ROUNDS=${ROUNDS}"; exit 0