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
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/cap-findings.sh (review-pr §6B.5 / review-pr-staging §7B.5 per-review cap).
6
+ # Run: bash scripts/cap-findings.test.sh (bash 3.2 compatible)
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ S="$DIR/cap-findings.sh"
11
+ pass=0; fail=0
12
+ T=$(printf '\t')
13
+
14
+ # rows <id:sev:conf:disp[:agent]>... -> TSV on stdout (disp may use ~ for space)
15
+ rows() {
16
+ local spec id sev conf disp agent
17
+ for spec in "$@"; do
18
+ IFS=: read -r id sev conf disp agent <<EOF2
19
+ $spec
20
+ EOF2
21
+ disp=$(printf '%s' "$disp" | tr '~' ' ')
22
+ printf '%s\t%s\t%s\t%s\t%s\n' "$id" "$sev" "$conf" "$disp" "$agent"
23
+ done
24
+ }
25
+
26
+ # expect <description> <expected "id=CLASS ..."> <script args> -- <row specs>
27
+ # Compares the first word of each output row's disposition, in input order, plus the CAPPED trailer.
28
+ expect() {
29
+ local desc="$1" want="$2"; shift 2
30
+ local args=()
31
+ while [ "$1" != "--" ]; do args+=("$1"); shift; done
32
+ shift
33
+ local got
34
+ got=$(rows "$@" | bash "$S" ${args[@]+"${args[@]}"} 2>/dev/null | awk -F'\t' 'NF >= 2 { split($2, w, " "); printf "%s=%s%s ", $1, w[1], (w[2] == "capped" ? "(capped)" : "") } /^CAPPED=/ { printf "%s", $0 }')
35
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc"; echo " want: $want"; echo " got: $got"; fi
36
+ }
37
+ expect_exit() { # expect_exit <code> <description> -- args...
38
+ local want="$1" desc="$2"; shift 3
39
+ bash "$S" "$@" </dev/null >/dev/null 2>&1; local got=$?
40
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want exit $want, got $got)"; fi
41
+ }
42
+
43
+ # --- Default cap is 2; ranking by severity, then confidence ---
44
+ expect "under the cap: nothing capped" "a=ISSUE b=ISSUE CAPPED=0" -- \
45
+ a:MEDIUM:LIKELY:ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x
46
+ expect "overflow becomes NOTE capped, lowest rank loses" "a=NOTE(capped) b=ISSUE c=ISSUE CAPPED=1" -- \
47
+ a:MEDIUM:POSSIBLE:ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x
48
+ expect "ranks by confidence CONFIRMED > LIKELY > POSSIBLE" "a=NOTE(capped) b=ISSUE c=ISSUE d=NOTE(capped) CAPPED=2" -- \
49
+ a:MEDIUM:POSSIBLE:ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x d:MEDIUM:POSSIBLE:ISSUE~x
50
+ expect "MEDIUM outranks LOW even at lower confidence" "a=NOTE(capped) b=ISSUE c=ISSUE CAPPED=1" -- \
51
+ a:LOW:CONFIRMED:ISSUE~x b:MEDIUM:POSSIBLE:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x
52
+ expect "ties broken by safety-domain agent" "a=NOTE(capped) b=NOTE(capped) c=ISSUE d=ISSUE CAPPED=2" -- \
53
+ a:MEDIUM:LIKELY:ISSUE~x:General b:MEDIUM:LIKELY:ISSUE~x:General c:MEDIUM:LIKELY:ISSUE~x:Auth d:MEDIUM:LIKELY:ISSUE~x:Concurrency_reviewer
54
+ expect "remaining ties broken by input order (stable)" "a=ISSUE b=ISSUE c=NOTE(capped) CAPPED=1" -- \
55
+ a:MEDIUM:LIKELY:ISSUE~x b:MEDIUM:LIKELY:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x
56
+
57
+ # --- HIGH/CRITICAL are never capped and do not consume the default budget ---
58
+ expect "HIGH and CRITICAL never capped past the cap" "a=ISSUE b=ISSUE c=ISSUE d=ISSUE e=ISSUE CAPPED=0" -- \
59
+ a:HIGH:POSSIBLE:ISSUE~x b:CRITICAL:LIKELY:ISSUE~x c:HIGH:CONFIRMED:ISSUE~x d:MEDIUM:CONFIRMED:ISSUE~x e:MEDIUM:LIKELY:ISSUE~x
60
+ expect "HIGH does not consume the default budget" "a=ISSUE b=ISSUE c=ISSUE d=NOTE(capped) CAPPED=1" -- \
61
+ a:HIGH:LIKELY:ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x d:MEDIUM:POSSIBLE:ISSUE~x
62
+ expect "lowercase high is never capped" "a=ISSUE b=NOTE(capped) CAPPED=1" --cap 0 -- \
63
+ a:high:POSSIBLE:ISSUE~x b:medium:LIKELY:ISSUE~x
64
+
65
+ # --- Unparseable severity/confidence ranks as MEDIUM/LIKELY and IS capped ---
66
+ expect "unparseable severity is capped like MEDIUM" "a=NOTE(capped) b=ISSUE c=ISSUE CAPPED=1" -- \
67
+ a:INFO:POSSIBLE:ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x
68
+ expect "missing severity and confidence rank MEDIUM/LIKELY (above POSSIBLE, below CONFIRMED)" "a=ISSUE b=NOTE(capped) c=ISSUE CAPPED=1" -- \
69
+ a:MEDIUM:CONFIRMED:ISSUE~x b:MEDIUM:POSSIBLE:ISSUE~x c::ISSUE~x
70
+ expect "unparseable severity cannot displace a ranked MEDIUM CONFIRMED when cap is 1" "a=NOTE(capped) b=ISSUE CAPPED=1" --cap 1 -- \
71
+ a::ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x
72
+
73
+ # --- Lineage review-finding: default 1, filed HIGH+ consumes the budget ---
74
+ expect "lineage default 1 keeps only the best" "a=NOTE(capped) b=ISSUE c=NOTE(capped) CAPPED=2" --lineage review-finding -- \
75
+ a:MEDIUM:LIKELY:ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x
76
+ expect "lineage: a filed HIGH consumes the budget, all MEDIUM capped" "a=ISSUE b=NOTE(capped) c=NOTE(capped) CAPPED=2" --lineage review-finding -- \
77
+ a:HIGH:LIKELY:ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x
78
+ expect "lineage: CRITICAL still never capped" "a=ISSUE b=ISSUE CAPPED=0" --lineage review-finding -- \
79
+ a:CRITICAL:CONFIRMED:ISSUE~x b:HIGH:CONFIRMED:ISSUE~x
80
+ expect "lineage: explicit cap 3 with one HIGH keeps two MEDIUM" "a=ISSUE b=ISSUE c=ISSUE d=NOTE(capped) CAPPED=1" --lineage review-finding --cap 3 -- \
81
+ a:HIGH:LIKELY:ISSUE~x b:MEDIUM:CONFIRMED:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x d:MEDIUM:POSSIBLE:ISSUE~x
82
+
83
+ # --- Explicit cap, 0, invalid cap ---
84
+ expect "explicit cap 3 overrides default 2" "a=ISSUE b=ISSUE c=ISSUE CAPPED=0" --cap 3 -- \
85
+ a:MEDIUM:LIKELY:ISSUE~x b:MEDIUM:LIKELY:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x
86
+ expect "cap 0 caps every cappable row, never HIGH" "a=NOTE(capped) b=ISSUE c=NOTE(capped) CAPPED=2" --cap 0 -- \
87
+ a:MEDIUM:CONFIRMED:ISSUE~x b:HIGH:LIKELY:ISSUE~x c:LOW:CONFIRMED:ISSUE~x
88
+ expect "invalid cap falls back to default 2" "a=ISSUE b=ISSUE c=NOTE(capped) CAPPED=1" --cap abc -- \
89
+ a:MEDIUM:CONFIRMED:ISSUE~x b:MEDIUM:LIKELY:ISSUE~x c:MEDIUM:POSSIBLE:ISSUE~x
90
+ expect "negative cap falls back to default 2" "a=ISSUE b=ISSUE c=NOTE(capped) CAPPED=1" --cap -1 -- \
91
+ a:MEDIUM:CONFIRMED:ISSUE~x b:MEDIUM:LIKELY:ISSUE~x c:MEDIUM:POSSIBLE:ISSUE~x
92
+ expect "invalid cap under lineage falls back to 1" "a=ISSUE b=NOTE(capped) CAPPED=1" --lineage review-finding --cap x -- \
93
+ a:MEDIUM:CONFIRMED:ISSUE~x b:MEDIUM:LIKELY:ISSUE~x
94
+
95
+ # --- Pass-through rows never consume the cap ---
96
+ expect "NOTE and INPR_FIX rows pass through" "a=NOTE b=INPR_FIX c=ISSUE d=ISSUE e=NOTE(capped) CAPPED=1" -- \
97
+ a:MEDIUM:LIKELY:NOTE~reflag-note b:MEDIUM:CONFIRMED:INPR_FIX~in-pr c:MEDIUM:CONFIRMED:ISSUE~x d:MEDIUM:LIKELY:ISSUE~x e:MEDIUM:POSSIBLE:ISSUE~x
98
+ expect "empty input prints only the trailer" "CAPPED=0" --
99
+
100
+ # --- Reason text and original rows survive ---
101
+ out=$(rows "a:MEDIUM:CONFIRMED:ISSUE~medium~confirmed" | bash "$S" 2>/dev/null | head -n 1)
102
+ if [ "$out" = "a${T}ISSUE medium confirmed" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: kept row keeps its classifier reason (got '$out')"; fi
103
+ out=$(rows "a:MEDIUM:CONFIRMED:NOTE~stale" | bash "$S" 2>/dev/null | head -n 1)
104
+ if [ "$out" = "a${T}NOTE stale" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: NOTE row passes through verbatim (got '$out')"; fi
105
+
106
+ # --- Severity text noise never demotes HIGH/CRITICAL (classifier strips whitespace and uppercases) ---
107
+ expect "whitespace/markdown HIGH still uncapped at cap 0" "a=ISSUE b=ISSUE c=ISSUE d=NOTE(capped) CAPPED=1" --cap 0 -- \
108
+ "a:HIGH :CONFIRMED:ISSUE~severity-HIGH" 'b:**HIGH**:CONFIRMED:ISSUE~severity-HIGH' c:High:LIKELY:ISSUE~severity-HIGH d:MEDIUM:LIKELY:ISSUE~x
109
+ expect "fix-efficacy ISSUE rows are never capped (#3857 exemption survives the cap)" "a=ISSUE b=ISSUE c=NOTE(capped) CAPPED=1" --cap 1 -- \
110
+ a:MEDIUM:LIKELY:ISSUE~fix-efficacy-fails b:MEDIUM:CONFIRMED:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x
111
+ expect "lineage: fix-efficacy beats an unrelated CONFIRMED MEDIUM and consumes the budget" "a=NOTE(capped) b=ISSUE CAPPED=1" --lineage review-finding -- \
112
+ a:MEDIUM:CONFIRMED:ISSUE~lineage-medium-CONFIRMED b:MEDIUM:LIKELY:ISSUE~fix-efficacy-regresses
113
+ expect "classifier severity-HIGH/CRITICAL reason is never capped" "a=ISSUE b=ISSUE CAPPED=0" --cap 0 -- \
114
+ "a:weird:CONFIRMED:ISSUE~severity-HIGH" "b:weird:CONFIRMED:ISSUE~severity-CRITICAL"
115
+ expect "lineage budget counts noisy HIGH" "a=ISSUE b=NOTE(capped) CAPPED=1" --cap 1 --lineage review-finding -- \
116
+ "a:HIGH :CONFIRMED:ISSUE~severity-HIGH" b:MEDIUM:CONFIRMED:ISSUE~x
117
+
118
+ # --- --input file and usage errors ---
119
+ tmp=$(mktemp)
120
+ rows a:MEDIUM:LIKELY:ISSUE~x b:MEDIUM:LIKELY:ISSUE~x c:MEDIUM:LIKELY:ISSUE~x > "$tmp"
121
+ got=$(bash "$S" --input "$tmp" 2>/dev/null | tail -n 1)
122
+ rm -f "$tmp"
123
+ if [ "$got" = "CAPPED=1" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: --input file (got '$got')"; fi
124
+ expect_exit 2 "unknown arg is a usage error" -- --bogus
125
+ expect_exit 2 "bad lineage is a usage error" -- --lineage sideways
126
+ expect_exit 2 "missing --input file is a usage error" -- --input /nonexistent/cap-findings-rows
127
+ expect_exit 0 "empty stdin exits 0" --
128
+
129
+ echo "cap-findings.test.sh: $pass passed, $fail failed"
130
+ [ "$fail" -eq 0 ]
@@ -165,12 +165,14 @@ MARKER_REGISTRY="
165
165
  REMEDIATION
166
166
  REMEDIATION_HELD
167
167
  REMEDIATION_DEFERRED
168
+ REMEDIATION_DISPATCH
168
169
  REREVIEW_DISPATCHED
169
170
  REREVIEW_RELEASED
170
171
  REREVIEW_SKIPPED
171
172
  REVIEW
172
173
  REVIEWER
173
174
  REVIEW_ROUTE
175
+ REVIEW_TIER
174
176
  REVIEW_STARTED
175
177
  SECURITY_AUDIT
176
178
  SIGNAL_RESOLVED
@@ -11,6 +11,7 @@
11
11
  # classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>]
12
12
  # [--lineage none|review-finding] [--text <T> | --text-file <F>]
13
13
  # [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]]
14
+ # [--fix-efficacy fails|regresses] [--fixes-issue <N>]
14
15
  # [--prior-findings <F> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]]
15
16
  #
16
17
  # --severity CRITICAL | HIGH | MEDIUM | LOW (case-insensitive). Missing or
@@ -21,6 +22,14 @@
21
22
  # --lineage `review-finding` when the PR's linked issue (MERGE_ISSUE) carries
22
23
  # the `review-finding` label — i.e. this PR is itself a fix for a
23
24
  # review finding. Default `none`.
25
+ # --fix-efficacy fails|regresses The finding says the PR's linked issue is not fixed (`fails`) or
26
+ # that the PR re-introduces its defect (`regresses`). A MEDIUM finding so marked is
27
+ # `ISSUE fix-efficacy-<value>` under any lineage and confidence (the cascade damper must
28
+ # not merge a known-broken fix). Without the flag the tag is read from `--text`
29
+ # (a line consisting only of `FIX_EFFICACY: fails|regresses` or `**Fix efficacy**: fails|regresses`;
30
+ # a mid-sentence mention never counts). Opt-in only: never keyword-based. Runs after the INPR_FIX and re-flag gates and
31
+ # before the contract-scope and lineage gates; LOW stays NOTE; HIGH/CRITICAL unchanged.
32
+ # --fixes-issue <N> The PR's linked issue; alias of `--merge-issue` (same variable).
24
33
  # --text/--text-file Finding title + body + affected file paths, used for the
25
34
  # content-based safety exemption.
26
35
  # --inpr-diff <F> --file <P> In-PR fix gate (#3387, narrowed): F is a file listing the
@@ -100,6 +109,7 @@ CONTRACT_SCOPE=""
100
109
  CONTRACT_OPEN=""
101
110
  PR_FILES=""
102
111
  MERGE_ISSUE=""
112
+ FIX_EFF=""
103
113
  PRIOR_FINDINGS=""
104
114
  TITLE=""
105
115
  LINE_START=""
@@ -107,7 +117,7 @@ LINE_END=""
107
117
  DIFF_MAP=""
108
118
 
109
119
  usage() {
110
- echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>] [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]] [--prior-findings <F> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]]" >&2
120
+ echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>] [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]] [--fix-efficacy fails|regresses] [--fixes-issue <N>] [--prior-findings <F> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]]" >&2
111
121
  exit 2
