forgedock 1.16.2 → 1.17.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 (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -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 +12 -1
  10. package/bin/engine.mjs +145 -40
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -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 +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. package/templates/forge.yaml.minimal +9 -0
@@ -147,13 +147,22 @@ OUT9=$( ( SELECTED_AGENT_COUNT="abc"; . "$TMP/selcase.sh"; echo "$SELECTED_COUNT
147
147
  [ "$OUT9" = "true" ] && ok || bad "Phase 6: non-integer SELECTED_AGENT_COUNT must be invalid (got '$OUT9')"
148
148
  mkdir -p "$TMP/sd8/full" "$TMP/sd8/empty" "$TMP/sd8/bad" "$TMP/bin"
149
149
  printf '#!/bin/sh\necho stub-value\n' > "$TMP/bin/yq"; chmod +x "$TMP/bin/yq"
150
- printf '42\n' > "$TMP/sd8/full/staging_pr_number"; printf '7\n8\n' > "$TMP/sd8/full/42_bundle-prs.txt"
151
- printf '42; rm -rf /\n' > "$TMP/sd8/bad/staging_pr_number"
150
+ printf '42\n' > "$TMP/sd8/full/staging_pr_number.runA"; printf '7\n8\n' > "$TMP/sd8/full/42_bundle-prs.txt"
151
+ printf '43\n' > "$TMP/sd8/full/staging_pr_number.runB"; printf '9\n' > "$TMP/sd8/full/43_bundle-prs.txt"
152
+ printf '42; rm -rf /\n' > "$TMP/sd8/bad/staging_pr_number.runA"
152
153
  boot() { # scratch-dir -> prints "PR_NUMBER|ALL_PR_NUMBERS(joined)|GH_REPO" then any warning
153
- ( unset PR_NUMBER ALL_PR_NUMBERS GH_REPO DEFAULT_BRANCH STAGING_BRANCH; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$1"
154
+ ( unset PR_NUMBER ALL_PR_NUMBERS GH_REPO DEFAULT_BRANCH STAGING_BRANCH; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$1" FORGE_RUN_KEY="${2:-runA}"
154
155
  export FORGEDOCK_HOME="$ROOT"; . "$TMP/boot.sh" 2>&1; echo "PR=${PR_NUMBER:-}|ALL=$(echo ${ALL_PR_NUMBERS:-})|REPO=${GH_REPO:-}|BR=${STAGING_BRANCH:-}" ) 2>&1; }
155
156
  OUT8=$(boot "$TMP/sd8/full")
156
157
  [ "$OUT8" = "PR=42|ALL=7 8|REPO=stub-value|BR=stub-value" ] && ok || bad "fresh shell must reload staging_pr_number and bundle-prs from the scratchpad (got '$OUT8')"
158
+ OUT8=$(boot "$TMP/sd8/full" runB)
159
+ [ "$OUT8" = "PR=43|ALL=9|REPO=stub-value|BR=stub-value" ] && ok || bad "two runs in one scratchpad must each reload their own PR (got '$OUT8')"
160
+ OUT8=$(boot "$TMP/sd8/full" runC)
161
+ case "$OUT8" in *"WARNING: PR_NUMBER is empty"*"PR=|"*) ok ;; *) bad "a run with no pointer must not read another run's pointer (got '$OUT8')" ;; esac
162
+ OUT8=$(boot "$TMP/sd8/full" '../x')
163
+ case "$OUT8" in *"PR=|"*) ok ;; *) bad "a path-like run key must be sanitised (got '$OUT8')" ;; esac
164
+ _KD='FORGE_RUN_KEY=$(printf'
165
+ [ "$(grep -F "$_KD" "$SPEC" | grep -F 'FORGE_RUN_KEY="${FORGE_RUN_KEY:-' | head -1)" = "$(grep -F "$_KD" "$ROOT/scripts/lib/fence-bootstrap.sh" | grep -F 'FORGE_RUN_KEY="${FORGE_RUN_KEY:-' | head -1)" ] && grep -qF 'FORGE_RUN_KEY="${FORGE_RUN_KEY:-' "$SPEC" && ok || bad "FORGE_RUN_KEY derivation must be identical in spec and fence-bootstrap.sh"
157
166
  OUT8=$(boot "$TMP/sd8/empty")
158
167
  case "$OUT8" in *"WARNING: PR_NUMBER is empty"*"PR=|ALL=|"*) ok ;; *) bad "empty scratchpad must leave PR_NUMBER empty and warn (got '$OUT8')" ;; esac
