@windyroad/risk-scorer 0.17.4 → 0.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/.agents/plugins/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +13 -2
- package/bin/install.mjs +42 -8
- package/hooks/codex-agent-completion.mjs +82 -0
- package/hooks/hooks.json +5 -2
- package/hooks/risk-scorer-dispatch.sh +3 -0
- package/hooks/risk-scorer-scaffold-nudge.sh +15 -4
- package/package.json +5 -2
- package/scripts/codex-agents.mjs +208 -0
- package/scripts/sync-codex-skills.mjs +8 -3
- package/skills/assess-external-comms/SKILL.md +5 -4
- package/skills/assess-inbound-report/SKILL.md +5 -4
- package/skills/assess-release/SKILL.md +5 -4
- package/skills/assess-wip/SKILL.md +5 -4
- package/skills/bootstrap-catalog/SKILL.md +5 -4
- package/skills/create-risk/SKILL.md +5 -4
- package/skills/external-comms/SKILL.md +5 -4
- package/skills/pipeline/SKILL.md +5 -4
- package/skills/update-policy/SKILL.md +6 -5
- package/skills/wip/SKILL.md +5 -4
- package/agents/test/inbound-report-contract.bats +0 -225
- package/agents/test/risk-scorer-above-appetite-stop.bats +0 -80
- package/agents/test/risk-scorer-catalog-consumption.bats +0 -138
- package/agents/test/risk-scorer-commit-verdict.bats +0 -66
- package/agents/test/risk-scorer-monitoring-not-a-control.bats +0 -76
- package/agents/test/risk-scorer-reducing-bypass-criteria.bats +0 -62
- package/agents/test/risk-scorer-register-hint.bats +0 -111
- package/agents/test/risk-scorer-structured-remediations.bats +0 -123
- package/agents/test/risk-scorer-user-stated-preconditions.bats +0 -89
- package/hooks/test/ci-status-gate.bats +0 -234
- package/hooks/test/external-comms-gate.bats +0 -528
- package/hooks/test/external-comms-key-substance.bats +0 -74
- package/hooks/test/git-push-gate.bats +0 -82
- package/hooks/test/pipeline-state-changeset-partition.bats +0 -181
- package/hooks/test/pipeline-state-hash.bats +0 -195
- package/hooks/test/plan-risk-guidance-once-per-session.bats +0 -95
- package/hooks/test/reducing-marker-persistence.bats +0 -236
- package/hooks/test/risk-gate.bats +0 -330
- package/hooks/test/risk-policy-project-root.bats +0 -20
- package/hooks/test/risk-score-commit-gate-adr-031-bypass.bats +0 -98
- package/hooks/test/risk-score-commit-gate-cadence-staleness.bats +0 -105
- package/hooks/test/risk-score-mark-external-comms-prompt-parse.bats +0 -94
- package/hooks/test/risk-score-mark-register-queue.bats +0 -253
- package/hooks/test/risk-score-mark.bats +0 -112
- package/hooks/test/risk-scorer-hook-dispatch.bats +0 -77
- package/hooks/test/risk-scorer-no-stop-hook.bats +0 -19
- package/hooks/test/risk-scorer-scaffold-nudge.bats +0 -132
- package/hooks/test/slide-marker-on-subprocess-return.bats +0 -118
- package/hooks/test/wip-risk-mark.bats +0 -64
- package/scripts/test/drain-register-queue.bats +0 -359
- package/scripts/test/evaluate-graduation.bats +0 -533
- package/scripts/test/extract-risks-from-reports.bats +0 -366
- package/scripts/test/restage-commit.bats +0 -137
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bats
|
|
2
|
-
# Behavioural coverage for pipeline-state.sh --unreleased changeset partition
|
|
3
|
-
# per P202: a changeset whose introducing commit is already on origin/<base>
|
|
4
|
-
# is awaiting only the release-PR merge to npm and must NOT count as a
|
|
5
|
-
# "pending consumer-facing change" at this commit's surface.
|
|
6
|
-
#
|
|
7
|
-
# Per ADR-052 (behavioural tests default) — fixture seeds two changesets
|
|
8
|
-
# with distinct commit provenance and asserts pipeline-state.sh emits
|
|
9
|
-
# distinct counts so the pipeline.md Layer-1 scoring contract can rely
|
|
10
|
-
# on the partition without re-deriving it.
|
|
11
|
-
|
|
12
|
-
setup() {
|
|
13
|
-
HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
14
|
-
SCRIPT="$HOOKS_DIR/lib/pipeline-state.sh"
|
|
15
|
-
|
|
16
|
-
TEST_DIR="$(mktemp -d)"
|
|
17
|
-
REMOTE_DIR="$(mktemp -d)"
|
|
18
|
-
|
|
19
|
-
(cd "$REMOTE_DIR" && git init --bare --initial-branch=main >/dev/null 2>&1)
|
|
20
|
-
|
|
21
|
-
cd "$TEST_DIR"
|
|
22
|
-
git init --initial-branch=main >/dev/null 2>&1
|
|
23
|
-
git config user.email "test@example.com"
|
|
24
|
-
git config user.name "Test"
|
|
25
|
-
git remote add origin "$REMOTE_DIR"
|
|
26
|
-
|
|
27
|
-
echo "initial" > README.md
|
|
28
|
-
git add README.md
|
|
29
|
-
git commit -m "initial" >/dev/null 2>&1
|
|
30
|
-
git push -u origin main >/dev/null 2>&1
|
|
31
|
-
|
|
32
|
-
mkdir -p .changeset
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
teardown() {
|
|
36
|
-
cd /
|
|
37
|
-
rm -rf "$TEST_DIR" "$REMOTE_DIR"
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
# Helper: seed a changeset, commit it, and push to origin (queued state).
|
|
41
|
-
seed_queued_changeset() {
|
|
42
|
-
local name="$1"
|
|
43
|
-
cat > ".changeset/${name}.md" <<EOF
|
|
44
|
-
---
|
|
45
|
-
"@windyroad/itil": patch
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
${name} changeset body.
|
|
49
|
-
EOF
|
|
50
|
-
git add ".changeset/${name}.md"
|
|
51
|
-
git commit -m "add changeset ${name}" >/dev/null 2>&1
|
|
52
|
-
git push origin main >/dev/null 2>&1
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
# Helper: seed a changeset and commit it locally without pushing (pending state).
|
|
56
|
-
seed_pending_changeset() {
|
|
57
|
-
local name="$1"
|
|
58
|
-
cat > ".changeset/${name}.md" <<EOF
|
|
59
|
-
---
|
|
60
|
-
"@windyroad/itil": patch
|
|
61
|
-
---
|
|
62
|
-
|
|
63
|
-
${name} changeset body.
|
|
64
|
-
EOF
|
|
65
|
-
git add ".changeset/${name}.md"
|
|
66
|
-
git commit -m "add changeset ${name}" >/dev/null 2>&1
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
# Helper: seed a changeset file but leave it untracked (uncommitted-pending state).
|
|
70
|
-
seed_untracked_changeset() {
|
|
71
|
-
local name="$1"
|
|
72
|
-
cat > ".changeset/${name}.md" <<EOF
|
|
73
|
-
---
|
|
74
|
-
"@windyroad/itil": patch
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
${name} changeset body.
|
|
78
|
-
EOF
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
@test "emits queued+pending breakdown when changesets straddle origin" {
|
|
82
|
-
cd "$TEST_DIR"
|
|
83
|
-
seed_queued_changeset queued-one
|
|
84
|
-
seed_pending_changeset pending-one
|
|
85
|
-
|
|
86
|
-
run bash "$SCRIPT" --unreleased
|
|
87
|
-
[ "$status" -eq 0 ]
|
|
88
|
-
|
|
89
|
-
echo "$output" | grep -q "Queued changesets (commits already on origin): 1" || {
|
|
90
|
-
echo "Missing 'Queued changesets (commits already on origin): 1' line."
|
|
91
|
-
echo "Output was:"
|
|
92
|
-
echo "$output"
|
|
93
|
-
return 1
|
|
94
|
-
}
|
|
95
|
-
echo "$output" | grep -q "Pending changesets (commits unpushed): 1" || {
|
|
96
|
-
echo "Missing 'Pending changesets (commits unpushed): 1' line."
|
|
97
|
-
echo "Output was:"
|
|
98
|
-
echo "$output"
|
|
99
|
-
return 1
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
@test "all-on-origin scenario: Queued > 0, Pending = 0" {
|
|
104
|
-
cd "$TEST_DIR"
|
|
105
|
-
seed_queued_changeset on-origin-a
|
|
106
|
-
seed_queued_changeset on-origin-b
|
|
107
|
-
|
|
108
|
-
run bash "$SCRIPT" --unreleased
|
|
109
|
-
[ "$status" -eq 0 ]
|
|
110
|
-
|
|
111
|
-
echo "$output" | grep -q "Queued changesets (commits already on origin): 2" || {
|
|
112
|
-
echo "Missing 'Queued changesets (commits already on origin): 2' line."
|
|
113
|
-
echo "Output was:"
|
|
114
|
-
echo "$output"
|
|
115
|
-
return 1
|
|
116
|
-
}
|
|
117
|
-
echo "$output" | grep -q "Pending changesets (commits unpushed): 0" || {
|
|
118
|
-
echo "Missing 'Pending changesets (commits unpushed): 0' line."
|
|
119
|
-
echo "Output was:"
|
|
120
|
-
echo "$output"
|
|
121
|
-
return 1
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
@test "all-local scenario: Pending > 0, Queued = 0" {
|
|
126
|
-
cd "$TEST_DIR"
|
|
127
|
-
seed_pending_changeset local-a
|
|
128
|
-
seed_pending_changeset local-b
|
|
129
|
-
|
|
130
|
-
run bash "$SCRIPT" --unreleased
|
|
131
|
-
[ "$status" -eq 0 ]
|
|
132
|
-
|
|
133
|
-
echo "$output" | grep -q "Pending changesets (commits unpushed): 2" || {
|
|
134
|
-
echo "Missing 'Pending changesets (commits unpushed): 2' line."
|
|
135
|
-
echo "Output was:"
|
|
136
|
-
echo "$output"
|
|
137
|
-
return 1
|
|
138
|
-
}
|
|
139
|
-
echo "$output" | grep -q "Queued changesets (commits already on origin): 0" || {
|
|
140
|
-
echo "Missing 'Queued changesets (commits already on origin): 0' line."
|
|
141
|
-
echo "Output was:"
|
|
142
|
-
echo "$output"
|
|
143
|
-
return 1
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
@test "untracked changeset counts as pending" {
|
|
148
|
-
cd "$TEST_DIR"
|
|
149
|
-
seed_untracked_changeset draft-one
|
|
150
|
-
|
|
151
|
-
run bash "$SCRIPT" --unreleased
|
|
152
|
-
[ "$status" -eq 0 ]
|
|
153
|
-
|
|
154
|
-
echo "$output" | grep -q "Pending changesets (commits unpushed): 1" || {
|
|
155
|
-
echo "Untracked changeset should count as pending."
|
|
156
|
-
echo "Output was:"
|
|
157
|
-
echo "$output"
|
|
158
|
-
return 1
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
@test "no changesets emits no breakdown lines" {
|
|
163
|
-
cd "$TEST_DIR"
|
|
164
|
-
# .changeset dir exists but is empty (setup creates it).
|
|
165
|
-
|
|
166
|
-
run bash "$SCRIPT" --unreleased
|
|
167
|
-
[ "$status" -eq 0 ]
|
|
168
|
-
|
|
169
|
-
! echo "$output" | grep -q "Queued changesets" || {
|
|
170
|
-
echo "Should not emit Queued line when no changesets present."
|
|
171
|
-
echo "Output was:"
|
|
172
|
-
echo "$output"
|
|
173
|
-
return 1
|
|
174
|
-
}
|
|
175
|
-
! echo "$output" | grep -q "Pending changesets" || {
|
|
176
|
-
echo "Should not emit Pending line when no changesets present."
|
|
177
|
-
echo "Output was:"
|
|
178
|
-
echo "$output"
|
|
179
|
-
return 1
|
|
180
|
-
}
|
|
181
|
-
}
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bats
|
|
2
|
-
# Tests for pipeline-state.sh --hash-inputs stability
|
|
3
|
-
# Covers P054: the hash must be stable across both commit AND push so the
|
|
4
|
-
# release gate does not fire spurious "state drift" denials after a
|
|
5
|
-
# policy-authorised push advances origin/main.
|
|
6
|
-
|
|
7
|
-
setup() {
|
|
8
|
-
HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
9
|
-
SCRIPT="$HOOKS_DIR/lib/pipeline-state.sh"
|
|
10
|
-
|
|
11
|
-
# Set up a temp git repo with an origin remote so origin/main is meaningful.
|
|
12
|
-
TEST_DIR="$(mktemp -d)"
|
|
13
|
-
REMOTE_DIR="$(mktemp -d)"
|
|
14
|
-
|
|
15
|
-
# Bare remote
|
|
16
|
-
(cd "$REMOTE_DIR" && git init --bare --initial-branch=main >/dev/null 2>&1)
|
|
17
|
-
|
|
18
|
-
# Working repo
|
|
19
|
-
cd "$TEST_DIR"
|
|
20
|
-
git init --initial-branch=main >/dev/null 2>&1
|
|
21
|
-
git config user.email "test@example.com"
|
|
22
|
-
git config user.name "Test"
|
|
23
|
-
git remote add origin "$REMOTE_DIR"
|
|
24
|
-
|
|
25
|
-
# Seed initial commit
|
|
26
|
-
echo "initial" > README.md
|
|
27
|
-
git add README.md
|
|
28
|
-
git commit -m "initial" >/dev/null 2>&1
|
|
29
|
-
git push -u origin main >/dev/null 2>&1
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
teardown() {
|
|
33
|
-
rm -rf "$TEST_DIR" "$REMOTE_DIR"
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
# Helper: compute the hash of --hash-inputs output in the current directory.
|
|
37
|
-
compute_hash() {
|
|
38
|
-
bash "$SCRIPT" --hash-inputs 2>/dev/null | shasum -a 256 | cut -d' ' -f1
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
@test "hash is stable across git commit of staged changes" {
|
|
42
|
-
cd "$TEST_DIR"
|
|
43
|
-
|
|
44
|
-
echo "line 1" > feature.ts
|
|
45
|
-
git add feature.ts
|
|
46
|
-
|
|
47
|
-
HASH_BEFORE=$(compute_hash)
|
|
48
|
-
|
|
49
|
-
git commit -m "add feature" >/dev/null 2>&1
|
|
50
|
-
|
|
51
|
-
HASH_AFTER=$(compute_hash)
|
|
52
|
-
|
|
53
|
-
[ "$HASH_BEFORE" = "$HASH_AFTER" ] || {
|
|
54
|
-
echo "Hash changed on commit."
|
|
55
|
-
echo "before: $HASH_BEFORE"
|
|
56
|
-
echo "after: $HASH_AFTER"
|
|
57
|
-
return 1
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
@test "hash is stable across git push (P054 regression guard)" {
|
|
62
|
-
cd "$TEST_DIR"
|
|
63
|
-
|
|
64
|
-
echo "line 1" > feature.ts
|
|
65
|
-
git add feature.ts
|
|
66
|
-
git commit -m "add feature" >/dev/null 2>&1
|
|
67
|
-
|
|
68
|
-
HASH_BEFORE=$(compute_hash)
|
|
69
|
-
|
|
70
|
-
git push origin main >/dev/null 2>&1
|
|
71
|
-
|
|
72
|
-
HASH_AFTER=$(compute_hash)
|
|
73
|
-
|
|
74
|
-
[ "$HASH_BEFORE" = "$HASH_AFTER" ] || {
|
|
75
|
-
echo "Hash changed on push — this is the P054 regression."
|
|
76
|
-
echo "before: $HASH_BEFORE"
|
|
77
|
-
echo "after: $HASH_AFTER"
|
|
78
|
-
return 1
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
@test "hash is stable across full commit-then-push sequence" {
|
|
83
|
-
cd "$TEST_DIR"
|
|
84
|
-
|
|
85
|
-
echo "line 1" > feature.ts
|
|
86
|
-
git add feature.ts
|
|
87
|
-
|
|
88
|
-
HASH_STAGED=$(compute_hash)
|
|
89
|
-
|
|
90
|
-
git commit -m "add feature" >/dev/null 2>&1
|
|
91
|
-
HASH_COMMITTED=$(compute_hash)
|
|
92
|
-
|
|
93
|
-
git push origin main >/dev/null 2>&1
|
|
94
|
-
HASH_PUSHED=$(compute_hash)
|
|
95
|
-
|
|
96
|
-
[ "$HASH_STAGED" = "$HASH_COMMITTED" ] || {
|
|
97
|
-
echo "Hash changed on commit step."
|
|
98
|
-
return 1
|
|
99
|
-
}
|
|
100
|
-
[ "$HASH_COMMITTED" = "$HASH_PUSHED" ] || {
|
|
101
|
-
echo "Hash changed on push step."
|
|
102
|
-
return 1
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
@test "hash changes when a new tracked file is edited" {
|
|
107
|
-
cd "$TEST_DIR"
|
|
108
|
-
|
|
109
|
-
echo "v1" > feature.ts
|
|
110
|
-
git add feature.ts
|
|
111
|
-
git commit -m "v1" >/dev/null 2>&1
|
|
112
|
-
git push origin main >/dev/null 2>&1
|
|
113
|
-
|
|
114
|
-
HASH_BEFORE=$(compute_hash)
|
|
115
|
-
|
|
116
|
-
echo "v2" > feature.ts
|
|
117
|
-
|
|
118
|
-
HASH_AFTER=$(compute_hash)
|
|
119
|
-
|
|
120
|
-
[ "$HASH_BEFORE" != "$HASH_AFTER" ] || {
|
|
121
|
-
echo "Hash did not change on content edit."
|
|
122
|
-
return 1
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
@test "hash changes when a new changeset is added" {
|
|
127
|
-
cd "$TEST_DIR"
|
|
128
|
-
|
|
129
|
-
mkdir -p .changeset
|
|
130
|
-
HASH_BEFORE=$(compute_hash)
|
|
131
|
-
|
|
132
|
-
cat > .changeset/abc.md <<'CS'
|
|
133
|
-
---
|
|
134
|
-
"@windyroad/itil": patch
|
|
135
|
-
---
|
|
136
|
-
|
|
137
|
-
Fix something.
|
|
138
|
-
CS
|
|
139
|
-
|
|
140
|
-
HASH_AFTER=$(compute_hash)
|
|
141
|
-
|
|
142
|
-
[ "$HASH_BEFORE" != "$HASH_AFTER" ] || {
|
|
143
|
-
echo "Hash did not change on new changeset."
|
|
144
|
-
return 1
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
@test "hash is stable with no upstream remote (works on a fresh repo)" {
|
|
149
|
-
# Fresh repo with no remote tracking branch.
|
|
150
|
-
NO_REMOTE_DIR="$(mktemp -d)"
|
|
151
|
-
cd "$NO_REMOTE_DIR"
|
|
152
|
-
git init --initial-branch=main >/dev/null 2>&1
|
|
153
|
-
git config user.email "test@example.com"
|
|
154
|
-
git config user.name "Test"
|
|
155
|
-
echo "init" > README.md
|
|
156
|
-
git add README.md
|
|
157
|
-
git commit -m "init" >/dev/null 2>&1
|
|
158
|
-
|
|
159
|
-
# Should not error out, should emit a hash input
|
|
160
|
-
run bash "$SCRIPT" --hash-inputs
|
|
161
|
-
[ "$status" -eq 0 ]
|
|
162
|
-
[ -n "$output" ]
|
|
163
|
-
|
|
164
|
-
# Two consecutive calls should produce the same hash (deterministic)
|
|
165
|
-
HASH_A=$(compute_hash)
|
|
166
|
-
HASH_B=$(compute_hash)
|
|
167
|
-
[ "$HASH_A" = "$HASH_B" ]
|
|
168
|
-
|
|
169
|
-
rm -rf "$NO_REMOTE_DIR"
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
@test "hash is stable with clean working tree (no stash-create content)" {
|
|
173
|
-
cd "$TEST_DIR"
|
|
174
|
-
# Working tree is clean after setup's push. Two consecutive calls are stable.
|
|
175
|
-
HASH_A=$(compute_hash)
|
|
176
|
-
HASH_B=$(compute_hash)
|
|
177
|
-
[ "$HASH_A" = "$HASH_B" ] || {
|
|
178
|
-
echo "Hash is non-deterministic on clean tree."
|
|
179
|
-
return 1
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
@test "hash is stable with dirty working tree (uncommitted edits)" {
|
|
184
|
-
cd "$TEST_DIR"
|
|
185
|
-
echo "dirty" > work.txt
|
|
186
|
-
git add work.txt
|
|
187
|
-
echo "more dirty" > unstaged.txt
|
|
188
|
-
|
|
189
|
-
HASH_A=$(compute_hash)
|
|
190
|
-
HASH_B=$(compute_hash)
|
|
191
|
-
[ "$HASH_A" = "$HASH_B" ] || {
|
|
192
|
-
echo "Hash is non-deterministic on dirty tree."
|
|
193
|
-
return 1
|
|
194
|
-
}
|
|
195
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bats
|
|
2
|
-
|
|
3
|
-
# P096 Phase 2: plan-risk-guidance.sh applies once-per-session gating
|
|
4
|
-
# (ADR-038 progressive disclosure pattern) so the advisory body emits
|
|
5
|
-
# in full only on the first EnterPlanMode of a session. Subsequent
|
|
6
|
-
# EnterPlanMode events within the same session emit a terse reminder
|
|
7
|
-
# (≤150 bytes payload after the systemMessage prefix).
|
|
8
|
-
#
|
|
9
|
-
# Reuses the shared session-marker.sh helper synced from
|
|
10
|
-
# packages/shared/hooks/lib/session-marker.sh.
|
|
11
|
-
|
|
12
|
-
setup() {
|
|
13
|
-
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
|
|
14
|
-
HOOK="$REPO_ROOT/packages/risk-scorer/hooks/plan-risk-guidance.sh"
|
|
15
|
-
|
|
16
|
-
WORKDIR="$(mktemp -d)"
|
|
17
|
-
# Minimal RISK-POLICY.md so the appetite extraction has something to read.
|
|
18
|
-
cat > "$WORKDIR/RISK-POLICY.md" <<'POLICY'
|
|
19
|
-
# Risk Policy
|
|
20
|
-
Threshold: 4
|
|
21
|
-
POLICY
|
|
22
|
-
|
|
23
|
-
SID="plan-risk-guidance-test-$$-$RANDOM"
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
teardown() {
|
|
27
|
-
rm -f "/tmp/risk-scorer-plan-guidance-announced-${SID}"
|
|
28
|
-
rm -f "/tmp/risk-scorer-plan-guidance-announced-${SID}-alt"
|
|
29
|
-
rm -rf "$WORKDIR"
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
run_hook() {
|
|
33
|
-
local sid="$1"
|
|
34
|
-
(cd "$WORKDIR" && \
|
|
35
|
-
echo "{\"session_id\":\"$sid\",\"tool_name\":\"EnterPlanMode\"}" | \
|
|
36
|
-
bash "$HOOK")
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
@test "plan-risk-guidance: first invocation emits the full RELEASE RISK GUIDANCE body" {
|
|
40
|
-
run run_hook "$SID"
|
|
41
|
-
[ "$status" -eq 0 ]
|
|
42
|
-
[[ "$output" == *"RELEASE RISK GUIDANCE FOR PLANNING"* ]]
|
|
43
|
-
[[ "$output" == *"Release risk:"* ]]
|
|
44
|
-
[[ "$output" == *"Appetite threshold"* ]]
|
|
45
|
-
[[ "$output" == *"release strategy"* ]] || [[ "$output" == *"release queue first"* ]]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
@test "plan-risk-guidance: first invocation writes the announcement marker" {
|
|
49
|
-
run_hook "$SID" >/dev/null
|
|
50
|
-
[ -f "/tmp/risk-scorer-plan-guidance-announced-${SID}" ]
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
@test "plan-risk-guidance: second invocation in the same session emits a terse reminder" {
|
|
54
|
-
run_hook "$SID" >/dev/null
|
|
55
|
-
run run_hook "$SID"
|
|
56
|
-
[ "$status" -eq 0 ]
|
|
57
|
-
# Terse reminder MUST carry imperative signal word + gate name + cross-ref.
|
|
58
|
-
[[ "$output" == *"MANDATORY"* ]] || [[ "$output" == *"REQUIRED"* ]] || [[ "$output" == *"NON-OPTIONAL"* ]]
|
|
59
|
-
[[ "$output" == *"release-risk gate"* ]] || [[ "$output" == *"risk"* ]]
|
|
60
|
-
# Must NOT re-emit the full prose (release-strategy listing, projected-risk paragraph).
|
|
61
|
-
[[ "$output" != *"RELEASE RISK GUIDANCE FOR PLANNING"* ]]
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
@test "plan-risk-guidance: second invocation reminder payload is ≤300 bytes" {
|
|
65
|
-
run_hook "$SID" >/dev/null
|
|
66
|
-
run run_hook "$SID"
|
|
67
|
-
# Total response is JSON wrapper + systemMessage; reminder body must be
|
|
68
|
-
# short enough that the full response fits well under the ADR-038 budget.
|
|
69
|
-
[ "${#output}" -lt 600 ]
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
@test "plan-risk-guidance: different session_id re-emits the full body" {
|
|
73
|
-
run_hook "$SID" >/dev/null
|
|
74
|
-
local SID2="${SID}-alt"
|
|
75
|
-
run run_hook "$SID2"
|
|
76
|
-
[[ "$output" == *"RELEASE RISK GUIDANCE FOR PLANNING"* ]]
|
|
77
|
-
rm -f "/tmp/risk-scorer-plan-guidance-announced-${SID2}"
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
@test "plan-risk-guidance: empty session_id emits the full body and writes no marker" {
|
|
81
|
-
run run_hook ""
|
|
82
|
-
[[ "$output" == *"RELEASE RISK GUIDANCE FOR PLANNING"* ]]
|
|
83
|
-
# Empty SESSION_ID fallback per shared session-marker contract.
|
|
84
|
-
[ ! -f "/tmp/risk-scorer-plan-guidance-announced-" ]
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
@test "plan-risk-guidance: emits valid JSON with permissionDecision allow on both first and subsequent invocations" {
|
|
88
|
-
run run_hook "$SID"
|
|
89
|
-
[[ "$output" == *'"permissionDecision": "allow"'* ]]
|
|
90
|
-
[[ "$output" == *'"hookEventName": "PreToolUse"'* ]]
|
|
91
|
-
|
|
92
|
-
run run_hook "$SID"
|
|
93
|
-
[[ "$output" == *'"permissionDecision": "allow"'* ]]
|
|
94
|
-
[[ "$output" == *'"hookEventName": "PreToolUse"'* ]]
|
|
95
|
-
}
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bats
|
|
2
|
-
# P192: Risk-reducing/within-appetite bypass markers (`reducing-commit`,
|
|
3
|
-
# `reducing-push`, `reducing-release`) must persist across multiple commits/
|
|
4
|
-
# pushes/releases within the standard TTL window AS LONG AS the pipeline-state
|
|
5
|
-
# hash still matches what was scored — eliminating the per-commit re-mint
|
|
6
|
-
# round-trip that drives the 3+-rescores-per-session friction. Drift or TTL
|
|
7
|
-
# expiry consumes the marker and forces a fresh `wr-risk-scorer:pipeline`
|
|
8
|
-
# rescore. `incident-release` remains single-use (deliberate one-time
|
|
9
|
-
# override).
|
|
10
|
-
#
|
|
11
|
-
# Behavioural contract:
|
|
12
|
-
# (a) reducing-* marker exists + tree hash matches stored state-hash + TTL
|
|
13
|
-
# not expired → gate allows AND marker persists (reusable).
|
|
14
|
-
# (b) reducing-* marker exists + tree hash differs from state-hash → marker
|
|
15
|
-
# consumed, gate falls through to check_risk_gate (which denies on
|
|
16
|
-
# drift or missing score).
|
|
17
|
-
# (c) reducing-* marker exists + TTL expired (relative to marker mtime) →
|
|
18
|
-
# marker consumed, gate falls through.
|
|
19
|
-
# (d) incident-release marker stays single-use (unchanged behaviour) —
|
|
20
|
-
# regression guard.
|
|
21
|
-
#
|
|
22
|
-
# Tests invoke the gate hooks directly (script + stdin JSON), the way the
|
|
23
|
-
# Claude Code hook runtime calls them.
|
|
24
|
-
|
|
25
|
-
setup() {
|
|
26
|
-
HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
27
|
-
COMMIT_GATE="$HOOKS_DIR/risk-score-commit-gate.sh"
|
|
28
|
-
PUSH_GATE="$HOOKS_DIR/git-push-gate.sh"
|
|
29
|
-
|
|
30
|
-
TEST_SESSION="bats-p192-$$-${BATS_TEST_NUMBER}"
|
|
31
|
-
RDIR="${TMPDIR:-/tmp}/claude-risk-${TEST_SESSION}"
|
|
32
|
-
rm -rf "$RDIR"
|
|
33
|
-
mkdir -p "$RDIR"
|
|
34
|
-
|
|
35
|
-
# Minimal git repo so pipeline-state.sh --hash-inputs produces a stable
|
|
36
|
-
# tree hash (git stash create needs a real repo).
|
|
37
|
-
TMP_REPO="$(mktemp -d)"
|
|
38
|
-
cd "$TMP_REPO"
|
|
39
|
-
git init -q -b main
|
|
40
|
-
git config user.email "test@example.com"
|
|
41
|
-
git config user.name "Test"
|
|
42
|
-
cat > RISK-POLICY.md <<EOF
|
|
43
|
-
# Risk Policy
|
|
44
|
-
|
|
45
|
-
Last reviewed: $(date -u +%Y-%m-%d)
|
|
46
|
-
|
|
47
|
-
## Risk Appetite
|
|
48
|
-
|
|
49
|
-
Pipeline gates block when cumulative residual risk exceeds 4.
|
|
50
|
-
EOF
|
|
51
|
-
git add RISK-POLICY.md
|
|
52
|
-
git commit -q -m "initial"
|
|
53
|
-
|
|
54
|
-
# Default short TTL so we can exercise expiry without slow tests.
|
|
55
|
-
export RISK_TTL=5
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
teardown() {
|
|
59
|
-
rm -rf "$RDIR"
|
|
60
|
-
rm -rf "$TMP_REPO"
|
|
61
|
-
unset RISK_TTL 2>/dev/null || true
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
# Compute the current pipeline-state hash the same way the gate does
|
|
65
|
-
_current_hash() {
|
|
66
|
-
bash -c "
|
|
67
|
-
source '$HOOKS_DIR/lib/gate-helpers.sh'
|
|
68
|
-
'$HOOKS_DIR/lib/pipeline-state.sh' --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1
|
|
69
|
-
"
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
# Portable backdate by N seconds
|
|
73
|
-
_backdate() {
|
|
74
|
-
local file="$1" seconds="$2"
|
|
75
|
-
local stamp
|
|
76
|
-
stamp=$(date -v-${seconds}S +%Y%m%d%H%M.%S 2>/dev/null \
|
|
77
|
-
|| date -d "${seconds} seconds ago" +%Y%m%d%H%M.%S 2>/dev/null)
|
|
78
|
-
touch -t "$stamp" "$file"
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
invoke_commit_gate() {
|
|
82
|
-
local cmd="$1"
|
|
83
|
-
local input
|
|
84
|
-
input=$(python3 -c "
|
|
85
|
-
import json, sys
|
|
86
|
-
print(json.dumps({
|
|
87
|
-
'tool_name': 'Bash',
|
|
88
|
-
'tool_input': {'command': sys.argv[1]},
|
|
89
|
-
'session_id': sys.argv[2],
|
|
90
|
-
}))
|
|
91
|
-
" "$cmd" "$TEST_SESSION")
|
|
92
|
-
echo "$input" | bash "$COMMIT_GATE"
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
invoke_push_gate() {
|
|
96
|
-
local cmd="$1"
|
|
97
|
-
local input
|
|
98
|
-
input=$(python3 -c "
|
|
99
|
-
import json, sys
|
|
100
|
-
print(json.dumps({
|
|
101
|
-
'tool_name': 'Bash',
|
|
102
|
-
'tool_input': {'command': sys.argv[1]},
|
|
103
|
-
'session_id': sys.argv[2],
|
|
104
|
-
}))
|
|
105
|
-
" "$cmd" "$TEST_SESSION")
|
|
106
|
-
echo "$input" | bash "$PUSH_GATE"
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
# ---------------------------------------------------------------------------
|
|
110
|
-
# Commit gate — reducing-commit persistence
|
|
111
|
-
# ---------------------------------------------------------------------------
|
|
112
|
-
|
|
113
|
-
@test "reducing-commit marker persists when tree hash matches stored state-hash" {
|
|
114
|
-
HASH=$(_current_hash)
|
|
115
|
-
echo "$HASH" > "$RDIR/state-hash"
|
|
116
|
-
touch "$RDIR/reducing-commit"
|
|
117
|
-
|
|
118
|
-
run invoke_commit_gate 'git commit -m "x"'
|
|
119
|
-
[ "$status" -eq 0 ]
|
|
120
|
-
[[ "$output" != *"deny"* ]]
|
|
121
|
-
|
|
122
|
-
# Marker MUST still exist after a successful allow — this is the load-
|
|
123
|
-
# bearing behaviour change.
|
|
124
|
-
[ -f "$RDIR/reducing-commit" ]
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
@test "reducing-commit marker survives back-to-back commits (no rescore round-trip)" {
|
|
128
|
-
HASH=$(_current_hash)
|
|
129
|
-
echo "$HASH" > "$RDIR/state-hash"
|
|
130
|
-
touch "$RDIR/reducing-commit"
|
|
131
|
-
|
|
132
|
-
# Three sequential allows — current single-use marker consumes on first,
|
|
133
|
-
# leaving #2 and #3 to fall through to check_risk_gate (which denies on
|
|
134
|
-
# missing score). New persistent-within-TTL contract: all three pass.
|
|
135
|
-
run invoke_commit_gate 'git commit -m "1"'; [ "$status" -eq 0 ]; [[ "$output" != *"deny"* ]]
|
|
136
|
-
run invoke_commit_gate 'git commit -m "2"'; [ "$status" -eq 0 ]; [[ "$output" != *"deny"* ]]
|
|
137
|
-
run invoke_commit_gate 'git commit -m "3"'; [ "$status" -eq 0 ]; [[ "$output" != *"deny"* ]]
|
|
138
|
-
|
|
139
|
-
[ -f "$RDIR/reducing-commit" ]
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
@test "reducing-commit marker is consumed when tree hash drifts from stored state-hash" {
|
|
143
|
-
echo "stale-hash-value-from-prior-tree" > "$RDIR/state-hash"
|
|
144
|
-
touch "$RDIR/reducing-commit"
|
|
145
|
-
|
|
146
|
-
run invoke_commit_gate 'git commit -m "x"'
|
|
147
|
-
# Marker MUST be consumed when drift detected.
|
|
148
|
-
[ ! -f "$RDIR/reducing-commit" ]
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
@test "reducing-commit marker is consumed when TTL has expired" {
|
|
152
|
-
HASH=$(_current_hash)
|
|
153
|
-
echo "$HASH" > "$RDIR/state-hash"
|
|
154
|
-
touch "$RDIR/reducing-commit"
|
|
155
|
-
_backdate "$RDIR/reducing-commit" 10 # TTL is 5
|
|
156
|
-
|
|
157
|
-
run invoke_commit_gate 'git commit -m "x"'
|
|
158
|
-
# TTL-expired marker MUST be consumed and the gate must NOT silently allow
|
|
159
|
-
# purely on marker presence.
|
|
160
|
-
[ ! -f "$RDIR/reducing-commit" ]
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
@test "reducing-commit marker without state-hash file is consumed (no invariance proof)" {
|
|
164
|
-
rm -f "$RDIR/state-hash"
|
|
165
|
-
touch "$RDIR/reducing-commit"
|
|
166
|
-
|
|
167
|
-
run invoke_commit_gate 'git commit -m "x"'
|
|
168
|
-
# No way to verify tree-invariance → consume the marker rather than ride it.
|
|
169
|
-
[ ! -f "$RDIR/reducing-commit" ]
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
# ---------------------------------------------------------------------------
|
|
173
|
-
# Push gate — reducing-push persistence
|
|
174
|
-
# ---------------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
@test "reducing-push marker persists when tree hash matches stored state-hash" {
|
|
177
|
-
HASH=$(_current_hash)
|
|
178
|
-
echo "$HASH" > "$RDIR/state-hash"
|
|
179
|
-
touch "$RDIR/reducing-push"
|
|
180
|
-
|
|
181
|
-
run invoke_push_gate 'npm run push:watch'
|
|
182
|
-
[ "$status" -eq 0 ]
|
|
183
|
-
[[ "$output" != *"deny"* ]]
|
|
184
|
-
|
|
185
|
-
[ -f "$RDIR/reducing-push" ]
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
@test "reducing-push marker is consumed when tree hash drifts" {
|
|
189
|
-
echo "stale-hash" > "$RDIR/state-hash"
|
|
190
|
-
touch "$RDIR/reducing-push"
|
|
191
|
-
|
|
192
|
-
run invoke_push_gate 'npm run push:watch'
|
|
193
|
-
[ ! -f "$RDIR/reducing-push" ]
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
# ---------------------------------------------------------------------------
|
|
197
|
-
# Release gate — reducing-release persistence
|
|
198
|
-
# ---------------------------------------------------------------------------
|
|
199
|
-
|
|
200
|
-
@test "reducing-release marker persists when tree hash matches stored state-hash" {
|
|
201
|
-
HASH=$(_current_hash)
|
|
202
|
-
echo "$HASH" > "$RDIR/state-hash"
|
|
203
|
-
touch "$RDIR/reducing-release"
|
|
204
|
-
|
|
205
|
-
run invoke_push_gate 'npm run release:watch'
|
|
206
|
-
[ "$status" -eq 0 ]
|
|
207
|
-
[[ "$output" != *"deny"* ]]
|
|
208
|
-
|
|
209
|
-
[ -f "$RDIR/reducing-release" ]
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
@test "reducing-release marker is consumed when tree hash drifts" {
|
|
213
|
-
echo "stale-hash" > "$RDIR/state-hash"
|
|
214
|
-
touch "$RDIR/reducing-release"
|
|
215
|
-
|
|
216
|
-
run invoke_push_gate 'npm run release:watch'
|
|
217
|
-
[ ! -f "$RDIR/reducing-release" ]
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
# ---------------------------------------------------------------------------
|
|
221
|
-
# incident-release — single-use regression guard
|
|
222
|
-
# ---------------------------------------------------------------------------
|
|
223
|
-
|
|
224
|
-
@test "incident-release marker REMAINS single-use (regression guard)" {
|
|
225
|
-
HASH=$(_current_hash)
|
|
226
|
-
echo "$HASH" > "$RDIR/state-hash"
|
|
227
|
-
touch "$RDIR/incident-release"
|
|
228
|
-
|
|
229
|
-
run invoke_push_gate 'npm run release:watch'
|
|
230
|
-
[ "$status" -eq 0 ]
|
|
231
|
-
[[ "$output" != *"deny"* ]]
|
|
232
|
-
|
|
233
|
-
# incident bypass is a deliberate one-time override — must be consumed
|
|
234
|
-
# even when tree hash matches.
|
|
235
|
-
[ ! -f "$RDIR/incident-release" ]
|
|
236
|
-
}
|