@windyroad/itil 0.34.0 → 0.35.0-preview.344
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,11 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# packages/itil/bin/wr-itil-plugin-maturity-render
|
|
3
|
+
#
|
|
4
|
+
# ADR-049 shim — resolves the canonical body in this package's scripts/
|
|
5
|
+
# dir. The canonical body lives at
|
|
6
|
+
# `packages/itil/scripts/plugin-maturity-render.sh`; this shim is the
|
|
7
|
+
# `$PATH`-resolvable entrypoint that adopter trees invoke.
|
|
8
|
+
#
|
|
9
|
+
# Phase 3b of the P087 plugin maturity rollout.
|
|
10
|
+
|
|
11
|
+
exec "$(dirname "$0")/../scripts/plugin-maturity-render.sh" "$@"
|
package/package.json
CHANGED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# packages/itil/scripts/plugin-maturity-render.sh
|
|
3
|
+
#
|
|
4
|
+
# Phase 3b (P087 / P238) plugin-maturity README renderer.
|
|
5
|
+
#
|
|
6
|
+
# Reads each `packages/<plugin>/.claude-plugin/plugin.json` `maturity:`
|
|
7
|
+
# field (populated by Phase 3a `wr-itil-plugin-maturity-populate`) and
|
|
8
|
+
# writes:
|
|
9
|
+
#
|
|
10
|
+
# 1. Prose-woven rollup badge into the README.md value-framing lead
|
|
11
|
+
# prose line (the first **bold** line after the H1). Format:
|
|
12
|
+
# Post-bootstrap: `*Maturity: <Band>.*`
|
|
13
|
+
# Bootstrapping window: `*Maturity: <Band> (suite-bootstrap window;
|
|
14
|
+
# <N> invocations / 30d).*`
|
|
15
|
+
# Markdown text only — no shields.io URL, no inline SVG (ADR-063
|
|
16
|
+
# §README badge rendering format).
|
|
17
|
+
#
|
|
18
|
+
# 2. Per-skill `Maturity` column populated in the existing `## Skills`
|
|
19
|
+
# table. Cell value is band name only (no compound — compound stays
|
|
20
|
+
# at the rollup per ADR-063). Adds the column header on first run;
|
|
21
|
+
# replaces cell values on subsequent runs.
|
|
22
|
+
#
|
|
23
|
+
# Idempotent: re-running with unchanged plugin.json + README produces
|
|
24
|
+
# byte-identical README output. Replaces existing `*Maturity: ...*` span
|
|
25
|
+
# rather than appending.
|
|
26
|
+
#
|
|
27
|
+
# Anti-patterns enforced (ADR-063 §Decision Outcome §"README badge
|
|
28
|
+
# rendering format" + §"Bootstrapping clause rendering"):
|
|
29
|
+
# - NEVER emit a standalone `## Maturity` section
|
|
30
|
+
# - NEVER emit a header block immediately after H1 before any prose
|
|
31
|
+
# - NEVER emit a shields.io URL or inline SVG
|
|
32
|
+
# - Compound rendering stays at rollup only — per-skill cell carries
|
|
33
|
+
# band name only
|
|
34
|
+
#
|
|
35
|
+
# ADR-044 silent-framework carve-out: band has already been computed by
|
|
36
|
+
# Phase 3a per ADR-053 §promotion criteria; the renderer is mechanical
|
|
37
|
+
# and never invokes AskUserQuestion.
|
|
38
|
+
#
|
|
39
|
+
# Usage:
|
|
40
|
+
# wr-itil-plugin-maturity-render
|
|
41
|
+
# [--project-root=PATH] # default: $PWD
|
|
42
|
+
# [--dry-run] # print diff to stdout, do not write
|
|
43
|
+
#
|
|
44
|
+
# Exit codes:
|
|
45
|
+
# 0 = always — ADR-013 Rule 6 fail-safe. Missing packages/ / opt-out
|
|
46
|
+
# marker / missing plugin.json / missing README / missing
|
|
47
|
+
# maturity field all hit no-write stderr-comment paths.
|
|
48
|
+
#
|
|
49
|
+
# Privacy (ADR-035 clauses adopted verbatim):
|
|
50
|
+
# - Opt-out marker `.claude/.skill-metrics-opt-out` disables writes.
|
|
51
|
+
# - No network egress — script body invokes no exfiltration primitive.
|
|
52
|
+
#
|
|
53
|
+
# @problem P238 (Phase 3b — README badge renderer + advisory drift detector)
|
|
54
|
+
# @problem P087 (parent — no maturity signal on plugin features)
|
|
55
|
+
# @adr ADR-063 (Plugin maturity presentation layer — Phase 3b contract)
|
|
56
|
+
# @adr ADR-053 (Plugin maturity taxonomy — Bootstrapping clause rendering)
|
|
57
|
+
# @adr ADR-051 (JTBD-anchored README — prose-weaving precedent)
|
|
58
|
+
# @adr ADR-049 (Shim grammar — `wr-itil-plugin-maturity-render` on $PATH)
|
|
59
|
+
# @adr ADR-044 (Decision delegation — silent-framework carve-out)
|
|
60
|
+
# @adr ADR-035 (Privacy posture — opt-out marker, no network primitive)
|
|
61
|
+
# @adr ADR-052 (Behavioural tests default)
|
|
62
|
+
# @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
|
|
63
|
+
# @jtbd JTBD-302 (Trust That the README Describes the Plugin I Just
|
|
64
|
+
# Installed — README is the contract surface)
|
|
65
|
+
# @jtbd JTBD-101 (Extend the Suite — clear patterns include stability)
|
|
66
|
+
# @jtbd JTBD-003 (Compose Only the Guardrails I Need — at-a-glance
|
|
67
|
+
# stability for composition decisions)
|
|
68
|
+
|
|
69
|
+
set -uo pipefail
|
|
70
|
+
|
|
71
|
+
# ── CLI parse ───────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
PROJECT_ROOT="$PWD"
|
|
74
|
+
DRY_RUN=0
|
|
75
|
+
|
|
76
|
+
for arg in "$@"; do
|
|
77
|
+
case "$arg" in
|
|
78
|
+
--project-root=*) PROJECT_ROOT="${arg#--project-root=}" ;;
|
|
79
|
+
--dry-run) DRY_RUN=1 ;;
|
|
80
|
+
--help|-h)
|
|
81
|
+
sed -n '4,70p' "$0" | sed 's/^# \{0,1\}//'
|
|
82
|
+
exit 0
|
|
83
|
+
;;
|
|
84
|
+
*)
|
|
85
|
+
echo "# wr-itil-plugin-maturity-render: ignoring unknown argument: $arg" >&2
|
|
86
|
+
;;
|
|
87
|
+
esac
|
|
88
|
+
done
|
|
89
|
+
|
|
90
|
+
# ── Opt-out marker check (ADR-035) ──────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
OPT_OUT_MARKER="${PROJECT_ROOT}/.claude/.skill-metrics-opt-out"
|
|
93
|
+
if [ -e "$OPT_OUT_MARKER" ]; then
|
|
94
|
+
echo "# wr-itil-plugin-maturity-render: opt-out marker present at ${OPT_OUT_MARKER}" >&2
|
|
95
|
+
exit 0
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
# ── packages/ discovery check (ADR-013 Rule 6 fail-safe) ────────────────────
|
|
99
|
+
|
|
100
|
+
if [ ! -d "${PROJECT_ROOT}/packages" ]; then
|
|
101
|
+
echo "# wr-itil-plugin-maturity-render: no packages/ directory at ${PROJECT_ROOT}" >&2
|
|
102
|
+
exit 0
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# ── Python body ─────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export PMR_PROJECT_ROOT="$PROJECT_ROOT"
|
|
108
|
+
export PMR_DRY_RUN="$DRY_RUN"
|
|
109
|
+
|
|
110
|
+
python3 - <<'PYEOF'
|
|
111
|
+
import json, os, re, sys
|
|
112
|
+
from pathlib import Path
|
|
113
|
+
|
|
114
|
+
project_root = Path(os.environ["PMR_PROJECT_ROOT"]).resolve()
|
|
115
|
+
dry_run = os.environ.get("PMR_DRY_RUN", "0") == "1"
|
|
116
|
+
|
|
117
|
+
packages_dir = project_root / "packages"
|
|
118
|
+
plugin_dirs = sorted(
|
|
119
|
+
[d for d in packages_dir.iterdir()
|
|
120
|
+
if d.is_dir() and (d / ".claude-plugin" / "plugin.json").is_file()]
|
|
121
|
+
)
|
|
122
|
+
if not plugin_dirs:
|
|
123
|
+
print("# wr-itil-plugin-maturity-render: no plugins under packages/", file=sys.stderr)
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
|
|
126
|
+
# Match an existing prose-woven badge: `*Maturity: <body>.*` where body
|
|
127
|
+
# may contain anything except a `*`. Anchored on word `Maturity:` to
|
|
128
|
+
# avoid eating arbitrary italic spans.
|
|
129
|
+
BADGE_RE = re.compile(r"\s*\*Maturity:\s+[^*]+\*")
|
|
130
|
+
|
|
131
|
+
# Match a standalone `## Maturity` heading (anti-pattern enforcement —
|
|
132
|
+
# never emitted; detector catches drift if introduced by hand).
|
|
133
|
+
ANTI_SECTION_RE = re.compile(r"(?m)^##\s+Maturity\s*$")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def format_rollup_badge(maturity_record):
|
|
137
|
+
"""Render the prose-woven rollup badge per ADR-063.
|
|
138
|
+
|
|
139
|
+
During the suite-bootstrap window the rollup carries the compound
|
|
140
|
+
form per ADR-053 §Bootstrapping clause rendering; post-sunset it
|
|
141
|
+
renders the band name only.
|
|
142
|
+
"""
|
|
143
|
+
band = maturity_record.get("band", "Experimental")
|
|
144
|
+
bootstrapping = bool(maturity_record.get("bootstrapping"))
|
|
145
|
+
inv = maturity_record.get("rollup_invocations_30d")
|
|
146
|
+
if bootstrapping and isinstance(inv, int) and inv > 0:
|
|
147
|
+
return f"*Maturity: {band} (suite-bootstrap window; {inv} invocations / 30d).*"
|
|
148
|
+
return f"*Maturity: {band}.*"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def weave_rollup_into_lead_prose(readme_text, badge):
|
|
152
|
+
"""Insert / replace the prose-woven rollup badge in the
|
|
153
|
+
value-framing lead-prose line (the first **bold** paragraph after
|
|
154
|
+
the H1). Returns the updated README text.
|
|
155
|
+
|
|
156
|
+
Idempotent: if the lead-prose line already ends with a `*Maturity:
|
|
157
|
+
...*` span, replace it; otherwise append `<space><badge>` to the
|
|
158
|
+
end of that line.
|
|
159
|
+
"""
|
|
160
|
+
lines = readme_text.split("\n")
|
|
161
|
+
h1_idx = None
|
|
162
|
+
for i, line in enumerate(lines):
|
|
163
|
+
if line.startswith("# "):
|
|
164
|
+
h1_idx = i
|
|
165
|
+
break
|
|
166
|
+
if h1_idx is None:
|
|
167
|
+
return readme_text # no H1 — skip (defensive)
|
|
168
|
+
|
|
169
|
+
# Find first non-empty line after H1 that looks like value-framing
|
|
170
|
+
# prose. Preferred: starts with `**` (bold-lead pattern). Fallback:
|
|
171
|
+
# first non-empty non-heading line.
|
|
172
|
+
lead_idx = None
|
|
173
|
+
fallback_idx = None
|
|
174
|
+
for j in range(h1_idx + 1, len(lines)):
|
|
175
|
+
s = lines[j].strip()
|
|
176
|
+
if not s:
|
|
177
|
+
continue
|
|
178
|
+
if s.startswith("#"):
|
|
179
|
+
continue
|
|
180
|
+
if fallback_idx is None:
|
|
181
|
+
fallback_idx = j
|
|
182
|
+
if s.startswith("**"):
|
|
183
|
+
lead_idx = j
|
|
184
|
+
break
|
|
185
|
+
if lead_idx is None:
|
|
186
|
+
lead_idx = fallback_idx
|
|
187
|
+
if lead_idx is None:
|
|
188
|
+
return readme_text # no prose found — skip (defensive)
|
|
189
|
+
|
|
190
|
+
line = lines[lead_idx]
|
|
191
|
+
# Strip any existing `*Maturity: ...*` span (idempotency contract).
|
|
192
|
+
stripped = BADGE_RE.sub("", line).rstrip()
|
|
193
|
+
new_line = f"{stripped} {badge}"
|
|
194
|
+
lines[lead_idx] = new_line
|
|
195
|
+
return "\n".join(lines)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
SKILLS_HEADER_RE = re.compile(r"(?m)^##\s+Skills\s*$")
|
|
199
|
+
TABLE_ROW_RE = re.compile(r"^\|.*\|\s*$")
|
|
200
|
+
SEPARATOR_RE = re.compile(r"^\|\s*[-:]+(\s*\|\s*[-:]+)+\s*\|\s*$")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def split_row(row):
|
|
204
|
+
"""Split a markdown table row by `|`. Returns the inner cells
|
|
205
|
+
(skips the leading + trailing empty splits from the outer `|`).
|
|
206
|
+
Cells are NOT stripped — preserves padding for round-trip.
|
|
207
|
+
"""
|
|
208
|
+
parts = row.split("|")
|
|
209
|
+
# Drop the leading empty (before first `|`) and trailing empty
|
|
210
|
+
# (after last `|`) when present.
|
|
211
|
+
if parts and parts[0].strip() == "":
|
|
212
|
+
parts = parts[1:]
|
|
213
|
+
if parts and parts[-1].strip() == "":
|
|
214
|
+
parts = parts[:-1]
|
|
215
|
+
return parts
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def join_row(cells):
|
|
219
|
+
return "| " + " | ".join(c.strip() for c in cells) + " |"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def extract_skill_name(cell):
|
|
223
|
+
"""Extract the skill name from a `## Skills` cell. Skill cells
|
|
224
|
+
typically read `/wr-<plugin>:<name>` or `\`/wr-<plugin>:<name>\``.
|
|
225
|
+
Returns the bare skill name (after the colon), or None if the cell
|
|
226
|
+
doesn't carry a skill identifier.
|
|
227
|
+
"""
|
|
228
|
+
text = cell.strip()
|
|
229
|
+
# Strip surrounding backticks.
|
|
230
|
+
text = text.strip("`")
|
|
231
|
+
m = re.search(r"/wr-[a-z0-9-]+:([a-z0-9-]+)", text)
|
|
232
|
+
if m:
|
|
233
|
+
return m.group(1)
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def populate_skills_column(readme_text, skills_map):
|
|
238
|
+
"""Insert / populate a `Maturity` column in the `## Skills` table.
|
|
239
|
+
|
|
240
|
+
`skills_map` is a dict[skill-name -> band]. If a cell's skill name
|
|
241
|
+
isn't in the map, the cell value is empty (not omitted). Idempotent:
|
|
242
|
+
if the `Maturity` column header already exists, cells are repopulated.
|
|
243
|
+
"""
|
|
244
|
+
if not skills_map:
|
|
245
|
+
return readme_text
|
|
246
|
+
|
|
247
|
+
lines = readme_text.split("\n")
|
|
248
|
+
skills_idx = None
|
|
249
|
+
for i, line in enumerate(lines):
|
|
250
|
+
if SKILLS_HEADER_RE.match(line):
|
|
251
|
+
skills_idx = i
|
|
252
|
+
break
|
|
253
|
+
if skills_idx is None:
|
|
254
|
+
return readme_text # no `## Skills` section — skip
|
|
255
|
+
|
|
256
|
+
# Find the first table row after the heading (header row of the
|
|
257
|
+
# markdown table). Skip blank lines.
|
|
258
|
+
header_idx = None
|
|
259
|
+
for j in range(skills_idx + 1, len(lines)):
|
|
260
|
+
s = lines[j].strip()
|
|
261
|
+
if not s:
|
|
262
|
+
continue
|
|
263
|
+
if TABLE_ROW_RE.match(lines[j]):
|
|
264
|
+
header_idx = j
|
|
265
|
+
break
|
|
266
|
+
# Hit a non-table non-blank line before any table — no table.
|
|
267
|
+
break
|
|
268
|
+
if header_idx is None:
|
|
269
|
+
return readme_text
|
|
270
|
+
if header_idx + 1 >= len(lines):
|
|
271
|
+
return readme_text
|
|
272
|
+
sep_line = lines[header_idx + 1]
|
|
273
|
+
if not SEPARATOR_RE.match(sep_line.rstrip()):
|
|
274
|
+
return readme_text # second row isn't a separator — malformed; skip
|
|
275
|
+
|
|
276
|
+
header_cells = split_row(lines[header_idx])
|
|
277
|
+
sep_cells = split_row(sep_line)
|
|
278
|
+
|
|
279
|
+
# Check whether `Maturity` column already exists.
|
|
280
|
+
norm_headers = [c.strip().lower() for c in header_cells]
|
|
281
|
+
if "maturity" in norm_headers:
|
|
282
|
+
mat_col = norm_headers.index("maturity")
|
|
283
|
+
else:
|
|
284
|
+
mat_col = len(header_cells)
|
|
285
|
+
header_cells.append("Maturity")
|
|
286
|
+
sep_cells.append("---")
|
|
287
|
+
|
|
288
|
+
new_header = join_row(header_cells)
|
|
289
|
+
new_sep = "| " + " | ".join(c.strip() for c in sep_cells) + " |"
|
|
290
|
+
lines[header_idx] = new_header
|
|
291
|
+
lines[header_idx + 1] = new_sep
|
|
292
|
+
|
|
293
|
+
# Walk subsequent table rows until a blank line or non-table line.
|
|
294
|
+
body_idx = header_idx + 2
|
|
295
|
+
while body_idx < len(lines):
|
|
296
|
+
row = lines[body_idx]
|
|
297
|
+
if not row.strip():
|
|
298
|
+
break
|
|
299
|
+
if not TABLE_ROW_RE.match(row):
|
|
300
|
+
break
|
|
301
|
+
cells = split_row(row)
|
|
302
|
+
# Pad / truncate to the column count.
|
|
303
|
+
target_cols = len(header_cells)
|
|
304
|
+
while len(cells) < target_cols:
|
|
305
|
+
cells.append("")
|
|
306
|
+
if len(cells) > target_cols:
|
|
307
|
+
cells = cells[:target_cols]
|
|
308
|
+
# Identify the skill in the row's first cell that names a skill.
|
|
309
|
+
skill_name = None
|
|
310
|
+
for c in cells:
|
|
311
|
+
sn = extract_skill_name(c)
|
|
312
|
+
if sn:
|
|
313
|
+
skill_name = sn
|
|
314
|
+
break
|
|
315
|
+
band = skills_map.get(skill_name, "") if skill_name else ""
|
|
316
|
+
cells[mat_col] = band
|
|
317
|
+
lines[body_idx] = join_row(cells)
|
|
318
|
+
body_idx += 1
|
|
319
|
+
|
|
320
|
+
return "\n".join(lines)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def render_plugin(pkg_dir):
|
|
324
|
+
"""Render a single plugin. Returns (changed_bool, new_readme_text)
|
|
325
|
+
or (False, None) when skipped (no maturity, no README, etc).
|
|
326
|
+
"""
|
|
327
|
+
plugin_json_path = pkg_dir / ".claude-plugin" / "plugin.json"
|
|
328
|
+
readme_path = pkg_dir / "README.md"
|
|
329
|
+
if not readme_path.is_file():
|
|
330
|
+
return (False, None)
|
|
331
|
+
try:
|
|
332
|
+
plugin_doc = json.loads(plugin_json_path.read_text(encoding="utf-8"))
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
print(f"# wr-itil-plugin-maturity-render: skipping unreadable plugin.json at {plugin_json_path}: {exc}", file=sys.stderr)
|
|
335
|
+
return (False, None)
|
|
336
|
+
if not isinstance(plugin_doc, dict):
|
|
337
|
+
return (False, None)
|
|
338
|
+
|
|
339
|
+
maturity = plugin_doc.get("maturity")
|
|
340
|
+
if not isinstance(maturity, dict) or "band" not in maturity:
|
|
341
|
+
return (False, None) # Phase 3a hasn't run for this plugin
|
|
342
|
+
|
|
343
|
+
badge = format_rollup_badge(maturity)
|
|
344
|
+
readme_text = readme_path.read_text(encoding="utf-8")
|
|
345
|
+
new_text = weave_rollup_into_lead_prose(readme_text, badge)
|
|
346
|
+
|
|
347
|
+
# Build per-skill band map from the plugin.json `skills:` map.
|
|
348
|
+
skills_map = {}
|
|
349
|
+
skills_section = plugin_doc.get("skills", {})
|
|
350
|
+
if isinstance(skills_section, dict):
|
|
351
|
+
for name, entry in skills_section.items():
|
|
352
|
+
if not isinstance(entry, dict):
|
|
353
|
+
continue
|
|
354
|
+
entry_mat = entry.get("maturity")
|
|
355
|
+
if isinstance(entry_mat, dict) and "band" in entry_mat:
|
|
356
|
+
skills_map[name] = entry_mat["band"]
|
|
357
|
+
|
|
358
|
+
new_text = populate_skills_column(new_text, skills_map)
|
|
359
|
+
|
|
360
|
+
if new_text == readme_text:
|
|
361
|
+
return (False, new_text)
|
|
362
|
+
return (True, new_text)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
plugins_processed = 0
|
|
366
|
+
plugins_written = 0
|
|
367
|
+
plugins_unchanged = 0
|
|
368
|
+
|
|
369
|
+
for pkg_dir in plugin_dirs:
|
|
370
|
+
plugins_processed += 1
|
|
371
|
+
changed, new_text = render_plugin(pkg_dir)
|
|
372
|
+
if new_text is None:
|
|
373
|
+
continue
|
|
374
|
+
if not changed:
|
|
375
|
+
plugins_unchanged += 1
|
|
376
|
+
continue
|
|
377
|
+
readme_path = pkg_dir / "README.md"
|
|
378
|
+
if dry_run:
|
|
379
|
+
sys.stdout.write(f"--- {readme_path}\n+++ would-write\n")
|
|
380
|
+
sys.stdout.write(new_text)
|
|
381
|
+
sys.stdout.write("\n")
|
|
382
|
+
else:
|
|
383
|
+
readme_path.write_text(new_text, encoding="utf-8")
|
|
384
|
+
plugins_written += 1
|
|
385
|
+
|
|
386
|
+
print(
|
|
387
|
+
f"# wr-itil-plugin-maturity-render: "
|
|
388
|
+
f"plugins={plugins_processed} written={plugins_written} "
|
|
389
|
+
f"unchanged={plugins_unchanged}",
|
|
390
|
+
file=sys.stderr,
|
|
391
|
+
)
|
|
392
|
+
PYEOF
|
|
393
|
+
|
|
394
|
+
exit 0
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# @problem P238 — Phase 3b renderer behavioural confirmation.
|
|
4
|
+
# @problem P087 — parent: plugin maturity battle-hardening signal.
|
|
5
|
+
#
|
|
6
|
+
# Contract under test: `packages/itil/scripts/plugin-maturity-render.sh`
|
|
7
|
+
# reads `plugin.json` `maturity:` field (per Phase 3a populate output)
|
|
8
|
+
# and writes a prose-woven rollup badge into each plugin's README.md
|
|
9
|
+
# value-framing lead prose AND populates a per-skill `Maturity` column
|
|
10
|
+
# in the existing `## Skills` table. Idempotent — re-running with
|
|
11
|
+
# unchanged inputs produces byte-identical README output.
|
|
12
|
+
#
|
|
13
|
+
# Anti-patterns enforced (ADR-063 §README badge rendering format):
|
|
14
|
+
# - NO standalone `## Maturity` section
|
|
15
|
+
# - NO header block immediately after H1 before any prose framing
|
|
16
|
+
# - NO shields.io URL or inline SVG (markdown text only)
|
|
17
|
+
#
|
|
18
|
+
# Bootstrapping rendering: rollup carries compound form during the
|
|
19
|
+
# suite-bootstrap window; per-skill column carries band name only
|
|
20
|
+
# (compound stays at rollup, never at cell).
|
|
21
|
+
#
|
|
22
|
+
# @adr ADR-063 (Plugin maturity presentation layer — Phase 3b contract)
|
|
23
|
+
# @adr ADR-053 (Plugin maturity taxonomy — Bootstrapping clause rendering)
|
|
24
|
+
# @adr ADR-051 (JTBD-anchored README — prose-weaving precedent)
|
|
25
|
+
# @adr ADR-049 (Shim grammar — `wr-itil-plugin-maturity-render` on $PATH)
|
|
26
|
+
# @adr ADR-052 (Behavioural tests default)
|
|
27
|
+
# @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
|
|
28
|
+
|
|
29
|
+
setup() {
|
|
30
|
+
SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
31
|
+
SCRIPT="$SCRIPTS_DIR/plugin-maturity-render.sh"
|
|
32
|
+
FIXTURE_DIR="$(mktemp -d)"
|
|
33
|
+
PROJECT_ROOT="$FIXTURE_DIR/project"
|
|
34
|
+
mkdir -p "$PROJECT_ROOT/packages"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
teardown() {
|
|
38
|
+
rm -rf "$FIXTURE_DIR"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Helper: write a synthetic plugin layout with plugin.json + README.md
|
|
42
|
+
make_plugin() {
|
|
43
|
+
local name="$1"
|
|
44
|
+
local plugin_json="$2"
|
|
45
|
+
local readme="$3"
|
|
46
|
+
local pkg="$PROJECT_ROOT/packages/$name"
|
|
47
|
+
mkdir -p "$pkg/.claude-plugin"
|
|
48
|
+
printf '%s\n' "$plugin_json" > "$pkg/.claude-plugin/plugin.json"
|
|
49
|
+
printf '%s\n' "$readme" > "$pkg/README.md"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# ── Pre-checks ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
@test "script file exists and is executable" {
|
|
55
|
+
[ -f "$SCRIPT" ]
|
|
56
|
+
[ -x "$SCRIPT" ]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@test "missing packages dir exits 0 with stderr comment" {
|
|
60
|
+
run bash "$SCRIPT" --project-root="$FIXTURE_DIR/does-not-exist"
|
|
61
|
+
[ "$status" -eq 0 ]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@test "opt-out marker present: skips all writes" {
|
|
65
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
|
|
66
|
+
"# @windyroad/stub
|
|
67
|
+
|
|
68
|
+
**Stub plugin description.**
|
|
69
|
+
|
|
70
|
+
## Skills
|
|
71
|
+
|
|
72
|
+
| Skill | Purpose |
|
|
73
|
+
|-------|---------|
|
|
74
|
+
| /wr-stub:thing | Does a thing |
|
|
75
|
+
"
|
|
76
|
+
mkdir -p "$PROJECT_ROOT/.claude"
|
|
77
|
+
touch "$PROJECT_ROOT/.claude/.skill-metrics-opt-out"
|
|
78
|
+
local before; before="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
79
|
+
|
|
80
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
81
|
+
[ "$status" -eq 0 ]
|
|
82
|
+
|
|
83
|
+
local after; after="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
84
|
+
[ "$before" = "$after" ]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# ── Confirmation #1 (ADR-063): rollup badge prose-woven into lead prose ─────
|
|
88
|
+
|
|
89
|
+
@test "rollup badge: inserts prose-woven Maturity span into bold lead prose line" {
|
|
90
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
|
|
91
|
+
"# @windyroad/stub
|
|
92
|
+
|
|
93
|
+
**Stub plugin description.** Some more prose.
|
|
94
|
+
|
|
95
|
+
## Skills
|
|
96
|
+
|
|
97
|
+
| Skill | Purpose |
|
|
98
|
+
|-------|---------|
|
|
99
|
+
| /wr-stub:thing | Does a thing |
|
|
100
|
+
"
|
|
101
|
+
|
|
102
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
103
|
+
[ "$status" -eq 0 ]
|
|
104
|
+
|
|
105
|
+
local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
106
|
+
# Prose-woven Maturity span appears, italicised, in the lead-prose area
|
|
107
|
+
[[ "$out" == *"*Maturity: Alpha"* ]]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@test "rollup badge: bootstrapping window renders compound form with invocation count" {
|
|
111
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Experimental","bootstrapping":true,"rollup_invocations_30d":796}}' \
|
|
112
|
+
"# @windyroad/stub
|
|
113
|
+
|
|
114
|
+
**Stub plugin description.**
|
|
115
|
+
|
|
116
|
+
## Skills
|
|
117
|
+
|
|
118
|
+
| Skill | Purpose |
|
|
119
|
+
|-------|---------|
|
|
120
|
+
| /wr-stub:thing | Does a thing |
|
|
121
|
+
"
|
|
122
|
+
|
|
123
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
124
|
+
[ "$status" -eq 0 ]
|
|
125
|
+
|
|
126
|
+
local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
127
|
+
[[ "$out" == *"Experimental"* ]]
|
|
128
|
+
[[ "$out" == *"suite-bootstrap window"* ]]
|
|
129
|
+
[[ "$out" == *"796 invocations"* ]]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@test "rollup badge: post-bootstrap renders band name only (no compound)" {
|
|
133
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Beta"}}' \
|
|
134
|
+
"# @windyroad/stub
|
|
135
|
+
|
|
136
|
+
**Stub plugin description.**
|
|
137
|
+
|
|
138
|
+
## Skills
|
|
139
|
+
|
|
140
|
+
| Skill | Purpose |
|
|
141
|
+
|-------|---------|
|
|
142
|
+
| /wr-stub:thing | Does a thing |
|
|
143
|
+
"
|
|
144
|
+
|
|
145
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
146
|
+
[ "$status" -eq 0 ]
|
|
147
|
+
|
|
148
|
+
local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
149
|
+
[[ "$out" == *"*Maturity: Beta.*"* ]]
|
|
150
|
+
[[ "$out" != *"suite-bootstrap"* ]]
|
|
151
|
+
[[ "$out" != *"invocations"* ]]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# ── Confirmation #2 (ADR-063): per-skill Maturity column populated ──────────
|
|
155
|
+
|
|
156
|
+
@test "per-skill column: adds Maturity column to existing Skills table" {
|
|
157
|
+
make_plugin "stub" '{
|
|
158
|
+
"name":"wr-stub","version":"0.1.0","description":"Stub",
|
|
159
|
+
"maturity":{"schema_version":"1.0","band":"Alpha"},
|
|
160
|
+
"skills":{
|
|
161
|
+
"thing":{"maturity":{"schema_version":"1.0","band":"Alpha","computed_at":"2026-05-18T00:00:00Z","evidence":{"invocations_30d":50,"days_shipped":30,"closed_tickets_window":5,"breaking_change_age_days":null}}},
|
|
162
|
+
"widget":{"maturity":{"schema_version":"1.0","band":"Experimental","computed_at":"2026-05-18T00:00:00Z","evidence":{"invocations_30d":2,"days_shipped":5,"closed_tickets_window":0,"breaking_change_age_days":null}}}
|
|
163
|
+
}
|
|
164
|
+
}' \
|
|
165
|
+
"# @windyroad/stub
|
|
166
|
+
|
|
167
|
+
**Stub plugin description.**
|
|
168
|
+
|
|
169
|
+
## Skills
|
|
170
|
+
|
|
171
|
+
| Skill | Purpose |
|
|
172
|
+
|-------|---------|
|
|
173
|
+
| /wr-stub:thing | Does a thing |
|
|
174
|
+
| /wr-stub:widget | Widgets things |
|
|
175
|
+
"
|
|
176
|
+
|
|
177
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
178
|
+
[ "$status" -eq 0 ]
|
|
179
|
+
|
|
180
|
+
local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
181
|
+
# Header row gains Maturity column
|
|
182
|
+
[[ "$out" == *"| Skill | Purpose | Maturity |"* ]]
|
|
183
|
+
# Cells populated
|
|
184
|
+
[[ "$out" == *"/wr-stub:thing"*"| Alpha |"* ]]
|
|
185
|
+
[[ "$out" == *"/wr-stub:widget"*"| Experimental |"* ]]
|
|
186
|
+
# Per-skill cell carries band name ONLY (no compound)
|
|
187
|
+
[[ "$out" != *"| Alpha (suite"* ]]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# ── Idempotency: second run produces no diff ────────────────────────────────
|
|
191
|
+
|
|
192
|
+
@test "idempotency: second run against unchanged plugin.json produces byte-equal README" {
|
|
193
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
|
|
194
|
+
"# @windyroad/stub
|
|
195
|
+
|
|
196
|
+
**Stub plugin description.**
|
|
197
|
+
|
|
198
|
+
## Skills
|
|
199
|
+
|
|
200
|
+
| Skill | Purpose |
|
|
201
|
+
|-------|---------|
|
|
202
|
+
| /wr-stub:thing | Does a thing |
|
|
203
|
+
"
|
|
204
|
+
|
|
205
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
206
|
+
[ "$status" -eq 0 ]
|
|
207
|
+
local first; first="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
208
|
+
|
|
209
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
210
|
+
[ "$status" -eq 0 ]
|
|
211
|
+
local second; second="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
212
|
+
|
|
213
|
+
[ "$first" = "$second" ]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
@test "idempotency: existing Maturity badge gets replaced (not appended)" {
|
|
217
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Beta"}}' \
|
|
218
|
+
"# @windyroad/stub
|
|
219
|
+
|
|
220
|
+
**Stub plugin description.** *Maturity: Alpha.*
|
|
221
|
+
|
|
222
|
+
## Skills
|
|
223
|
+
|
|
224
|
+
| Skill | Purpose |
|
|
225
|
+
|-------|---------|
|
|
226
|
+
| /wr-stub:thing | Does a thing |
|
|
227
|
+
"
|
|
228
|
+
|
|
229
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
230
|
+
[ "$status" -eq 0 ]
|
|
231
|
+
|
|
232
|
+
local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
233
|
+
# Beta replaced Alpha (no duplication)
|
|
234
|
+
[[ "$out" == *"*Maturity: Beta"* ]]
|
|
235
|
+
[[ "$out" != *"*Maturity: Alpha"* ]]
|
|
236
|
+
# Single Maturity span (no duplication)
|
|
237
|
+
local count
|
|
238
|
+
count="$(grep -oE '\*Maturity: [^*]+\*' "$PROJECT_ROOT/packages/stub/README.md" | wc -l)"
|
|
239
|
+
[ "$count" -eq 1 ]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# ── Anti-pattern: no standalone ## Maturity section emitted ─────────────────
|
|
243
|
+
|
|
244
|
+
@test "anti-pattern: renderer never emits a standalone ## Maturity section" {
|
|
245
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
|
|
246
|
+
"# @windyroad/stub
|
|
247
|
+
|
|
248
|
+
**Stub plugin description.**
|
|
249
|
+
|
|
250
|
+
## Skills
|
|
251
|
+
|
|
252
|
+
| Skill | Purpose |
|
|
253
|
+
|-------|---------|
|
|
254
|
+
| /wr-stub:thing | Does a thing |
|
|
255
|
+
"
|
|
256
|
+
|
|
257
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
258
|
+
[ "$status" -eq 0 ]
|
|
259
|
+
|
|
260
|
+
local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
261
|
+
[[ "$out" != *"## Maturity"* ]]
|
|
262
|
+
[[ "$out" != *"# Maturity"* ]]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
@test "anti-pattern: no shields.io badge URL emitted" {
|
|
266
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
|
|
267
|
+
"# @windyroad/stub
|
|
268
|
+
|
|
269
|
+
**Stub plugin description.**
|
|
270
|
+
|
|
271
|
+
## Skills
|
|
272
|
+
|
|
273
|
+
| Skill | Purpose |
|
|
274
|
+
|-------|---------|
|
|
275
|
+
| /wr-stub:thing | Does a thing |
|
|
276
|
+
"
|
|
277
|
+
|
|
278
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
279
|
+
[ "$status" -eq 0 ]
|
|
280
|
+
|
|
281
|
+
local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
282
|
+
[[ "$out" != *"shields.io"* ]]
|
|
283
|
+
[[ "$out" != *"img.shields"* ]]
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# ── Fail-safe: missing maturity field is a no-op (Phase 3a not yet run) ─────
|
|
287
|
+
|
|
288
|
+
@test "fail-safe: plugin.json without maturity: field is skipped" {
|
|
289
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub"}' \
|
|
290
|
+
"# @windyroad/stub
|
|
291
|
+
|
|
292
|
+
**Stub plugin description.**
|
|
293
|
+
|
|
294
|
+
## Skills
|
|
295
|
+
|
|
296
|
+
| Skill | Purpose |
|
|
297
|
+
|-------|---------|
|
|
298
|
+
| /wr-stub:thing | Does a thing |
|
|
299
|
+
"
|
|
300
|
+
local before; before="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
301
|
+
|
|
302
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
303
|
+
[ "$status" -eq 0 ]
|
|
304
|
+
|
|
305
|
+
local after; after="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
306
|
+
[ "$before" = "$after" ]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
# ── Fail-safe: plugin without README is skipped ─────────────────────────────
|
|
310
|
+
|
|
311
|
+
@test "fail-safe: plugin without README.md is silently skipped" {
|
|
312
|
+
local pkg="$PROJECT_ROOT/packages/no-readme"
|
|
313
|
+
mkdir -p "$pkg/.claude-plugin"
|
|
314
|
+
echo '{"name":"wr-no-readme","version":"0.1.0","description":"No README","maturity":{"schema_version":"1.0","band":"Alpha"}}' > "$pkg/.claude-plugin/plugin.json"
|
|
315
|
+
|
|
316
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
317
|
+
[ "$status" -eq 0 ]
|
|
318
|
+
[ ! -f "$pkg/README.md" ]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# ── No-AskUserQuestion: ADR-044 silent-framework carve-out ──────────────────
|
|
322
|
+
|
|
323
|
+
@test "ADR-044: renderer never invokes AskUserQuestion per re-render" {
|
|
324
|
+
# Negative-presence behavioural assertion per ADR-052 §carve-out — the
|
|
325
|
+
# renderer is mechanical (band already computed by Phase 3a) and must
|
|
326
|
+
# not surface a consent gate per re-render. Scans combined stdout +
|
|
327
|
+
# stderr output for any AskUserQuestion-token spelling, case-insensitive
|
|
328
|
+
# per architect adjustment G to plugin-maturity-populate.bats.
|
|
329
|
+
make_plugin "silentp" '{"name":"wr-silentp","version":"0.1.0","description":"Silent","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
|
|
330
|
+
"# @windyroad/silentp
|
|
331
|
+
|
|
332
|
+
**Silent plugin description.**
|
|
333
|
+
|
|
334
|
+
## Skills
|
|
335
|
+
|
|
336
|
+
| Skill | Purpose |
|
|
337
|
+
|-------|---------|
|
|
338
|
+
| /wr-silentp:thing | Does a thing |
|
|
339
|
+
"
|
|
340
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
341
|
+
[ "$status" -eq 0 ]
|
|
342
|
+
printf '%s' "$output" | grep -i -E 'askuserquestion|<askuser' && return 1 || true
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# ── No-network primitive: ADR-035 privacy posture ───────────────────────────
|
|
346
|
+
|
|
347
|
+
@test "ADR-035: script body invokes no network primitive" {
|
|
348
|
+
# Negative-presence behavioural assertion — the renderer reads
|
|
349
|
+
# plugin.json + README.md from filesystem only; never reaches a
|
|
350
|
+
# network endpoint. Mirrors plugin-maturity-populate.bats.
|
|
351
|
+
run grep -E "(curl|wget|nc -|netcat|ssh |scp |rsync|http\.client|urllib|requests)" "$SCRIPT"
|
|
352
|
+
[ "$status" -ne 0 ]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# ── Multi-plugin: renders each plugin independently ─────────────────────────
|
|
356
|
+
|
|
357
|
+
@test "multi-plugin: renders each packages/<plugin>/README.md independently" {
|
|
358
|
+
make_plugin "alpha" '{"name":"wr-alpha","version":"0.1.0","description":"Alpha","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
|
|
359
|
+
"# @windyroad/alpha
|
|
360
|
+
|
|
361
|
+
**Alpha plugin description.**
|
|
362
|
+
|
|
363
|
+
## Skills
|
|
364
|
+
|
|
365
|
+
| Skill | Purpose |
|
|
366
|
+
|-------|---------|
|
|
367
|
+
| /wr-alpha:thing | Does a thing |
|
|
368
|
+
"
|
|
369
|
+
make_plugin "bravo" '{"name":"wr-bravo","version":"0.1.0","description":"Bravo","maturity":{"schema_version":"1.0","band":"Beta"}}' \
|
|
370
|
+
"# @windyroad/bravo
|
|
371
|
+
|
|
372
|
+
**Bravo plugin description.**
|
|
373
|
+
|
|
374
|
+
## Skills
|
|
375
|
+
|
|
376
|
+
| Skill | Purpose |
|
|
377
|
+
|-------|---------|
|
|
378
|
+
| /wr-bravo:thing | Does a thing |
|
|
379
|
+
"
|
|
380
|
+
|
|
381
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
|
|
382
|
+
[ "$status" -eq 0 ]
|
|
383
|
+
|
|
384
|
+
local out_a; out_a="$(cat "$PROJECT_ROOT/packages/alpha/README.md")"
|
|
385
|
+
local out_b; out_b="$(cat "$PROJECT_ROOT/packages/bravo/README.md")"
|
|
386
|
+
[[ "$out_a" == *"*Maturity: Alpha"* ]]
|
|
387
|
+
[[ "$out_b" == *"*Maturity: Beta"* ]]
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# ── Dry-run: prints diff to stdout, does not write ──────────────────────────
|
|
391
|
+
|
|
392
|
+
@test "dry-run: --dry-run flag prints intended diff to stdout without modifying README" {
|
|
393
|
+
make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
|
|
394
|
+
"# @windyroad/stub
|
|
395
|
+
|
|
396
|
+
**Stub plugin description.**
|
|
397
|
+
|
|
398
|
+
## Skills
|
|
399
|
+
|
|
400
|
+
| Skill | Purpose |
|
|
401
|
+
|-------|---------|
|
|
402
|
+
| /wr-stub:thing | Does a thing |
|
|
403
|
+
"
|
|
404
|
+
local before; before="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
405
|
+
|
|
406
|
+
run bash "$SCRIPT" --project-root="$PROJECT_ROOT" --dry-run
|
|
407
|
+
[ "$status" -eq 0 ]
|
|
408
|
+
[[ "$output" == *"Maturity: Alpha"* ]]
|
|
409
|
+
|
|
410
|
+
local after; after="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
|
|
411
|
+
[ "$before" = "$after" ]
|
|
412
|
+
}
|