@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.
Files changed (55) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +13 -2
  5. package/bin/install.mjs +42 -8
  6. package/hooks/codex-agent-completion.mjs +82 -0
  7. package/hooks/hooks.json +5 -2
  8. package/hooks/risk-scorer-dispatch.sh +3 -0
  9. package/hooks/risk-scorer-scaffold-nudge.sh +15 -4
  10. package/package.json +5 -2
  11. package/scripts/codex-agents.mjs +208 -0
  12. package/scripts/sync-codex-skills.mjs +8 -3
  13. package/skills/assess-external-comms/SKILL.md +5 -4
  14. package/skills/assess-inbound-report/SKILL.md +5 -4
  15. package/skills/assess-release/SKILL.md +5 -4
  16. package/skills/assess-wip/SKILL.md +5 -4
  17. package/skills/bootstrap-catalog/SKILL.md +5 -4
  18. package/skills/create-risk/SKILL.md +5 -4
  19. package/skills/external-comms/SKILL.md +5 -4
  20. package/skills/pipeline/SKILL.md +5 -4
  21. package/skills/update-policy/SKILL.md +6 -5
  22. package/skills/wip/SKILL.md +5 -4
  23. package/agents/test/inbound-report-contract.bats +0 -225
  24. package/agents/test/risk-scorer-above-appetite-stop.bats +0 -80
  25. package/agents/test/risk-scorer-catalog-consumption.bats +0 -138
  26. package/agents/test/risk-scorer-commit-verdict.bats +0 -66
  27. package/agents/test/risk-scorer-monitoring-not-a-control.bats +0 -76
  28. package/agents/test/risk-scorer-reducing-bypass-criteria.bats +0 -62
  29. package/agents/test/risk-scorer-register-hint.bats +0 -111
  30. package/agents/test/risk-scorer-structured-remediations.bats +0 -123
  31. package/agents/test/risk-scorer-user-stated-preconditions.bats +0 -89
  32. package/hooks/test/ci-status-gate.bats +0 -234
  33. package/hooks/test/external-comms-gate.bats +0 -528
  34. package/hooks/test/external-comms-key-substance.bats +0 -74
  35. package/hooks/test/git-push-gate.bats +0 -82
  36. package/hooks/test/pipeline-state-changeset-partition.bats +0 -181
  37. package/hooks/test/pipeline-state-hash.bats +0 -195
  38. package/hooks/test/plan-risk-guidance-once-per-session.bats +0 -95
  39. package/hooks/test/reducing-marker-persistence.bats +0 -236
  40. package/hooks/test/risk-gate.bats +0 -330
  41. package/hooks/test/risk-policy-project-root.bats +0 -20
  42. package/hooks/test/risk-score-commit-gate-adr-031-bypass.bats +0 -98
  43. package/hooks/test/risk-score-commit-gate-cadence-staleness.bats +0 -105
  44. package/hooks/test/risk-score-mark-external-comms-prompt-parse.bats +0 -94
  45. package/hooks/test/risk-score-mark-register-queue.bats +0 -253
  46. package/hooks/test/risk-score-mark.bats +0 -112
  47. package/hooks/test/risk-scorer-hook-dispatch.bats +0 -77
  48. package/hooks/test/risk-scorer-no-stop-hook.bats +0 -19
  49. package/hooks/test/risk-scorer-scaffold-nudge.bats +0 -132
  50. package/hooks/test/slide-marker-on-subprocess-return.bats +0 -118
  51. package/hooks/test/wip-risk-mark.bats +0 -64
  52. package/scripts/test/drain-register-queue.bats +0 -359
  53. package/scripts/test/evaluate-graduation.bats +0 -533
  54. package/scripts/test/extract-risks-from-reports.bats +0 -366
  55. 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
- }