adr-lint.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. #!/usr/bin/env python3
  2. """Conformance linter for Architecture Decision Records.
  3. Validates every ADR-*.md in --dir against the canonical protocol: required
  4. frontmatter (present + well-typed), the `# ADR-NNN: Title` line matching the
  5. filename, the BLUF `## Decision (one sentence)` right after the title, the fixed
  6. core section order, no duplicate numbers, and — the high-value cross-file check —
  7. supersession bidirectionality.
  8. Usage: adr-lint.py [--dir DIR] [--repo-root DIR] [--strict] [--json]
  9. Input: argv flags only (no stdin).
  10. Output: stdout = findings (plain table, or --json envelope). Data only.
  11. Stderr: headers, the yq/PyYAML fallback notice, errors.
  12. Exit: 0 conformant, 2 usage, 3 dir not found, 4 a file's frontmatter
  13. unparseable, 10 findings present (errors; or warnings too under --strict)
  14. Beyond format/order/duplicate/supersession-bidirectionality, also checks:
  15. lifecycle consistency (status vs superseded-by), and — when a touches: entry is a
  16. literal filesystem path — whether it still resolves under --repo-root (a stale
  17. discovery surface), reported as a warning.
  18. Prefers PyYAML for frontmatter; falls back to a minimal parser when it is absent
  19. (announced on stderr). The supersession cross-check is the one most worth running.
  20. Examples:
  21. adr-lint.py
  22. adr-lint.py --dir docs/decisions --strict
  23. adr-lint.py --json | jq '.data[] | select(.severity=="error")'
  24. """
  25. from __future__ import annotations
  26. import argparse
  27. import json
  28. import os
  29. import re
  30. import sys
  31. from pathlib import Path
  32. class Term:
  33. """Tiny ANSI helper mirroring skills/_lib/term.sh (term.sh is bash-only; per
  34. TERMINAL-DESIGN.md §9 the Python port is inline with matching keys/glyphs).
  35. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII (+ legacy FLEET_ASCII). Color tracks
  36. the bound stream's TTY so piped data stays plain; ASCII mode swaps every glyph
  37. for its registered proxy (✓✗▲—? -> +x!-?)."""
  38. _C = {
  39. "green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m",
  40. "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m",
  41. }
  42. _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—", "na": "—", "unknown": "?"}
  43. _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"}
  44. _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim",
  45. "na": "dim", "unknown": "yellow"}
  46. def __init__(self, stream=sys.stdout):
  47. # ASCII fallback: explicit env, OR the bound stream can't encode UTF (e.g. a
  48. # Windows cp1252 pipe) — mirrors term.sh's non-UTF-locale rule and prevents a
  49. # UnicodeEncodeError when a glyph hits a legacy codec.
  50. enc = (getattr(stream, "encoding", "") or "").lower()
  51. self.ascii = (
  52. os.environ.get("TERM_ASCII") == "1"
  53. or os.environ.get("FLEET_ASCII") == "1"
  54. or "utf" not in enc
  55. )
  56. if os.environ.get("FORCE_COLOR"):
  57. self.color = True
  58. elif (os.environ.get("NO_COLOR") is not None
  59. or os.environ.get("TERM") == "dumb"
  60. or not getattr(stream, "isatty", lambda: False)()):
  61. self.color = False
  62. else:
  63. self.color = True
  64. def c(self, name, text):
  65. if not self.color:
  66. return text
  67. return f"{self._C.get(name, '')}{text}{self._C['off']}"
  68. def mark(self, state):
  69. glyph = (self._ASCII if self.ascii else self._GLYPH).get(state, "." )
  70. return self.c(self._MARK_COLOR.get(state, ""), glyph)
  71. EX_OK = 0
  72. EX_USAGE = 2
  73. EX_NOTFOUND = 3
  74. EX_UNPARSEABLE = 4
  75. EX_FINDINGS = 10
  76. VALID_STATUS = {"proposed", "accepted", "superseded", "deprecated"}
  77. IN_FORCE_STATUS = {"proposed", "accepted"}
  78. LIST_FIELDS = ("supersedes", "superseded-by")
  79. REQUIRED_FIELDS = ("status", "date", "supersedes", "superseded-by", "touches")
  80. DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
  81. ADR_ID_RE = re.compile(r"^ADR-\d+$")
  82. FILENAME_RE = re.compile(r"^ADR-(\d+)-.+\.md$")
  83. TITLE_RE = re.compile(r"^# ADR-(\d+):\s+\S")
  84. GLOB_CHARS_RE = re.compile(r"[*?\[]")
  85. EXT_RE = re.compile(r"\.[A-Za-z0-9]{1,8}$")
  86. CORE_SECTIONS = [
  87. "## Decision", # may be "## Decision (one sentence)"
  88. "## Context",
  89. "## Alternatives considered",
  90. "## Consequences",
  91. "## See also",
  92. ]
  93. try:
  94. import yaml # type: ignore
  95. _HAVE_YAML = True
  96. except Exception: # pragma: no cover - environment dependent
  97. yaml = None # type: ignore
  98. _HAVE_YAML = False
  99. class FrontmatterError(Exception):
  100. """Frontmatter block is absent or structurally unparseable."""
  101. def split_frontmatter(text: str) -> tuple[str, str]:
  102. """Return (frontmatter_text, body_text). Raises FrontmatterError if absent."""
  103. lines = text.splitlines()
  104. if not lines or lines[0].strip() != "---":
  105. raise FrontmatterError("no opening '---' frontmatter fence")
  106. for i in range(1, len(lines)):
  107. if lines[i].strip() == "---":
  108. return "\n".join(lines[1:i]), "\n".join(lines[i + 1 :])
  109. raise FrontmatterError("no closing '---' frontmatter fence")
  110. def parse_frontmatter(fm_text: str) -> dict:
  111. """Parse the frontmatter block to a dict. PyYAML if present, else minimal."""
  112. _yaml = yaml # local alias narrows cleanly (module global won't)
  113. if _yaml is not None:
  114. try:
  115. data = _yaml.safe_load(fm_text)
  116. except Exception as exc: # malformed YAML
  117. raise FrontmatterError(f"YAML parse error: {exc}") from exc
  118. if data is None:
  119. return {}
  120. if not isinstance(data, dict):
  121. raise FrontmatterError("frontmatter is not a mapping")
  122. return data
  123. return _minimal_parse(fm_text)
  124. def _minimal_parse(fm_text: str) -> dict:
  125. """Tiny frontmatter parser for `key: scalar` and `key: [a, b]` / block lists."""
  126. out: dict = {}
  127. lines = fm_text.splitlines()
  128. i = 0
  129. while i < len(lines):
  130. raw = lines[i]
  131. if not raw.strip() or raw.lstrip().startswith("#"):
  132. i += 1
  133. continue
  134. m = re.match(r"^(\S[^:]*):\s*(.*)$", raw)
  135. if not m:
  136. i += 1
  137. continue
  138. key, val = m.group(1).strip(), m.group(2).strip()
  139. if val == "":
  140. # Possible block list: subsequent " - item" lines.
  141. items = []
  142. j = i + 1
  143. while j < len(lines) and re.match(r"^\s*-\s+", lines[j]):
  144. item = re.sub(r"^\s*-\s+", "", lines[j]).strip()
  145. item = item.strip("\"'")
  146. items.append(item)
  147. j += 1
  148. if items:
  149. out[key] = items
  150. i = j
  151. continue
  152. out[key] = ""
  153. i += 1
  154. continue
  155. if val.startswith("[") and val.endswith("]"):
  156. inner = val[1:-1].strip()
  157. out[key] = (
  158. [x.strip().strip("\"'") for x in inner.split(",") if x.strip()]
  159. if inner
  160. else []
  161. )
  162. else:
  163. out[key] = val.strip("\"'")
  164. i += 1
  165. return out
  166. def as_list(value) -> list | None:
  167. """Return value as a list, or None if it is not list-typed."""
  168. if isinstance(value, list):
  169. return value
  170. return None
  171. def is_literal_path(entry: str) -> bool:
  172. """True if a touches: entry is a literal filesystem path we can check on disk.
  173. A literal path contains a '/' or a file extension; is NOT a glob (no * ? [);
  174. and is NOT a config-key (no `file:key` colon segment — but a Windows drive
  175. letter `C:` at position 1 doesn't count as a marker).
  176. """
  177. s = entry.strip()
  178. if not s:
  179. return False
  180. if GLOB_CHARS_RE.search(s):
  181. return False
  182. if s.find(":") > 1: # config-key marker (drive letters live at index 1)
  183. return False
  184. return ("/" in s) or bool(EXT_RE.search(s))
  185. def find_title(body: str):
  186. """Return (line_number_in_body, match) for the first ADR title, or (None, None)."""
  187. for idx, line in enumerate(body.splitlines()):
  188. m = TITLE_RE.match(line.strip())
  189. if m:
  190. return idx, m
  191. return None, None
  192. def section_sequence(body: str) -> list[str]:
  193. """Ordered list of the core `## ` headings that appear (normalised)."""
  194. seen = []
  195. for line in body.splitlines():
  196. s = line.strip()
  197. if not s.startswith("## "):
  198. continue
  199. for canon in CORE_SECTIONS:
  200. if s == canon or s.startswith(canon + " "):
  201. seen.append(canon)
  202. break
  203. return seen
  204. def lint_dir(adr_dir: Path, repo_root: Path | None = None) -> tuple[list[dict], bool]:
  205. """Return (findings, any_unparseable)."""
  206. findings: list[dict] = []
  207. any_unparseable = False
  208. files = sorted(
  209. p for p in adr_dir.glob("ADR-*.md") if FILENAME_RE.match(p.name)
  210. )
  211. # number -> list of filenames (duplicate detection)
  212. by_number: dict[str, list[str]] = {}
  213. # adr-id -> parsed record (for supersession cross-check)
  214. records: dict[str, dict] = {}
  215. def add(file: str, severity: str, message: str) -> None:
  216. findings.append({"file": file, "severity": severity, "message": message})
  217. for path in files:
  218. name = path.name
  219. fn_match = FILENAME_RE.match(name)
  220. if fn_match is None:
  221. continue # files are pre-filtered to ADR-NNN-*.md; defensive guard
  222. fm_num = fn_match.group(1)
  223. adr_id = f"ADR-{fm_num}"
  224. by_number.setdefault(fm_num, []).append(name)
  225. try:
  226. text = path.read_text(encoding="utf-8")
  227. except Exception as exc:
  228. add(name, "error", f"could not read file: {exc}")
  229. any_unparseable = True
  230. continue
  231. try:
  232. fm_text, body = split_frontmatter(text)
  233. fm = parse_frontmatter(fm_text)
  234. except FrontmatterError as exc:
  235. add(name, "error", f"unparseable frontmatter: {exc}")
  236. any_unparseable = True
  237. continue
  238. records[adr_id] = {"file": name, "fm": fm, "number": fm_num}
  239. # ── required frontmatter present + typed ──
  240. for field in REQUIRED_FIELDS:
  241. if field not in fm:
  242. add(name, "error", f"missing required frontmatter field: {field}")
  243. status = fm.get("status")
  244. if status is not None and status not in VALID_STATUS:
  245. add(
  246. name,
  247. "error",
  248. f"status '{status}' not in {sorted(VALID_STATUS)}",
  249. )
  250. date = fm.get("date")
  251. if date is not None and not DATE_RE.match(str(date)):
  252. add(name, "error", f"date '{date}' is not YYYY-MM-DD")
  253. for field in LIST_FIELDS:
  254. if field in fm and as_list(fm[field]) is None:
  255. add(name, "error", f"{field} must be a YAML list (got {type(fm[field]).__name__})")
  256. if "touches" in fm and as_list(fm["touches"]) is None:
  257. add(name, "warning", "touches should be a YAML list of paths/globs/keys")
  258. # ── title line + filename agreement ──
  259. t_idx, t_match = find_title(body)
  260. if t_match is None:
  261. add(name, "error", "missing '# ADR-NNN: Title' line after frontmatter")
  262. else:
  263. title_num = t_match.group(1)
  264. if title_num != fm_num:
  265. add(
  266. name,
  267. "error",
  268. f"title number ADR-{title_num} != filename number ADR-{fm_num}",
  269. )
  270. # ── BLUF: '## Decision (one sentence)' right after the title ──
  271. if t_match is not None and t_idx is not None:
  272. body_lines = body.splitlines()
  273. nxt = None
  274. for line in body_lines[t_idx + 1 :]:
  275. if line.strip():
  276. nxt = line.strip()
  277. break
  278. if nxt != "## Decision (one sentence)":
  279. add(
  280. name,
  281. "error",
  282. "first section after title must be '## Decision (one sentence)' (BLUF)",
  283. )
  284. # ── core section order ──
  285. seq = section_sequence(body)
  286. present = [s for s in CORE_SECTIONS if s in seq]
  287. # Filter the observed sequence down to core headings only, dedup-first-occurrence.
  288. observed = []
  289. for s in seq:
  290. if s in present and s not in observed:
  291. observed.append(s)
  292. expected_order = [s for s in CORE_SECTIONS if s in observed]
  293. if observed != expected_order:
  294. add(
  295. name,
  296. "error",
  297. f"core sections out of order: {observed} (expected {expected_order})",
  298. )
  299. # ── lifecycle consistency (status vs superseded-by) ──
  300. # Complements the bidirectionality cross-check below: these are local,
  301. # single-record contradictions and never double-report with it.
  302. superseded_by_here = as_list(fm.get("superseded-by")) or []
  303. has_successor = len(superseded_by_here) > 0
  304. if status == "superseded" and not has_successor:
  305. add(
  306. name,
  307. "error",
  308. "status is 'superseded' but superseded-by is empty "
  309. "(a superseded ADR must name its successor in superseded-by)",
  310. )
  311. elif status == "deprecated" and has_successor:
  312. add(
  313. name,
  314. "error",
  315. "status is 'deprecated' but superseded-by is non-empty "
  316. "(deprecated means nothing replaces it; if something does, use 'superseded')",
  317. )
  318. elif status in IN_FORCE_STATUS and has_successor:
  319. add(
  320. name,
  321. "error",
  322. f"status is '{status}' (in force) but superseded-by is non-empty "
  323. "(an in-force ADR cannot list a superseded-by)",
  324. )
  325. # ── stale touches: a literal path that no longer exists (warning) ──
  326. if repo_root is not None:
  327. touches_here = as_list(fm.get("touches")) or []
  328. for entry in touches_here:
  329. if not isinstance(entry, str) or not is_literal_path(entry):
  330. continue
  331. target = (repo_root / entry).resolve()
  332. if not target.exists():
  333. add(
  334. name,
  335. "warning",
  336. f"touches path no longer exists: {entry} "
  337. "(discovery surface may be stale)",
  338. )
  339. # ── duplicate numbers (error) / gaps (warning) ──
  340. for num, names in sorted(by_number.items()):
  341. if len(names) > 1:
  342. for n in names:
  343. add(n, "error", f"duplicate ADR number {num} (also: {[x for x in names if x != n]})")
  344. if by_number:
  345. nums = sorted(int(n) for n in by_number)
  346. full = set(range(min(nums), max(nums) + 1))
  347. missing = sorted(full - set(nums))
  348. for gap in missing:
  349. add(
  350. f"ADR-{gap:03d}",
  351. "warning",
  352. f"number {gap:03d} is missing — a gap in the sequence (numbers are normally contiguous)",
  353. )
  354. # ── supersession bidirectionality (the high-value cross-file check) ──
  355. for adr_id, rec in records.items():
  356. fm = rec["fm"]
  357. name = rec["file"]
  358. supersedes = as_list(fm.get("supersedes")) or []
  359. for target in supersedes:
  360. if not isinstance(target, str) or not ADR_ID_RE.match(target):
  361. add(name, "error", f"supersedes entry '{target}' is not a valid ADR-NNN id")
  362. continue
  363. other = records.get(target)
  364. if other is None:
  365. add(name, "error", f"supersedes {target}, but no such record exists")
  366. continue
  367. o_fm = other["fm"]
  368. o_by = as_list(o_fm.get("superseded-by")) or []
  369. if adr_id not in o_by:
  370. add(
  371. other["file"],
  372. "error",
  373. f"{target} is superseded by {adr_id} but its superseded-by does not list {adr_id}",
  374. )
  375. if o_fm.get("status") != "superseded":
  376. add(
  377. other["file"],
  378. "error",
  379. f"{target} is superseded by {adr_id} but its status is '{o_fm.get('status')}', not 'superseded'",
  380. )
  381. superseded_by = as_list(fm.get("superseded-by")) or []
  382. for target in superseded_by:
  383. if not isinstance(target, str) or not ADR_ID_RE.match(target):
  384. add(name, "error", f"superseded-by entry '{target}' is not a valid ADR-NNN id")
  385. continue
  386. other = records.get(target)
  387. if other is None:
  388. add(name, "error", f"superseded-by {target}, but no such record exists")
  389. continue
  390. o_sup = as_list(other["fm"].get("supersedes")) or []
  391. if adr_id not in o_sup:
  392. add(
  393. other["file"],
  394. "error",
  395. f"{target} claims to supersede nothing back to {adr_id} (its supersedes omits {adr_id})",
  396. )
  397. return findings, any_unparseable
  398. def resolve_repo_root(explicit: str | None) -> Path | None:
  399. """Resolve the repo root for touches-path checks.
  400. Explicit --repo-root wins (must be a directory). Otherwise try `git
  401. rev-parse --show-toplevel`; fall back to cwd. Returns None only if an
  402. explicit path was given but is not a directory (caller treats as usage).
  403. """
  404. if explicit is not None:
  405. p = Path(explicit)
  406. return p if p.is_dir() else None
  407. import subprocess # local: only needed when no explicit root
  408. try:
  409. out = subprocess.run(
  410. ["git", "rev-parse", "--show-toplevel"],
  411. capture_output=True,
  412. text=True,
  413. timeout=5,
  414. )
  415. if out.returncode == 0 and out.stdout.strip():
  416. return Path(out.stdout.strip())
  417. except (OSError, subprocess.SubprocessError):
  418. pass
  419. return Path.cwd()
  420. def main(argv: list[str]) -> int:
  421. parser = argparse.ArgumentParser(
  422. prog="adr-lint.py",
  423. description="Conformance linter for Architecture Decision Records.",
  424. add_help=True,
  425. )
  426. parser.add_argument("--dir", default="docs/adr", help="ADR directory (default: docs/adr)")
  427. parser.add_argument(
  428. "--repo-root",
  429. default=None,
  430. help="repo root for resolving literal touches: paths "
  431. "(default: git toplevel if in a git repo, else cwd)",
  432. )
  433. parser.add_argument(
  434. "--strict", action="store_true", help="count warnings toward the exit-10 signal"
  435. )
  436. parser.add_argument("--json", action="store_true", help="emit a JSON envelope")
  437. try:
  438. args = parser.parse_args(argv)
  439. except SystemExit as exc:
  440. # argparse exits 2 on usage error already; normalise non-zero to EX_USAGE.
  441. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
  442. if not _HAVE_YAML:
  443. print("note: PyYAML not found — using built-in minimal frontmatter parser.", file=sys.stderr)
  444. adr_dir = Path(args.dir)
  445. if not adr_dir.is_dir():
  446. print(f"error: ADR directory not found: {adr_dir}", file=sys.stderr)
  447. return EX_NOTFOUND
  448. repo_root = resolve_repo_root(args.repo_root)
  449. if args.repo_root is not None and repo_root is None:
  450. print(f"error: --repo-root is not a directory: {args.repo_root}", file=sys.stderr)
  451. return EX_USAGE
  452. findings, any_unparseable = lint_dir(adr_dir, repo_root)
  453. errors = [f for f in findings if f["severity"] == "error"]
  454. warnings = [f for f in findings if f["severity"] == "warning"]
  455. if args.json:
  456. envelope = {
  457. "data": findings,
  458. "meta": {
  459. "count": len(findings),
  460. "errors": len(errors),
  461. "warnings": len(warnings),
  462. "dir": str(adr_dir),
  463. "schema": "claude-mods.adr-ops.lint/v1",
  464. },
  465. }
  466. print(json.dumps(envelope, indent=2))
  467. else:
  468. tout = Term(sys.stdout)
  469. terr = Term(sys.stderr)
  470. for f in findings:
  471. sev = f["severity"]
  472. if tout.color:
  473. state = "bad" if sev == "error" else "warn"
  474. col = "red" if sev == "error" else "orange"
  475. print(f"{tout.mark(state)} {tout.c(col, f'{sev.upper():7}')} {f['file']}: {f['message']}")
  476. else:
  477. # Plain stream stays byte-identical to the legacy data format.
  478. print(f"{sev.upper():7} {f['file']}: {f['message']}")
  479. print(
  480. f"--- {terr.c('red', str(len(errors)))} error(s), "
  481. f"{terr.c('orange', str(len(warnings)))} warning(s) across {args.dir}",
  482. file=sys.stderr,
  483. )
  484. if any_unparseable:
  485. return EX_UNPARSEABLE
  486. if errors or (args.strict and warnings):
  487. return EX_FINDINGS
  488. return EX_OK
  489. if __name__ == "__main__":
  490. sys.exit(main(sys.argv[1:]))