Skill Tracer
Skills › BMAD Method › bmad-architecture › lint_spine.py

lint_spine.py

273 lines · skills/bmad-architecture/scripts/lint_spine.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""lint-spine — the mechanical half of spine decision-integrity, done deterministically.
6
7LLMs miscount IDs and miss literal placeholders; a grep does not. This linter owns the
8checks a script does better than a prompt, and leaves the semantic half (is each Rule
9actually enforceable? does the boundary make sense?) to the rubric walker.
11It reads the spine, `<folder name>.md`, from a workspace and reports, as compact JSON on stdout:
13 - placeholder literal TBD / TODO / "similar to AD-n" / unfilled {template-token}
14 - ad_id duplicate or non-monotonic AD-n identifiers
15 - ad_fields an AD-n block missing Binds / Prevents / Rule
16 - version_pin a ## Stack table row with no version
18Fenced code blocks are blanked (replaced with equal-count blank lines) before scanning, so
19mermaid and source trees don't trip false positives AND reported line numbers still line up
20with the real file. Reported lines are absolute file lines (frontmatter offset added). Exit
21code is always 0 — findings travel in the JSON; the caller (Reviewer Gate / rubric walker)
22decides what to do with them.
23"""
25from __future__ import annotations
27import argparse
28import json
29import re
30import sys
31from pathlib import Path
33AD_HEADING = re.compile(r"^#{2,4}\s*AD-(\d+)\b(.*)$", re.MULTILINE)
34HEADING = re.compile(r"^#{1,6}\s", re.MULTILINE)
35FENCE = re.compile(r"```.*?```", re.DOTALL)
36PLACEHOLDER_WORD = re.compile(r"\b(TBD|TODO|FIXME|XXX)\b")
37SIMILAR_TO = re.compile(r"similar to AD-\d+", re.IGNORECASE)
38TEMPLATE_TOKEN = re.compile(r"\{[a-z_][a-z0-9_ /.-]*\}")
41def split_frontmatter(text: str) -> tuple[str, str, int]:
42 """Return (frontmatter, body, body_line_offset).
44 Frontmatter is the content between the first two lines that are *exactly* `---`
45 (line-exact, like memlog.split — a `---` inside a value or a body thematic break never
46 truncates it). body_line_offset is the number of file lines before the body begins, so a
47 body-relative line number plus the offset gives the absolute file line. Absent frontmatter
48 → ('', text, 0)."""
49 lines = text.split("\n")
50 if lines and lines[0] == "---":
51 for i in range(1, len(lines)):
52 if lines[i] == "---":
53 fm = "\n".join(lines[1:i])
54 body = "\n".join(lines[i + 1 :])
55 return fm, body, i + 1
56 return "", text, 0
59def blank_fences(text: str) -> str:
60 """Replace each fenced block with the same number of newlines, so scanning skips fenced
61 content while every line number outside the fence stays put."""
62 return FENCE.sub(lambda m: "\n" * m.group(0).count("\n"), text)
65def line_of(text: str, idx: int) -> int:
66 return text.count("\n", 0, idx) + 1
69def find_placeholders(body: str, offset: int, name: str) -> list[dict]:
70 findings: list[dict] = []
71 scan = blank_fences(body)
72 # (regex, label, severity) — TBD/TODO and dangling cross-refs are unambiguous; a bare
73 # {template-token} can be legitimate brace prose, so it is flagged low ("possible") to keep
74 # the mechanical pass near-zero false-positive rather than train reviewers to ignore it.
75 for rx, label, severity in (
76 (PLACEHOLDER_WORD, "placeholder marker", "high"),
77 (SIMILAR_TO, "unresolved cross-reference", "high"),
78 (TEMPLATE_TOKEN, "possible unfilled template token (verify)", "low"),
79 ):
80 for m in rx.finditer(scan):
81 findings.append(
82 {
83 "category": "placeholder",
84 "severity": severity,
85 "detail": f"{label}: {m.group(0)!r}",
86 "location": f"{name} (line {offset + line_of(scan, m.start())})",
87 }
88 )
89 return findings
92def find_frontmatter_placeholders(frontmatter: str, name: str) -> list[dict]:
93 """Catch unfilled tokens left in frontmatter (e.g. paradigm/scope/date) — part of the
94 spine contract, but outside the body that find_placeholders scans."""
95 findings: list[dict] = []
96 for rx, label, severity in (
97 (PLACEHOLDER_WORD, "placeholder marker", "high"),
98 (TEMPLATE_TOKEN, "possible unfilled template token (verify)", "low"),
99 ):
100 for m in rx.finditer(frontmatter):
101 findings.append(
102 {
103 "category": "placeholder",
104 "severity": severity,
105 "detail": f"frontmatter {label}: {m.group(0)!r}",
106 "location": f"{name} frontmatter (line {1 + line_of(frontmatter, m.start())})",
107 }
108 )
109 return findings
112def find_ad_issues(body: str, offset: int, name: str) -> list[dict]:
113 findings: list[dict] = []
114 scan = blank_fences(body) # AD headings shown inside a code fence are not live ADs
115 matches = list(AD_HEADING.finditer(scan))
116 seen: dict[int, int] = {}
117 prev: int | None = None
118 for m in matches:
119 num = int(m.group(1))
120 file_line = offset + line_of(scan, m.start())
121 loc = f"{name} AD-{num} (line {file_line})"
122 if num in seen:
123 findings.append(
124 {
125 "category": "ad_id",
126 "severity": "high",
127 "detail": f"AD-{num} id reused (also at line {seen[num]})",
128 "location": loc,
129 }
130 )
131 else:
132 seen[num] = file_line
133 if prev is not None and num <= prev:
134 findings.append(
135 {
136 "category": "ad_id",
137 "severity": "high",
138 "detail": f"AD-{num} is non-monotonic (follows AD-{prev}); ids must ascend and never renumber",
139 "location": loc,
140 }
141 )
142 prev = num if prev is None else max(prev, num)
144 # block text = from this heading to the next heading of any level
145 start = m.end()
146 nxt = HEADING.search(scan, start)
147 block = scan[start : nxt.start()] if nxt else scan[start:]
148 low = block.lower()
149 missing = [f for f in ("binds", "prevents", "rule") if f not in low]
150 if missing:
151 findings.append(
152 {
153 "category": "ad_fields",
154 "severity": "high",
155 "detail": f"AD-{num} missing required field(s): {', '.join(missing)}",
156 "location": loc,
157 }
158 )
159 return findings
162def find_unpinned_stack(body: str, offset: int, name: str) -> list[dict]:
163 """Flag a `## Stack` table row that names something but leaves its version blank or a
164 placeholder. Pinning lives in the body table now, not frontmatter. A row whose name is
165 still a `{token}` skeleton is left to the placeholder pass, not double-reported here.
167 Fences are blanked first (like find_placeholders / find_ad_issues), so a pipe-row or
168 heading inside a code block is never read as live Stack content. The heading match is
169 `## Stack` with a word boundary, so a renamed heading (`## Stack & Versions`) still
170 counts. Name and Version columns are located from the header row, so a reordered table
171 pairs name to version correctly; both default to the canonical positions (0, 1)."""
172 findings: list[dict] = []
173 in_stack = False
174 header_seen = False
175 name_idx, ver_idx = 0, 1
176 scan = blank_fences(body)
177 for i, raw in enumerate(scan.splitlines()):
178 if HEADING.match(raw):
179 in_stack = re.match(r"^##\s+Stack\b", raw) is not None
180 header_seen = False
181 name_idx, ver_idx = 0, 1
182 continue
183 if not in_stack or not raw.lstrip().startswith("|"):
184 continue
185 if set(raw.strip()) <= set("|-: "):
186 continue # separator row
187 cells = _table_cells(raw)
188 if not header_seen:
189 header_seen = True
190 for j, c in enumerate(cells):
191 if c.lower() == "name":
192 name_idx = j
193 elif c.lower() == "version":
194 ver_idx = j
195 continue
196 dep = cells[name_idx] if len(cells) > name_idx else ""
197 version = cells[ver_idx] if len(cells) > ver_idx else ""
198 if not dep or TEMPLATE_TOKEN.search(dep):
199 continue
200 if not version or TEMPLATE_TOKEN.search(version):
201 findings.append(
202 {
203 "category": "version_pin",
204 "severity": "medium",
205 "detail": f"Stack entry {dep!r} has no version",
206 "location": f"{name} (line {offset + i + 1})",
207 }
208 )
209 return findings
212def _table_cells(row: str) -> list[str]:
213 """Split a markdown table row into trimmed cells, dropping the leading/trailing pipe."""
214 s = row.strip()
215 if s.startswith("|"):
216 s = s[1:]
217 if s.endswith("|"):
218 s = s[:-1]
219 return [c.strip() for c in s.split("|")]
222def lint(text: str, name: str = "spine") -> dict:
223 frontmatter, body, offset = split_frontmatter(text)
224 findings: list[dict] = []
225 findings += find_frontmatter_placeholders(frontmatter, name)
226 findings += find_placeholders(body, offset, name)
227 findings += find_ad_issues(body, offset, name)
228 findings += find_unpinned_stack(body, offset, name)
229 counts: dict[str, int] = {}
230 for f in findings:
231 counts[f["severity"]] = counts.get(f["severity"], 0) + 1
232 return {
233 "ok": len(findings) == 0,
234 "spine": name,
235 "total_findings": len(findings),
236 "by_severity": counts,
237 "findings": findings,
238 }
241def main(argv: list[str] | None = None) -> int:
242 ap = argparse.ArgumentParser(description="Lint an architecture spine for mechanical integrity.")
243 ap.add_argument("--workspace", required=True, help="run folder containing the spine, named after the folder")
244 ap.add_argument("-o", "--output", help="write JSON here instead of stdout")
245 args = ap.parse_args(argv)
247 workspace = Path(args.workspace).resolve()
248 spine_path = workspace / f"{workspace.name}.md"
249 if not spine_path.exists():
250 result = {"ok": False, "error": f"{spine_path} not found", "findings": [], "total_findings": 0}
251 else:
252 try:
253 text = spine_path.read_text(encoding="utf-8")
254 except (OSError, UnicodeDecodeError) as e:
255 # honor the "exit code is always 0" contract: a read/decode failure travels in JSON
256 result = {"ok": False, "error": f"could not read {spine_path}: {e}", "findings": [], "total_findings": 0}
257 else:
258 result = lint(text, spine_path.name)
260 out = json.dumps(result, indent=2)
261 if args.output:
262 Path(args.output).write_text(out + "\n", encoding="utf-8")
263 else:
264 print(out)
265 return 0
268if __name__ == "__main__":
269 if sys.platform == "win32":
270 # Piped output on Windows defaults to a legacy code page, not UTF-8.
271 sys.stdout.reconfigure(encoding="utf-8")
272 sys.stderr.reconfigure(encoding="utf-8")
273 sys.exit(main())