lint_spine.py 10 KB

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