112
122
  }
113
123
 
@@ -124,6 +134,8 @@ while [ "$#" -gt 0 ]; do
124
134
  --contract-open) [ "$#" -ge 2 ] || usage; CONTRACT_OPEN="$2"; shift 2 ;;
125
135
  --pr-files) [ "$#" -ge 2 ] || usage; PR_FILES="$2"; shift 2 ;;
126
136
  --merge-issue) [ "$#" -ge 2 ] || usage; MERGE_ISSUE="${2#\#}"; shift 2 ;;
137
+ --fixes-issue) [ "$#" -ge 2 ] || usage; MERGE_ISSUE="${2#\#}"; shift 2 ;;
138
+ --fix-efficacy) [ "$#" -ge 2 ] || usage; FIX_EFF=$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]'); shift 2 ;;
127
139
  --prior-findings) [ "$#" -ge 2 ] || usage; PRIOR_FINDINGS="$2"; shift 2 ;;
128
140
  --title) [ "$#" -ge 2 ] || usage; TITLE="$2"; shift 2 ;;
129
141
  --line-start) [ "$#" -ge 2 ] || usage; LINE_START="$2"; shift 2 ;;
@@ -142,6 +154,11 @@ case "$LINEAGE" in
142
154
  *) echo "ERROR: --lineage must be 'none' or 'review-finding' (got '$LINEAGE')" >&2; exit 2 ;;
