forgedock 1.8.4 → 1.9.1

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 (40) hide show
  1. package/bin/engine/admission.mjs +23 -4
  2. package/bin/engine/admission.test.mjs +37 -0
  3. package/commands/orchestrate/phase-1-resolve.md +40 -24
  4. package/commands/orchestrate/phase-3-dependency.md +56 -11
  5. package/commands/orchestrate/phase-4-execution.md +359 -111
  6. package/commands/orchestrate/phase-5-cleanup.md +17 -0
  7. package/commands/orchestrate/phase-6-report.md +1 -1
  8. package/commands/quality-gate.md +80 -0
  9. package/commands/recover-orphans.md +3 -3
  10. package/commands/review-pr.md +98 -16
  11. package/commands/work-on/build/context.md +25 -4
  12. package/commands/work-on/build/validate.md +16 -9
  13. package/commands/work-on/build.md +26 -9
  14. package/commands/work-on/close.md +104 -40
  15. package/commands/work-on/decompose.md +13 -7
  16. package/commands/work-on/investigate.md +13 -7
  17. package/commands/work-on/remediate.md +15 -4
  18. package/commands/work-on/review.md +48 -19
  19. package/commands/work-on.md +62 -9
  20. package/docs/CODEX.md +12 -0
  21. package/docs/CONFIG.md +49 -1
  22. package/docs/RELEASE-NOTES.md +61 -0
  23. package/package.json +1 -1
  24. package/scripts/amplification-breaker.sh +89 -0
  25. package/scripts/amplification-breaker.test.sh +39 -0
  26. package/scripts/check-admission-parity.mjs +74 -1
  27. package/scripts/check-spec-bash.sh +120 -0
  28. package/scripts/check-spec-bash.test.sh +90 -0
  29. package/scripts/check-spec-markers.sh +67 -3
  30. package/scripts/classify-finding.sh +123 -0
  31. package/scripts/classify-finding.test.sh +77 -0
  32. package/scripts/close-dossier-no-main-commit.test.sh +139 -0
  33. package/scripts/forge-root.test.sh +84 -27
  34. package/scripts/lint-build-validate-files.sh +29 -0
  35. package/scripts/lint-build-validate-files.test.sh +25 -0
  36. package/scripts/lint-dispatch-prompt.test.sh +2 -1
  37. package/scripts/recovery-claim-live.sh +73 -0
  38. package/scripts/recovery-claim-live.test.sh +66 -0
  39. package/scripts/verify-phase-trail.sh +183 -4
  40. package/scripts/verify-phase-trail.test.sh +211 -1
@@ -13,7 +13,7 @@
13
13
  * Usage: node scripts/check-admission-parity.mjs
14
14
  */
15
15
 
16
- import { readFileSync } from "node:fs";
16
+ import { readFileSync, readdirSync } from "node:fs";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import path from "node:path";
19
19
 
@@ -78,6 +78,56 @@ function extractBashMirrorPresets(specText) {
78
78
  return presets;
79
79
  }
80
80
 
