forgedock 1.16.2 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- package/templates/forge.yaml.minimal +9 -0
|
@@ -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)
|
|
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
|
-
*)
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
334
|
+
disp "NOTE contract-deferred #$_inum"; exit 0
|
|
286
335
|
fi ;;
|
|
287
336
|
accepted-risk)
|
|
288
|
-
if [ -z "$SAFETY" ]; then
|
|
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
|
-
|
|
345
|
+
disp "ISSUE lineage-medium-${CONF:-UNKNOWN}"
|
|
297
346
|
else
|
|
298
|
-
|
|
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
|
-
|
|
354
|
+
disp "ISSUE safety-exemption-$SAFETY"
|
|
306
355
|
else
|
|
307
|
-
|
|
356
|
+
disp "NOTE ${SEV}-${CONF:-UNKNOWN}"
|
|
308
357
|
fi
|
|
309
358
|
exit 0
|
|
310
359
|
fi
|
|
311
360
|
|
|
312
|
-
|
|
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
|
|
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,123 @@
|
|
|
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
|
+
# ---- Verified-retry checkbox write + state-derived OPEN_SUBS (concurrent sibling close race) ----
|
|
55
|
+
WRITE_BLOCK=$(awk '/^PARENT_UPDATED=false$/{p=1} p{print} p&&/^\[ "\$PARENT_UPDATED" = true \]/{exit}' "$CLOSE")
|
|
56
|
+
DERIVE_BLOCK=$(awk '/^PARENT_FRESH=/{p=1} p{print} p&&/^OPEN_SUBS=\$\{OPEN_SUBS:-0\}$/{exit}' "$CLOSE")
|
|
57
|
+
[ -n "$WRITE_BLOCK" ] || fail "close.md C3 verified-retry write block not found"
|
|
58
|
+
[ -n "$DERIVE_BLOCK" ] || fail "close.md C3 state-derivation block not found"
|
|
59
|
+
|
|
60
|
+
# run_derive <parent body> <states "N=STATE ..."> [lost_edits]
|
|
61
|
+
# gh stub: parent body lives in a file; the first <lost_edits> edits are clobbered by a stale sibling write.
|
|
62
|
+
run_derive() {
|
|
63
|
+
local body="$1" states="$2" lost="${3:-0}" dir rendered
|
|
64
|
+
dir=$(mktemp -d)
|
|
65
|
+
printf '%s\n' "$body" >"$dir/body"; printf '%s\n' "$body" >"$dir/stale"
|
|
66
|
+
echo 0 >"$dir/edits"
|
|
67
|
+
gh() {
|
|
68
|
+
case "$1 $2" in
|
|
69
|
+
"issue view")
|
|
70
|
+
if [ "$6" = "--json" ] && [ "$7" = "body" ]; then cat "$dir/body"; return 0; fi
|
|
71
|
+
if [ "$6" = "--json" ] && [ "$7" = "state" ]; then
|
|
72
|
+
local kv; for kv in $states; do [ "${kv%%=*}" = "$3" ] && { echo "${kv#*=}"; return 0; }; done
|
|
73
|
+
return 1
|
|
74
|
+
fi ;;
|
|
75
|
+
"issue edit")
|
|
76
|
+
local n; n=$(( $(cat "$dir/edits") + 1 )); echo "$n" >"$dir/edits"
|
|
77
|
+
if [ "$n" -le "$lost" ]; then cp "$dir/stale" "$dir/body"; else printf '%s\n' "$7" >"$dir/body"; fi ;;
|
|
78
|
+
esac
|
|
79
|
+
}
|
|
80
|
+
sleep() { :; }
|
|
81
|
+
rendered=$(printf '%s\n%s\n' "$WRITE_BLOCK" "$DERIVE_BLOCK" | sed -e 's/{GH_FLAG}/-R o\/r/g' -e 's/{GH_REPO}/o\/r/g' -e 's/{NUMBER}/7/g')
|
|
82
|
+
PARENT_REF=5
|
|
83
|
+
eval "$rendered" >/dev/null 2>&1 || true
|
|
84
|
+
printf 'OPEN_SUBS=%s UPDATED=%s EDITS=%s CHECKED7=%s\n' "$OPEN_SUBS" "$PARENT_UPDATED" "$(cat "$dir/edits")" \
|
|
85
|
+
"$(grep -cE '^- \[x\] #7( |$)' "$dir/body" || true)"
|
|
86
|
+
rm -rf "$dir"; unset -f gh sleep
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
TWO=$'- [ ] #7 first\n- [ ] #8 second'
|
|
90
|
+
|
|
91
|
+
# Lost checkbox is re-applied by the retry loop (first write clobbered by a sibling).
|
|
92
|
+
OUT=$(run_derive "$TWO" "7=CLOSED 8=CLOSED" 1)
|
|
93
|
+
printf '%s\n' "$OUT" | grep -q 'CHECKED7=1' || fail "retry: checkbox not restored after a lost write ($OUT)"
|
|
94
|
+
printf '%s\n' "$OUT" | grep -q 'UPDATED=true' || fail "retry: PARENT_UPDATED not set ($OUT)"
|
|
95
|
+
printf '%s\n' "$OUT" | grep -q 'EDITS=2' || fail "retry: expected exactly 2 writes ($OUT)"
|
|
96
|
+
|
|
97
|
+
# Race: every write is lost, boxes stay unchecked, but all subs are CLOSED => 0 open (parent completes).
|
|
98
|
+
OUT=$(run_derive "$TWO" "7=CLOSED 8=CLOSED" 9)
|
|
99
|
+
printf '%s\n' "$OUT" | grep -q 'OPEN_SUBS=0' || fail "race: stale checklist with all subs CLOSED must derive 0 open ($OUT)"
|
|
100
|
+
printf '%s\n' "$OUT" | grep -q 'EDITS=3' || fail "race: retries must be bounded at 3 writes ($OUT)"
|
|
101
|
+
|
|
102
|
+
# A still-OPEN sibling keeps the parent open.
|
|
103
|
+
OUT=$(run_derive "$TWO" "7=CLOSED 8=OPEN")
|
|
104
|
+
printf '%s\n' "$OUT" | grep -q 'OPEN_SUBS=1' || fail "one open sibling must derive 1 open ($OUT)"
|
|
105
|
+
|
|
106
|
+
# State lookup failure falls back to the checklist count (fail closed, never 0 from a failed lookup).
|
|
107
|
+
OUT=$(run_derive "$TWO" "7=CLOSED")
|
|
108
|
+
printf '%s\n' "$OUT" | grep -q 'OPEN_SUBS=1' || fail "lookup failure must fall back to checklist count ($OUT)"
|
|
109
|
+
|
|
110
|
+
# Already-checked box: no write needed.
|
|
111
|
+
OUT=$(run_derive $'- [x] #7 first\n- [ ] #8 second' "7=CLOSED 8=CLOSED")
|
|
112
|
+
printf '%s\n' "$OUT" | grep -q 'EDITS=0' || fail "already checked: must not rewrite the parent ($OUT)"
|
|
113
|
+
|
|
114
|
+
# #7 must not match #70.
|
|
115
|
+
OUT=$(run_derive $'- [ ] #70 other\n- [ ] #7 first' "7=CLOSED 70=OPEN")
|
|
116
|
+
printf '%s\n' "$OUT" | grep -q 'OPEN_SUBS=1' || fail "#70 must stay open and not be confused with #7 ($OUT)"
|
|
117
|
+
printf '%s\n' "$OUT" | grep -q 'CHECKED7=1' || fail "#7 checkbox must be checked without touching #70 ($OUT)"
|
|
118
|
+
|
|
119
|
+
# A non-leading #N mention on an unchecked line is an open item, not a sub-issue ref (no fail-open close).
|
|
120
|
+
OUT=$(run_derive $'- [ ] #7 first\n- [ ] #8 second\n- [ ] Write docs (see #9)' "7=CLOSED 8=CLOSED")
|
|
121
|
+
printf '%s\n' "$OUT" | grep -q 'OPEN_SUBS=1' || fail "non-leading ref line must count as open ($OUT)"
|
|
122
|
+
|
|
123
|
+
printf 'PASS: close-parent-c3\n'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# cost-report.sh — pipeline token cost per ForgeDock version and repo (forge#3824).
|
|
6
|
+
#
|
|
7
|
+
# Reads the engine run logs (<runs-dir>/*.jsonl) and prints a markdown table, one row per
|
|
8
|
+
# (version, repo): finished-run count n, mean/median cache-read tokens per issue, mean per
|
|
9
|
+
# phase, remediated %, merged %, and cache-read tokens per merged issue.
|
|
10
|
+
#
|
|
11
|
+
# Rules:
|
|
12
|
+
# - A run counts only when finished: the last RUN_START..end slice of a log has a
|
|
13
|
+
# RUN_TERMINAL after any RUN_REOPEN / RUN_NEXT_CYCLE. A log is counted once.
|
|
14
|
+
# - Runs with no usage events, or with a hydrated (reconstructed) RUN_START, are excluded:
|
|
15
|
+
# they carry no usage and would drag the means down.
|
|
16
|
+
# - Tokens = usage.cache_read_input_tokens summed per phase over PHASE_ATTEMPT_USAGE when the
|
|
17
|
+
# phase has any (PHASE_COMMIT.usage is then cumulative, so it is not added again), else over
|
|
18
|
+
# PHASE_COMMIT and PHASE_FAILED (older logs).
|
|
19
|
+
# Per-phase means divide by n (a run that skipped a phase counts 0), so the phase
|
|
20
|
+
# columns add up to the per-issue mean. "merged" is RUN_TERMINAL.reason == "merged".
|
|
21
|
+
# - Version/repo come from RUN_START. Older logs lack them: repo becomes "unknown", and the
|
|
22
|
+
# version is estimated from the log's mtime against release times (marked with "~").
|
|
23
|
+
# - Groups with n < --min-n (default 10) are flagged low-n.
|
|
24
|
+
#
|
|
25
|
+
# Usage: bash scripts/cost-report.sh [--runs-dir DIR] [--last N] [--versions a,b] [--min-n N]
|
|
26
|
+
# [--release-times FILE]
|
|
27
|
+
# --runs-dir default $FORGE_RUNS_DIR, else ~/.forge/runs
|
|
28
|
+
# --last N only the N highest versions (unknown excluded)
|
|
29
|
+
# --versions comma list of versions (a trailing "~" in the data is ignored)
|
|
30
|
+
# --release-times file of "<version> <epoch-seconds>" lines; default: git tags v* of this repo
|
|
31
|
+
# Exit: 0 report printed (also when there are no finished runs) | 2 bad usage / missing runs dir
|
|
32
|
+
# Portable: bash 3.2, jq, POSIX stat/git.
|
|
33
|
+
|
|
34
|
+
set -u
|
|
35
|
+
|
|
36
|
+
RUNS_DIR="${FORGE_RUNS_DIR:-$HOME/.forge/runs}"
|
|
37
|
+
LAST=""
|
|
38
|
+
VERSIONS=""
|
|
39
|
+
MIN_N=10
|
|
40
|
+
REL_FILE=""
|
|
41
|
+
|
|
42
|
+
die() { echo "cost-report: $1" >&2; exit 2; }
|
|
43
|
+
is_uint() { case "$1" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }
|
|
44
|
+
|
|
45
|
+
while [ $# -gt 0 ]; do
|
|
46
|
+
case "$1" in
|
|
47
|
+
--runs-dir) [ $# -ge 2 ] || die "--runs-dir needs a value"; RUNS_DIR="$2"; shift 2 ;;
|
|
48
|
+
--last) [ $# -ge 2 ] || die "--last needs a value"; LAST="$2"; shift 2 ;;
|
|
49
|
+
--versions) [ $# -ge 2 ] || die "--versions needs a value"; VERSIONS="$2"; shift 2 ;;
|
|
50
|
+
--min-n) [ $# -ge 2 ] || die "--min-n needs a value"; MIN_N="$2"; shift 2 ;;
|
|
51
|
+
--release-times) [ $# -ge 2 ] || die "--release-times needs a value"; REL_FILE="$2"; shift 2 ;;
|
|
52
|
+
-h|--help) sed -n '2,32p' "$0"; exit 0 ;;
|
|
53
|
+
*) die "unknown argument: $1" ;;
|
|
54
|
+
esac
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
[ -d "$RUNS_DIR" ] || die "runs dir not found: $RUNS_DIR"
|
|
58
|
+
command -v jq >/dev/null 2>&1 || die "jq is required"
|
|
59
|
+
is_uint "$MIN_N" || die "--min-n must be a non-negative integer"
|
|
60
|
+
if [ -n "$LAST" ]; then is_uint "$LAST" || die "--last must be a non-negative integer"; fi
|
|
61
|
+
if [ -n "$REL_FILE" ] && [ ! -f "$REL_FILE" ]; then die "release-times file not found: $REL_FILE"; fi
|
|
62
|
+
|
|
63
|
+
# Release times: "<version> <epoch>" lines, from the file or this repo's v* tags (fail-soft: none).
|
|
64
|
+
if [ -n "$REL_FILE" ]; then
|
|
65
|
+
REL_RAW=$(cat "$REL_FILE")
|
|
66
|
+
else
|
|
67
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
68
|
+
REL_RAW=$(git -C "$ROOT" for-each-ref refs/tags --format='%(refname:short) %(creatordate:unix)' 2>/dev/null \
|
|
69
|
+
| sed -n 's/^v\([0-9][0-9.]*\) /\1 /p')
|
|
70
|
+
fi
|
|
71
|
+
REL_JSON=$(printf '%s\n' "$REL_RAW" | jq -R -s -c '
|
|
72
|
+
split("\n") | map(split(" ") | select(length == 2 and (.[1] | test("^[0-9]+$"))) | {v: .[0], t: (.[1] | tonumber)})
|
|
73
|
+
| sort_by(.t)')
|
|
74
|
+
[ -n "$REL_JSON" ] || REL_JSON='[]'
|
|
75
|
+
|
|
76
|
+
mtime_of() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
|
|
77
|
+
|
|
78
|
+
# Pass 1: one summary object per log (empty for logs that cannot be parsed or have no RUN_START).
|
|
79
|
+
ROWS=""
|
|
80
|
+
for f in "$RUNS_DIR"/*.jsonl; do
|
|
81
|
+
[ -f "$f" ] || continue
|
|
82
|
+
mt=$(mtime_of "$f")
|
|
83
|
+
row=$(jq -c -s --argjson mt "$mt" '
|
|
84
|
+
. as $all
|
|
85
|
+
| ([range(0; length) | select($all[.].event == "RUN_START")] | last) as $s
|
|
86
|
+
| if $s == null then empty else
|
|
87
|
+
$all[$s:] as $ev
|
|
88
|
+
| $ev[0] as $start
|
|
89
|
+
| ([range(0; $ev | length) | select($ev[.].event == "RUN_TERMINAL")] | last) as $t
|
|
90
|
+
| ([range(0; $ev | length) | select($ev[.].event == "RUN_REOPEN" or $ev[.].event == "RUN_NEXT_CYCLE")] | last) as $r
|
|
91
|
+
| ([$ev[] | select(.event == "PHASE_ATTEMPT_USAGE") | .phase] | unique) as $ap
|
|
92
|
+
| [$ev[] | select(((.event == "PHASE_ATTEMPT_USAGE")
|
|
93
|
+
or ((.event == "PHASE_COMMIT" or .event == "PHASE_FAILED") and ((.phase as $p | $ap | index($p)) == null)))
|
|
94
|
+
and (.usage | type) == "object")] as $u
|
|
95
|
+
| {
|
|
96
|
+
version: ($start.version // null), repo: ($start.repo // null), mt: $mt,
|
|
97
|
+
hydrated: ($start.hydrated == true),
|
|
98
|
+
finished: ($t != null and ($r == null or $t > $r)),
|
|
99
|
+
reason: (if $t == null then null else $ev[$t].reason end),
|
|
100
|
+
usage_events: ($u | length),
|
|
101
|
+
remediated: ([$ev[] | select(.event == "PHASE_COMMIT" and .phase == "remediate")] | length > 0),
|
|
102
|
+
cr: ([$u[] | .usage.cache_read_input_tokens // 0] | add // 0),
|
|
103
|
+
out: ([$u[] | .usage.output_tokens // 0] | add // 0),
|
|
104
|
+
phase: ($u | group_by(.phase) | map({key: (.[0].phase // "other"), value: ([.[] | .usage.cache_read_input_tokens // 0] | add)}) | from_entries)
|
|
105
|
+
}
|
|
106
|
+
end' "$f" 2>/dev/null) || row=""
|
|
107
|
+
[ -n "$row" ] && ROWS="$ROWS$row
|
|
108
|
+
"
|
|
109
|
+
done
|
|
110
|
+
|
|
111
|
+
printf '%s' "$ROWS" | jq -s -r \
|
|
112
|
+
--argjson rel "$REL_JSON" --argjson minn "$MIN_N" \
|
|
113
|
+
--arg last "$LAST" --arg versions "$VERSIONS" '
|
|
114
|
+
def vkey: (sub("~$"; "") | split(".") | map(tonumber? // 0));
|
|
115
|
+
def mean: if length == 0 then 0 else add / length end;
|
|
116
|
+
def median: sort | if length == 0 then 0 elif length % 2 == 1 then .[length / 2 | floor] else (.[length / 2 - 1] + .[length / 2]) / 2 end;
|
|
117
|
+
def k: ((. / 1000) | round | tostring) + "k";
|
|
118
|
+
def pct: ((. * 100) | round | tostring) + "%";
|
|
119
|
+
def ver($mt): if .version != null then .version
|
|
120
|
+
else ([$rel[] | select(.t <= $mt)] | last | if . == null then "unknown" else .v + "~" end) end;
|
|
121
|
+
|
|
122
|
+
. as $rows
|
|
123
|
+
| ($rows | map(select(.finished | not)) | length) as $unfinished
|
|
124
|
+
| ($rows | map(select(.finished and (.hydrated or .usage_events == 0))) | length) as $nousage
|
|
125
|
+
| [ $rows[] | select(.finished and (.hydrated | not) and .usage_events > 0)
|
|
126
|
+
| . as $r | $r + {ver: ($r | ver($r.mt)), repo: ($r.repo // "unknown")} ] as $runs
|
|
127
|
+
| ($versions | split(",") | map(select(length > 0))) as $vf
|
|
128
|
+
| ($runs | map(.ver | select(. != "unknown")) | unique | sort_by(vkey)) as $vers
|
|
129
|
+
| (if $last == "" then $vers else ($vers | .[(length - ($last | tonumber) | if . < 0 then 0 else . end):]) end) as $keep
|
|
130
|
+
| [ $runs[] | select((.ver | IN($keep[])) or ($last == "" and .ver == "unknown"))
|
|
131
|
+
| select($vf == [] or ((.ver | sub("~$"; "")) | IN($vf[]))) ]
|
|
132
|
+
| group_by([.ver, .repo]) | sort_by(.[0].ver | vkey) as $groups
|
|
133
|
+
| "| Version | Repo | n | Cache-read/issue mean | median | investigate | build | review | remediate | close | Remediated | Merged | Per merged issue | Confidence |",
|
|
134
|
+
"|---|---|---|---|---|---|---|---|---|---|---|---|---|---|",
|
|
135
|
+
( $groups[] | . as $g | length as $n
|
|
136
|
+
| ([.[] | select(.reason == "merged")] | length) as $m
|
|
137
|
+
| (map(.cr) | add) as $tot
|
|
138
|
+
| def pm($p): (map(.phase[$p] // 0) | add) / $n;
|
|
139
|
+
"| \($g[0].ver) | \($g[0].repo) | \($n) | \((map(.cr) | mean) | k) | \((map(.cr) | median) | k)"
|
|
140
|
+
+ " | \(pm("investigate") | k) | \(pm("build") | k) | \(pm("review") | k) | \(pm("remediate") | k) | \(pm("close") | k)"
|
|
141
|
+
+ " | \(([.[] | select(.remediated)] | length) / $n | pct) | \($m / $n | pct)"
|
|
142
|
+
+ " | \(if $m == 0 then "n/a" else ($tot / $m | k) end)"
|
|
143
|
+
+ " | \(if $n < $minn then "low-n (<\($minn))" else "ok" end) |" ),
|
|
144
|
+
"",
|
|
145
|
+
"Finished runs only; tokens are cache-read per issue. Excluded: \($unfinished) unfinished, \($nousage) without usage data (hydrated or empty). \"~\" = version estimated from log mtime; \"unknown\" = no version available."
|
|
146
|
+
'
|