adr-touching.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. #!/usr/bin/env python3
  2. """Find which ADRs govern a given path, glob, or config key via `touches:`.
  3. The third leg of the toolkit: adr-lint checks integrity, adr-index gives an
  4. overview, and adr-touching answers the pre-edit question — "is there a decision
  5. record governing the thing I'm about to change?". It reads every ADR's `touches:`
  6. list and reports the records whose discovery surface matches the query.
  7. A query matches a `touches:` entry by any of: exact string equality; fnmatch glob
  8. in EITHER direction (touches `src/**` matches query `src/auth.py`; query `src/*`
  9. matches touches `src/auth.py`); or path-prefix containment (touches `src/auth.py`
  10. is governed by query `src/`; touches `src/` governs query `src/auth.py`).
  11. Config-key entries (`file.yaml:db.host`) match by exact-or-prefix on the whole
  12. string. Pragmatic, not exhaustive.
  13. Usage: adr-touching.py [--dir DIR] [--json] <path-or-glob-or-key>
  14. Input: one positional query + argv flags (no stdin).
  15. Output: stdout = matching ADRs, "number | status | title | matched-entry" rows.
  16. Data only. --json: {"data":[...],"meta":{...,"schema":
  17. "claude-mods.adr-ops.touching/v1"}}.
  18. Stderr: headers, the PyYAML fallback notice, errors.
  19. Exit: 0 NO governing ADR found, 2 usage, 3 dir not found,
  20. 10 at least one governing ADR found (domain signal — a pre-edit hook or
  21. CI can branch on it: "heads up, ADR-NNN governs this path").
  22. Prefers PyYAML for frontmatter; falls back to a minimal parser when absent
  23. (announced on stderr).
  24. Examples:
  25. adr-touching.py src/auth.py
  26. adr-touching.py 'src/**'
  27. adr-touching.py --dir docs/decisions config.yaml:db.host
  28. adr-touching.py --json src/ | jq '.data[].number'
  29. """
  30. from __future__ import annotations
  31. import argparse
  32. import fnmatch
  33. import json
  34. import os
  35. import re
  36. import sys
  37. from pathlib import Path
  38. class Term:
  39. """Tiny ANSI helper mirroring skills/_lib/term.sh (term.sh is bash-only; per
  40. TERMINAL-DESIGN.md §9 the Python port is inline with matching keys/glyphs).
  41. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII (+ legacy FLEET_ASCII). Color tracks
  42. the bound stream's TTY so piped data stays plain; ASCII mode swaps every glyph
  43. for its registered proxy (✓✗▲—? -> +x!-?)."""
  44. _C = {
  45. "green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m",
  46. "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m",
  47. }
  48. _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—", "na": "—", "unknown": "?"}
  49. _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"}
  50. _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim",
  51. "na": "dim", "unknown": "yellow"}
  52. def __init__(self, stream=sys.stdout):
  53. # ASCII fallback: explicit env, OR the bound stream can't encode UTF (e.g. a
  54. # Windows cp1252 pipe) — mirrors term.sh's non-UTF-locale rule and prevents a
  55. # UnicodeEncodeError when a glyph hits a legacy codec.
  56. enc = (getattr(stream, "encoding", "") or "").lower()
  57. self.ascii = (
  58. os.environ.get("TERM_ASCII") == "1"
  59. or os.environ.get("FLEET_ASCII") == "1"
  60. or "utf" not in enc
  61. )
  62. if os.environ.get("FORCE_COLOR"):
  63. self.color = True
  64. elif (os.environ.get("NO_COLOR") is not None
  65. or os.environ.get("TERM") == "dumb"
  66. or not getattr(stream, "isatty", lambda: False)()):
  67. self.color = False
  68. else:
  69. self.color = True
  70. def c(self, name, text):
  71. if not self.color:
  72. return text
  73. return f"{self._C.get(name, '')}{text}{self._C['off']}"
  74. def mark(self, state):
  75. glyph = (self._ASCII if self.ascii else self._GLYPH).get(state, ".")
  76. return self.c(self._MARK_COLOR.get(state, ""), glyph)
  77. EX_OK = 0
  78. EX_USAGE = 2
  79. EX_NOTFOUND = 3
  80. EX_FOUND = 10
  81. FILENAME_RE = re.compile(r"^ADR-(\d+)-.+\.md$")
  82. TITLE_RE = re.compile(r"^# ADR-(\d+):\s+(\S.*)$")
  83. GLOB_CHARS_RE = re.compile(r"[*?\[]")
  84. try:
  85. import yaml # type: ignore
  86. _HAVE_YAML = True
  87. except Exception: # pragma: no cover - environment dependent
  88. yaml = None # type: ignore
  89. _HAVE_YAML = False
  90. class FrontmatterError(Exception):
  91. """Frontmatter block is absent or structurally unparseable."""
  92. def split_frontmatter(text: str) -> tuple[str, str]:
  93. """Return (frontmatter_text, body_text). Raises FrontmatterError if absent."""
  94. lines = text.splitlines()
  95. if not lines or lines[0].strip() != "---":
  96. raise FrontmatterError("no opening '---' frontmatter fence")
  97. for i in range(1, len(lines)):
  98. if lines[i].strip() == "---":
  99. return "\n".join(lines[1:i]), "\n".join(lines[i + 1 :])
  100. raise FrontmatterError("no closing '---' frontmatter fence")
  101. def parse_frontmatter(fm_text: str) -> dict:
  102. """Parse the frontmatter block to a dict. PyYAML if present, else minimal."""
  103. _yaml = yaml # local alias narrows cleanly (module global won't)
  104. if _yaml is not None:
  105. try:
  106. data = _yaml.safe_load(fm_text)
  107. except Exception as exc: # malformed YAML
  108. raise FrontmatterError(f"YAML parse error: {exc}") from exc
  109. if data is None:
  110. return {}
  111. if not isinstance(data, dict):
  112. raise FrontmatterError("frontmatter is not a mapping")
  113. return data
  114. return _minimal_parse(fm_text)
  115. def _minimal_parse(fm_text: str) -> dict:
  116. """Tiny frontmatter parser for `key: scalar` and `key: [a, b]` / block lists."""
  117. out: dict = {}
  118. lines = fm_text.splitlines()
  119. i = 0
  120. while i < len(lines):
  121. raw = lines[i]
  122. if not raw.strip() or raw.lstrip().startswith("#"):
  123. i += 1
  124. continue
  125. m = re.match(r"^(\S[^:]*):\s*(.*)$", raw)
  126. if not m:
  127. i += 1
  128. continue
  129. key, val = m.group(1).strip(), m.group(2).strip()
  130. if val == "":
  131. items = []
  132. j = i + 1
  133. while j < len(lines) and re.match(r"^\s*-\s+", lines[j]):
  134. item = re.sub(r"^\s*-\s+", "", lines[j]).strip()
  135. item = item.strip("\"'")
  136. items.append(item)
  137. j += 1
  138. if items:
  139. out[key] = items
  140. i = j
  141. continue
  142. out[key] = ""
  143. i += 1
  144. continue
  145. if val.startswith("[") and val.endswith("]"):
  146. inner = val[1:-1].strip()
  147. out[key] = (
  148. [x.strip().strip("\"'") for x in inner.split(",") if x.strip()]
  149. if inner
  150. else []
  151. )
  152. else:
  153. out[key] = val.strip("\"'")
  154. i += 1
  155. return out
  156. def as_list(value) -> list:
  157. """Return value coerced to a list of strings (best-effort)."""
  158. if isinstance(value, list):
  159. return [str(x) for x in value]
  160. if value is None or value == "":
  161. return []
  162. return [str(value)]
  163. def _norm(p: str) -> str:
  164. """Normalise a path-ish string for comparison: backslashes -> /, no trailing /."""
  165. s = p.strip().replace("\\", "/")
  166. while len(s) > 1 and s.endswith("/"):
  167. s = s[:-1]
  168. return s
  169. def _is_glob(s: str) -> bool:
  170. return bool(GLOB_CHARS_RE.search(s))
  171. def _is_config_key(s: str) -> bool:
  172. """A `file.ext:dotted.key` entry — a colon segment that isn't a drive letter."""
  173. # Treat any ':' not at position 1 (Windows drive like C:) as a config-key marker.
  174. idx = s.find(":")
  175. return idx > 1
  176. def _prefix_governs(prefix: str, child: str) -> bool:
  177. """True if `prefix` is a directory-prefix of `child` (or equal)."""
  178. prefix = _norm(prefix)
  179. child = _norm(child)
  180. if prefix == child:
  181. return True
  182. return child.startswith(prefix + "/")
  183. def matches(query: str, entry: str) -> bool:
  184. """Does `query` select the ADR carrying `touches:` entry `entry`?"""
  185. q = _norm(query)
  186. e = _norm(entry)
  187. if q == e:
  188. return True
  189. # Config-key entries: match by exact-or-prefix on the whole string only.
  190. if _is_config_key(entry) or _is_config_key(query):
  191. # exact handled above; allow prefix containment either direction
  192. if e.startswith(q) or q.startswith(e):
  193. return True
  194. return False
  195. # Glob in either direction.
  196. if _is_glob(entry) and fnmatch.fnmatch(q, e):
  197. return True
  198. if _is_glob(query) and fnmatch.fnmatch(e, q):
  199. return True
  200. # Recursive-glob convenience: fnmatch treats ** like * (no path awareness),
  201. # which already lets `src/**` match `src/auth.py`. Nothing more needed.
  202. # Path-prefix containment in either direction.
  203. if not _is_glob(entry) and not _is_glob(query):
  204. if _prefix_governs(query, entry) or _prefix_governs(entry, query):
  205. return True
  206. return False
  207. def find_title(body: str) -> str:
  208. for line in body.splitlines():
  209. m = TITLE_RE.match(line.strip())
  210. if m:
  211. return m.group(2).strip()
  212. return ""
  213. def scan(adr_dir: Path, query: str) -> list[dict]:
  214. """Return the list of matching ADR records (sorted by number)."""
  215. results: list[dict] = []
  216. files = sorted(p for p in adr_dir.glob("ADR-*.md") if FILENAME_RE.match(p.name))
  217. for path in files:
  218. fn = FILENAME_RE.match(path.name)
  219. if fn is None:
  220. continue
  221. number = f"ADR-{fn.group(1)}"
  222. try:
  223. text = path.read_text(encoding="utf-8")
  224. fm_text, body = split_frontmatter(text)
  225. fm = parse_frontmatter(fm_text)
  226. except (OSError, FrontmatterError) as exc:
  227. print(f"warning: skipping {path.name}: {exc}", file=sys.stderr)
  228. continue
  229. touches = as_list(fm.get("touches"))
  230. matched = next((t for t in touches if matches(query, t)), None)
  231. if matched is not None:
  232. results.append(
  233. {
  234. "number": number,
  235. "status": str(fm.get("status", "")),
  236. "title": find_title(body),
  237. "matched": matched,
  238. "file": path.name,
  239. }
  240. )
  241. return results
  242. def main(argv: list[str]) -> int:
  243. parser = argparse.ArgumentParser(
  244. prog="adr-touching.py",
  245. description="Find which ADRs govern a path/glob/config-key via touches:.",
  246. add_help=True,
  247. )
  248. parser.add_argument("--dir", default="docs/adr", help="ADR directory (default: docs/adr)")
  249. parser.add_argument("--json", action="store_true", help="emit a JSON envelope")
  250. parser.add_argument("query", nargs="?", help="path, glob, or config key to look up")
  251. try:
  252. args = parser.parse_args(argv)
  253. except SystemExit as exc:
  254. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
  255. if args.query is None or args.query.strip() == "":
  256. print("error: a path/glob/config-key query is required", file=sys.stderr)
  257. return EX_USAGE
  258. if not _HAVE_YAML:
  259. print("note: PyYAML not found — using built-in minimal frontmatter parser.", file=sys.stderr)
  260. adr_dir = Path(args.dir)
  261. if not adr_dir.is_dir():
  262. print(f"error: ADR directory not found: {adr_dir}", file=sys.stderr)
  263. return EX_NOTFOUND
  264. results = scan(adr_dir, args.query)
  265. if args.json:
  266. envelope = {
  267. "data": results,
  268. "meta": {
  269. "count": len(results),
  270. "query": args.query,
  271. "dir": str(adr_dir),
  272. "schema": "claude-mods.adr-ops.touching/v1",
  273. },
  274. }
  275. print(json.dumps(envelope, indent=2))
  276. else:
  277. tout = Term(sys.stdout)
  278. terr = Term(sys.stderr)
  279. status_color = {"accepted": "green", "proposed": "yellow"}
  280. for r in results:
  281. if tout.color:
  282. num = tout.c("cyan", r["number"])
  283. st = tout.c(status_color.get(r["status"], "dim"), r["status"])
  284. print(f"{tout.mark('warn')} {num} | {st} | {r['title']} | {r['matched']}")
  285. else:
  286. # Plain stream stays byte-identical to the legacy data format.
  287. print(f"{r['number']} | {r['status']} | {r['title']} | {r['matched']}")
  288. if results:
  289. print(
  290. f"--- {terr.c('orange', str(len(results)))} ADR(s) govern '{args.query}'",
  291. file=sys.stderr,
  292. )
  293. else:
  294. print(f"--- no ADR governs '{args.query}'", file=sys.stderr)
  295. return EX_FOUND if results else EX_OK
  296. if __name__ == "__main__":
  297. sys.exit(main(sys.argv[1:]))