81
+ /** Recursively list *.md files under a directory. */
82
+ function listMarkdown(dir) {
83
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
84
+ const full = path.join(dir, entry.name);
85
+ if (entry.isDirectory()) return listMarkdown(full);
86
+ return entry.name.endsWith(".md") ? [full] : [];
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Collect identifiers the orchestrate specs name as admission.mjs APIs:
92
+ * (a) names destructured from an admission.mjs import snippet (`({ a, b }) =>`), and (b) backticked `name()` calls
93
+ * on any line that mentions admission.mjs. Returns [{ name, file, line }], plus
94
+ * `{ unattributed: true, file, line }` for destructure snippets that name no module.
95
+ *
96
+ * Heuristic limits: a destructure snippet is attributed to the first `engine/<name>.mjs` path found between its
97
+ * `.then(({` and the next `.then(({` (or end of text), capped at 1500 chars. A snippet naming no module in that
98
+ * window is reported as unattributable (the lint fails closed). The backtick-call rule is line-scoped: it only
99
+ * fires on lines that themselves mention admission.mjs, so `plan*()` calls in other prose are not linted.
100
+ */
101
+ function extractSpecNamedIdentifiers(file, text) {
102
+ const found = [];
103
+ const lineOf = (index) => text.slice(0, index).split("\n").length;
104
+ // The module loaded by a snippet is the first `engine/<name>.mjs` path passed after its `.then(({...}) =>`.
105
+ const destructure = /\.then\(\(\s*\{([^}]*)\}\s*\)\s*=>/g;
106
+ const starts = [...text.matchAll(destructure)].map((x) => x.index);
107
+ let m;
108
+ while ((m = destructure.exec(text))) {
109
+ const next = starts.find((s) => s > m.index) ?? text.length;
110
+ const target = text.slice(m.index, Math.min(next, m.index + 1500)).match(/engine\/([\w-]+)\.mjs/)?.[1];
111
+ if (!target) {
112
+ found.push({ unattributed: true, file, line: lineOf(m.index) });
113
+ continue;
114
+ }
115
+ if (target !== "admission") continue;
116
+ for (const part of m[1].split(",")) {
117
+ const name = part.trim().split(/\s*:\s*/)[0];
118
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) found.push({ name, file, line: lineOf(m.index) });
119
+ }
120
+ }
121
+ const lines = text.split("\n");
122
+ lines.forEach((lineText, i) => {
123
+ if (!lineText.includes("admission.mjs")) return;
124
+ for (const call of lineText.matchAll(/`([A-Za-z_$][\w$]*)\(\)`/g)) {
125
+ found.push({ name: call[1], file, line: i + 1 });
126
+ }
127
+ });
128
+ return found;
129
+ }
130
+
81
131
  async function main() {
82
132
  let admissionModule;
83
133
  try {
@@ -149,6 +199,28 @@ async function main() {
149
199
  }
150
200
  }
151
201
 
202
+ // Spec-lint: every admission.mjs identifier named in commands/orchestrate/** must be exported.
203
+ const specDir = path.join(REPO_ROOT, "commands/orchestrate");
204
+ let namedCount = 0;
205
+ for (const file of listMarkdown(specDir)) {
206
+ for (const { name, line, unattributed } of extractSpecNamedIdentifiers(file, readFileSync(file, "utf8"))) {
207
+ if (unattributed) {
208
+ console.error(
209
+ `UNATTRIBUTABLE: ${path.relative(REPO_ROOT, file)}:${line} could not attribute .then(({...}) => snippet to an engine/<module>.mjs path`,
210
+ );
211
+ failed = true;
212
+ continue;
213
+ }
214
+ namedCount++;
215
+ if (!(name in admissionModule)) {
216
+ console.error(
217
+ `MISSING EXPORT: ${path.relative(REPO_ROOT, file)}:${line} names "${name}" from admission.mjs, but it is not exported`,
218
+ );
219
+ failed = true;
220
+ }
221
+ }
222
+ }
223
+
152
224
  if (failed) {
153
225
  console.error("");
154
226
  console.error(
@@ -160,6 +232,7 @@ async function main() {
160
232
  process.exit(1);
161
233
  }
162
234
 
235
+ console.log(`OK: all ${namedCount} admission.mjs identifiers named in commands/orchestrate/** are exported`);
163
236
  console.log(
164
237
  `OK: admission.mjs CASCADE_PRESETS matches the phase-4-execution.md bash mirror for: ${mjsNames.join(", ")}`,
165
238
  );
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-spec-bash.sh — Syntax-check the ```bash fenced blocks inside Markdown spec files.
6
+ #
7
+ # ForgeDock's pipeline is bash embedded in Markdown, and the quality gate's executable
8
+ # checks skip .md files. Spec bash therefore merged without ever being parsed, and
9
+ # reviewers became the first thing to run it (2026-10-08 audit). This script runs
10
+ # `bash -n` on each fenced block after neutralising spec notation, and optionally
11
+ # `shellcheck -S error`.
12
+ #
13
+ # Usage:
14
+ # check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>...
15
+ #
16
+ # --base <ref> Only check blocks that contain a line changed relative to <ref>
17
+ # (`git diff -U0 <ref> -- <file>`), so pre-existing blocks never fail
18
+ # a new change. Without --base every block is checked.
19
+ # --shellcheck Also run `shellcheck -S error -s bash` when installed. Advisory: its hits are
20
+ # reported as WARN and never fail the run (spec blocks are often fragments).
21
+ #
22
+ # Spec notation that is not bash is neutralised before parsing:
23
+ # {PLACEHOLDER} / {FORGE_SKILL_PREFIX}x → a plain word
24
+ # blocks containing pseudo tool calls (Skill( / Agent( / Task( / Read( ) → SKIPPED
25
+ # a block whose preceding non-blank line is exactly `<!-- allowlist:check-spec-bash -->` → SKIPPED
26
+ # (for intentional fragments, e.g. an `if` opened in one block and closed in the next)
27
+ #
28
+ # Output: one line per checked block, `OK|FAIL|WARN|SKIP <file>:<start-line> <reason>`,
29
+ # then `SPEC-BASH: checked=N failed=F skipped=S shellcheck_warnings=W`. Only bash -n fails a run.
30
+ # Exit codes: 0 no failures, 1 at least one block failed, 2 usage error.
31
+ #
32
+ # Portable: bash 3.2, BSD/GNU awk and sed. Requires git only with --base.
33
+
34
+ set -u
35
+
36
+ BASE=""; SHELLCHECK=0; FILES=""
37
+ usage() { echo "ERROR: Usage: check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>..." >&2; exit 2; }
38
+
39
+ while [ "$#" -gt 0 ]; do
40
+ case "$1" in
41
+ --base) [ "$#" -ge 2 ] || usage; BASE="$2"; shift 2 ;;
42
+ --shellcheck) SHELLCHECK=1; shift ;;
43
+ --*) usage ;;
44
+ *) FILES="$FILES
45
+ $1"; shift ;;
46
+ esac
47
+ done
48
+ [ -n "$FILES" ] || usage
49
+
50
+ TMPD=$(mktemp -d "${TMPDIR:-/tmp}/check-spec-bash.XXXXXX") || exit 2
51
+ trap 'rm -rf "$TMPD"' EXIT
52
+
53
+ checked=0; failed=0; skipped=0; warned=0
54
+
55
+ changed_lines() { # changed_lines <file> → one new-side line number per line
56
+ git diff -U0 "$BASE" -- "$1" 2>/dev/null | awk '
57
+ /^@@/ { split($3, a, ","); s = substr(a[1], 2) + 0; n = (a[2] == "") ? 1 : a[2] + 0
58
+ for (i = 0; i < n; i++) print s + i }'
59
+ }
60
+
61
+ while IFS= read -r f; do
62
+ [ -n "$f" ] || continue
63
+ case "$f" in *.md) ;; *) continue ;; esac
64
+ [ -f "$f" ] || { echo "SKIP $f missing"; continue; }
65
+
66
+ CH="$TMPD/changed"; : > "$CH"
67
+ if [ -n "$BASE" ]; then
68
+ changed_lines "$f" > "$CH"
69
+ [ -s "$CH" ] || continue
70
+ fi
71
+
72
+ # Split the file into one file per ```bash block, named by its opening line number.
73
+ rm -f "$TMPD"/blk.*
74
+ awk -v d="$TMPD" '
75
+ !inb && /^[ \t]*```(bash|sh)[ \t]*$/ { inb = 1; start = NR; out = d "/blk." NR; printf "" > out
76
+ if (prev ~ /^[ \t]*<!--[ \t]*allowlist:check-spec-bash[ \t]*-->[ \t]*$/) print "#ALLOWLISTED" > out
77
+ next }
78
+ inb && /^[ \t]*```[ \t]*$/ { inb = 0; close(out); print start "\t" NR > (d "/ranges"); next }
79
+ inb { print > out; next }
80
+ { if ($0 !~ /^[ \t]*$/) prev = $0 }
81
+ ' "$f"
82
+ [ -f "$TMPD/ranges" ] || continue
83
+
84
+ while IFS="$(printf '\t')" read -r start end; do
85
+ blk="$TMPD/blk.$start"
86
+ if [ -n "$BASE" ]; then
87
+ hit=$(awk -v s="$start" -v e="$end" '$1 >= s && $1 <= e { print "y"; exit }' "$CH")
88
+ [ "$hit" = "y" ] || continue
89
+ fi
90
+ if head -1 "$blk" | grep -q '^#ALLOWLISTED$'; then
91
+ skipped=$((skipped + 1)); echo "SKIP $f:$start allowlisted fragment"; continue
92
+ fi
93
+ if grep -Eq '(^|[^A-Za-z_])(Skill|Agent|Task|Read)\(' "$blk"; then
94
+ skipped=$((skipped + 1)); echo "SKIP $f:$start pseudo tool call"; continue
95
+ fi
96
+ # Neutralise {PLACEHOLDER} notation (and {FORGE_SKILL_PREFIX}name) into a variable expansion,
97
+ # so tests like [ -z "{X}" ] are not misread as constant strings.
98
+ sed -E 's/\{[A-Z][A-Z0-9_]*\}/${PH}/g' "$blk" > "$blk.sh"
99
+ checked=$((checked + 1))
100
+ if ! err=$(bash -n "$blk.sh" 2>&1); then
101
+ failed=$((failed + 1))
102
+ echo "FAIL $f:$start bash -n: $(printf '%s' "$err" | head -1 | sed "s|$blk.sh|block|")"
103
+ continue
104
+ fi
105
+ if [ "$SHELLCHECK" = 1 ] && command -v shellcheck >/dev/null 2>&1; then
106
+ if ! err=$(shellcheck -S error -s bash "$blk.sh" 2>&1); then
107
+ warned=$((warned + 1))
108
+ echo "WARN $f:$start shellcheck: $(printf '%s' "$err" | grep -m1 -E 'SC[0-9]+' || printf '%s' "$err" | head -1)"
109
+ continue
110
+ fi
111
+ fi
112
+ echo "OK $f:$start"
113
+ done < "$TMPD/ranges"
114
+ rm -f "$TMPD/ranges"
115
+ done <<EOF
116
+ $FILES
117
+ EOF
118
+
119
+ echo "SPEC-BASH: checked=$checked failed=$failed skipped=$skipped shellcheck_warnings=$warned"
120
+ [ "$failed" -eq 0 ]
@@ -0,0 +1,90 @@
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/check-spec-bash.sh. Run: bash scripts/check-spec-bash.test.sh (bash 3.2 compatible)
6
+
7
+ set -u
8
+ DIR=$(cd "$(dirname "$0")" && pwd)
9
+ S="$DIR/check-spec-bash.sh"
10
+ T=$(mktemp -d "${TMPDIR:-/tmp}/check-spec-bash-test.XXXXXX")
11
+ trap 'rm -rf "$T"' EXIT
12
+ pass=0; fail=0
13
+ FENCE='```'
14
+
15
+ check() { # check <want_rc> <want_substring> <desc> -- args...
16
+ want_rc="$1"; want="$2"; desc="$3"; shift 4
17
+ out=$(cd "$T" && bash "$S" "$@" 2>&1); rc=$?
18
+ case "$out" in *"$want"*) ok=1 ;; *) ok=0 ;; esac
19
+ if [ "$rc" = "$want_rc" ] && [ "$ok" = 1 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (rc=$rc)"; printf '%s\n' "$out" | sed 's/^/ /'; fi
20
+ }
21
+
22
+ cat > "$T/good.md" <<EOF
23
+ # Good
24
+ ${FENCE}bash
25
+ if [ -z "{GH_REPO}" ]; then echo none; fi
26
+ gh issue view {NUMBER} -R {GH_REPO} --json labels
27
+ ${FENCE}
28
+ Prose with it's apostrophe and an unbalanced ( paren.
29
+ EOF
30
+
31
+ cat > "$T/bad.md" <<EOF
32
+ # Bad
33
+ ${FENCE}bash
34
+ if [ -n "\$X" ]; then
35
+ echo "unterminated
36
+ fi
37
+ ${FENCE}
38
+ EOF
39
+
40
+ cat > "$T/pseudo.md" <<EOF
41
+ ${FENCE}bash
42
+ OUT=\$(Skill(skill="x", args="y"))
43
+ ${FENCE}
44
+ EOF
45
+
46
+ cat > "$T/allow.md" <<EOF
47
+ <!-- allowlist:check-spec-bash -->
48
+ ${FENCE}bash
49
+ if true; then
50
+ ${FENCE}
51
+ EOF
52
+
53
+ cat > "$T/prose-mention.md" <<EOF
54
+ Use \`<!-- allowlist:check-spec-bash -->\` before an intentional fragment.
55
+ ${FENCE}bash
56
+ if true; then
57
+ ${FENCE}
58
+ EOF
59
+
60
+ cat > "$T/notbash.md" <<EOF
61
+ ${FENCE}yaml
62
+ key: [unclosed
63
+ ${FENCE}
64
+ EOF
65
+
66
+ check 0 "OK good.md:2" "valid block with placeholders passes" -- good.md
67
+ check 1 "FAIL bad.md:2 bash -n" "unterminated quote fails" -- bad.md
68
+ check 0 "SKIP pseudo.md:1 pseudo tool" "pseudo tool call skipped" -- pseudo.md
69
+ check 0 "SKIP allow.md:2 allowlisted" "allowlisted fragment skipped" -- allow.md
70
+ check 0 "checked=0 failed=0" "non-bash fence ignored" -- notbash.md
71
+ check 1 "FAIL prose-mention.md:2" "prose mention of the marker does not allowlist" -- prose-mention.md
72
+ check 1 "checked=2 failed=1 skipped=1" "multi-file summary" -- good.md bad.md pseudo.md
73
+ check 2 "" "no files is a usage error" --
74
+ check 2 "" "unknown flag" -- --bogus good.md
75
+
76
+ # --base: only blocks touching changed lines are checked, so pre-existing breakage never fails a change.
77
+ ( cd "$T" && git init -q . && git -c user.email=t@t -c user.name=t add bad.md good.md && git -c user.email=t@t -c user.name=t commit -q -m base )
78
+ check 0 "checked=0 failed=0" "--base with no change checks nothing" -- --base HEAD bad.md good.md
79
+ printf '\nnew prose line\n' >> "$T/bad.md"
80
+ check 0 "checked=0 failed=0" "--base: prose change outside a broken block passes" -- --base HEAD bad.md
81
+ cat >> "$T/good.md" <<EOF
82
+ ${FENCE}bash
83
+ for x in a b; do
84
+ echo "\$x"
85
+ ${FENCE}
86
+ EOF
87
+ check 1 "FAIL good.md:" "--base: newly added broken block fails" -- --base HEAD good.md
88
+
89
+ echo "check-spec-bash.test.sh: passed=$pass failed=$fail"
90
+ [ "$fail" -eq 0 ]
@@ -131,6 +131,7 @@ MARKER_REGISTRY="
131
131
  MEMORY_INDEXED
132
132
  MILESTONE_INDEX
133
133
  MODEL_TIER_NOTE
134
+ NOTE_DISPOSITION
134
135
  ORPHAN_ESCALATED
135
136
  ORPHAN_RECOVERED
136
137
  PATTERN
@@ -138,6 +139,8 @@ MARKER_REGISTRY="
138
139
  PHASE_COMPLETE
139
140
  PHASE_TRAIL_ERROR
140
141
  PHASE_TRAIL_FAILED
142
+ PHASE_TRAIL_OVERRIDE
143
+ PHASE_TRAIL_OVERRIDE_APPLIED
141
144
  PHASE_TRAIL_RELEASED
142
145
  PLAN_DAG
143
146
  PRIOR_DECISIONS
@@ -150,6 +153,7 @@ MARKER_REGISTRY="
150
153
  RECOVERY_CLAIM
151
154
  RECOVERY_CLAIM_RELEASED
152
155
  REMEDIATION
156
+ REREVIEW_DISPATCHED
153
157
  REREVIEW_SKIPPED
154
158
  REVIEW
155
159
  REVIEWER
@@ -289,9 +293,69 @@ fi
289
293
  # ---------------------------------------------------------------------------
290
294
 
291
295
  P4_SPEC="$COMMANDS_DIR/orchestrate/phase-4-execution.md"
292
- if [ -f "$P4_SPEC" ] && grep -qE 'ABSENT\) +\[ "\$\(reverify_merged_trail "\$PRED"[^)]*\)" = "OK" \] *&& *echo "DONE"' "$P4_SPEC"; then
293
- echo "HIGH | $P4_SPEC | merged + needs-human + ABSENT classifies DONE via the verifier (must fail closed to GATED)" >&2
294
- VIOLATIONS=$((VIOLATIONS + 1))
296
+ if [ -f "$P4_SPEC" ]; then
297
+ # ABSENT arm of classify_predecessor_state() (scoped to that function body): text from a line-start "ABSENT)"
298
+ # inside that function body (an ABSENT) arm elsewhere is ignored) up to the first ";;", whitespace-collapsed so one-line and multi-line
299
+ # forms reduce to the same span.
300
+ ABSENT_ARM=$(awk '
301
+ /^classify_predecessor_state\(\)[[:space:]]*\{/ { infn = 1; next }
302
+ infn && !inarm && /^\}/ { infn = 0 }
303
+ infn && !inarm && /^[[:space:]]*ABSENT\)/ { inarm = 1 }
304
+ inarm {
305
+ line = $0
306
+ # Comments are not code: skip full-line comments and drop a trailing
307
+ # whitespace-preceded "#..." so comment text cannot satisfy or perturb
308
+ # the assertions below (not quote-aware; the arm text has no quoted "#").
309
+ if (line ~ /^[[:space:]]*#/) next
310
+ sub(/[[:space:]]+#.*$/, "", line)
311
+ idx = index(line, ";;")
312
+ if (idx > 0) { buf = buf " " substr(line, 1, idx - 1); exit }
313
+ buf = buf " " line
314
+ }
315
+ END { print buf }
316
+ ' "$P4_SPEC" | tr -s '[:space:]' ' ' || true)
317
+ # WIRE:PROVEN — manual mutation in a temp copy of commands/: renamed hold_merged_trail, renamed the ABSENT arm, ABSENT->echo DONE, and moved the comment before add-label; each fired its matching HIGH violation; comment-only GATED fires HIGH, comment-only DONE/reverify_merged_trail does not, comment-only gh issue comment before add-label does not count, real arm still passes; a decoy earlier ABSENT) arm outside classify_predecessor_state does not change the result, removing the function declaration fires ABSENT arm missing, and add-label=needs-human / single-quoted forms are accepted
318
+ if [ -z "${ABSENT_ARM// /}" ]; then
319
+ echo "HIGH | $P4_SPEC | ABSENT arm missing from classify_predecessor_state (label-only hold must be classified explicitly)" >&2
320
+ VIOLATIONS=$((VIOLATIONS + 1))
321
+ else
322
+ if [[ "$ABSENT_ARM" == *reverify_merged_trail* ]] || [[ "$ABSENT_ARM" == *DONE* ]]; then
323
+ echo "HIGH | $P4_SPEC | merged + needs-human + ABSENT classifies DONE / calls the verifier (must fail closed to GATED)" >&2
324
+ VIOLATIONS=$((VIOLATIONS + 1))
325
+ fi
326
+ if ! [[ "$ABSENT_ARM" =~ (echo|printf)[^\;]*GATED ]]; then
327
+ echo "HIGH | $P4_SPEC | ABSENT arm does not resolve to GATED" >&2
328
+ VIOLATIONS=$((VIOLATIONS + 1))
329
+ fi
330
+ fi
331
+
332
+ # hold_merged_trail() (forge#3223): the needs-human label must be added
333
+ # before the FORGE:PHASE_TRAIL_FAILED comment is posted, so a comment can
334
+ # never exist without the label (comment-without-label reads as a release).
335
+ HOLD_ORDER=$(awk '
336
+ /^hold_merged_trail\(\)[[:space:]]*\{/ { infn = 1; found = 1; next }
337
+ infn && /^\}/ { infn = 0 }
338
+ infn {
339
+ line = $0
340
+ if (line ~ /^[[:space:]]*#/) next
341
+ sub(/[[:space:]]+#.*$/, "", line)
342
+ if (!cmt && (line ~ /gh issue comment/ || line ~ /FORGE:PHASE_TRAIL_FAILED/)) cmt = NR
343
+ if (!lbl && line ~ /add-label(=|[[:space:]]+)[\047"]?needs-human/) lbl = NR
344
+ }
345
+ END { printf "%d %d %d\n", found + 0, cmt + 0, lbl + 0 }
346
+ ' "$P4_SPEC" || true)
347
+ set -- $HOLD_ORDER
348
+ HOLD_FOUND="${1:-0}"; HOLD_CMT="${2:-0}"; HOLD_LBL="${3:-0}"
349
+ if [ "$HOLD_FOUND" -eq 0 ]; then
350
+ echo "HIGH | $P4_SPEC | hold_merged_trail() function missing" >&2
351
+ VIOLATIONS=$((VIOLATIONS + 1))
352
+ elif [ "$HOLD_CMT" -eq 0 ] || [ "$HOLD_LBL" -eq 0 ]; then
353
+ echo "HIGH | $P4_SPEC | hold_merged_trail() lacks the PHASE_TRAIL_FAILED comment or the needs-human add-label" >&2
354
+ VIOLATIONS=$((VIOLATIONS + 1))
355
+ elif [ "$HOLD_LBL" -ge "$HOLD_CMT" ]; then
356
+ echo "HIGH | $P4_SPEC | hold_merged_trail() posts the PHASE_TRAIL_FAILED comment before adding needs-human (label must come first)" >&2
357
+ VIOLATIONS=$((VIOLATIONS + 1))
358
+ fi
295
359
  fi
296
360
 
297
361
  # ---------------------------------------------------------------------------
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # classify-finding.sh — Deterministic review-pr §6B.5 note disposition.
6
+ # Decides whether one deduped review finding is filed
7
+ # as a standalone `review-finding` issue (ISSUE) or
8
+ # handled as a non-blocking note (NOTE).
9
+ #
10
+ # Usage:
11
+ # classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>]
12
+ # [--lineage none|review-finding] [--text <T> | --text-file <F>]
13
+ #
14
+ # --severity CRITICAL | HIGH | MEDIUM | LOW (case-insensitive). Missing or
15
+ # unparseable → ISSUE (fail toward filing, never drop).
16
+ # --confidence CONFIRMED | LIKELY | POSSIBLE (case-insensitive). Missing is
17
+ # treated as not-POSSIBLE.
18
+ # --agent The `**Agent**:` value of the finding (e.g. "Security", "Auth").
19
+ # --lineage `review-finding` when the PR's linked issue (MERGE_ISSUE) carries
20
+ # the `review-finding` label — i.e. this PR is itself a fix for a
21
+ # review finding. Default `none`.
22
+ # --text/--text-file Finding title + body + affected file paths, used for the
23
+ # content-based safety exemption.
24
+ #
25
+ # Output (stdout, one line): `ISSUE <reason>` or `NOTE <reason>`.
26
+ # Exit codes: 0 classified, 2 usage error.
27
+ #
28
+ # Rules (forge#3060, tightened after the 2026-10-08 cascade audit):
29
+ # 1. HIGH/CRITICAL, or unparseable severity → ISSUE.
30
+ # 2. Safety exemption is CONTENT-based: the text matches the security/billing
31
+ # keyword set, or the finding came from a dedicated, signal-selected domain
32
+ # agent (Auth, Billing, Concurrency, Database) and is MEDIUM+ or CONFIRMED. Origin from the always-on
33
+ # "General Security & Quality" agent alone NO LONGER exempts a finding —
34
+ # that agent runs on every PR, so origin-based exemption filed nearly every
35
+ # LOW note it raised (50 of 60 would-be notes in the audited batch).
36
+ # 3. Review-finding lineage (a fix for a finding): only MEDIUM findings that are
37
+ # CONFIRMED, or LIKELY and safety-exempt, become issues. LOW is always a
38
+ # NOTE — a fix for a finding must not mint a new generation of findings.
39
+ # 4. Otherwise: LOW, or POSSIBLE below HIGH, is a NOTE unless safety-exempt.
40
+ #
41
+ # NOTEs are never discarded silently: review-pr §6B.5 fixes them in-PR, lists
42
+ # them in the PR body, or records a drop reason in the review summary.
43
+ #
44
+ # Portable: bash 3.2 (macOS /bin/bash), BSD and GNU grep/tr. No network.
45
+
46
+ set -u
47
+
48
+ SEVERITY=""
49
+ CONFIDENCE=""
50
+ AGENT=""
51
+ LINEAGE="none"
52
+ TEXT=""
53
+
54
+ usage() {
55
+ echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>]" >&2
56
+ exit 2
57
+ }
58
+
59
+ while [ "$#" -gt 0 ]; do
60
+ case "$1" in
61
+ --severity) [ "$#" -ge 2 ] || usage; SEVERITY="$2"; shift 2 ;;
62
+ --confidence) [ "$#" -ge 2 ] || usage; CONFIDENCE="$2"; shift 2 ;;
63
+ --agent) [ "$#" -ge 2 ] || usage; AGENT="$2"; shift 2 ;;
64
+ --lineage) [ "$#" -ge 2 ] || usage; LINEAGE="$2"; shift 2 ;;
65
+ --text) [ "$#" -ge 2 ] || usage; TEXT="$2"; shift 2 ;;
66
+ --text-file)
67
+ [ "$#" -ge 2 ] || usage
68
+ [ -r "$2" ] || { echo "ERROR: --text-file not readable: $2" >&2; exit 2; }
69
+ TEXT=$(cat "$2"); shift 2 ;;
70
+ *) usage ;;
71
+ esac
72
+ done
73
+
74
+ case "$LINEAGE" in
75
+ none|review-finding) ;;
76
+ *) echo "ERROR: --lineage must be 'none' or 'review-finding' (got '$LINEAGE')" >&2; exit 2 ;;
77
+ esac
78
+
79
+ upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
80
+ SEV=$(upper "$SEVERITY")
81
+ CONF=$(upper "$CONFIDENCE")
82
+
83
+ case "$SEV" in
84
+ CRITICAL|HIGH) echo "ISSUE severity-$SEV"; exit 0 ;;
85
+ MEDIUM|LOW) ;;
86
+ *) echo "ISSUE unparseable-severity"; exit 0 ;;
87
+ esac
88
+
89
+ # Safety exemption. Keywords match whole words, where `_`, `-`, `/` and other
90
+ # punctuation separate words (so `auth_service` matches, `author` does not).
91
+ SAFETY=""
92
+ KW='security|auth|authz|authn|billing|payment|stripe|charge|invoice|injection|xss|csrf|ssrf|idor|secret|secrets|credential|credentials|permission|permissions|sql|token|password|redact'
93
+ if printf '%s' "$TEXT" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' ' ' | grep -Eqw "($KW)"; then
94
+ SAFETY="keyword"
95
+ fi
96
+ # A dedicated domain agent rescues a finding only when it is MEDIUM+ or CONFIRMED:
97
+ # a LOW/POSSIBLE finding stays a NOTE whichever reviewer raised it.
98
+ AGENT_LC=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
99
+ case "$AGENT_LC" in
100
+ auth*|billing*|concurrency*|database*)
101
+ if [ "$SEV" = "MEDIUM" ] || [ "$CONF" = "CONFIRMED" ]; then SAFETY="${SAFETY:-domain-agent}"; fi ;;
102
+ esac
103
+
104
+ if [ "$LINEAGE" = "review-finding" ]; then
105
+ if [ "$SEV" = "MEDIUM" ] && { [ "$CONF" = "CONFIRMED" ] || { [ -n "$SAFETY" ] && [ "$CONF" = "LIKELY" ]; }; }; then
106
+ echo "ISSUE lineage-medium-${CONF:-UNKNOWN}"
107
+ else
108
+ echo "NOTE lineage-${SEV}-${CONF:-UNKNOWN}"
109
+ fi
110
+ exit 0
111
+ fi
112
+
113
+ if [ "$SEV" = "LOW" ] || [ "$CONF" = "POSSIBLE" ]; then
114
+ if [ -n "$SAFETY" ]; then
115
+ echo "ISSUE safety-exemption-$SAFETY"
116
+ else
117
+ echo "NOTE ${SEV}-${CONF:-UNKNOWN}"
118
+ fi
119
+ exit 0
120
+ fi
121
+
122
+ echo "ISSUE ${SEV}-${CONF:-UNKNOWN}"
123
+ exit 0
@@ -0,0 +1,77 @@
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/classify-finding.sh (review-pr §6B.5 note disposition).
6
+ # Run: bash scripts/classify-finding.test.sh (bash 3.2 compatible)
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ S="$DIR/classify-finding.sh"
11
+ pass=0; fail=0
12
+
13
+ expect() { # expect <ISSUE|NOTE> <description> -- args...
14
+ want="$1"; desc="$2"; shift 3
15
+ got=$(bash "$S" "$@" 2>/dev/null | cut -d' ' -f1)
16
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want $want, got '${got}')"; fi
17
+ }
18
+ expect_exit() { # expect_exit <code> <description> -- args...
19
+ want="$1"; desc="$2"; shift 3
20
+ bash "$S" "$@" >/dev/null 2>&1; got=$?
21
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want exit $want, got $got)"; fi
22
+ }
23
+
24
+ # --- Rule 1: high severity and unparseable severity always file ---
25
+ expect ISSUE "CRITICAL files" -- --severity CRITICAL --confidence POSSIBLE
26
+ expect ISSUE "HIGH POSSIBLE files" -- --severity HIGH --confidence POSSIBLE
27
+ expect ISSUE "HIGH on lineage files" -- --severity HIGH --confidence LIKELY --lineage review-finding
28
+ expect ISSUE "missing severity files" -- --severity "" --confidence LIKELY
29
+ expect ISSUE "unknown severity files" -- --severity INFO
30
+ expect ISSUE "lowercase high files" -- --severity high
31
+
32
+ # --- Rule 4: non-lineage ---
33
+ expect ISSUE "MEDIUM CONFIRMED files" -- --severity MEDIUM --confidence CONFIRMED --text "loop exits early"
34
+ expect ISSUE "MEDIUM LIKELY files" -- --severity MEDIUM --confidence LIKELY --text "loop exits early"
35
+ expect NOTE "MEDIUM POSSIBLE no-safety note" -- --severity MEDIUM --confidence POSSIBLE --text "loop exits early"
36
+ expect NOTE "LOW CONFIRMED no-safety note" -- --severity LOW --confidence CONFIRMED --text "stale comment wording"
37
+ expect NOTE "LOW from Security agent, no keyword, is a NOTE (audit fix)" -- --severity LOW --confidence CONFIRMED --agent "Security" --text "comment wording drifted"
38
+ expect NOTE "LOW from General Security & Quality, no keyword" -- --severity LOW --confidence LIKELY --agent "General Security & Quality" --text "duplicated predicate in NOTE count"
39
+
40
+ # --- Safety exemption (content-based) ---
41
+ expect ISSUE "LOW with injection keyword" -- --severity LOW --confidence POSSIBLE --text "shell injection via unquoted var"
42
+ expect ISSUE "LOW with auth_service path" -- --severity LOW --confidence LIKELY --text "src/auth_service.py:12 missing check"
43
+ expect ISSUE "LOW with Secrets (case)" -- --severity LOW --confidence LIKELY --text "Secrets leak to log"
44
+ expect ISSUE "LOW with permission-check" -- --severity LOW --confidence LIKELY --text "permission-check skipped"
45
+ expect NOTE "'author' is not 'auth'" -- --severity LOW --confidence LIKELY --text "author field unused"
46
+ expect NOTE "'tokenizer' is not 'token'" -- --severity LOW --confidence LIKELY --text "tokenizer splits oddly"
47
+ expect ISSUE "LOW CONFIRMED from Auth domain agent" -- --severity LOW --confidence CONFIRMED --agent "Auth" --text "nit"
48
+ expect ISSUE "MEDIUM POSSIBLE from Billing agent" -- --severity MEDIUM --confidence POSSIBLE --agent "billing" --text "nit"
49
+ expect NOTE "LOW LIKELY from Auth domain agent is a NOTE" -- --severity LOW --confidence LIKELY --agent "Auth" --text "nit"
50
+ expect NOTE "LOW POSSIBLE from Database agent is a NOTE" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "nit"
51
+ expect NOTE "LOW POSSIBLE Concurrency reviewer is a NOTE (AlterLab #34671)" -- --severity LOW --confidence POSSIBLE --agent "Concurrency reviewer" --text "lock edge cases"
52
+ expect ISSUE "LOW CONFIRMED Concurrency reviewer files" -- --severity LOW --confidence CONFIRMED --agent "Concurrency reviewer" --text "lock edge cases"
53
+ expect ISSUE "keyword still rescues LOW POSSIBLE from domain agent" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "sql injection"
54
+
55
+ # --- Rule 3: review-finding lineage (fix for a finding) ---
56
+ expect NOTE "lineage LOW CONFIRMED note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "nit"
57
+ expect NOTE "lineage LOW security keyword still note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "injection nit"
58
+ expect ISSUE "lineage MEDIUM CONFIRMED files" -- --severity MEDIUM --confidence CONFIRMED --lineage review-finding --text "logic bug"
59
+ expect NOTE "lineage MEDIUM LIKELY no-safety note" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "logic bug"
60
+ expect ISSUE "lineage MEDIUM LIKELY with keyword files" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "token leaks"
61
+ expect NOTE "lineage MEDIUM POSSIBLE with keyword note" -- --severity MEDIUM --confidence POSSIBLE --lineage review-finding --text "token leaks"
62
+
63
+ # --- text-file input ---
64
+ TMPF=$(mktemp "${TMPDIR:-/tmp}/classify-finding-test.XXXXXX")
65
+ printf 'Title: unchecked xss sink\n' > "$TMPF"
66
+ expect ISSUE "text-file keyword" -- --severity LOW --confidence LIKELY --text-file "$TMPF"
67
+ rm -f "$TMPF"
68
+
69
+ # --- usage errors ---
70
+ expect_exit 2 "unknown flag" -- --bogus x
71
+ expect_exit 2 "bad lineage" -- --severity LOW --lineage P3
72
+ expect_exit 2 "missing flag value" -- --severity
73
+ expect_exit 2 "unreadable text-file" -- --severity LOW --text-file /nonexistent/x
74
+ expect_exit 0 "valid call exits 0" -- --severity LOW
75
+
76
+ echo "classify-finding.test.sh: passed=$pass failed=$fail"
77
+ [ "$fail" -eq 0 ]