143
155
  esac
144
156
 
157
+ case "$FIX_EFF" in
158
+ ''|fails|regresses) ;;
159
+ *) echo "ERROR: --fix-efficacy must be 'fails' or 'regresses' (got '$FIX_EFF')" >&2; exit 2 ;;
160
+ esac
161
+
145
162
  if [ -n "$INPR_DIFF" ] && [ ! -r "$INPR_DIFF" ]; then
146
163
  echo "ERROR: --inpr-diff not readable: $INPR_DIFF" >&2; exit 2
147
164
  fi
@@ -165,10 +182,32 @@ upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
165
182
  SEV=$(upper "$SEVERITY")
166
183
  CONF=$(upper "$CONFIDENCE")
167
184
 
185
+ # Telemetry (forge#3834): one best-effort record per finding, then the unchanged stdout line.
186
+ _TEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/telemetry.sh"; [ -f "$_TEL" ] && . "$_TEL" 2>/dev/null
187
+ type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
188
+ disp() { # "<DISPOSITION> <reason>"
189
+ forge_emit finding_disposition severity="$SEV" confidence="${CONF:-UNKNOWN}" agent="${AGENT:-none}" disposition="${1%% *}" reason="${1#* }"
190
+ echo "$1"
191
+ }
192
+
193
+ # In-PR fix gate predicate (#3387, widened to HIGH/CRITICAL in #3592): CONFIRMED, linked to a
194
+ # --inpr-diff file list, and the finding's file is one of those files. Fails toward ISSUE.
195
+ inpr_match() {
196
+ [ -n "$INPR_DIFF" ] && [ -n "$FINDING_FILE" ] && [ "$CONF" = "CONFIRMED" ] || return 1
197
+ _path="${FINDING_FILE#./}"; _path="${_path%%:*}"
198
+ grep -Fxq -- "$_path" "$INPR_DIFF"
199
+ }
200
+
168
201
  case "$SEV" in
