Skills › BMAD Method › bmad-toolsmith › cut_check.py
cut_check.py
126 lines · skills/bmad-toolsmith/scripts/cut_check.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""cut_check: what a rewrite dropped that must survive character for character.
7Compares each prompt file (the .md files outside scripts/, and the root customize.toml) in a copy of the
8skill taken before the rewrite with the same file after it. Judging whether a rule survived is the
9Checker's work; this catches only what a string comparison can.
11Usage:
12 cut_check.py <before-folder> <after-folder> [--protect <path>[#<heading>] ...]
14Output, one JSON object on stdout:
15 {"files": [{"path", "missing", "lost": [str]}], "missing": [path], "lost": [path]}
16A file's lost lists each Jinja construct ({{ }} or {% %}) and {skill-root} or {project-root} path that is
17gone, "frontmatter" when the frontmatter changed, and "protected" or "protected: <heading>" for a
18--protect file or section no longer kept word for word. Top-level missing and lost list the files with any.
19"""
21from __future__ import annotations
23import argparse
24import json
25import re
26import sys
27from collections import Counter
28from pathlib import Path
30FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
31HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$", re.MULTILINE)
32JINJA_RE = re.compile(r"\{\{.*?\}\}|\{%.*?%\}", re.DOTALL)
33ROOT_PATH_RE = re.compile(r"\{(?:skill-root|project-root)\}[^\s`'\"()<>\[\],;]*")
34SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"}
37def prompt_files(root: Path) -> list[str]:
38 out = []
39 for path in sorted(root.rglob("*")):
40 rel = path.relative_to(root)
41 if not path.is_file() or SKIP_DIRS & set(rel.parts):
42 continue
43 if (path.suffix == ".md" and rel.parts[0] != "scripts") or rel == Path("customize.toml"):
44 out.append(rel.as_posix())
45 # Path order folds case on Windows; strings sort the same on every OS.
46 return sorted(out)
49def section(text: str, heading: str) -> str | None:
50 """The heading line and everything under it up to the next heading of the same or a higher level."""
51 for match in HEADING_RE.finditer(text):
52 if match.group(2) == heading:
53 level = len(match.group(1))
54 for nxt in HEADING_RE.finditer(text, match.end()):
55 if len(nxt.group(1)) <= level:
56 return text[match.start() : nxt.start()]
57 return text[match.start() :]
58 return None
61def lost_in(before: str, after: str, protects: list[str | None]) -> list[str]:
62 jinja = Counter(" ".join(m.split()) for m in JINJA_RE.findall(before))
63 jinja -= Counter(" ".join(m.split()) for m in JINJA_RE.findall(after))
64 lost = list(jinja.elements())
65 after_paths = {p.rstrip(".:") for p in ROOT_PATH_RE.findall(after)}
66 lost += sorted({p.rstrip(".:") for p in ROOT_PATH_RE.findall(before)} - after_paths)
67 fm_before, fm_after = FRONTMATTER_RE.match(before), FRONTMATTER_RE.match(after)
68 if fm_before and (not fm_after or fm_before.group(0) != fm_after.group(0)):
69 lost.append("frontmatter")
70 for heading in protects:
71 if heading is None:
72 if before != after:
73 lost.append("protected")
74 else:
75 kept = section(before, heading)
76 if kept is None:
77 raise ValueError(f"--protect section not found: #{heading}")
78 if kept not in after:
79 lost.append(f"protected: {heading}")
80 return lost
83def check(before: Path, after: Path, protect: list[str]) -> dict:
84 files = prompt_files(before)
85 protects: dict[str, list[str | None]] = {}
86 for entry in protect:
87 path, _, heading = entry.partition("#")
88 if path not in files:
89 raise ValueError(f"--protect matches no prompt file: {entry}")
90 protects.setdefault(path, []).append(heading.strip() or None)
92 out = []
93 for rel in files:
94 target = after / rel
95 missing = not target.is_file()
96 lost = []
97 if not missing:
98 old = (before / rel).read_text(encoding="utf-8")
99 lost = lost_in(old, target.read_text(encoding="utf-8"), protects.get(rel, []))
100 out.append({"path": rel, "missing": missing, "lost": lost})
101 return {
102 "files": out,
103 "missing": [f["path"] for f in out if f["missing"]],
104 "lost": [f["path"] for f in out if f["lost"]],
105 }
108def main(argv: list[str] | None = None) -> int:
109 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
110 p.add_argument("before", type=Path, help="copy of the skill folder taken before the rewrite")
111 p.add_argument("after", type=Path, help="the skill folder now")
112 p.add_argument("--protect", action="append", default=[], help="file, or file#heading, relative to the skill")
113 args = p.parse_args(argv)
114 for folder in (args.before, args.after):
115 if not folder.is_dir():
116 p.error(f"not a directory: {folder}")
117 try:
118 result = check(args.before, args.after, args.protect)
119 except ValueError as exc:
120 p.error(str(exc))
121 print(json.dumps(result, indent=2))
122 return 0
125if __name__ == "__main__":
126 sys.exit(main())