159
168
  OUT8=$(boot "$TMP/sd8/bad")
@@ -191,16 +200,38 @@ jq -n --arg sha "$OLDSHA" '[{author_association:"MEMBER",user:{type:"User",login
191
200
  PRIOR_OUT=$( ( export FIX_COMMENTS="$TMP/member-comments.json" PATH="$TMP/bin:$PATH"; REPO=o/r; _pscr="$TMP"; TRUSTED_SCRIPT="$TC"; . "$TMP/prior-fn.sh"; PF_SKIP="" PF_PRS=41 prior_findings_tsv ) 2>/dev/null )
192
201
  printf '%s\n' "$PRIOR_OUT" > "$TMP/prior.tsv"
193
202
  [ "$(printf '%s\n' "$PRIOR_OUT" | grep -c .)" = "1" ] && printf '%s' "$PRIOR_OUT" | grep -q '^src/api.py 12 12 Missing retry on timeout handler filed 123$' && ok || bad "member PR finding must reach the TSV (open issue linked) and an untrusted comment must not (got: $PRIOR_OUT)"
203
+ awk '/^cap_pass\(\) \{/,/^\}/' "$SPEC" > "$TMP/cap-fn.sh"
204
+ [ -s "$TMP/cap-fn.sh" ] && ok || bad "cap_pass not found in the spec"
194
205
  run_dup() { # finding-file finding-line -> "DISPOSITION|FILED|REFLAG|LISTED"
195
206
  ( _SD="$TMP"; PR_NUMBER=9; CLASSIFY_SCRIPT="$ROOT/scripts/classify-finding.sh"; PRIOR_FILE="$TMP/prior.tsv"; SCOPE_MODE=full; CROSS_PR_FILES=""; UNREVIEWED_FILES=""; DEPLOY_WIRING_FILES=""
196
207
  NOTE_LIST_FILE="$TMP/dup-notes.md"; : > "$NOTE_LIST_FILE"; NOTES_LISTED=0; FINDINGS_FILED=0; OUT_OF_SCOPE=0; NOTES_REFLAG=0
197
208
  FINDING_ID=Z1; FINDING_SEVERITY="${3:-MEDIUM}"; FINDING_CONFIDENCE=CONFIRMED; FINDING_AGENT="General"; FINDING_TITLE="${4:-retry missing for the timeout handler}"; FINDING_BODY=b; FINDING_FILE="$1"; FINDING_PATHS="$1"
198
- . "$TMP/fn.sh"; classify_one; echo "$DISPOSITION|$FINDINGS_FILED|$NOTES_REFLAG|$NOTES_LISTED" ) 2>&1 | tail -1; }
209
+ CAP_ROWS_FILE="$TMP/dup-rows.tsv"; : > "$CAP_ROWS_FILE"; CAPPED_IDS_FILE="$TMP/dup-capped.txt"; CAP_SCRIPT="$ROOT/scripts/cap-findings.sh"; NOTES_CAPPED=0
210
+ . "$TMP/fn.sh"; . "$TMP/cap-fn.sh"; classify_one; cap_pass >/dev/null; echo "$DISPOSITION|$FINDINGS_FILED|$NOTES_REFLAG|$NOTES_LISTED" ) 2>&1 | tail -1; }
199
211
  [ "$(run_dup src/api.py:12)" = "NOTE reflag-filed #123|0|1|0" ] && ok || bad "a staging finding duplicating a member PR finding must be NOTE reflag-filed and file nothing (got '$(run_dup src/api.py:12)')"
