forgedock 1.8.3 → 1.9.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/admission.mjs +23 -4
- package/bin/engine/admission.test.mjs +37 -0
- package/commands/orchestrate/phase-1-resolve.md +40 -24
- package/commands/orchestrate/phase-3-dependency.md +56 -11
- package/commands/orchestrate/phase-4-execution.md +351 -111
- package/commands/orchestrate/phase-5-cleanup.md +17 -0
- package/commands/orchestrate/phase-6-report.md +1 -1
- package/commands/quality-gate.md +51 -0
- package/commands/recover-orphans.md +3 -3
- package/commands/review-pr.md +92 -19
- package/commands/work-on/build/context.md +25 -4
- package/commands/work-on/build/validate.md +1 -0
- package/commands/work-on/build.md +29 -9
- package/commands/work-on/close.md +107 -40
- package/commands/work-on/decompose.md +16 -7
- package/commands/work-on/investigate.md +16 -7
- package/commands/work-on/remediate.md +18 -4
- package/commands/work-on/review.md +51 -19
- package/commands/work-on.md +68 -9
- package/docs/CODEX.md +12 -0
- package/docs/CONFIG.md +49 -1
- package/docs/RELEASE-NOTES.md +52 -0
- package/package.json +1 -1
- package/scripts/amplification-breaker.sh +89 -0
- package/scripts/amplification-breaker.test.sh +39 -0
- package/scripts/check-admission-parity.mjs +74 -1
- package/scripts/check-spec-markers.sh +67 -3
- package/scripts/classify-finding.sh +120 -0
- package/scripts/classify-finding.test.sh +74 -0
- package/scripts/close-dossier-no-main-commit.test.sh +139 -0
- package/scripts/forge-root.test.sh +84 -27
- package/scripts/lint-build-validate-files.sh +29 -0
- package/scripts/lint-build-validate-files.test.sh +25 -0
- package/scripts/lint-dispatch-prompt.test.sh +2 -1
- package/scripts/recovery-claim-live.sh +73 -0
- package/scripts/recovery-claim-live.test.sh +66 -0
- package/scripts/verify-phase-trail.sh +183 -4
- package/scripts/verify-phase-trail.test.sh +211 -1
|
@@ -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" ]
|
|
293
|
-
|
|
294
|
-
|
|
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,120 @@
|
|
|
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). 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
|
+
AGENT_LC=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
|
|
97
|
+
case "$AGENT_LC" in
|
|
98
|
+
auth*|billing*|concurrency*|database*) SAFETY="${SAFETY:-domain-agent}" ;;
|
|
99
|
+
esac
|
|
100
|
+
|
|
101
|
+
if [ "$LINEAGE" = "review-finding" ]; then
|
|
102
|
+
if [ "$SEV" = "MEDIUM" ] && { [ "$CONF" = "CONFIRMED" ] || { [ -n "$SAFETY" ] && [ "$CONF" = "LIKELY" ]; }; }; then
|
|
103
|
+
echo "ISSUE lineage-medium-${CONF:-UNKNOWN}"
|
|
104
|
+
else
|
|
105
|
+
echo "NOTE lineage-${SEV}-${CONF:-UNKNOWN}"
|
|
106
|
+
fi
|
|
107
|
+
exit 0
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
if [ "$SEV" = "LOW" ] || [ "$CONF" = "POSSIBLE" ]; then
|
|
111
|
+
if [ -n "$SAFETY" ]; then
|
|
112
|
+
echo "ISSUE safety-exemption-$SAFETY"
|
|
113
|
+
else
|
|
114
|
+
echo "NOTE ${SEV}-${CONF:-UNKNOWN}"
|
|
115
|
+
fi
|
|
116
|
+
exit 0
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
echo "ISSUE ${SEV}-${CONF:-UNKNOWN}"
|
|
120
|
+
exit 0
|
|
@@ -0,0 +1,74 @@
|
|
|
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 from Auth domain agent" -- --severity LOW --confidence LIKELY --agent "Auth" --text "nit"
|
|
48
|
+
expect ISSUE "LOW from Billing domain agent" -- --severity LOW --confidence POSSIBLE --agent "billing" --text "nit"
|
|
49
|
+
expect ISSUE "LOW from Database agent" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "nit"
|
|
50
|
+
expect ISSUE "LOW from Concurrency agent" -- --severity LOW --confidence POSSIBLE --agent "Concurrency" --text "nit"
|
|
51
|
+
|
|
52
|
+
# --- Rule 3: review-finding lineage (fix for a finding) ---
|
|
53
|
+
expect NOTE "lineage LOW CONFIRMED note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "nit"
|
|
54
|
+
expect NOTE "lineage LOW security keyword still note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "injection nit"
|
|
55
|
+
expect ISSUE "lineage MEDIUM CONFIRMED files" -- --severity MEDIUM --confidence CONFIRMED --lineage review-finding --text "logic bug"
|
|
56
|
+
expect NOTE "lineage MEDIUM LIKELY no-safety note" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "logic bug"
|
|
57
|
+
expect ISSUE "lineage MEDIUM LIKELY with keyword files" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "token leaks"
|
|
58
|
+
expect NOTE "lineage MEDIUM POSSIBLE with keyword note" -- --severity MEDIUM --confidence POSSIBLE --lineage review-finding --text "token leaks"
|
|
59
|
+
|
|
60
|
+
# --- text-file input ---
|
|
61
|
+
TMPF=$(mktemp "${TMPDIR:-/tmp}/classify-finding-test.XXXXXX")
|
|
62
|
+
printf 'Title: unchecked xss sink\n' > "$TMPF"
|
|
63
|
+
expect ISSUE "text-file keyword" -- --severity LOW --confidence LIKELY --text-file "$TMPF"
|
|
64
|
+
rm -f "$TMPF"
|
|
65
|
+
|
|
66
|
+
# --- usage errors ---
|
|
67
|
+
expect_exit 2 "unknown flag" -- --bogus x
|
|
68
|
+
expect_exit 2 "bad lineage" -- --severity LOW --lineage P3
|
|
69
|
+
expect_exit 2 "missing flag value" -- --severity
|
|
70
|
+
expect_exit 2 "unreadable text-file" -- --severity LOW --text-file /nonexistent/x
|
|
71
|
+
expect_exit 0 "valid call exits 0" -- --severity LOW
|
|
72
|
+
|
|
73
|
+
echo "classify-finding.test.sh: passed=$pass failed=$fail"
|
|
74
|
+
[ "$fail" -eq 0 ]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# close-dossier-no-main-commit.test.sh — close.md Phase C1.7 must never write or commit in the main checkout.
|
|
3
|
+
#
|
|
4
|
+
# Static asserts on the spec plus execution of the real extracted C1.7 block
|
|
5
|
+
# against a temp repo with a bare origin and a stubbed gh.
|
|
6
|
+
#
|
|
7
|
+
# Usage: bash scripts/close-dossier-no-main-commit.test.sh
|
|
8
|
+
# Exit code: 0 if all assertions pass, 1 if any fail.
|
|
9
|
+
#
|
|
10
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
11
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
12
|
+
|
|
13
|
+
set -uo pipefail
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
16
|
+
CLOSE_MD="$SCRIPT_DIR/../commands/work-on/close.md"
|
|
17
|
+
|
|
18
|
+
TMP=$(mktemp -d)
|
|
19
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
20
|
+
PASS=0; FAIL=0
|
|
21
|
+
ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
|
|
22
|
+
bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
|
|
23
|
+
|
|
24
|
+
# --- Static asserts on the spec ---
|
|
25
|
+
if grep -nE 'git -C "\$\{REPO_PATH\}" (commit|add)' "$CLOSE_MD" >/dev/null; then
|
|
26
|
+
bad "close.md still runs git add/commit against REPO_PATH"
|
|
27
|
+
else ok "close.md has no git add/commit against REPO_PATH"; fi
|
|
28
|
+
|
|
29
|
+
C17=$(sed -n '/^## Phase C1.7/,/^## Phase C1.5/p' "$CLOSE_MD")
|
|
30
|
+
if printf '%s\n' "$C17" | grep -nE '^\s*cd "\$\{REPO_PATH\}"' >/dev/null; then
|
|
31
|
+
bad "C1.7 changes directory into REPO_PATH"
|
|
32
|
+
else ok "C1.7 does not cd into REPO_PATH"; fi
|
|
33
|
+
printf '%s\n' "$C17" | grep -q 'Phase C1.7: skipped' && ok "C1.7 logs skip reasons" || bad "C1.7 missing 'Phase C1.7: skipped'"
|
|
34
|
+
printf '%s\n' "$C17" | grep -q 'worktree add --detach' && ok "C1.7 uses a temporary detached worktree" || bad "C1.7 missing temp worktree"
|
|
35
|
+
printf '%s\n' "$C17" | grep -q 'gh pr create' && ok "C1.7 opens a PR" || bad "C1.7 missing gh pr create"
|
|
36
|
+
[ "$(printf '%s\n' "$C17" | grep -c '^```bash')" -eq 1 ] && ok "C1.7 is a single bash block (no cross-block shell state)" || bad "C1.7 must be exactly one bash block"
|
|
37
|
+
printf '%s\n' "$C17" | grep -qE "^\s*trap .*EXIT" && ok "C1.7 registers an EXIT trap" || bad "C1.7 missing EXIT trap"
|
|
38
|
+
printf '%s\n' "$C17" | grep -q 'worktree remove --force' && ok "C1.7 cleanup removes the worktree" || bad "C1.7 cleanup missing worktree remove"
|
|
39
|
+
ADD_LN=$(printf '%s\n' "$C17" | grep -n 'worktree add --detach' | head -1 | cut -d: -f1)
|
|
40
|
+
TRAP_LN=$(printf '%s\n' "$C17" | grep -nE "^\s*trap .*EXIT" | head -1 | cut -d: -f1)
|
|
41
|
+
[ -n "$ADD_LN" ] && [ -n "$TRAP_LN" ] && [ "$TRAP_LN" -gt "$ADD_LN" ] && ok "EXIT trap registered after worktree add" || bad "EXIT trap must follow worktree add"
|
|
42
|
+
printf '%s\n' "$C17" | grep -q 'force-with-lease' && ok "C1.7 pushes with --force-with-lease" || bad "C1.7 missing force-with-lease"
|
|
43
|
+
printf '%s\n' "$C17" | grep -qE 'push .*--force( |$)' && bad "C1.7 uses bare --force" || ok "C1.7 has no bare --force"
|
|
44
|
+
printf '%s\n' "$C17" | grep -q 'gh pr list' && ok "C1.7 checks for an existing open PR" || bad "C1.7 missing gh pr list"
|
|
45
|
+
grep -q 'FORGE:DOSSIER_UPDATED' "$CLOSE_MD" && ok "FORGE:DOSSIER_UPDATED marker kept" || bad "FORGE:DOSSIER_UPDATED marker missing"
|
|
46
|
+
|
|
47
|
+
# --- Dynamic: execute the REAL C1.7 block extracted from close.md (gh stubbed) ---
|
|
48
|
+
BLOCK=$(printf '%s\n' "$C17" | awk '/^```bash/{f=1;next} /^```/{f=0} f')
|
|
49
|
+
if [ -z "$BLOCK" ]; then
|
|
50
|
+
bad "could not extract the C1.7 bash block"
|
|
51
|
+
elif ! command -v perl >/dev/null 2>&1 || ! yq --version 2>&1 | grep -q 'mikefarah'; then
|
|
52
|
+
bad "dynamic section needs perl and mikefarah yq v4 (not silently skipped)"
|
|
53
|
+
else
|
|
54
|
+
printf '%s\n' "$BLOCK" | grep -q '^_dossier_run()' && printf '%s\n' "$BLOCK" | grep -q '^_dossier_cleanup()' \
|
|
55
|
+
&& ok "extracted block defines _dossier_run and _dossier_cleanup" || bad "extracted block missing _dossier_run/_dossier_cleanup"
|
|
56
|
+
|
|
57
|
+
# Substitute only the exact {NAME} placeholders; leave ${NAME} shell expansions intact.
|
|
58
|
+
printf '%s\n' "$BLOCK" | perl -pe 's/(?<!\$)\{GH_REPO\}/o\/r/g; s/(?<!\$)\{NUMBER\}/1/g; s/(?<!\$)\{PR_NUMBER\}/2/g; s/(?<!\$)\{GH_FLAG\}/-R o\/r/g' > "$TMP/block.sh"
|
|
59
|
+
bash -n "$TMP/block.sh" && ok "substituted C1.7 block parses (bash -n)" || bad "substituted C1.7 block has a syntax error"
|
|
60
|
+
|
|
61
|
+
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com
|
|
62
|
+
git init -q --bare "$TMP/origin.git"
|
|
63
|
+
git init -q -b staging "$TMP/main"
|
|
64
|
+
mkdir -p "$TMP/main/devdocs/modules"
|
|
65
|
+
echo seed > "$TMP/main/devdocs/modules/m.md"
|
|
66
|
+
printf 'modules:\n - name: m\n glob: "*.sh"\n path: modules/m.md\n' > "$TMP/main/devdocs/index.yaml"
|
|
67
|
+
git -C "$TMP/main" add -A && git -C "$TMP/main" commit -q -m seed
|
|
68
|
+
git -C "$TMP/main" remote add origin "$TMP/origin.git"
|
|
69
|
+
git -C "$TMP/main" push -q origin staging 2>/dev/null
|
|
70
|
+
|
|
71
|
+
# gh stub: never touches the network; every call is logged.
|
|
72
|
+
mkdir -p "$TMP/bin" "$TMP/tmpd" "$TMP/cwd"
|
|
73
|
+
cat > "$TMP/bin/gh" <<'GHEOF'
|
|
74
|
+
#!/usr/bin/env bash
|
|
75
|
+
echo "gh $*" >> "$GH_LOG"
|
|
76
|
+
case "$1 $2" in
|
|
77
|
+
"api repos/o/r/issues/1/comments")
|
|
78
|
+
printf '<!-- FORGE:BUILDER -->\n### Changes\n- `scripts/foo.sh` changed\n### Next\n' ;;
|
|
79
|
+
"pr view") echo "Test PR title" ;;
|
|
80
|
+
"pr list")
|
|
81
|
+
echo "pr-list worktrees: $(git -C "$REPO_PATH" worktree list | wc -l)" >> "$GH_LOG"
|
|
82
|
+
[ "${GH_KILL_ON_PR_LIST:-}" = 1 ] && kill -TERM "$(cat "$PIDF")" && sleep 1
|
|
83
|
+
echo "${GH_PR_LIST_URL:-}" ;;
|
|
84
|
+
"pr create") [ "${GH_FAIL_PR_CREATE:-}" = 1 ] && exit 1; echo "https://example.invalid/o/r/pull/9" ;;
|
|
85
|
+
esac
|
|
86
|
+
exit 0
|
|
87
|
+
GHEOF
|
|
88
|
+
chmod +x "$TMP/bin/gh"
|
|
89
|
+
export GH_LOG="$TMP/gh.log" PIDF="$TMP/pid" REPO_PATH="$TMP/main" PR_BASE=staging NUMBER=1
|
|
90
|
+
[ "$(PATH="$TMP/bin:$PATH" command -v gh)" = "$TMP/bin/gh" ] && ok "gh resolves to the stub" || bad "gh stub not first on PATH"
|
|
91
|
+
|
|
92
|
+
HEAD_BEFORE=$(git -C "$TMP/main" rev-parse HEAD)
|
|
93
|
+
BRANCH_BEFORE=$(git -C "$TMP/main" branch --show-current)
|
|
94
|
+
|
|
95
|
+
# run_block: executes the real block in a fresh bash, cwd without forge.yaml.
|
|
96
|
+
run_block() { : > "$GH_LOG"; ( cd "$TMP/cwd" && env PATH="$TMP/bin:$PATH" TMPDIR="$TMP/tmpd" "$@" bash -c 'echo $$ > "$PIDF"; source "$1"' _ "$TMP/block.sh" ) > "$TMP/out.log" 2>&1; }
|
|
97
|
+
main_untouched() {
|
|
98
|
+
[ "$(git -C "$TMP/main" rev-parse HEAD)" = "$HEAD_BEFORE" ] && [ "$(git -C "$TMP/main" branch --show-current)" = "$BRANCH_BEFORE" ] \
|
|
99
|
+
&& [ -z "$(git -C "$TMP/main" status --porcelain)" ]
|
|
100
|
+
}
|
|
101
|
+
no_leak() {
|
|
102
|
+
[ -z "$(ls -A "$TMP/tmpd" 2>/dev/null)" ] && [ "$(git -C "$TMP/main" worktree list | wc -l)" -eq 1 ]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Scenario A: happy path
|
|
106
|
+
run_block; RC=$?
|
|
107
|
+
[ "$RC" -eq 0 ] && ok "A: real block exits 0" || bad "A: real block exit $RC"
|
|
108
|
+
main_untouched && ok "A: main checkout untouched (HEAD, branch, clean tree)" || bad "A: main checkout modified"
|
|
109
|
+
git -C "$TMP/origin.git" rev-parse -q --verify refs/heads/docs/dossier-1 >/dev/null && ok "A: dossier branch pushed to origin" || bad "A: dossier branch not pushed"
|
|
110
|
+
git -C "$TMP/origin.git" show refs/heads/docs/dossier-1:devdocs/modules/m.md 2>/dev/null | grep -q '^## Entry' && ok "A: dossier entry appended on pushed branch" || bad "A: no entry on pushed branch"
|
|
111
|
+
grep -q '^gh pr create' "$GH_LOG" && ok "A: gh pr create called" || bad "A: gh pr create not called"
|
|
112
|
+
grep -q 'FORGE:DOSSIER_UPDATED' "$GH_LOG" && ok "A: FORGE:DOSSIER_UPDATED comment posted" || bad "A: no FORGE:DOSSIER_UPDATED comment"
|
|
113
|
+
no_leak && ok "A: temporary worktree removed" || bad "A: temporary worktree leaked"
|
|
114
|
+
|
|
115
|
+
# Scenario B: retry over the existing remote branch (real fetch + --force-with-lease)
|
|
116
|
+
OLD=$(git -C "$TMP/origin.git" rev-parse refs/heads/docs/dossier-1)
|
|
117
|
+
sleep 1 # commit SHAs embed second-resolution timestamps; ensure the retry differs
|
|
118
|
+
run_block; RC=$?
|
|
119
|
+
NEW=$(git -C "$TMP/origin.git" rev-parse refs/heads/docs/dossier-1)
|
|
120
|
+
[ "$RC" -eq 0 ] && [ "$OLD" != "$NEW" ] && ok "B: retry push over existing remote branch succeeds" || bad "B: retry push failed (rc=$RC)"
|
|
121
|
+
main_untouched && no_leak && ok "B: main untouched and worktree removed" || bad "B: main modified or worktree leaked"
|
|
122
|
+
|
|
123
|
+
# Scenario C: fault injection - SIGTERM after worktree add, before the explicit cleanup.
|
|
124
|
+
# Only the block's real EXIT trap can remove the worktree.
|
|
125
|
+
run_block GH_KILL_ON_PR_LIST=1 2>/dev/null
|
|
126
|
+
grep -q '^pr-list worktrees: 2' "$GH_LOG" && ok "C: worktree existed when the fault fired" || bad "C: fault did not fire after worktree add (vacuous)"
|
|
127
|
+
grep -q '^gh pr create' "$GH_LOG" && bad "C: block continued past the injected fault" || ok "C: block aborted before pr create"
|
|
128
|
+
no_leak && ok "C: real EXIT trap cleaned the worktree" || bad "C: worktree leaked after abort (EXIT trap regression)"
|
|
129
|
+
main_untouched && ok "C: main checkout untouched" || bad "C: main checkout modified"
|
|
130
|
+
|
|
131
|
+
# Scenario D: gh pr create fails - block still exits 0, logs a skip, cleans up
|
|
132
|
+
run_block GH_FAIL_PR_CREATE=1; RC=$?
|
|
133
|
+
[ "$RC" -eq 0 ] && grep -q 'skipped - gh pr create failed' "$TMP/out.log" && ok "D: pr create failure logged as skip, exit 0" || bad "D: pr create failure not handled"
|
|
134
|
+
grep -q 'issue comment' "$GH_LOG" && bad "D: annotation posted despite no PR" || ok "D: no annotation without a PR"
|
|
135
|
+
no_leak && ok "D: worktree removed" || bad "D: worktree leaked"
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
139
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
set -uo pipefail
|
|
9
9
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$HERE/.."
|
|
10
10
|
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
|
11
|
-
PASS=0; FAILN=0
|
|
11
|
+
PASS=0; FAILN=0; SKIPPED=0
|
|
12
12
|
ok() { PASS=$((PASS+1)); }
|
|
13
13
|
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
14
14
|
expect() { [ "$2" = "$3" ] && ok || bad "$1 (got '$3' want '$2')"; }
|
|
@@ -40,6 +40,8 @@ grep -v '^[[:space:]]*#' "$T/canon" > "$T/canon.code" # code only: the comment
|
|
|
40
40
|
grep -qE 'mapfile|readarray|declare -A|sort[^|]* -[a-zA-Z]*V' "$T/canon.code" && bad "canonical bootstrap uses a bash-4+/non-portable construct" || ok
|
|
41
41
|
grep -q 'sort -k1,1nr -k2,2nr -k3,3nr' "$T/canon.code" && ok || bad "canonical bootstrap lacks numeric component version sort"
|
|
42
42
|
grep -qF 'marketplaces/*; do' "$T/canon" && bad "canonical bootstrap globs all marketplaces" || ok
|
|
43
|
+
grep -qF -- "-iname '*forgedock*'" "$T/canon.code" && bad "canonical bootstrap wildcard-matches marketplaces" || ok
|
|
44
|
+
grep -qF '[A-Za-z]:' "$T/canon.code" && ok || bad "canonical bootstrap lacks drive-letter path handling"
|
|
43
45
|
# review.md must hard-exit on an unreadable trail
|
|
44
46
|
grep -qE 'TRAIL_RC.* -ge 2 .*exit 1' "$ROOT/commands/work-on/review.md" && ok || bad "work-on/review.md lacks hard exit guard for TRAIL_RC>=2"
|
|
45
47
|
# behavioral: execute the guard line itself. rc>=2 => BLOCKED on STDOUT + exit 1; rc 0/1 => falls through.
|
|
@@ -88,9 +90,9 @@ mkscripts() {
|
|
|
88
90
|
: > "$1/bin/engine/resolve.mjs"; : > "$1/bin/engine/orchestrate-canary.mjs"; : > "$1/bin/engine/admission.mjs"
|
|
89
91
|
}
|
|
90
92
|
# plugin install: no env vars at all
|
|
91
|
-
mkscripts "$T/h1/.claude/plugins/cache/
|
|
92
|
-
expect "plugin cache resolves with no env" "$T/h1/.claude/plugins/cache/
|
|
93
|
-
expect "CLAUDE_PLUGIN_ROOT resolves" "$T/h1/.claude/plugins/cache/
|
|
93
|
+
mkscripts "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0"
|
|
94
|
+
expect "plugin cache resolves with no env" "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h1")"
|
|
95
|
+
expect "CLAUDE_PLUGIN_ROOT resolves" "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/empty" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0")"
|
|
94
96
|
# nothing installed: empty (never the consumer repo, never a relative path)
|
|
95
97
|
expect "unresolvable stays empty" "" "$(run "$T/empty")"
|
|
96
98
|
expect "relative FORGE_HOME ignored" "" "$(run "$T/empty" FORGE_HOME=.)"
|
|
@@ -98,31 +100,38 @@ expect "relative FORGE_HOME ignored" "" "$(run "$T/empty" FORGE_HOME=.)"
|
|
|
98
100
|
expect "FORGEDOCK_HOME authoritative" "$T/nowhere" "$(run "$T/h1" FORGEDOCK_HOME="$T/nowhere")"
|
|
99
101
|
# symlink install (install.sh): ~/.claude/commands/work-on.md -> <clone>/commands/work-on.md
|
|
100
102
|
mkdir -p "$T/clone/commands" "$T/h2/.claude/commands"; mkscripts "$T/clone"; : > "$T/clone/commands/work-on.md"
|
|
101
|
-
ln -
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
# Git Bash (MSYS) makes a COPY for `ln -s` unless native symlinks are requested, which leaves readlink
|
|
104
|
+
# nothing to resolve; request them, and run the two symlink cases only when a real symlink exists (a runner
|
|
105
|
+
# without symlink privilege cannot exercise install.sh's layout, which is itself a symlink install).
|
|
106
|
+
MSYS=winsymlinks:nativestrict ln -sf "$T/clone/commands/work-on.md" "$T/h2/.claude/commands/work-on.md" 2>/dev/null
|
|
107
|
+
if [ -L "$T/h2/.claude/commands/work-on.md" ]; then
|
|
108
|
+
expect "install.sh symlink resolves" "$(cd "$T/clone" && pwd -P)" "$(cd "$(run "$T/h2")" && pwd -P)"
|
|
109
|
+
expect "FORGE_HOME without scripts falls through" "$(cd "$T/clone" && pwd -P)" "$(cd "$(run "$T/h2" FORGE_HOME="$T/h2/.claude")" && pwd -P)"
|
|
110
|
+
else
|
|
111
|
+
echo "SKIP: symlink cases (this platform cannot create symlinks)"; SKIPPED=$((SKIPPED+1))
|
|
112
|
+
fi
|
|
104
113
|
|
|
105
114
|
# newest cached version wins (1.10.0 must beat 1.9.0)
|
|
106
|
-
for v in 1.9.0 1.10.0 1.2.0; do mkscripts "$T/h3/.claude/plugins/cache/
|
|
107
|
-
expect "newest cached version wins" "$T/h3/.claude/plugins/cache/
|
|
115
|
+
for v in 1.9.0 1.10.0 1.2.0; do mkscripts "$T/h3/.claude/plugins/cache/forgedock/forgedock/$v"; done
|
|
116
|
+
expect "newest cached version wins" "$T/h3/.claude/plugins/cache/forgedock/forgedock/1.10.0" "$(run "$T/h3")"
|
|
108
117
|
# HOME containing a space still resolves
|
|
109
|
-
mkscripts "$T/sp ace/.claude/plugins/cache/
|
|
110
|
-
expect "space in HOME resolves" "$T/sp ace/.claude/plugins/cache/
|
|
118
|
+
mkscripts "$T/sp ace/.claude/plugins/cache/forgedock/forgedock/2.0.0"
|
|
119
|
+
expect "space in HOME resolves" "$T/sp ace/.claude/plugins/cache/forgedock/forgedock/2.0.0" "$(run "$T/sp ace")"
|
|
111
120
|
# relative FORGEDOCK_HOME / CLAUDE_PLUGIN_ROOT rejected (fail closed => empty)
|
|
112
121
|
expect "relative FORGEDOCK_HOME rejected" "" "$(run "$T/h1" FORGEDOCK_HOME=scripts/..)"
|
|
113
122
|
expect "relative CLAUDE_PLUGIN_ROOT ignored" "" "$(run "$T/empty" CLAUDE_PLUGIN_ROOT=../consumer)"
|
|
114
123
|
# a candidate with only one of the two needed scripts is rejected
|
|
115
|
-
mkdir -p "$T/h4/.claude/plugins/cache/
|
|
124
|
+
mkdir -p "$T/h4/.claude/plugins/cache/forgedock/forgedock/1.0.0/scripts"; : > "$T/h4/.claude/plugins/cache/forgedock/forgedock/1.0.0/scripts/verify-phase-trail.sh"
|
|
116
125
|
expect "partial install (missing lint script) rejected" "" "$(run "$T/h4")"
|
|
117
126
|
# stale install: has the two original gate scripts but lacks is-docs-only.sh / engine modules => rejected
|
|
118
127
|
for missing in scripts/is-docs-only.sh bin/engine/resolve.mjs bin/engine/orchestrate-canary.mjs bin/engine/admission.mjs; do
|
|
119
|
-
rm -rf "$T/h12"; mkscripts "$T/h12/.claude/plugins/cache/
|
|
128
|
+
rm -rf "$T/h12"; mkscripts "$T/h12/.claude/plugins/cache/forgedock/forgedock/1.0.0"; rm -f "$T/h12/.claude/plugins/cache/forgedock/forgedock/1.0.0/$missing"
|
|
120
129
|
expect "stale install missing $missing rejected" "" "$(run "$T/h12")"
|
|
121
130
|
done
|
|
122
131
|
# a stale newest version is skipped in favor of a complete older one
|
|
123
|
-
rm -rf "$T/h13"; mkscripts "$T/h13/.claude/plugins/cache/
|
|
124
|
-
rm -f "$T/h13/.claude/plugins/cache/
|
|
125
|
-
expect "stale newest skipped for complete older" "$T/h13/.claude/plugins/cache/
|
|
132
|
+
rm -rf "$T/h13"; mkscripts "$T/h13/.claude/plugins/cache/forgedock/forgedock/1.0.0"; mkscripts "$T/h13/.claude/plugins/cache/forgedock/forgedock/2.0.0"
|
|
133
|
+
rm -f "$T/h13/.claude/plugins/cache/forgedock/forgedock/2.0.0/scripts/is-docs-only.sh"
|
|
134
|
+
expect "stale newest skipped for complete older" "$T/h13/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h13")"
|
|
126
135
|
# non-ForgeDock marketplace dirs are ignored; ForgeDock-named ones resolve
|
|
127
136
|
mkscripts "$T/h5/.claude/plugins/marketplaces/other-tool"
|
|
128
137
|
expect "non-ForgeDock marketplace ignored" "" "$(run "$T/h5")"
|
|
@@ -131,21 +140,67 @@ expect "forgedock marketplace resolves" "$T/h6/.claude/plugins/marketplaces/forg
|
|
|
131
140
|
|
|
132
141
|
# release outranks its own pre-release; a newer pre-release core still beats an older release
|
|
133
142
|
rm -rf "$T/h7" # fixture is mutated below; reset per shell
|
|
134
|
-
mkscripts "$T/h7/.claude/plugins/cache/
|
|
135
|
-
expect "release beats its pre-release" "$T/h7/.claude/plugins/cache/
|
|
136
|
-
mkscripts "$T/h7/.claude/plugins/cache/
|
|
137
|
-
expect "newer-core pre-release beats older release" "$T/h7/.claude/plugins/cache/
|
|
138
|
-
#
|
|
139
|
-
mkscripts "$T/h8/.claude/plugins/cache/
|
|
140
|
-
expect "
|
|
143
|
+
mkscripts "$T/h7/.claude/plugins/cache/forgedock/forgedock/1.9.0-rc1"; mkscripts "$T/h7/.claude/plugins/cache/forgedock/forgedock/1.9.0"
|
|
144
|
+
expect "release beats its pre-release" "$T/h7/.claude/plugins/cache/forgedock/forgedock/1.9.0" "$(run "$T/h7")"
|
|
145
|
+
mkscripts "$T/h7/.claude/plugins/cache/forgedock/forgedock/2.0.0-rc1"
|
|
146
|
+
expect "newer-core pre-release beats older release" "$T/h7/.claude/plugins/cache/forgedock/forgedock/2.0.0-rc1" "$(run "$T/h7")"
|
|
147
|
+
# marketplace pinning: a higher-versioned forgedock plugin from a non-official marketplace is never used
|
|
148
|
+
mkscripts "$T/h8/.claude/plugins/cache/evil-market/forgedock/99.0.0"; mkscripts "$T/h8/.claude/plugins/cache/forgedock/forgedock/1.1.0"
|
|
149
|
+
expect "hostile marketplace at 99.0.0 ignored" "$T/h8/.claude/plugins/cache/forgedock/forgedock/1.1.0" "$(run "$T/h8")"
|
|
150
|
+
rm -rf "$T/h8b"; mkscripts "$T/h8b/.claude/plugins/cache/evil-market/forgedock/99.0.0"
|
|
151
|
+
expect "only a hostile marketplace => empty" "" "$(run "$T/h8b")"
|
|
152
|
+
expect "FORGEDOCK_MARKETPLACE pins another marketplace" "$T/h8b/.claude/plugins/cache/evil-market/forgedock/99.0.0" "$(run "$T/h8b" FORGEDOCK_MARKETPLACE=evil-market)"
|
|
153
|
+
expect "invalid FORGEDOCK_MARKETPLACE falls back to forgedock" "" "$(run "$T/h8b" FORGEDOCK_MARKETPLACE=..)"
|
|
154
|
+
rm -rf "$T/h8c"; mkscripts "$T/h8c/.claude/plugins/marketplaces/evil-forgedock-tools"
|
|
155
|
+
expect "wildcard-named marketplace dir ignored" "" "$(run "$T/h8c")"
|
|
156
|
+
# non-semver cache dir names (commit SHAs, 1e5x) are skipped, never ranked by numeric coercion
|
|
157
|
+
rm -rf "$T/h14"; P14="$T/h14/.claude/plugins/cache/forgedock/forgedock"
|
|
158
|
+
mkscripts "$P14/1.10.0"; mkscripts "$P14/1e5abcdef"; mkscripts "$P14/abc1234def"; mkscripts "$P14/6f3a9c0"
|
|
159
|
+
expect "SHA-named cache dirs ignored" "$P14/1.10.0" "$(run "$T/h14")"
|
|
160
|
+
rm -rf "$T/h14b"; mkscripts "$T/h14b/.claude/plugins/cache/forgedock/forgedock/abc1234def"
|
|
161
|
+
expect "only SHA-named dir => empty" "" "$(run "$T/h14b")"
|
|
162
|
+
mkscripts "$P14/1.10.0-rc1"; expect "pre-release dir name still accepted (release wins)" "$P14/1.10.0" "$(run "$T/h14")"
|
|
163
|
+
# Windows drive-letter FORGEDOCK_HOME is normalized (/c/...), relative stays rejected
|
|
164
|
+
rm -rf "$T/win"; mkscripts "$T/win/c/forge"
|
|
165
|
+
expect "C:/ FORGEDOCK_HOME authoritative (no cygpath, bogus path)" "/c/nowhere/forge" "$(run "$T/h1" FORGEDOCK_HOME='C:/nowhere/forge')"
|
|
166
|
+
expect "C:\\ FORGEDOCK_HOME normalized" "/d/x/y" "$(run "$T/h1" FORGEDOCK_HOME='D:\x\y')"
|
|
167
|
+
# Codex: forge-home pointer file written by install-codex.sh
|
|
168
|
+
mkscripts "$T/hx/clone"; mkdir -p "$T/hx/.codex"; printf '%s\n' "$T/hx/clone" > "$T/hx/.codex/forge-home"
|
|
169
|
+
expect "codex forge-home pointer resolves" "$T/hx/clone" "$(run "$T/hx")"
|
|
170
|
+
expect "CODEX_HOME override resolves" "$T/hx/clone" "$(run "$T/empty" CODEX_HOME="$T/hx/.codex")"
|
|
171
|
+
# A relative CODEX_HOME must not resolve the pointer against the consumer cwd
|
|
172
|
+
mkdir -p "$T/consumer/.codex"; printf '%s\n' "$T/hx/clone" > "$T/consumer/.codex/forge-home"
|
|
173
|
+
expect "relative CODEX_HOME ignored (cwd-local pointer not read)" "" "$(run "$T/empty" CODEX_HOME=.codex)"
|
|
174
|
+
rm -rf "$T/consumer/.codex"
|
|
175
|
+
printf 'relative/path\n' > "$T/hx/.codex/forge-home"
|
|
176
|
+
expect "relative forge-home pointer rejected" "" "$(run "$T/hx")"
|
|
177
|
+
# install-codex.sh: FORGE_HOME is shell-escaped in env files, and absent env files are not created (forge#3241)
|
|
178
|
+
IC="$T/ic/we ird\$x's"; mkdir -p "$IC/commands" "$T/ic-home"
|
|
179
|
+
cp "$ROOT/install-codex.sh" "$IC/install-codex.sh"
|
|
180
|
+
: > "$T/ic-home/.zshenv"
|
|
181
|
+
( cd "$IC" && env -i PATH="$PATH" HOME="$T/ic-home" CODEX_HOME="$T/ic-home/.codex" bash ./install-codex.sh >/dev/null 2>&1 )
|
|
182
|
+
[ ! -e "$T/ic-home/.profile" ] && ok || bad "install-codex.sh created a missing ~/.profile"
|
|
183
|
+
# The installer records its own resolved dir (macOS /var -> /private/var, Git Bash /tmp -> /c/Users/...), so compare against that, not $IC.
|
|
184
|
+
IC_REAL=$( cd "$IC" && env -i PATH="$PATH" bash -c 'cd "$(dirname ./install-codex.sh)" && pwd' )
|
|
185
|
+
got=$(env -i PATH="$PATH" HOME="$T/ic-home" bash -c '. "$HOME/.zshenv"; printf %s "$FORGE_HOME"' 2>&1)
|
|
186
|
+
expect "env-file FORGE_HOME round-trips space, \$ and single quote" "$IC_REAL" "$got"
|
|
187
|
+
got=$(env -i PATH="$PATH" HOME="$T/ic-home" sh -c '. "$HOME/.zshenv"; printf %s "$FORGE_HOME"' 2>&1)
|
|
188
|
+
expect "env-file FORGE_HOME round-trips under POSIX sh" "$IC_REAL" "$got"
|
|
189
|
+
grep -qF "\$'" "$T/ic-home/.zshenv" && bad "env file contains bash-only \$'...' quoting" || ok
|
|
190
|
+
expect "pointer file still written" "$IC_REAL" "$(cat "$T/ic-home/.codex/forge-home" 2>/dev/null)"
|
|
191
|
+
# errexit/pipefail safety: absent readlink target, absent cache dir, no ~/.claude at all
|
|
192
|
+
for opts in "-e" "-eo pipefail" "-euo pipefail"; do
|
|
193
|
+
expect "survives set $opts with nothing installed" "ok:" "$( cd "$T/consumer" && env -i PATH="$PATH" HOME="$T/empty" "$SH" -c "set $opts; $(cat "$T/canon"); printf 'ok:%s' \"\$FORGE_ROOT\"" 2>&1 )"
|
|
194
|
+
expect "resolves under set $opts" "ok:$T/h3/.claude/plugins/cache/forgedock/forgedock/1.10.0" "$( cd "$T/consumer" && env -i PATH="$PATH" HOME="$T/h3" "$SH" -c "set $opts; $(cat "$T/canon"); printf 'ok:%s' \"\$FORGE_ROOT\"" 2>&1 )"
|
|
195
|
+
done
|
|
141
196
|
# a non-forgedock plugin in the cache is ignored even with a higher version
|
|
142
|
-
mkscripts "$T/h9/.claude/plugins/cache/
|
|
143
|
-
expect "non-forgedock cache plugin ignored" "$T/h9/.claude/plugins/cache/
|
|
197
|
+
mkscripts "$T/h9/.claude/plugins/cache/forgedock/otherplugin/9.9.9"; mkscripts "$T/h9/.claude/plugins/cache/forgedock/forgedock/1.0.0"
|
|
198
|
+
expect "non-forgedock cache plugin ignored" "$T/h9/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h9")"
|
|
144
199
|
# unmatched globs: empty cache dir, absent marketplaces dir, absent ~/.claude entirely (must not abort under zsh)
|
|
145
200
|
mkdir -p "$T/h10/.claude/plugins/cache"
|
|
146
201
|
expect "empty cache, no match, no abort" "" "$(run "$T/h10")"
|
|
147
202
|
expect "no ~/.claude at all, no abort" "" "$(run "$T/empty")"
|
|
148
|
-
expect "unmatched glob does not break later candidates" "$T/h1/.claude/plugins/cache/
|
|
203
|
+
expect "unmatched glob does not break later candidates" "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h10" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0")"
|
|
149
204
|
# plugin root (textually substituted) outranks an exported FORGE_HOME that also has the scripts:
|
|
150
205
|
# the running plugin's own files win over a stale clone named by a global env var (forge#3147 field test)
|
|
151
206
|
mkscripts "$T/h11/stale-clone"; mkscripts "$T/h11/running-plugin"
|
|
@@ -166,11 +221,13 @@ for f in $SITES commands/work-on/close.md; do
|
|
|
166
221
|
done
|
|
167
222
|
if command -v node >/dev/null 2>&1; then
|
|
168
223
|
for d in "we#ird" "sp ace" "q?x"; do
|
|
224
|
+
# '?' is not a legal Windows filename character: MSYS remaps it, so node sees a different path.
|
|
225
|
+
case "$d:$(uname -s)" in *'?'*:MINGW*|*'?'*:MSYS*|*'?'*:CYGWIN*) echo "SKIP: path '$d' (illegal on Windows)"; SKIPPED=$((SKIPPED+1)); continue ;; esac
|
|
169
226
|
mkdir -p "$T/url/$d/bin/engine"; echo 'export const v = 42;' > "$T/url/$d/bin/engine/resolve.mjs"
|
|
170
227
|
out=$(node -e 'import(require("node:url").pathToFileURL(process.argv[1]).href).then(m => process.stdout.write(String(m.v)))' "$T/url/$d/bin/engine/resolve.mjs" 2>&1)
|
|
171
228
|
expect "pathToFileURL import survives path '$d'" 42 "$out"
|
|
172
229
|
done
|
|
173
230
|
fi
|
|
174
231
|
|
|
175
|
-
echo "forge-root tests: pass=$PASS fail=$FAILN"
|
|
232
|
+
echo "forge-root tests: pass=$PASS fail=$FAILN skipped=$SKIPPED"
|
|
176
233
|
[ "$FAILN" -eq 0 ]
|