@windyroad/itil 0.30.2-preview.317 → 0.30.3-preview.319

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.
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P087 — Phase 2b git-axis script behavioural confirmation.
4
+ #
5
+ # Contract under test: `packages/itil/scripts/plugin-exercise-index.sh` runs
6
+ # `git log --since=<N>d --name-only --pretty=format:%H|%aI|%s` once at the
7
+ # project root, auto-discovers plugins by listing `packages/*/`, and emits
8
+ # one NDJSON record per plugin with the v1.0 schema fields per ADR-058
9
+ # §Script contracts (line 87-113). Exit code 0 always per ADR-013 Rule 6
10
+ # fail-safe (outside-git-repo, missing `packages/`, opt-out marker all hit
11
+ # the zero-records/stderr-comment path).
12
+ #
13
+ # Confirmation criteria 6-8 from ADR-058 §Confirmation are the load-bearing
14
+ # behavioural assertions in this file. Sibling to Phase 2a's
15
+ # `skill-invocations.bats` (criteria 1-5).
16
+ #
17
+ # @adr ADR-058 (Plugin maturity measurement mechanism)
18
+ # @adr ADR-049 (Shim grammar — `wr-itil-plugin-exercise-index` on $PATH)
19
+ # @adr ADR-035 (Privacy posture — opt-out marker, no network primitive,
20
+ # content sanitisation — commit subjects parsed only for `BREAKING|feat!|fix!`
21
+ # token presence, never echoed to stdout)
22
+ # @adr ADR-052 (Behavioural tests default — NDJSON-output-driven against
23
+ # fixture git repos, not source-greps on script body; the no-network
24
+ # negative-grep at Confirmation #3 is the documented carve-out)
25
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
26
+ # @jtbd JTBD-101 (Extend the Suite — hardening-prioritisation outcome,
27
+ # commits_window + closed_tickets_window + days_shipped serve the
28
+ # git-axis half of the 2026-05-04 outcome amendment)
29
+ # @jtbd JTBD-201 (Restore Service Fast — audit-trail composition)
30
+
31
+ setup() {
32
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
33
+ SCRIPT="$SCRIPTS_DIR/plugin-exercise-index.sh"
34
+ FIXTURE_DIR="$(mktemp -d)"
35
+ REPO_ROOT="$FIXTURE_DIR/repo"
36
+ mkdir -p "$REPO_ROOT"
37
+ }
38
+
39
+ teardown() {
40
+ rm -rf "$FIXTURE_DIR"
41
+ }
42
+
43
+ # Helper: initialise a temp git repo at REPO_ROOT with a stable author/email
44
+ # (otherwise `git commit` aborts on systems without user.name configured).
45
+ init_repo() {
46
+ (
47
+ cd "$REPO_ROOT"
48
+ git init -q -b main
49
+ git config user.email "bats@example.com"
50
+ git config user.name "bats"
51
+ git config commit.gpgsign false
52
+ )
53
+ }
54
+
55
+ # Helper: stage a file under packages/<plugin>/ and commit at a given date.
56
+ # Date is ISO 8601 (e.g. 2026-05-01T12:00:00) — fed to GIT_AUTHOR_DATE /
57
+ # GIT_COMMITTER_DATE so log --since works deterministically.
58
+ commit_under_plugin() {
59
+ local plugin="$1"; local relpath="$2"; local subject="$3"; local date="$4"
60
+ local full="$REPO_ROOT/packages/$plugin/$relpath"
61
+ mkdir -p "$(dirname "$full")"
62
+ echo "content-$RANDOM" >> "$full"
63
+ (
64
+ cd "$REPO_ROOT"
65
+ GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \
66
+ git -c user.email=bats@example.com -c user.name=bats \
67
+ add "packages/$plugin/$relpath" >/dev/null
68
+ GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \
69
+ git -c user.email=bats@example.com -c user.name=bats \
70
+ commit -q -m "$subject"
71
+ )
72
+ }
73
+
74
+ # Helper: ISO timestamp N days ago (UTC). Cross-platform — uses python3.
75
+ days_ago_iso() {
76
+ python3 -c "import sys, datetime; print((datetime.datetime.utcnow() - datetime.timedelta(days=int(sys.argv[1]))).strftime('%Y-%m-%dT%H:%M:%S'))" "$1"
77
+ }
78
+
79
+ # ── Existence / executable ──────────────────────────────────────────────────
80
+
81
+ @test "plugin-exercise-index: canonical script exists" {
82
+ [ -f "$SCRIPT" ]
83
+ }
84
+
85
+ @test "plugin-exercise-index: canonical script is executable" {
86
+ [ -x "$SCRIPT" ]
87
+ }
88
+
89
+ @test "plugin-exercise-index: shim file exists with ADR-049 grammar" {
90
+ local shim="$SCRIPTS_DIR/../bin/wr-itil-plugin-exercise-index"
91
+ [ -f "$shim" ]
92
+ [ -x "$shim" ]
93
+ grep -q 'exec.*scripts/plugin-exercise-index.sh' "$shim"
94
+ }
95
+
96
+ # ── Confirmation #6: git-axis composite fixture ─────────────────────────────
97
+ # Seed a temp git repo with packages/dummy/ + three commits (one in window,
98
+ # two out of window). Assert NDJSON record for plugin="dummy" has
99
+ # commits_window=1 under the 60-day default window.
100
+
101
+ @test "Confirmation #6: three commits, one in window, commits_window=1" {
102
+ init_repo
103
+ local in_window=$(days_ago_iso 10)
104
+ local out_window_1=$(days_ago_iso 90)
105
+ local out_window_2=$(days_ago_iso 120)
106
+ commit_under_plugin "dummy" "src/a.txt" "feat: in-window change" "$in_window"
107
+ commit_under_plugin "dummy" "src/b.txt" "feat: out-of-window 1" "$out_window_1"
108
+ commit_under_plugin "dummy" "src/c.txt" "feat: out-of-window 2" "$out_window_2"
109
+
110
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
111
+ [ "$status" -eq 0 ]
112
+ # Exactly one NDJSON record for plugin="dummy"
113
+ local rec
114
+ rec="$(echo "$output" | grep '"plugin":"dummy"')"
115
+ [ -n "$rec" ]
116
+ echo "$rec" | python3 -c "
117
+ import json, sys
118
+ r = json.loads(sys.stdin.read().strip())
119
+ assert r['schema_version'] == '1.0', r
120
+ assert r['axis'] == 'plugin-exercise-index', r
121
+ assert r['plugin'] == 'dummy', r
122
+ assert r['commits_window'] == 1, r
123
+ assert r['window_days'] == 60, r
124
+ assert r['days_shipped'] >= 120, r
125
+ "
126
+ }
127
+
128
+ @test "Confirmation #6: emits one record per discovered plugin" {
129
+ init_repo
130
+ local ts=$(days_ago_iso 5)
131
+ commit_under_plugin "alpha" "src/a.txt" "feat: alpha change" "$ts"
132
+ commit_under_plugin "beta" "src/b.txt" "feat: beta change" "$ts"
133
+
134
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
135
+ [ "$status" -eq 0 ]
136
+ echo "$output" | grep -q '"plugin":"alpha"'
137
+ echo "$output" | grep -q '"plugin":"beta"'
138
+ # Line count = number of discovered plugins (alpha + beta)
139
+ local line_count
140
+ line_count="$(printf '%s' "$output" | grep -c .)"
141
+ [ "$line_count" -eq 2 ]
142
+ }
143
+
144
+ @test "Confirmation #6: commit subject containing literal pipe parses correctly" {
145
+ # ADR-058 line 89 pins `--pretty=format:%H|%aI|%s` — the parser must
146
+ # split on first two `|` only (line.split('|', 2)) so subjects with
147
+ # literal `|` characters do not corrupt parsing. Architect advisory.
148
+ init_repo
149
+ local ts=$(days_ago_iso 5)
150
+ commit_under_plugin "pipey" "src/a.txt" "feat: subject with | pipe in it" "$ts"
151
+
152
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
153
+ [ "$status" -eq 0 ]
154
+ echo "$output" | grep -q '"plugin":"pipey"'
155
+ echo "$output" | grep -q '"commits_window":1'
156
+ }
157
+
158
+ # ── Confirmation #7: outside-git-repo fixture ───────────────────────────────
159
+
160
+ @test "Confirmation #7: outside-git-repo emits zero records, stderr comment, exit 0" {
161
+ # REPO_ROOT exists but no `git init` ran — it is not a git repo.
162
+ local stdout_file="$FIXTURE_DIR/nogit.out"
163
+ local stderr_file="$FIXTURE_DIR/nogit.err"
164
+ "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT" >"$stdout_file" 2>"$stderr_file"
165
+ local rc=$?
166
+ [ "$rc" -eq 0 ]
167
+ [ ! -s "$stdout_file" ]
168
+ grep -q "not a git repository" "$stderr_file"
169
+ }
170
+
171
+ @test "Confirmation #7: missing packages/ directory emits zero records, exit 0" {
172
+ init_repo
173
+ # Repo has no packages/ directory at all. Make a non-packages commit so
174
+ # `git log` returns something but no plugins are discovered.
175
+ (
176
+ cd "$REPO_ROOT"
177
+ echo readme > README.md
178
+ GIT_AUTHOR_DATE="$(days_ago_iso 5)" GIT_COMMITTER_DATE="$(days_ago_iso 5)" \
179
+ git -c user.email=bats@example.com -c user.name=bats add README.md >/dev/null
180
+ GIT_AUTHOR_DATE="$(days_ago_iso 5)" GIT_COMMITTER_DATE="$(days_ago_iso 5)" \
181
+ git -c user.email=bats@example.com -c user.name=bats commit -q -m "feat: readme"
182
+ )
183
+
184
+ local stdout_file="$FIXTURE_DIR/nopkg.out"
185
+ local stderr_file="$FIXTURE_DIR/nopkg.err"
186
+ "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT" >"$stdout_file" 2>"$stderr_file"
187
+ local rc=$?
188
+ [ "$rc" -eq 0 ]
189
+ [ ! -s "$stdout_file" ]
190
+ grep -q "no packages/ directory" "$stderr_file"
191
+ }
192
+
193
+ # ── Confirmation #8: schema-version contract ────────────────────────────────
194
+
195
+ @test "Confirmation #8: every NDJSON record has schema_version=1.0" {
196
+ init_repo
197
+ local ts=$(days_ago_iso 5)
198
+ commit_under_plugin "p1" "src/a.txt" "feat: p1" "$ts"
199
+ commit_under_plugin "p2" "src/b.txt" "feat: p2" "$ts"
200
+
201
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
202
+ [ "$status" -eq 0 ]
203
+ # Validate every line carries schema_version="1.0"
204
+ echo "$output" | python3 -c "
205
+ import json, sys
206
+ for line in sys.stdin:
207
+ line = line.strip()
208
+ if not line: continue
209
+ r = json.loads(line)
210
+ assert r['schema_version'] == '1.0', r
211
+ assert r['axis'] == 'plugin-exercise-index', r
212
+ "
213
+ }
214
+
215
+ # ── Privacy: opt-out marker ─────────────────────────────────────────────────
216
+
217
+ @test "opt-out marker disables reads, stderr comment, exit 0" {
218
+ init_repo
219
+ local ts=$(days_ago_iso 5)
220
+ commit_under_plugin "x" "src/a.txt" "feat: would-be-recorded" "$ts"
221
+ mkdir -p "$REPO_ROOT/.claude"
222
+ touch "$REPO_ROOT/.claude/.skill-metrics-opt-out"
223
+
224
+ local stdout_file="$FIXTURE_DIR/opt.out"
225
+ local stderr_file="$FIXTURE_DIR/opt.err"
226
+ "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT" >"$stdout_file" 2>"$stderr_file"
227
+ local rc=$?
228
+ [ "$rc" -eq 0 ]
229
+ [ ! -s "$stdout_file" ]
230
+ grep -q "opt-out marker present at" "$stderr_file"
231
+ }
232
+
233
+ # ── Privacy: no-network-primitive (ADR-052 negative-grep carve-out) ─────────
234
+
235
+ @test "canonical body contains no network primitives" {
236
+ run grep -nE '\bcurl\b|\bwget\b|\bnc[[:space:]]|\bfetch\b|http\.client|\burllib\b' "$SCRIPT"
237
+ [ "$status" -eq 1 ]
238
+ [ -z "$output" ]
239
+ }
240
+
241
+ # ── Privacy: commit-subject prose never leaks beyond breaking-marker test ───
242
+
243
+ @test "commit subjects never appear in NDJSON output beyond BREAKING/feat!/fix! parse" {
244
+ init_repo
245
+ local ts=$(days_ago_iso 5)
246
+ # Subject contains a recognisable token that must NOT echo to stdout.
247
+ commit_under_plugin "secrety" "src/a.txt" "feat: contains SECRETPROSEXYZ token" "$ts"
248
+
249
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
250
+ [ "$status" -eq 0 ]
251
+ echo "$output" | grep -q '"plugin":"secrety"'
252
+ ! echo "$output" | grep -q "SECRETPROSEXYZ"
253
+ }
254
+
255
+ # ── composite_index calculation ─────────────────────────────────────────────
256
+
257
+ @test "composite_index = log10(commits+1) + log10(closed+1) + days_shipped_bonus" {
258
+ init_repo
259
+ # Set the OLDEST commit > 60 days ago to earn the days_shipped >= 60 bonus
260
+ # of +1.0. Then add 9 in-window commits so log10(9+1) = 1.0.
261
+ commit_under_plugin "calc" "src/old.txt" "feat: old" "$(days_ago_iso 90)"
262
+ for i in 1 2 3 4 5 6 7 8 9; do
263
+ commit_under_plugin "calc" "src/n$i.txt" "feat: n$i" "$(days_ago_iso 5)"
264
+ done
265
+
266
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
267
+ [ "$status" -eq 0 ]
268
+ # commits_window=9 (only the 9 recent ones), days_shipped >= 60 (90 days
269
+ # since oldest), closed_tickets_window=0 (no docs/problems/).
270
+ # composite_index = log10(10) + log10(1) + 1.0 = 1.0 + 0.0 + 1.0 = 2.0
271
+ echo "$output" | grep '"plugin":"calc"' | python3 -c "
272
+ import json, sys
273
+ r = json.loads(sys.stdin.read().strip())
274
+ assert r['commits_window'] == 9, r
275
+ assert r['days_shipped'] >= 60, r
276
+ assert r['closed_tickets_window'] == 0, r
277
+ assert abs(r['composite_index'] - 2.0) < 0.01, r
278
+ "
279
+ }
280
+
281
+ @test "composite_index respects days_shipped < 60 (no bonus)" {
282
+ init_repo
283
+ # All commits within last 30 days; days_shipped < 60.
284
+ for i in 1 2 3 4 5 6 7 8 9; do
285
+ commit_under_plugin "young" "src/n$i.txt" "feat: n$i" "$(days_ago_iso 5)"
286
+ done
287
+
288
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
289
+ [ "$status" -eq 0 ]
290
+ # commits_window=9, days_shipped=5, closed_tickets_window=0
291
+ # composite_index = log10(10) + log10(1) + 0.0 = 1.0
292
+ echo "$output" | grep '"plugin":"young"' | python3 -c "
293
+ import json, sys
294
+ r = json.loads(sys.stdin.read().strip())
295
+ assert r['commits_window'] == 9, r
296
+ assert r['days_shipped'] < 60, r
297
+ assert abs(r['composite_index'] - 1.0) < 0.01, r
298
+ "
299
+ }
300
+
301
+ # ── closed_tickets_window ──────────────────────────────────────────────────
302
+
303
+ @test "closed_tickets_window counts .closed/.verifying tickets citing the plugin" {
304
+ init_repo
305
+ local ts=$(days_ago_iso 5)
306
+ commit_under_plugin "cited" "src/a.txt" "feat: cited" "$ts"
307
+ # Seed two closed tickets in docs/problems/ — one cites packages/cited/,
308
+ # one does not. Both have recent mtime so the 90-day window includes them.
309
+ mkdir -p "$REPO_ROOT/docs/problems"
310
+ echo "## Related
311
+ - packages/cited/scripts/foo.sh" > "$REPO_ROOT/docs/problems/100-something.closed.md"
312
+ echo "## Related
313
+ - packages/other/scripts/bar.sh" > "$REPO_ROOT/docs/problems/101-other.closed.md"
314
+
315
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
316
+ [ "$status" -eq 0 ]
317
+ echo "$output" | grep '"plugin":"cited"' | python3 -c "
318
+ import json, sys
319
+ r = json.loads(sys.stdin.read().strip())
320
+ assert r['closed_tickets_window'] == 1, r
321
+ "
322
+ }
323
+
324
+ # ── breaking_change_age_days ───────────────────────────────────────────────
325
+
326
+ @test "breaking_change_age_days surfaces BREAKING/feat!/fix! marker presence" {
327
+ init_repo
328
+ local recent=$(days_ago_iso 3)
329
+ local older=$(days_ago_iso 20)
330
+ commit_under_plugin "brk" "src/a.txt" "feat: normal change" "$older"
331
+ commit_under_plugin "brk" "src/b.txt" "feat!: breaking change" "$recent"
332
+
333
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
334
+ [ "$status" -eq 0 ]
335
+ echo "$output" | grep '"plugin":"brk"' | python3 -c "
336
+ import json, sys
337
+ r = json.loads(sys.stdin.read().strip())
338
+ v = r['breaking_change_age_days']
339
+ assert v is not None, r
340
+ assert 0 <= v <= 7, r
341
+ "
342
+ }
343
+
344
+ @test "breaking_change_age_days is null when no breaking marker in window" {
345
+ init_repo
346
+ local ts=$(days_ago_iso 5)
347
+ commit_under_plugin "nobreak" "src/a.txt" "feat: ordinary" "$ts"
348
+ commit_under_plugin "nobreak" "src/b.txt" "fix: ordinary" "$ts"
349
+
350
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT"
351
+ [ "$status" -eq 0 ]
352
+ echo "$output" | grep '"plugin":"nobreak"' | python3 -c "
353
+ import json, sys
354
+ r = json.loads(sys.stdin.read().strip())
355
+ assert r['breaking_change_age_days'] is None, r
356
+ "
357
+ }
358
+
359
+ # ── Forward-extension flag: --category-overrides ────────────────────────────
360
+
361
+ @test "category-overrides flag is accepted without functional effect" {
362
+ init_repo
363
+ commit_under_plugin "cat" "src/a.txt" "feat: cat" "$(days_ago_iso 5)"
364
+ echo '{}' > "$FIXTURE_DIR/overrides.json"
365
+
366
+ run "$SCRIPT" --window-days=60 --project-root="$REPO_ROOT" --category-overrides="$FIXTURE_DIR/overrides.json"
367
+ [ "$status" -eq 0 ]
368
+ echo "$output" | grep -q '"plugin":"cat"'
369
+ }
370
+
371
+ # ── Window-days filter ──────────────────────────────────────────────────────
372
+
373
+ @test "window-days filter excludes commits older than the window" {
374
+ init_repo
375
+ commit_under_plugin "win" "src/old.txt" "feat: old" "$(days_ago_iso 30)"
376
+ commit_under_plugin "win" "src/new.txt" "feat: new" "$(days_ago_iso 1)"
377
+
378
+ run "$SCRIPT" --window-days=7 --project-root="$REPO_ROOT"
379
+ [ "$status" -eq 0 ]
380
+ echo "$output" | grep '"plugin":"win"' | python3 -c "
381
+ import json, sys
382
+ r = json.loads(sys.stdin.read().strip())
383
+ assert r['commits_window'] == 1, r
384
+ assert r['window_days'] == 7, r
385
+ "
386
+ }