200
212
  [ "$(run_dup src/api.py:12 x HIGH)" = "ISSUE severity-HIGH|1|0|0" ] && ok || bad "a HIGH duplicate of a member PR finding must still be an ISSUE (got '$(run_dup src/api.py:12 x HIGH)')"
201
213
  [ "$(run_dup src/other.py:12)" = "ISSUE MEDIUM-CONFIRMED|1|0|0" ] && ok || bad "a non-duplicate on another file must classify normally (got '$(run_dup src/other.py:12)')"
202
214
  run_dup src/api.py:12 >/dev/null
203
215
  grep -q 'reflag-filed #123' "$TMP/dup-notes.md" && ok || bad "a re-flag NOTE must be listed, not dropped"
216
+ # (8b) per-review cap (forge#3642): classify_one only records ISSUE rows; cap_pass keeps review.max_filed_findings (default 2), lists the rest as NOTE capped, never caps HIGH.
217
+ run_cap() { # "<sev>:<conf>:<id>"... -> "FILED|CAPPED|NOTES_LISTED|capped-ids(comma)|note-lines-with-capped"
218
+ ( _SD="$TMP"; PR_NUMBER=9; CLASSIFY_SCRIPT="$ROOT/scripts/classify-finding.sh"; PRIOR_FILE=""; SCOPE_MODE=full; CROSS_PR_FILES=""; UNREVIEWED_FILES=""; DEPLOY_WIRING_FILES=""
219
+ NOTE_LIST_FILE="$TMP/cap-notes.md"; : > "$NOTE_LIST_FILE"; NOTES_LISTED=0; FINDINGS_FILED=0; OUT_OF_SCOPE=0; NOTES_REFLAG=0; NOTES_CAPPED=0
220
+ CAP_ROWS_FILE="$TMP/cap-rows.tsv"; : > "$CAP_ROWS_FILE"; CAPPED_IDS_FILE="$TMP/cap-capped.txt"; CAP_SCRIPT="$ROOT/scripts/cap-findings.sh"; MAX_FILED_FINDINGS="${CAPMAX:-}"
221
+ . "$TMP/fn.sh"; . "$TMP/cap-fn.sh"
222
+ for spec in "$@"; do
223
+ IFS=: read -r FINDING_SEVERITY FINDING_CONFIDENCE FINDING_ID <<EOF3
224
+ $spec
225
+ EOF3
226
+ FINDING_AGENT=General; FINDING_TITLE="finding $FINDING_ID"; FINDING_BODY=b; FINDING_FILE="src/$FINDING_ID.py"; FINDING_PATHS="$FINDING_FILE"
227
+ classify_one
228
+ done
229
+ cap_pass >/dev/null
230
+ echo "$FINDINGS_FILED|$NOTES_CAPPED|$NOTES_LISTED|$(paste -sd, "$CAPPED_IDS_FILE")|$(grep -c 'NOTE capped (' "$NOTE_LIST_FILE")" ) 2>&1 | tail -1; }
231
+ [ "$(run_cap MEDIUM:LIKELY:a MEDIUM:CONFIRMED:b MEDIUM:LIKELY:c)" = "2|1|0|c|1" ] && ok || bad "3 MEDIUM findings: 2 filed, the lowest-ranked capped and listed (got '$(run_cap MEDIUM:LIKELY:a MEDIUM:CONFIRMED:b MEDIUM:LIKELY:c)')"
232
+ [ "$(run_cap HIGH:LIKELY:a HIGH:POSSIBLE:b CRITICAL:CONFIRMED:c MEDIUM:LIKELY:d)" = "4|0|0||0" ] && ok || bad "HIGH/CRITICAL must never be capped (got '$(run_cap HIGH:LIKELY:a HIGH:POSSIBLE:b CRITICAL:CONFIRMED:c MEDIUM:LIKELY:d)')"
233
+ [ "$(CAPMAX=1 run_cap MEDIUM:LIKELY:a MEDIUM:CONFIRMED:b)" = "1|1|0|a|1" ] && ok || bad "review.max_filed_findings=1 must be honoured (got '$(CAPMAX=1 run_cap MEDIUM:LIKELY:a MEDIUM:CONFIRMED:b)')"
234
+ [ "$(CAPMAX=zzz run_cap MEDIUM:LIKELY:a MEDIUM:LIKELY:b MEDIUM:LIKELY:c)" = "2|1|0|c|1" ] && ok || bad "an invalid review.max_filed_findings must fall back to the default 2"
204
235
  # (9) stale-head delta credit (forge#3638): the spec's bundle_delta_credit runs against real git fixtures and the REAL review-delta.sh / trusted-comments.sh.