169
- CRITICAL|HIGH) echo "ISSUE severity-$SEV"; exit 0 ;;
202
+ CRITICAL|HIGH)
203
+ # HIGH/CRITICAL skip the re-flag and safety gates below, so the in-PR check runs here, before
204
+ # the exit. Everything else stays ISSUE: a HIGH is never demoted to NOTE.
205
+ if inpr_match; then
206
+ disp "INPR_FIX $(printf '%s' "$SEV" | tr '[:upper:]' '[:lower:]')-confirmed-in-diff"; exit 0
207
+ fi
208
+ disp "ISSUE severity-$SEV"; exit 0 ;;
170
209
  MEDIUM|LOW) ;;
171
- *) echo "ISSUE unparseable-severity"; exit 0 ;;
210
+ *) disp "ISSUE unparseable-severity"; exit 0 ;;
172
211
  esac
173
212
 
174
213
  # Cross-head / cross-PR re-flag gate (#3641). Runs after the severity parse so HIGH/CRITICAL and
@@ -236,17 +275,27 @@ if [ -n "$PRIOR_FINDINGS" ] && [ -n "$FINDING_FILE" ]; then
236
275
  }
237
276
  END { if (!found && note) print note }
238
277
  ' "$PRIOR_FINDINGS" 2>/dev/null) || _reflag=""
239
- if [ -n "$_reflag" ]; then echo "$_reflag"; exit 0; fi
278
+ if [ -n "$_reflag" ]; then disp "$_reflag"; exit 0; fi
240
279
  fi
241
280
 
242
281
  # In-PR fix gate (#3387, narrowed). MEDIUM CONFIRMED is an ISSUE under every rule below
243
282
  # (lineage included), so checking here changes only where it is handled, never whether.
