@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,320 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P087 — Phase 2a transcript-axis script behavioural confirmation.
4
+ #
5
+ # Contract under test: `packages/itil/scripts/skill-invocations.sh` reads
6
+ # `~/.claude/projects/**/*.jsonl` (recursive), tallies tool_use invocations
7
+ # by `Skill` / `Agent` / `Bash` (per ADR-058 §Script contracts), and emits
8
+ # one NDJSON record per surface to stdout. Exit code 0 always per ADR-013
9
+ # Rule 6 fail-safe (inaccessible root, opt-out marker, no data all hit the
10
+ # zero-records/stderr-comment path).
11
+ #
12
+ # Confirmation criteria 1-5 from ADR-058 §Confirmation are the load-bearing
13
+ # behavioural assertions in this file. Phase 2b's git-axis script ships
14
+ # criteria 6-8 in a sibling bats file.
15
+ #
16
+ # @adr ADR-058 (Plugin maturity measurement mechanism)
17
+ # @adr ADR-049 (Shim grammar — `wr-itil-skill-invocations` on $PATH)
18
+ # @adr ADR-035 (Privacy posture — opt-out marker, no network primitive,
19
+ # content sanitisation, path-hashing)
20
+ # @adr ADR-052 (Behavioural tests default — NDJSON-output-driven against
21
+ # fixture transcripts, not source-greps on script body; the no-network
22
+ # negative-grep at Confirmation #3 is the documented carve-out)
23
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
24
+ # @jtbd JTBD-101 (Extend the Suite — hardening-prioritisation outcome)
25
+ # @jtbd JTBD-201 (Restore Service Fast — audit-trail composition)
26
+
27
+ setup() {
28
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
29
+ SCRIPT="$SCRIPTS_DIR/skill-invocations.sh"
30
+ FIXTURE_DIR="$(mktemp -d)"
31
+ TRANSCRIPT_ROOT="$FIXTURE_DIR/transcripts"
32
+ PROJECT_ROOT="$FIXTURE_DIR/project"
33
+ mkdir -p "$TRANSCRIPT_ROOT" "$PROJECT_ROOT"
34
+ }
35
+
36
+ teardown() {
37
+ rm -rf "$FIXTURE_DIR"
38
+ }
39
+
40
+ # Helper: write a synthetic JSONL transcript under the fixture root.
41
+ # Each call appends one assistant message carrying a single tool_use entry.
42
+ # Layout mirrors the live `~/.claude/projects/<encoded-path>/<UUID>.jsonl`
43
+ # shape (recursive rglob discovers it).
44
+ write_skill_invocation() {
45
+ local file="$1"; local skill_name="$2"; local ts="$3"
46
+ mkdir -p "$(dirname "$file")"
47
+ python3 - "$file" "$skill_name" "$ts" <<'PYEOF'
48
+ import json, sys
49
+ file, skill, ts = sys.argv[1], sys.argv[2], sys.argv[3]
50
+ rec = {
51
+ "type": "assistant",
52
+ "timestamp": ts,
53
+ "message": {
54
+ "role": "assistant",
55
+ "content": [
56
+ {"type": "tool_use", "name": "Skill", "input": {"skill": skill}}
57
+ ]
58
+ }
59
+ }
60
+ with open(file, "a") as fh:
61
+ fh.write(json.dumps(rec) + "\n")
62
+ PYEOF
63
+ }
64
+
65
+ write_agent_invocation() {
66
+ local file="$1"; local agent_kind="$2"; local ts="$3"
67
+ mkdir -p "$(dirname "$file")"
68
+ python3 - "$file" "$agent_kind" "$ts" <<'PYEOF'
69
+ import json, sys
70
+ file, kind, ts = sys.argv[1], sys.argv[2], sys.argv[3]
71
+ rec = {
72
+ "type": "assistant",
73
+ "timestamp": ts,
74
+ "message": {
75
+ "role": "assistant",
76
+ "content": [
77
+ {"type": "tool_use", "name": "Agent", "input": {"subagent_type": kind}}
78
+ ]
79
+ }
80
+ }
81
+ with open(file, "a") as fh:
82
+ fh.write(json.dumps(rec) + "\n")
83
+ PYEOF
84
+ }
85
+
86
+ write_bash_invocation() {
87
+ local file="$1"; local cmd="$2"; local ts="$3"
88
+ mkdir -p "$(dirname "$file")"
89
+ python3 - "$file" "$cmd" "$ts" <<'PYEOF'
90
+ import json, sys
91
+ file, cmd, ts = sys.argv[1], sys.argv[2], sys.argv[3]
92
+ rec = {
93
+ "type": "assistant",
94
+ "timestamp": ts,
95
+ "message": {
96
+ "role": "assistant",
97
+ "content": [
98
+ {"type": "tool_use", "name": "Bash", "input": {"command": cmd}}
99
+ ]
100
+ }
101
+ }
102
+ with open(file, "a") as fh:
103
+ fh.write(json.dumps(rec) + "\n")
104
+ PYEOF
105
+ }
106
+
107
+ # Helper: produce an ISO 8601 timestamp N hours before "now" (UTC).
108
+ recent_iso() {
109
+ python3 -c "import sys, datetime; print((datetime.datetime.utcnow() - datetime.timedelta(hours=int(sys.argv[1]))).strftime('%Y-%m-%dT%H:%M:%SZ'))" "$1"
110
+ }
111
+
112
+ # ── Existence / executable ──────────────────────────────────────────────────
113
+
114
+ @test "skill-invocations: canonical script exists" {
115
+ [ -f "$SCRIPT" ]
116
+ }
117
+
118
+ @test "skill-invocations: canonical script is executable" {
119
+ [ -x "$SCRIPT" ]
120
+ }
121
+
122
+ @test "skill-invocations: shim file exists with ADR-049 grammar" {
123
+ local shim="$SCRIPTS_DIR/../bin/wr-itil-skill-invocations"
124
+ [ -f "$shim" ]
125
+ [ -x "$shim" ]
126
+ grep -q 'exec.*scripts/skill-invocations.sh' "$shim"
127
+ }
128
+
129
+ # ── Confirmation #1: NDJSON-shape fixture ───────────────────────────────────
130
+ # Seed three Skill invocations of `wr-itil:manage-problem` within the
131
+ # default 30-day window. Assert one NDJSON record on stdout with
132
+ # axis="skill-invocations", surface="wr-itil:manage-problem", kind="skill",
133
+ # plugin="itil", invocations=3. Each line is valid JSON with the expected
134
+ # schema keys.
135
+
136
+ @test "Confirmation #1: NDJSON record for 3 wr-itil:manage-problem invocations" {
137
+ local sess="$TRANSCRIPT_ROOT/proj/aaa.jsonl"
138
+ local ts=$(recent_iso 1)
139
+ write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
140
+ write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
141
+ write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
142
+
143
+ run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
144
+ [ "$status" -eq 0 ]
145
+ # Exactly one record line, no extras, no leading/trailing junk on stdout
146
+ local line_count
147
+ line_count="$(printf '%s' "$output" | grep -c .)"
148
+ [ "$line_count" -eq 1 ]
149
+ # Validate schema fields
150
+ echo "$output" | python3 -c "
151
+ import json, sys
152
+ rec = json.loads(sys.stdin.read().strip())
153
+ assert rec['schema_version'] == '1.0', rec
154
+ assert rec['axis'] == 'skill-invocations', rec
155
+ assert rec['surface'] == 'wr-itil:manage-problem', rec
156
+ assert rec['kind'] == 'skill', rec
157
+ assert rec['plugin'] == 'itil', rec
158
+ assert rec['window_days'] == 30, rec
159
+ assert rec['invocations'] == 3, rec
160
+ assert rec.get('first_invocation_iso') is not None, rec
161
+ assert rec.get('last_invocation_iso') is not None, rec
162
+ "
163
+ }
164
+
165
+ @test "Confirmation #1: aggregates Skill + Agent + Bash kinds into distinct records" {
166
+ local sess="$TRANSCRIPT_ROOT/proj/mix.jsonl"
167
+ local ts=$(recent_iso 1)
168
+ write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
169
+ write_agent_invocation "$sess" "wr-architect:agent" "$ts"
170
+ write_bash_invocation "$sess" "wr-itil-reconcile-readme docs/problems" "$ts"
171
+
172
+ run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
173
+ [ "$status" -eq 0 ]
174
+ # Three distinct NDJSON lines, one per kind
175
+ local line_count
176
+ line_count="$(printf '%s' "$output" | grep -c .)"
177
+ [ "$line_count" -eq 3 ]
178
+ echo "$output" | grep -q '"kind":"skill"'
179
+ echo "$output" | grep -q '"kind":"agent"'
180
+ echo "$output" | grep -q '"kind":"bash-attributed"'
181
+ echo "$output" | grep -q '"plugin":"itil"'
182
+ echo "$output" | grep -q '"plugin":"architect"'
183
+ }
184
+
185
+ @test "Confirmation #1: filters unknown short-form skill names from plugin attribution" {
186
+ local sess="$TRANSCRIPT_ROOT/proj/short.jsonl"
187
+ local ts=$(recent_iso 1)
188
+ # Bare names without `wr-<plugin>:` prefix should be excluded from
189
+ # per-plugin attribution per ADR-058 §Script contracts.
190
+ write_skill_invocation "$sess" "commit" "$ts"
191
+ write_skill_invocation "$sess" "loop" "$ts"
192
+
193
+ run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
194
+ [ "$status" -eq 0 ]
195
+ # No records emitted — both invocations are short-form and unattributable
196
+ [ -z "$output" ]
197
+ }
198
+
199
+ # ── Confirmation #2: opt-out marker fixture ─────────────────────────────────
200
+
201
+ @test "Confirmation #2: opt-out marker disables reads, stderr comment, exit 0" {
202
+ # Seed a transcript that WOULD produce records if read.
203
+ local sess="$TRANSCRIPT_ROOT/proj/optout.jsonl"
204
+ local ts=$(recent_iso 1)
205
+ write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
206
+ # Plant the opt-out marker in the project root.
207
+ mkdir -p "$PROJECT_ROOT/.claude"
208
+ touch "$PROJECT_ROOT/.claude/.skill-metrics-opt-out"
209
+
210
+ # Capture stdout and stderr separately so we can assert: zero records
211
+ # on stdout, opt-out comment on stderr, exit 0.
212
+ local stdout_file="$FIXTURE_DIR/optout.out"
213
+ local stderr_file="$FIXTURE_DIR/optout.err"
214
+ "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT" >"$stdout_file" 2>"$stderr_file"
215
+ local rc=$?
216
+ [ "$rc" -eq 0 ]
217
+ [ ! -s "$stdout_file" ]
218
+ grep -q "opt-out marker present at" "$stderr_file"
219
+ grep -q "$PROJECT_ROOT/.claude/.skill-metrics-opt-out" "$stderr_file"
220
+ }
221
+
222
+ # ── Confirmation #3: no-network-primitive (negative grep carve-out) ─────────
223
+ # ADR-052 carves out this case from "behavioural unless declarative-only".
224
+ # Asserts the canonical script body invokes none of the standard exfiltration
225
+ # primitives. Negative-grep is the closest behavioural approximation of
226
+ # "this script is incapable of network egress".
227
+
228
+ @test "Confirmation #3: canonical body contains no network primitives" {
229
+ # Grep for curl, wget, raw `nc ` (space-bounded to avoid the `since`,
230
+ # `synchronise`, etc. false-positives), fetch, http.client, urllib.
231
+ run grep -nE '\bcurl\b|\bwget\b|\bnc[[:space:]]|\bfetch\b|http\.client|\burllib\b' "$SCRIPT"
232
+ # `run` captures exit; grep exits 1 on no matches — that is the pass.
233
+ [ "$status" -eq 1 ]
234
+ [ -z "$output" ]
235
+ }
236
+
237
+ # ── Confirmation #4: path-hashing / no-content-leak fixture ─────────────────
238
+
239
+ @test "Confirmation #4: literal secret-shaped token never appears in NDJSON" {
240
+ local sess="$TRANSCRIPT_ROOT/proj/secret.jsonl"
241
+ local ts=$(recent_iso 1)
242
+ # Bash command carries an absolute-path-shaped string containing a
243
+ # synthetic secret token. The plugin attribution extracts the wr-itil-*
244
+ # shim name only; the surrounding path and secret are NOT in the
245
+ # emitted NDJSON.
246
+ write_bash_invocation "$sess" "/private/users/foo/password-XXXX-secret/bin/wr-itil-reconcile-readme docs/problems" "$ts"
247
+
248
+ run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
249
+ [ "$status" -eq 0 ]
250
+ # NDJSON contains the surface attribution
251
+ echo "$output" | grep -q '"plugin":"itil"'
252
+ echo "$output" | grep -q '"kind":"bash-attributed"'
253
+ # NDJSON does NOT contain the secret token or the raw absolute path
254
+ ! echo "$output" | grep -q "password-XXXX-secret"
255
+ ! echo "$output" | grep -q "/private/users/foo"
256
+ }
257
+
258
+ @test "Confirmation #4: any surface containing a project path is sha256-12hex hashed" {
259
+ # Structural assertion on the schema: when path-bearing fields are added
260
+ # in future schema versions, they MUST be 12-hex-char sha256 prefixes.
261
+ # The v1.0 schema does not include path-bearing fields; this fixture
262
+ # asserts the structural invariant by verifying no unhashed path shape
263
+ # surfaces. If a future schema bump introduces a `project_path_hash`
264
+ # field, the assertion regex below catches non-hex shapes.
265
+ local sess="$TRANSCRIPT_ROOT/proj/hash.jsonl"
266
+ local ts=$(recent_iso 1)
267
+ write_bash_invocation "$sess" "wr-itil-reconcile-readme docs/problems" "$ts"
268
+
269
+ run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
270
+ [ "$status" -eq 0 ]
271
+ # Any value matching a `path_hash` field (current or future schema)
272
+ # MUST be exactly 12 lowercase hex chars. Negative regex: no field with
273
+ # value matching a raw path shape.
274
+ ! echo "$output" | grep -qE '"[a-z_]+":"/[^"]*"'
275
+ }
276
+
277
+ # ── Confirmation #5: inaccessible-directory fixture ─────────────────────────
278
+
279
+ @test "Confirmation #5: inaccessible --root emits zero records, stderr comment, exit 0" {
280
+ local stdout_file="$FIXTURE_DIR/inacc.out"
281
+ local stderr_file="$FIXTURE_DIR/inacc.err"
282
+ "$SCRIPT" --window-days=30 --root="/nonexistent/path/that/cannot/exist" --project-root="$PROJECT_ROOT" >"$stdout_file" 2>"$stderr_file"
283
+ local rc=$?
284
+ [ "$rc" -eq 0 ]
285
+ [ ! -s "$stdout_file" ]
286
+ grep -q "transcript root inaccessible" "$stderr_file"
287
+ grep -q "/nonexistent/path/that/cannot/exist" "$stderr_file"
288
+ }
289
+
290
+ # ── Forward-extension flag: --category-overrides ────────────────────────────
291
+ # ADR-058 §"Per-category override hook" — ships unused in Phase 2; the flag
292
+ # is accepted-and-validated, no functional effect yet.
293
+
294
+ @test "category-overrides flag is accepted without functional effect" {
295
+ local sess="$TRANSCRIPT_ROOT/proj/cat.jsonl"
296
+ local ts=$(recent_iso 1)
297
+ write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
298
+ # Empty JSON file is a valid (no-op) overrides file.
299
+ echo '{}' > "$FIXTURE_DIR/overrides.json"
300
+
301
+ run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT" --category-overrides="$FIXTURE_DIR/overrides.json"
302
+ [ "$status" -eq 0 ]
303
+ echo "$output" | grep -q '"invocations":1'
304
+ }
305
+
306
+ # ── Window-days filtering ───────────────────────────────────────────────────
307
+
308
+ @test "window-days filter excludes invocations older than the window" {
309
+ local sess="$TRANSCRIPT_ROOT/proj/old.jsonl"
310
+ # Hours-back exceeds the 1-day window (24h).
311
+ local old_ts=$(recent_iso 48)
312
+ local recent_ts=$(recent_iso 1)
313
+ write_skill_invocation "$sess" "wr-itil:manage-problem" "$old_ts"
314
+ write_skill_invocation "$sess" "wr-itil:manage-problem" "$recent_ts"
315
+
316
+ run "$SCRIPT" --window-days=1 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
317
+ [ "$status" -eq 0 ]
318
+ # Only the in-window invocation counts; old one drops.
319
+ echo "$output" | grep -q '"invocations":1'
320
+ }