205
236
  # Fail-closed cases first: every FULL / error / untrusted path must leave the PR's commits UNREVIEWED (empty credit).
206
237
  if command -v git >/dev/null 2>&1; then
@@ -284,10 +315,39 @@ if grep -qE 'merge-base --is-ancestor.*REVIEWED_SHA_STALE|diff_shape\(\)' "$SPEC
284
315
  grep -qF 'printf '"'"'%s\n'"'"' "$_FILES" | sort -u | sed "s|^|$pr |" >> "$_PF"' "$SPEC" && grep -qF 'git diff-tree --no-commit-id --name-only -r --cc "$c"' "$SPEC" && ok || bad "CROSS_PR_FILES accumulation and --cc merge scoping must stay intact"
285
316
  [ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC" | head -1)" -ge 3 ] && ok || bad "FENCE_BOOTSTRAP stubs must remain"
286
317
  # an unresolvable helper fails the fence closed (exit 1), never an unsourced bootstrap
287
- OUT8=$( ( unset FORGEDOCK_HOME; export HOME="$TMP/nohome"; cd "$TMP" && bash -c "$(cat "$TMP/boot.sh"); echo reached" ) 2>&1 )
318
+ OUT8=$( ( unset FORGEDOCK_HOME FORGE_HOME FORGE_ROOT CLAUDE_PLUGIN_ROOT CLAUDE_CONFIG_DIR CODEX_HOME FORGE_SOURCED_ROOT FORGEDOCK_MARKETPLACE; export HOME="$TMP/nohome"; cd "$TMP" && bash -c "$(cat "$TMP/boot.sh"); echo reached" ) 2>&1 )
288
319
  case "$OUT8" in *BLOCKED*) case "$OUT8" in *reached*) bad "unresolved bootstrap helper must stop the fence" ;; *) ok ;; esac ;; *) bad "unresolved bootstrap helper must print BLOCKED (got '$OUT8')" ;; esac
289
320
  # a stale-head PR must not credit its own merge commit (it contains the post-review commits): the DELTA branch only appends DELTA_COMMITS
290
321
  if awk '/^bundle_delta_credit\(\)/,/^\}/' "$SPEC" | grep -q 'mergeCommit'; then bad "the DELTA path must not credit the PR merge commit"; else ok; fi