244
- if [ -n "$INPR_DIFF" ] && [ -n "$FINDING_FILE" ] && [ "$SEV" = "MEDIUM" ] && [ "$CONF" = "CONFIRMED" ]; then
245
- _path="${FINDING_FILE#./}"; _path="${_path%%:*}"
246
- if grep -Fxq -- "$_path" "$INPR_DIFF"; then
247
- echo "INPR_FIX medium-confirmed-in-diff"
248
- exit 0
283
+ # HIGH/CRITICAL were handled in the severity case above.
284
+ if [ "$SEV" = "MEDIUM" ] && inpr_match; then
285
+ disp "INPR_FIX medium-confirmed-in-diff"
286
+ exit 0
287
+ fi
288
+
289
+ # Fix-efficacy exemption: a MEDIUM finding saying the PR's linked issue is not fixed, or that the PR
290
+ # re-introduces its defect, is never damped (any lineage, any confidence, no contract demotion).
291
+ # Opt-in on an explicit flag or a line-anchored tag only; never keyword-based (a bare "regression test for #N" must not match).
292
+ if [ "$SEV" = "MEDIUM" ]; then
293
+ if [ -z "$FIX_EFF" ]; then
294
+ FIX_EFF=$(printf '%s' "$TEXT" | tr '[:upper:]' '[:lower:]' \
295
+ | grep -Eo '^[[:space:]]*(fix_efficacy:|\*\*fix efficacy\*\*:)[[:space:]]*(fails|regresses)[[:space:]]*$' | head -n 1 \
296
+ | grep -Eo '(fails|regresses)$') || FIX_EFF=""
249
297
  fi
298
+ if [ -n "$FIX_EFF" ]; then disp "ISSUE fix-efficacy-$FIX_EFF"; exit 0; fi
250
299
  fi
251
300
 
252
301
  # Safety exemption. Keywords match whole words, where `_`, `-`, `/` and other
@@ -282,10 +331,10 @@ if [ -n "$CONTRACT_SCOPE" ] && [ -n "$FINDING_FILE" ]; then
282
331
  deferred)
283
332
  if [ -z "$SAFETY" ] && [ -n "$_inum" ] && [ "$_inum" != "$MERGE_ISSUE" ] \
284
333
  && [ -n "$CONTRACT_OPEN" ] && grep -Fxq -- "$_inum" "$CONTRACT_OPEN"; then
285
- echo "NOTE contract-deferred #$_inum"; exit 0
334
+ disp "NOTE contract-deferred #$_inum"; exit 0
286
335
  fi ;;
287
336
  accepted-risk)
288
- if [ -z "$SAFETY" ]; then echo "NOTE contract-accepted-risk"; exit 0; fi ;;
337
+ if [ -z "$SAFETY" ]; then disp "NOTE contract-accepted-risk"; exit 0; fi ;;
289
338
  esac
290
339
  done < "$CONTRACT_SCOPE"
291
340
  fi
@@ -293,21 +342,21 @@ fi
293
342
 
294
343
  if [ "$LINEAGE" = "review-finding" ]; then
295
344
  if [ "$SEV" = "MEDIUM" ] && { [ "$CONF" = "CONFIRMED" ] || { [ -n "$SAFETY" ] && [ "$CONF" = "LIKELY" ]; }; }; then
296
- echo "ISSUE lineage-medium-${CONF:-UNKNOWN}"
345
+ disp "ISSUE lineage-medium-${CONF:-UNKNOWN}"
297
346
  else
298
- echo "NOTE lineage-${SEV}-${CONF:-UNKNOWN}"
347
+ disp "NOTE lineage-${SEV}-${CONF:-UNKNOWN}"
299
348
  fi
300
349
  exit 0
301
350
  fi
302
351
 
303
352
  if [ "$SEV" = "LOW" ] || [ "$CONF" = "POSSIBLE" ]; then
304
353
  if [ -n "$SAFETY" ]; then
305
- echo "ISSUE safety-exemption-$SAFETY"
354
+ disp "ISSUE safety-exemption-$SAFETY"
306
355
  else
307
- echo "NOTE ${SEV}-${CONF:-UNKNOWN}"
356
+ disp "NOTE ${SEV}-${CONF:-UNKNOWN}"
308
357
  fi
309
358
  exit 0
310
359
  fi
311
360
 
312
- echo "ISSUE ${SEV}-${CONF:-UNKNOWN}"
361
+ disp "ISSUE ${SEV}-${CONF:-UNKNOWN}"
313
362
  exit 0
@@ -68,6 +68,35 @@ expect NOTE "lineage MEDIUM LIKELY no-safety note" -- --severity MEDIUM --confi
68
68
  expect ISSUE "lineage MEDIUM LIKELY with keyword files" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "token leaks"
69
69
  expect NOTE "lineage MEDIUM POSSIBLE with keyword note" -- --severity MEDIUM --confidence POSSIBLE --lineage review-finding --text "token leaks"
70
70
 
