@windyroad/itil 0.35.2-preview.349 → 0.35.3-preview.352

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.
@@ -482,5 +482,5 @@
482
482
  }
483
483
  },
484
484
  "name": "wr-itil",
485
- "version": "0.35.2"
485
+ "version": "0.35.3"
486
486
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.35.2-preview.349",
3
+ "version": "0.35.3-preview.352",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P239 — Phase 3c doc-lint per plugin: `plugin.json` maturity field
4
+ # shape + rendered README badge currency + anti-pattern absence.
5
+ # @problem P087 — parent: plugin maturity battle-hardening signal.
6
+ #
7
+ # Contract under test: every `packages/<plugin>/.claude-plugin/plugin.json`
8
+ # that carries a `maturity:` field MUST conform to the schema pinned by
9
+ # ADR-063 §Decision Outcome + iter-10 Amendment 2026-05-18, AND the sibling
10
+ # `packages/<plugin>/README.md` MUST carry the prose-woven badge marker
11
+ # matching the canonical rollup band. Anti-patterns (standalone `## Maturity`
12
+ # heading, shields.io URL, compound bootstrapping rendering inside per-skill
13
+ # table cells) are negative-asserted.
14
+ #
15
+ # Discovery is dynamic — the lint walks `packages/*/.claude-plugin/plugin.json`
16
+ # at run time. Plugins without a `maturity:` field are SKIPPED (Phase 3a
17
+ # hasn't been run for that plugin yet; the lint asserts SHAPE WHEN PRESENT,
18
+ # not mandatory presence per-plugin — presence enforcement belongs to a
19
+ # Phase 4+ release-blocking gate, not the doc-lint).
20
+ #
21
+ # Compound-vs-bare badge form is **out of scope** for this lint per
22
+ # architect adjustment A3 (P087 iter-11 architect review 2026-05-18). The
23
+ # renderer's compound-rendering fall-through is a separate sub-iter defect;
24
+ # the lint asserts band-substring-match only and remains agnostic to the
25
+ # `(suite-bootstrap window; <N> invocations / 30d)` form.
26
+ #
27
+ # Schema_version range: closed enum `{"1.0", "2.0"}` per ADR-058 §Confirmation
28
+ # #8 precedent + iter-10 Amendment 2026-05-18 hotfix. `"2.0"` is the canonical
29
+ # value post-hotfix; `"1.0"` records exist in pre-hotfix history (architect
30
+ # adjustment A4 — both accepted; future amendment may close to `"2.0"` only).
31
+ #
32
+ # @adr ADR-063 (Plugin maturity presentation layer — Phase 3c contract)
33
+ # @adr ADR-053 (Plugin maturity taxonomy — granularity contract,
34
+ # rollup-equals-worst-case invariant)
35
+ # @adr ADR-058 (Phase 2 measurement — schema_version precedent)
36
+ # @adr ADR-052 (Behavioural tests default — JSON-shape + README-marker
37
+ # structural-grep on the renderer-emitted stable marker per the documented
38
+ # carve-out for renderer-output assertions)
39
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always; the lint
40
+ # asserts contract, not policy enforcement)
41
+
42
+ setup() {
43
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
44
+ FIXTURE_DIR="$(mktemp -d)"
45
+ }
46
+
47
+ teardown() {
48
+ rm -rf "$FIXTURE_DIR"
49
+ }
50
+
51
+ # ── Helper: list plugin packages that carry a `maturity:` field ─────────────
52
+ # Returns one bare plugin name per line (e.g. `itil`, `architect`).
53
+ plugins_with_maturity() {
54
+ local pkg
55
+ for pkg in "$REPO_ROOT"/packages/*/; do
56
+ local pj="$pkg.claude-plugin/plugin.json"
57
+ [ -f "$pj" ] || continue
58
+ python3 -c "
59
+ import json
60
+ try:
61
+ d = json.load(open('$pj'))
62
+ except Exception:
63
+ raise SystemExit
64
+ if isinstance(d, dict) and isinstance(d.get('maturity'), dict) and 'band' in d['maturity']:
65
+ print('$(basename "$pkg")')
66
+ " 2>/dev/null
67
+ done
68
+ }
69
+
70
+ # ── Helper: assert a JSON Python expression evaluates truthy, with diagnostic ─
71
+ # Args: plugin-json-path, python-expr, diagnostic-prefix
72
+ assert_json() {
73
+ local file="$1" expr="$2" diag="$3"
74
+ python3 -c "
75
+ import json, sys
76
+ d = json.load(open('$file'))
77
+ ok = bool($expr)
78
+ if not ok:
79
+ print(f'$diag: failed on $file', file=sys.stderr)
80
+ sys.exit(1)
81
+ "
82
+ }
83
+
84
+ # ── Existence + discovery ───────────────────────────────────────────────────
85
+
86
+ @test "plugin-maturity-doc-lint: at least one plugin in the monorepo carries maturity field" {
87
+ # Defensive — the lint is a no-op without discovered plugins. After the
88
+ # iter-10 retroactive rollout (P087 line 133) every shipped plugin carries
89
+ # `maturity:`; this test guards against a future regression where the
90
+ # rollout is reverted.
91
+ local n
92
+ n=$(plugins_with_maturity | wc -l | tr -d ' ')
93
+ [ "$n" -ge 1 ]
94
+ }
95
+
96
+ # ── Confirmation: per-surface schema shape (ADR-063 §rich record per-surface) ─
97
+
98
+ @test "plugin-maturity-doc-lint: every per-surface record carries full schema" {
99
+ # For every entry under `maturity.skills`, `maturity.agents`,
100
+ # `maturity.hooks`, `maturity.commands`, assert presence of mandatory keys
101
+ # and correct value types. `invocations_30d` MAY be null (hook surfaces are
102
+ # not transcript-observable); `breaking_change_age_days` MAY be null (no
103
+ # breaking-marker commits observed).
104
+ local plugin
105
+ for plugin in $(plugins_with_maturity); do
106
+ local pj="$REPO_ROOT/packages/$plugin/.claude-plugin/plugin.json"
107
+ python3 <<PYEOF
108
+ import json, sys
109
+ d = json.load(open("$pj"))
110
+ m = d["maturity"]
111
+ bands = {"Experimental", "Alpha", "Beta", "Stable", "Deprecated"}
112
+ schema_versions = {"1.0", "2.0"}
113
+ for kind in ("skills", "agents", "hooks", "commands"):
114
+ section = m.get(kind, {})
115
+ if not isinstance(section, dict):
116
+ sys.exit(f"$plugin: maturity.{kind} is not a dict")
117
+ for name, rec in section.items():
118
+ if not isinstance(rec, dict):
119
+ sys.exit(f"$plugin: maturity.{kind}.{name} is not a dict")
120
+ sv = rec.get("schema_version")
121
+ if sv not in schema_versions:
122
+ sys.exit(f"$plugin: maturity.{kind}.{name}.schema_version = {sv!r} not in {schema_versions}")
123
+ band = rec.get("band")
124
+ if band not in bands:
125
+ sys.exit(f"$plugin: maturity.{kind}.{name}.band = {band!r} not in {bands}")
126
+ if not isinstance(rec.get("computed_at"), str):
127
+ sys.exit(f"$plugin: maturity.{kind}.{name}.computed_at is not a string")
128
+ ev = rec.get("evidence")
129
+ if not isinstance(ev, dict):
130
+ sys.exit(f"$plugin: maturity.{kind}.{name}.evidence is not a dict")
131
+ for field in ("invocations_30d", "days_shipped", "closed_tickets_window", "breaking_change_age_days"):
132
+ if field not in ev:
133
+ sys.exit(f"$plugin: maturity.{kind}.{name}.evidence.{field} missing")
134
+ # invocations_30d: int OR null (hooks)
135
+ inv = ev["invocations_30d"]
136
+ if inv is not None and not isinstance(inv, int):
137
+ sys.exit(f"$plugin: invocations_30d must be int or null, got {type(inv).__name__}")
138
+ # days_shipped: int
139
+ if not isinstance(ev["days_shipped"], int):
140
+ sys.exit(f"$plugin: days_shipped must be int")
141
+ # closed_tickets_window: int
142
+ if not isinstance(ev["closed_tickets_window"], int):
143
+ sys.exit(f"$plugin: closed_tickets_window must be int")
144
+ # breaking_change_age_days: int OR null
145
+ bca = ev["breaking_change_age_days"]
146
+ if bca is not None and not isinstance(bca, int):
147
+ sys.exit(f"$plugin: breaking_change_age_days must be int or null")
148
+ PYEOF
149
+ done
150
+ }
151
+
152
+ # ── Confirmation: rollup shape (ADR-063 §rollup schema + iter-10 Amendment) ──
153
+
154
+ @test "plugin-maturity-doc-lint: rollup carries schema_version + band (mandatory pair)" {
155
+ # ADR-063 §rollup-schema names `{schema_version, band}` as the rollup keys.
156
+ # iter-10 Amendment 2026-05-18 (P0 hotfix) nests per-kind maps UNDER
157
+ # `maturity:` — kind-keyed entries are tolerated additionally; the
158
+ # schema_version + band pair remains mandatory.
159
+ local plugin
160
+ for plugin in $(plugins_with_maturity); do
161
+ local pj="$REPO_ROOT/packages/$plugin/.claude-plugin/plugin.json"
162
+ python3 <<PYEOF
163
+ import json, sys
164
+ d = json.load(open("$pj"))
165
+ m = d["maturity"]
166
+ sv = m.get("schema_version")
167
+ if sv not in {"1.0", "2.0"}:
168
+ sys.exit(f"$plugin: maturity.schema_version = {sv!r} not in {{'1.0','2.0'}}")
169
+ band = m.get("band")
170
+ if band not in {"Experimental", "Alpha", "Beta", "Stable", "Deprecated"}:
171
+ sys.exit(f"$plugin: maturity.band = {band!r} not in taxonomy")
172
+ PYEOF
173
+ done
174
+ }
175
+
176
+ # ── Regression fence (architect adjustment A1, iter-11): no top-level kind maps ─
177
+
178
+ @test "plugin-maturity-doc-lint: no top-level skills/agents/hooks/commands maturity-shaped records (iter-10 P0 hotfix fence)" {
179
+ # The iter-10 P0 hotfix moved per-kind maturity maps from TOP-LEVEL
180
+ # (which the Claude Code plugin manifest validator rejects) to NESTED
181
+ # UNDER `maturity:`. Regression fence: assert no top-level `skills:` /
182
+ # `agents:` / `hooks:` / `commands:` key carries a maturity-shaped record
183
+ # map (a dict whose values are dicts containing only `maturity` or
184
+ # carrying the full per-surface record shape). Existing legitimate
185
+ # top-level surfaces (e.g. itil's top-level `skills:` listing names + paths)
186
+ # are NOT maturity-shaped and pass this fence.
187
+ local plugin
188
+ for plugin in $(plugins_with_maturity); do
189
+ local pj="$REPO_ROOT/packages/$plugin/.claude-plugin/plugin.json"
190
+ python3 <<PYEOF
191
+ import json, sys
192
+ d = json.load(open("$pj"))
193
+ maturity_keys = {"schema_version", "band", "computed_at", "evidence"}
194
+ for legacy_key in ("skills", "agents", "hooks", "commands"):
195
+ inner = d.get(legacy_key)
196
+ if not isinstance(inner, dict):
197
+ continue
198
+ for name, val in inner.items():
199
+ if isinstance(val, dict):
200
+ # Maturity-shaped if it has the maturity schema_version + band
201
+ # pair OR is a wrapper that contains only `maturity`.
202
+ if "schema_version" in val and "band" in val and val.get("band") in {"Experimental","Alpha","Beta","Stable","Deprecated"}:
203
+ sys.exit(f"$plugin: top-level {legacy_key}.{name} carries maturity-shaped record — must nest under maturity.{legacy_key}.{name}")
204
+ if set(val.keys()) <= {"maturity"} and isinstance(val.get("maturity"), dict):
205
+ sys.exit(f"$plugin: top-level {legacy_key}.{name} carries .maturity wrapper — must nest under maturity.{legacy_key}.{name}")
206
+ PYEOF
207
+ done
208
+ }
209
+
210
+ # ── Confirmation: rollup = worst-case of constituent surfaces (ADR-053 granularity) ─
211
+
212
+ @test "plugin-maturity-doc-lint: rollup band equals worst-case among constituent surfaces" {
213
+ # ADR-053 §granularity contract: rollup band = worst-case
214
+ # (Experimental ≻ Alpha ≻ Beta ≻ Stable). Deprecated is an overlay axis
215
+ # elided from rollup compute. A plugin whose ONLY surfaces are Deprecated
216
+ # is itself Deprecated.
217
+ local plugin
218
+ for plugin in $(plugins_with_maturity); do
219
+ local pj="$REPO_ROOT/packages/$plugin/.claude-plugin/plugin.json"
220
+ python3 <<PYEOF
221
+ import json, sys
222
+ d = json.load(open("$pj"))
223
+ m = d["maturity"]
224
+ ORDER = ["Experimental", "Alpha", "Beta", "Stable"]
225
+ surface_bands = []
226
+ for kind in ("skills", "agents", "hooks", "commands"):
227
+ for rec in (m.get(kind, {}) or {}).values():
228
+ if isinstance(rec, dict) and "band" in rec:
229
+ surface_bands.append(rec["band"])
230
+ if not surface_bands:
231
+ # No surface records — rollup not constrained by worst-case.
232
+ sys.exit(0)
233
+ non_dep = [b for b in surface_bands if b in ORDER]
234
+ if non_dep:
235
+ expected = next(b for b in ORDER if b in non_dep)
236
+ elif all(b == "Deprecated" for b in surface_bands):
237
+ expected = "Deprecated"
238
+ else:
239
+ expected = None
240
+ got = m.get("band")
241
+ if expected is not None and got != expected:
242
+ sys.exit(f"$plugin: rollup band = {got!r}, expected {expected!r} (worst-case of {sorted(set(surface_bands))})")
243
+ PYEOF
244
+ done
245
+ }
246
+
247
+ # ── Synthetic-fixture confirmation: multi-band rollup invariant ──────────────
248
+
249
+ @test "plugin-maturity-doc-lint: synthetic multi-band fixture — Experimental ≻ Beta ⇒ rollup Experimental" {
250
+ # Builds a synthetic plugin.json with one Experimental skill + one Beta
251
+ # agent and asserts rollup band MUST be Experimental. This guards the
252
+ # worst-case invariant against future populate-script regressions that
253
+ # might compute rollup as best-case or median.
254
+ local pj="$FIXTURE_DIR/synthetic-plugin.json"
255
+ cat >"$pj" <<'EOF'
256
+ {
257
+ "name": "wr-synthetic",
258
+ "version": "0.0.0",
259
+ "maturity": {
260
+ "schema_version": "2.0",
261
+ "band": "Experimental",
262
+ "skills": {
263
+ "expt-skill": {
264
+ "schema_version": "2.0",
265
+ "band": "Experimental",
266
+ "computed_at": "2026-05-18T00:00:00Z",
267
+ "evidence": {"invocations_30d": 5, "days_shipped": 10, "closed_tickets_window": 0, "breaking_change_age_days": null}
268
+ }
269
+ },
270
+ "agents": {
271
+ "beta-agent": {
272
+ "schema_version": "2.0",
273
+ "band": "Beta",
274
+ "computed_at": "2026-05-18T00:00:00Z",
275
+ "evidence": {"invocations_30d": 500, "days_shipped": 90, "closed_tickets_window": 15, "breaking_change_age_days": 60}
276
+ }
277
+ }
278
+ }
279
+ }
280
+ EOF
281
+ # Run the worst-case derivation as a free-standing python check; we don't
282
+ # invoke the populate script here — we assert the invariant the doc-lint
283
+ # enforces against a hand-crafted shape.
284
+ python3 <<PYEOF
285
+ import json
286
+ d = json.load(open("$pj"))
287
+ m = d["maturity"]
288
+ ORDER = ["Experimental", "Alpha", "Beta", "Stable"]
289
+ bands = []
290
+ for kind in ("skills","agents","hooks","commands"):
291
+ for rec in (m.get(kind, {}) or {}).values():
292
+ bands.append(rec["band"])
293
+ expected = next(b for b in ORDER if b in bands)
294
+ assert m["band"] == expected, f"rollup {m['band']!r} != expected {expected!r}"
295
+ PYEOF
296
+ }
297
+
298
+ @test "plugin-maturity-doc-lint: synthetic all-Deprecated fixture — rollup Deprecated" {
299
+ # Companion to the worst-case test: a plugin whose ONLY surfaces are
300
+ # Deprecated MUST roll up to Deprecated. Asserts the Deprecated-overlay
301
+ # invariant from ADR-053 §granularity contract.
302
+ local pj="$FIXTURE_DIR/all-deprecated.json"
303
+ cat >"$pj" <<'EOF'
304
+ {
305
+ "name": "wr-deprecated",
306
+ "version": "0.0.0",
307
+ "maturity": {
308
+ "schema_version": "2.0",
309
+ "band": "Deprecated",
310
+ "skills": {
311
+ "dep-1": {
312
+ "schema_version": "2.0",
313
+ "band": "Deprecated",
314
+ "computed_at": "2026-05-18T00:00:00Z",
315
+ "evidence": {"invocations_30d": 0, "days_shipped": 200, "closed_tickets_window": 0, "breaking_change_age_days": null},
316
+ "supersededBy": "wr-other:replacement"
317
+ }
318
+ }
319
+ }
320
+ }
321
+ EOF
322
+ python3 <<PYEOF
323
+ import json
324
+ d = json.load(open("$pj"))
325
+ m = d["maturity"]
326
+ bands = [rec["band"] for rec in (m.get("skills", {}) or {}).values()]
327
+ assert all(b == "Deprecated" for b in bands)
328
+ assert m["band"] == "Deprecated", f"all-Deprecated rollup must be Deprecated, got {m['band']!r}"
329
+ PYEOF
330
+ }
331
+
332
+ # ── Confirmation: README badge marker matches canonical rollup band ──────────
333
+
334
+ @test "plugin-maturity-doc-lint: README contains *Maturity: <band> marker matching canonical rollup" {
335
+ # Architect adjustment A2 (iter-11): anchored regex requires band ∈
336
+ # taxonomy followed by either `.` (bare form) or `(` (compound prefix).
337
+ # Renderer emits one of these two; structural-grep on the stable marker
338
+ # is the documented ADR-052 carve-out for renderer-output assertions.
339
+ local plugin
340
+ for plugin in $(plugins_with_maturity); do
341
+ local pj="$REPO_ROOT/packages/$plugin/.claude-plugin/plugin.json"
342
+ local rdm="$REPO_ROOT/packages/$plugin/README.md"
343
+ [ -f "$rdm" ] || continue # README absent — skip (no marker to check)
344
+ local band
345
+ band=$(python3 -c "import json; print(json.load(open('$pj'))['maturity']['band'])")
346
+ # Anchored regex: `*Maturity: <band>` followed by `.` OR `(`.
347
+ if ! grep -qE "\\*Maturity: ${band}[.(]" "$rdm"; then
348
+ echo "$plugin: README missing badge marker *Maturity: ${band}<.|(>" >&2
349
+ false
350
+ fi
351
+ done
352
+ }
353
+
354
+ # ── Anti-pattern: no standalone `## Maturity` heading ───────────────────────
355
+
356
+ @test "plugin-maturity-doc-lint: README has no standalone ## Maturity heading" {
357
+ # ADR-063 §README badge rendering format anti-pattern: NEVER emit a
358
+ # standalone `## Maturity` section. The badge is woven into existing
359
+ # value-framing prose per ADR-051; a standalone section drifts the JTBD
360
+ # anchor away from the prose-weaving precedent.
361
+ local plugin
362
+ for plugin in $(plugins_with_maturity); do
363
+ local rdm="$REPO_ROOT/packages/$plugin/README.md"
364
+ [ -f "$rdm" ] || continue
365
+ if grep -qE '^##[[:space:]]+Maturity[[:space:]]*$' "$rdm"; then
366
+ echo "$plugin: README contains standalone ## Maturity heading (anti-pattern)" >&2
367
+ false
368
+ fi
369
+ done
370
+ }
371
+
372
+ # ── Anti-pattern: no shields.io URL ─────────────────────────────────────────
373
+
374
+ @test "plugin-maturity-doc-lint: README has no shields.io maturity badge URL" {
375
+ # ADR-063 §Decision Outcome rejected shields.io: external-dep blast
376
+ # radius + Bootstrapping clause compound rendering cannot be expressed
377
+ # in static badge URL + offline-broken. Markdown text only.
378
+ local plugin
379
+ for plugin in $(plugins_with_maturity); do
380
+ local rdm="$REPO_ROOT/packages/$plugin/README.md"
381
+ [ -f "$rdm" ] || continue
382
+ if grep -qE 'img\.shields\.io/badge/maturity' "$rdm"; then
383
+ echo "$plugin: README contains shields.io maturity badge URL (anti-pattern)" >&2
384
+ false
385
+ fi
386
+ done
387
+ }
388
+
389
+ # ── Anti-pattern: no compound bootstrapping rendering inside per-skill table cells ─
390
+
391
+ @test "plugin-maturity-doc-lint: per-skill table cells do not contain compound bootstrapping rendering" {
392
+ # ADR-063 §Bootstrapping clause rendering: compound form
393
+ # `(suite-bootstrap window; <N> invocations / 30d)` stays at ROLLUP only;
394
+ # per-skill cells carry band name only. This anti-pattern asserts no
395
+ # table row contains the `(suite-bootstrap window;` substring (the
396
+ # rollup-only compound substring should appear at most ONCE per README,
397
+ # in the lead-prose line).
398
+ local plugin
399
+ for plugin in $(plugins_with_maturity); do
400
+ local rdm="$REPO_ROOT/packages/$plugin/README.md"
401
+ [ -f "$rdm" ] || continue
402
+ # Grep table rows (`|`-leading, allowing leading whitespace) containing
403
+ # the bootstrapping compound substring. Table rows are pipe-delimited
404
+ # markdown; the compound substring inside a cell is the anti-pattern.
405
+ if grep -qE '^[[:space:]]*\|.*\(suite-bootstrap window;' "$rdm"; then
406
+ echo "$plugin: README has compound bootstrapping rendering inside table cell (rollup-only contract)" >&2
407
+ false
408
+ fi
409
+ done
410
+ }