@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.
package/package.json
CHANGED
|
@@ -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
|
+
}
|