71
+ # --- Fix-efficacy exemption (#3693): #3683 SEC-1 shape ---
72
+ SEC1="enqueue_breaker_decision helper never declared in the fences that call it; fresh shell never queues the breaker decision"
73
+ LN=(--severity MEDIUM --confidence LIKELY --lineage review-finding --agent Security --text "$SEC1")
74
+ expect NOTE "SEC-1 untagged stays damped" -- "${LN[@]}"
75
+ expect ISSUE "SEC-1 --fix-efficacy fails" -- "${LN[@]}" --fix-efficacy fails --fixes-issue 3621
76
+ expect ISSUE "SEC-1 --fix-efficacy regresses" -- "${LN[@]}" --fix-efficacy regresses
77
+ expect ISSUE "SEC-1 POSSIBLE with fix-efficacy" -- --severity MEDIUM --confidence POSSIBLE --lineage review-finding --text "x" --fix-efficacy fails
78
+ expect ISSUE "text tag FIX_EFFICACY fallback" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "$(printf 'helper missing.\nFIX_EFFICACY: fails')"
79
+ expect ISSUE "bold Fix efficacy line fallback" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "**Fix efficacy**: regresses"
80
+ expect NOTE "mid-line FIX_EFFICACY token stays NOTE" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "helper missing. FIX_EFFICACY: fails"
81
+ expect NOTE "#N + still-fails phrase no longer opts in" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "this still fails to fix #3621 in a fresh shell" --fixes-issue 3621
82
+ expect NOTE "regression test for #N stays NOTE" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "Missing regression test for #3621" --fixes-issue 3621
83
+ expect NOTE "no regression #N stays NOTE" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "no regression #3621, but wording is off" --fixes-issue 3621
84
+ expect NOTE "reintroduce-style phrase stays NOTE" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "could reintroduce #3621 naming" --fixes-issue 3621
85
+ expect NOTE "LOW + fix-efficacy stays NOTE" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "x" --fix-efficacy fails
86
+ expect ISSUE "non-lineage MEDIUM POSSIBLE + efficacy" -- --severity MEDIUM --confidence POSSIBLE --text "x" --fix-efficacy fails
87
+ expect ISSUE "HIGH unchanged" -- --severity HIGH --confidence LIKELY --lineage review-finding --fix-efficacy fails
88
+ expect_exit 2 "bad --fix-efficacy value" -- --severity MEDIUM --fix-efficacy broken
89
+ FX_DIFF=$(mktemp "${TMPDIR:-/tmp}/classify-finding-fx.XXXXXX"); printf 'src/a.sh\n' > "$FX_DIFF"
90
+ expect INPR_FIX "CONFIRMED in-PR efficacy stays INPR_FIX" -- --severity MEDIUM --confidence CONFIRMED --lineage review-finding --fix-efficacy fails --inpr-diff "$FX_DIFF" --file src/a.sh
91
+ rm -f "$FX_DIFF"
92
+ FX_SCOPE=$(mktemp "${TMPDIR:-/tmp}/classify-finding-fxs.XXXXXX"); FX_OPEN=$(mktemp "${TMPDIR:-/tmp}/classify-finding-fxo.XXXXXX")
93
+ printf 'deferred\tsrc/b.sh\t3446\n' > "$FX_SCOPE"; printf '3446\n' > "$FX_OPEN"
94
+ expect NOTE "contract-deferred untagged demotes" -- --severity MEDIUM --confidence LIKELY --text "x" --file src/b.sh --contract-scope "$FX_SCOPE" --contract-open "$FX_OPEN"
95
+ expect ISSUE "contract-deferred + efficacy files" -- --severity MEDIUM --confidence LIKELY --text "x" --file src/b.sh --contract-scope "$FX_SCOPE" --contract-open "$FX_OPEN" --fix-efficacy fails
96
+ rm -f "$FX_SCOPE" "$FX_OPEN"
97
+ _a=$(bash "$S" --severity MEDIUM --lineage review-finding --text "x" --fix-efficacy fails --fixes-issue 5); _b=$(bash "$S" --severity MEDIUM --lineage review-finding --text "x" --fix-efficacy fails --merge-issue 5)
98
+ if [ "$_a" = "$_b" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: --fixes-issue/--merge-issue equivalence"; fi
99
+
71
100
  # --- text-file input ---
72
101
  TMPF=$(mktemp "${TMPDIR:-/tmp}/classify-finding-test.XXXXXX")
73
102
  printf 'Title: unchecked xss sink\n' > "$TMPF"
@@ -83,7 +112,20 @@ expect INPR_FIX "./ prefix matches" -- --severity MEDIUM --con
83
112
  expect INPR_FIX "lineage PR still fixes in-PR" -- --severity MEDIUM --confidence CONFIRMED --lineage review-finding --inpr-diff "$DIFF" --file src/api/jobs.py
84
113
  expect ISSUE "MEDIUM CONFIRMED outside diff files" -- --severity MEDIUM --confidence CONFIRMED --inpr-diff "$DIFF" --file src/api/other.py
85
114
  expect ISSUE "MEDIUM LIKELY in diff is not gated" -- --severity MEDIUM --confidence LIKELY --inpr-diff "$DIFF" --file src/api/jobs.py
86
- expect ISSUE "HIGH in diff stays ISSUE (7B blocks it)" -- --severity HIGH --confidence CONFIRMED --inpr-diff "$DIFF" --file src/api/jobs.py
115
+ expect INPR_FIX "HIGH CONFIRMED in diff (#3592)" -- --severity HIGH --confidence CONFIRMED --inpr-diff "$DIFF" --file src/api/jobs.py
116
+ expect INPR_FIX "CRITICAL CONFIRMED in diff (#3592)" -- --severity CRITICAL --confidence CONFIRMED --inpr-diff "$DIFF" --file "src/api/jobs.py:9"
117
+ expect INPR_FIX "HIGH in diff with safety keyword" -- --severity HIGH --confidence CONFIRMED --title "billing refund leg unreachable" --inpr-diff "$DIFF" --file src/api/jobs.py
118
+ expect ISSUE "HIGH LIKELY in diff stays ISSUE" -- --severity HIGH --confidence LIKELY --inpr-diff "$DIFF" --file src/api/jobs.py
119
+ expect ISSUE "CRITICAL LIKELY in diff stays ISSUE" -- --severity CRITICAL --confidence LIKELY --inpr-diff "$DIFF" --file src/api/jobs.py
120
+ expect ISSUE "HIGH CONFIRMED outside diff stays ISSUE" -- --severity HIGH --confidence CONFIRMED --inpr-diff "$DIFF" --file src/api/other.py
121
+ expect ISSUE "CRITICAL CONFIRMED outside diff" -- --severity CRITICAL --confidence CONFIRMED --inpr-diff "$DIFF" --file src/api/other.py
122
+ expect ISSUE "HIGH CONFIRMED without --inpr-diff" -- --severity HIGH --confidence CONFIRMED --file src/api/jobs.py
123
+ expect ISSUE "HIGH CONFIRMED without --file" -- --severity HIGH --confidence CONFIRMED --inpr-diff "$DIFF"
124
+ for _s in HIGH CRITICAL MEDIUM; do
125
+ _want="INPR_FIX $(printf '%s' "$_s" | tr '[:upper:]' '[:lower:]')-confirmed-in-diff"
126
+ _got=$(bash "$S" --severity "$_s" --confidence CONFIRMED --inpr-diff "$DIFF" --file src/api/jobs.py 2>/dev/null)
127
+ if [ "$_got" = "$_want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: reason carries severity $_s (want '$_want', got '$_got')"; fi
128
+ done
87
129
  expect NOTE "LOW in diff stays NOTE" -- --severity LOW --confidence CONFIRMED --inpr-diff "$DIFF" --file src/api/jobs.py
88
130
  expect ISSUE "no --inpr-diff: unchanged behaviour" -- --severity MEDIUM --confidence CONFIRMED --file src/api/jobs.py
89
131
  expect ISSUE "no --file: unchanged behaviour" -- --severity MEDIUM --confidence CONFIRMED --inpr-diff "$DIFF"
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # classify-grep-exit.sh <rc> <stderr> <target> — is a grep exit >= 2 a malformed check, or a real failure?
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Exit 0 + reason on stdout = malformed (a usage/regex error, a check defect, never a verdict on the code).
7
+ # Exit 1 = real verdict (exit 1 and exit 2 both fail the check). Never executes the target; it only reads its text.
8
+ # Wording covers GNU grep and BSD/macOS regcomp (`brackets ([ ]) not balanced`, `repetition-operator operand invalid`, ...).
9
+ # Fail closed <!-- Added: forge#3802 -->: malformed only when stderr POSITIVELY names a regex/option usage error.
10
+ # A target that suppresses stderr (`2>/dev/null`, `-s`) leaves it empty, so a missing/unreadable/looping operand
11
+ # file can never hide as malformed. Unknown or empty stderr is a real failure.
12
+ RC="${1:-0}"; ERR="${2:-}"; TARGET="${3:-}"
13
+ case "$RC" in ''|*[!0-9]*) exit 1 ;; esac
14
+ [ "$RC" -ge 2 ] || exit 1
15
+ case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) ;; *) exit 1 ;; esac
16
+ if printf '%s' "$ERR" | LC_ALL=C grep -qiE 'Unmatched|Invalid (regular expression|preceding|back reference|range|character class|content of)|unrecognized option|invalid option|option requires an argument|Usage:|trailing backslash|character class syntax|conflicting matchers|error at position|mismatched|invalid escape|not balanced|operand invalid|empty \(sub\)expression|invalid repetition count|empty \(sub\)|illegal repetition|repetition-operator'; then
17
+ echo "grep usage error (exit $RC)"
18
+ exit 0
19
+ fi
20
+ exit 1
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # classify-grep-exit.test.sh — verdicts of scripts/classify-grep-exit.sh (forge#3802). Runs real grep, no network.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ CLS="$SCRIPT_DIR/classify-grep-exit.sh"
8
+ PASS=0; FAILN=0
9
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
11
+ TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
12
+ cd "$TMP" || exit 1
13
+ echo hello > present.txt
14
+ ln -s loop1 loop2; ln -s loop2 loop1
15
+
16
+ # run_target <target>: execute like B6.5 does, then classify. Prints "malformed" or "real".
17
+ verdict() {
18
+ local t="$1" err rc
19
+ err=$(eval "$t" 2>&1 >/dev/null); rc=$?
20
+ if bash "$CLS" "$rc" "$err" "$t" >/dev/null; then echo malformed; else echo real; fi
21
+ }
22
+ expect() { local got; got=$(verdict "$2"); if [ "$got" = "$3" ]; then ok "$1"; else bad "$1 (got $got, want $3)"; fi; }
23
+
24
+ expect "missing file, plain" "grep -qF -e x nofile.md" real
25
+ expect "missing file, 2>/dev/null" "grep -qF -e x nofile.md 2>/dev/null" real
26
+ expect "missing file, -s" "grep -sqF -e x nofile.md" real
27
+ expect "missing file, --no-messages" "grep --no-messages -qF -e x nofile.md" real
28
+ expect "symlink loop, stderr suppressed" "grep -qF -e x loop1 2>/dev/null" real
29
+ expect "directory operand, stderr suppressed" "grep -qF -e x . 2>/dev/null" real
30
+ expect "unmatched bracket regex" "grep -qE '[abc' present.txt" malformed
31
+ expect "unmatched paren regex" "grep -qE '(abc' present.txt" malformed
32
+ expect "pattern read as option" "grep -qE '--no-such-flag' present.txt" malformed
33
+ expect "option-looking pattern" "grep -qE '-z foo' present.txt" malformed
34
+ expect "non-grep target" "bash -c 'exit 2'" real
35
+ for _w in "grep: brackets ([ ]) not balanced" "grep: parentheses not balanced" "grep: braces not balanced" "grep: repetition-operator operand invalid" "grep: empty (sub)expression" "grep: invalid repetition count(s)"; do
36
+ bash "$CLS" 2 "$_w" "grep -E x f" >/dev/null && ok "BSD wording malformed: $_w" || bad "BSD wording malformed: $_w"
37
+ done
38
+ [ "$(bash "$CLS" 2 "grep: Unmatched [" "grep -q x f")" = "grep usage error (exit 2)" ] && ok "reason text" || bad "reason text"
39
+ bash "$CLS" 2 "grep: Unmatched [" "ls -l" >/dev/null && bad "non-grep with usage stderr is not malformed" || ok "non-grep with usage stderr is not malformed"
40
+ bash "$CLS" 1 "grep: Unmatched [" "grep -q x f" >/dev/null && bad "rc 1 is not malformed" || ok "rc 1 is not malformed"
41
+ bash "$CLS" 0 "" "grep -q x f" >/dev/null && bad "rc 0 is not malformed" || ok "rc 0 is not malformed"
42
+ bash "$CLS" 2 "" "grep -q x f" >/dev/null && bad "rc 2 empty stderr is a real failure" || ok "rc 2 empty stderr is a real failure"
43
+ bash "$CLS" 2 "" >/dev/null && bad "missing args fail closed" || ok "missing args fail closed"
44
+
45
+ echo "passed=$PASS failed=$FAILN"
46
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ # close-parent-c3.test.sh — Phase C3 (close.md) behaviour for decomposition.close_parent:
3
+ # the last child of a CLOSED parent posts "All sub-issues complete" and sets merged
4
+ # without closing or reopening; an OPEN parent is auto-closed as before.
5
+ #
6
+ # Usage: bash scripts/close-parent-c3.test.sh
7
+
8
+ set -euo pipefail
9
+
10
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
11
+ CLOSE="$ROOT/commands/work-on/close.md"
12
+
13
+ # The all-children-checked-off branch of C3.
14
+ BLOCK=$(awk '/^if \[ "\$OPEN_SUBS" -eq 0 \]; then/{p=1} p{print} p&&/^fi$/{exit}' "$CLOSE")
15
+ [ -n "$BLOCK" ] || { printf 'FAIL: close.md C3 completion block not found\n' >&2; exit 1; }
16
+ grep -q 'close_parent' "$CLOSE" || { printf 'FAIL: close.md does not mention close_parent\n' >&2; exit 1; }
17
+
18
+ run_case() {
19
+ local parent_state="$1" already_posted="$2" log
20
+ log=$(mktemp)
21
+ gh() {
22
+ case "$1 $2" in
23
+ "issue view") echo "$parent_state" ;;
24
+ "api repos/"*) echo "$already_posted" ;;
25
+ *) echo "gh $*" >>"$log" ;;
26
+ esac
27
+ }
28
+ set_workflow_state() { echo "set_workflow_state $*" >>"$log"; }
29
+ # Render the spec placeholders.
30
+ local rendered
31
+ rendered=$(printf '%s\n' "$BLOCK" | sed -e 's/{GH_FLAG}/-R o\/r/g' -e 's/{GH_REPO}/o\/r/g' -e 's/{NUMBER}/7/g')
32
+ OPEN_SUBS=0 PARENT_REF=5 PR_NUMBER=9 PARENT_CLOSED=false
33
+ eval "$rendered"
34
+ cat "$log"; rm -f "$log"
35
+ }
36
+
37
+ fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
38
+
39
+ OUT=$(run_case CLOSED 0)
40
+ printf '%s\n' "$OUT" | grep -q 'gh issue comment 5 .*All sub-issues complete' || fail "closed parent: completion comment missing"
41
+ printf '%s\n' "$OUT" | grep -q 'set_workflow_state 5 merged' || fail "closed parent: merged not set"
42
+ printf '%s\n' "$OUT" | grep -q 'gh issue close' && fail "closed parent: must not be closed again"
43
+ printf '%s\n' "$OUT" | grep -qE 'reopen|--state open' && fail "closed parent: must not be reopened"
44
+
45
+ OUT=$(run_case CLOSED 1)
46
+ printf '%s\n' "$OUT" | grep -q 'gh issue comment' && fail "closed parent: completion comment must not repeat"
47
+ printf '%s\n' "$OUT" | grep -q 'set_workflow_state 5 merged' || fail "closed parent resume: merged not set"
48
+
49
+ OUT=$(run_case OPEN 0)
50
+ printf '%s\n' "$OUT" | grep -q 'gh issue close 5 .*All sub-issues complete' || fail "open parent: must be auto-closed"
51
+ printf '%s\n' "$OUT" | grep -q 'set_workflow_state 5 merged' || fail "open parent: merged not set"
52
+ printf '%s\n' "$OUT" | grep -q 'gh issue comment' && fail "open parent: no separate comment expected"
53
+
54
+ printf 'PASS: close-parent-c3\n'