322
+ # (10) fail-closed gate verdict (forge#3734): an empty/missing/invalid persisted verdict must never become SKIP
323
+ awk '/^\*\*Verdict handling\*\*:/{f=1;next} f&&/^```bash/{g=1;next} g&&/^```/{exit} g' "$SPEC" | grep -v '# FENCE_BOOTSTRAP$' > "$TMP/vh.sh"
324
+ [ -s "$TMP/vh.sh" ] && ok || bad "could not extract the Phase 6.5 verdict-handling fence"
325
+ if awk '/^ \*\)/,/;;/' "$SPEC" | grep -q 'TEST_GATE_VERDICT="SKIP"'; then bad "catch-all arm must not set TEST_GATE_VERDICT=SKIP"; else ok; fi
326
+ grep -qF 'missing/invalid' "$SPEC" && ok || bad "spec must document missing/invalid gate-verdict handling"
327
+ if grep -F 'FORGE:TEST_GATE:RESULT=' "$SPEC" | grep -q 'tail -1 || echo "SKIP"'; then bad "producer must not coerce an absent marker to SKIP"; else ok; fi
328
+ vh10() { # $1 posture, $2 persisted verdict content ('-' = no file), $3 env verdict (optional)
329
+ local sd="$TMP/vh10.$RANDOM"; mkdir -p "$sd"
330
+ [ "$2" = "-" ] || printf '%s\n' "$2" > "$sd/5_test_gate_verdict"
331
+ ( export _SD="$sd" PR_NUMBER=5 GH_REPO=o/r DEFAULT_BRANCH=main ALL_PR_NUMBERS="5" CONFIG_FILE=/dev/null P10="$1"
332
+ [ -z "${3:-}" ] || export TEST_GATE_VERDICT="$3"
333
+ forge_resolve() { :; }; yq() { case "$*" in *posture*) echo "$P10" ;; *) echo "OVERRIDE: x" ;; esac; }; gh() { return 0; }
334
+ . "$TMP/vh.sh" >"$sd/out" 2>&1; echo "rc=0" >> "$sd/out" ) ; local rc=$?
335
+ VH10_OUT=$(cat "$sd/out"); VH10_V=$(cat "$sd/5_test_gate_verdict" 2>/dev/null); VH10_R=$(cat "$sd/5_test_gate_reason" 2>/dev/null)
336
+ }
337
+ for case10 in "-" "" "garbage" "pass" "SKIP extra"; do
338
+ vh10 blocking "$case10"
339
+ case "$VH10_OUT" in *rc=0*) bad "blocking posture must exit non-zero for persisted verdict '$case10'" ;; *) ok ;; esac
340
+ [ "$VH10_V" = "BLOCK" ] && case "$VH10_R" in *missing/invalid*) true ;; *) false ;; esac && ok || bad "blocking posture must persist BLOCK + missing/invalid reason for '$case10' (got '$VH10_V' / '$VH10_R')"
341
+ vh10 advisory "$case10"
342
+ case "$VH10_OUT" in *rc=0*) ok ;; *) bad "advisory posture must not block for persisted verdict '$case10'" ;; esac
343
+ [ "$VH10_V" = "BLOCK" ] && case "$VH10_R" in *advisory*missing/invalid*) true ;; *) false ;; esac && ok || bad "advisory posture must record BLOCK (advisory) missing/invalid for '$case10' (got '$VH10_V' / '$VH10_R')"
344
+ done
345
+ vh10 blocking "-" "bogus"
346
+ case "$VH10_OUT" in *rc=0*) bad "an unrecognised in-env verdict must fail closed under blocking posture" ;; *) ok ;; esac
347
+ vh10 blocking "PASS"
348
+ case "$VH10_OUT" in *rc=0*) [ "$VH10_V" = "PASS" ] && ok || bad "a valid persisted PASS must stay PASS" ;; *) bad "a valid persisted PASS must not block" ;; esac
349
+ vh10 blocking "SKIP"
350
+ case "$VH10_OUT" in *rc=0*) [ "$VH10_V" = "SKIP" ] && ok || bad "a valid persisted SKIP must stay SKIP" ;; *) bad "a valid persisted SKIP must not block" ;; esac
291
351
  else echo "review-pr-staging-scope.test.sh: git missing, delta section skipped"; fi
292
352
  echo "review-pr-staging-scope.test.sh: passed=$PASS failed=$FAILN"
293
353
  [ "$FAILN" -eq 0 ]