@windyroad/risk-scorer 0.3.4 → 0.3.5

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-risk-scorer",
3
- "version": "0.3.0",
3
+ "version": "0.3.5",
4
4
  "description": "Pipeline risk scoring, commit/push/release gates for Claude Code"
5
- }
5
+ }
@@ -33,25 +33,41 @@ for arg in "$@"; do
33
33
  done
34
34
 
35
35
  # --- Fast hash inputs mode: output only metadata for drift detection hash ---
36
- # NOTE: Do NOT include `git status --porcelain` (changes on staging) or
37
- # `git rev-parse HEAD` (changes on commit). Both cause false drift detection.
38
- # Use diff --stat which reflects actual content changes only.
36
+ # Tree-based hash stable across BOTH commit and push operations (P054).
37
+ # Captures the conceptual "HEAD + index + working tree" state by asking git
38
+ # to build a tree object that reflects what a commit of the current state
39
+ # would contain. This makes the hash invariant over:
40
+ # - git commit (content moves from index to HEAD; tree SHA is identical)
41
+ # - git push (HEAD is unchanged locally; tree SHA is identical)
42
+ # The previous approach hashed `git diff origin/main --stat`, which shrinks
43
+ # to empty after a push advances origin/main, producing spurious drift
44
+ # denials on npm run release:watch even though the commits being released
45
+ # were the same ones already scored.
39
46
  if [ "$SHOW_HASH_INPUTS" = true ]; then
40
- # Single stable diff: all local changes (staged + committed) vs remote.
41
- # This is stable across commit and push operations — content moves between
42
- # pipeline stages (staged → committed → pushed) without changing the hash.
43
- # Previous approach used git diff HEAD + git diff origin/main..HEAD which
44
- # broke on every commit because content shifted between the two diffs.
45
- # Exclude docs/governance paths from hash — they cannot affect the running application
47
+ # Exclude docs/governance paths they cannot affect the running application.
46
48
  EXCL=$(_doc_exclusions)
47
- if git rev-parse --verify origin/main >/dev/null 2>&1; then
48
- eval "git diff origin/main --stat -- $EXCL" 2>/dev/null || true
49
- elif git rev-parse --verify origin/master >/dev/null 2>&1; then
50
- eval "git diff origin/master --stat -- $EXCL" 2>/dev/null || true
49
+ # git stash create writes a commit object (tree + parents) representing
50
+ # index + working tree, without touching HEAD, the index, or any refs.
51
+ # Returns empty when there is nothing to stash (clean tree).
52
+ STASH_COMMIT=$(git stash create 2>/dev/null || true)
53
+ if [ -n "$STASH_COMMIT" ]; then
54
+ CONCEPTUAL_TREE=$(git rev-parse "${STASH_COMMIT}^{tree}" 2>/dev/null || echo "")
51
55
  else
52
- eval "git diff HEAD --stat -- $EXCL" 2>/dev/null || true
56
+ CONCEPTUAL_TREE=$(git rev-parse HEAD^{tree} 2>/dev/null || echo "")
53
57
  fi
54
- # Changeset count (affects release/changeset risk)
58
+ if [ -n "$CONCEPTUAL_TREE" ]; then
59
+ # Diff against the empty tree to enumerate every file in the
60
+ # conceptual tree with its blob SHA (shown as "added"). git diff
61
+ # supports `:!` pathspec exclusions where `git ls-tree` does not,
62
+ # so this is the path that honours the doc exclusions above.
63
+ # Same-content trees produce identical output regardless of which
64
+ # ref points at them, which is what gives push stability.
65
+ # 4b825dc642cb6eb9a060e54bf8d69288fbee4904 is git's well-known empty-tree SHA.
66
+ eval "git diff --raw 4b825dc642cb6eb9a060e54bf8d69288fbee4904 $CONCEPTUAL_TREE -- $EXCL" 2>/dev/null || true
67
+ fi
68
+ # Changeset count (affects release/changeset risk — tracked separately
69
+ # because .changeset/ is in the doc-exclusions list and therefore not
70
+ # reflected in the tree listing above).
55
71
  if [ -d ".changeset" ]; then
56
72
  find .changeset -name '*.md' -not -name 'README.md' 2>/dev/null | wc -l | tr -d ' '
57
73
  fi
@@ -0,0 +1,195 @@
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "bin": {
6
6
  "windyroad-risk-scorer": "./bin/install.mjs"