Skill Tracer
Skills › BMAD Method › bmad-toolsmith › scan_scripts.py

scan_scripts.py

155 lines · skills/bmad-toolsmith/scripts/scan_scripts.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""scan_scripts: lint a skill's scripts/*.py for the repository's script conventions.
6
7Rules, each a `rule` value in the findings:
8 pep723-missing no `# /// script` inline metadata block; `uv run` needs it to pick dependencies
9 pep723-floor the block has no requires-python, or its floor is below 3.11
10 test-missing no scripts/tests/test_<name>.py beside the script
11 network-call imports a network module (urllib.request, requests, httpx, socket, http.client, aiohttp);
12 a skill script should work offline unless its docstring says why it cannot
13 model-id a string literal naming a model id (claude-*, gpt-*, gemini-*); users run any model,
14 so a script never hardcodes or validates against one
15 custom-io a string literal naming `_bmad/custom` or a `.user.toml` file; resolve_customization.py reads
16 overrides and the bmad-customize skill writes them, so a skill's own script touches neither
17 syntax-error the file does not parse
19A shebang is optional. Files under scripts/tests/ are not linted.
21Usage:
22 scan_scripts.py <skill-folder>
24Output, one JSON object on stdout:
25 {"skill": str, "scripts": [{"path", "has_pep723", "floor", "has_test"}, ...],
26 "findings": [{"path", "line", "rule", "text", "fix"}, ...]}
27Exit 0 when clean, 1 when there are findings, 2 on a usage error.
28"""
30from __future__ import annotations
32import argparse
33import ast
34import json
35import re
36import sys
37from pathlib import Path
39FLOOR = (3, 11)
40PEP723_RE = re.compile(r"^# /// script\n(?P<body>(?:^#(?: .*)?\n)+?)^# ///$", re.MULTILINE)
41REQUIRES_RE = re.compile(r'^#\s*requires-python\s*=\s*"([^"]*)"', re.MULTILINE)
42VERSION_RE = re.compile(r">=\s*(\d+)\.(\d+)")
43# Pieces are joined so this file's own literals do not match the rule.
44CUSTOM_IO_RE = re.compile("|".join([r"_bmad/" + "custom", r"\.user\." + "toml"]))
45NETWORK_MODULES = {"urllib.request", "requests", "httpx", "socket", "http.client", "aiohttp"}
46# A model id carries a version digit; "claude-code" is a product name, not a model.
47MODEL_ID_RE = re.compile(r"\b(?:claude|gpt|gemini)-(?:[a-z]+-)*\d[a-z0-9.-]*\b", re.IGNORECASE)
50def finding(path: str, line: int, rule: str, text: str, fix: str) -> dict:
51 return {"path": path, "line": line, "rule": rule, "text": text.strip()[:200], "fix": fix}
54def pep723_floor(content: str) -> tuple[bool, str | None]:
55 """Return (has_block, requires-python value or None)."""
56 match = PEP723_RE.search(content)
57 if not match:
58 return False, None
59 requires = REQUIRES_RE.search(match.group("body"))
60 return True, requires.group(1) if requires else None
63def floor_ok(requires: str) -> bool:
64 match = VERSION_RE.search(requires)
65 return bool(match) and (int(match.group(1)), int(match.group(2))) >= FLOOR
68def scan_ast(tree: ast.AST, rel: str) -> list[dict]:
69 findings = []
70 docstring = tree.body[0].value if tree.body and isinstance(tree.body[0], ast.Expr) else None
71 for node in ast.walk(tree):
72 if node is docstring:
73 continue
74 names: list[str] = []
75 if isinstance(node, ast.Import):
76 names = [alias.name for alias in node.names]
77 elif isinstance(node, ast.ImportFrom) and node.module:
78 names = [node.module] + [f"{node.module}.{alias.name}" for alias in node.names]
79 for name in names:
80 if name in NETWORK_MODULES or name.split(".")[0] in {"requests", "httpx", "socket", "aiohttp"}:
81 findings.append(
82 finding(rel, node.lineno, "network-call", name, "work offline, or say in the docstring why not")
83 )
84 break
85 if isinstance(node, ast.Constant) and isinstance(node.value, str):
86 if CUSTOM_IO_RE.search(node.value):
87 findings.append(
88 finding(
89 rel,
90 node.lineno,
91 "custom-io",
92 node.value,
93 "drop it: resolve_customization.py reads overrides and the bmad-customize skill writes them",
94 )
95 )
96 match = MODEL_ID_RE.search(node.value)
97 if match:
98 findings.append(
99 finding(
100 rel, node.lineno, "model-id", match.group(0), "take the model from the caller, never a list"
101 )
102 )
103 return findings
106def scan_script(path: Path, scripts_dir: Path) -> tuple[dict, list[dict]]:
107 rel = f"scripts/{path.name}"
108 content = path.read_text(encoding="utf-8", errors="replace")
109 has_block, requires = pep723_floor(content)
110 test_file = scripts_dir / "tests" / f"test_{path.stem}.py"
111 info = {"path": rel, "has_pep723": has_block, "floor": requires, "has_test": test_file.is_file()}
112 findings = []
113 if not has_block:
114 findings.append(
115 finding(rel, 1, "pep723-missing", path.name, 'add `# /// script` with requires-python = ">=3.11"')
116 )
117 elif requires is None or not floor_ok(requires):
118 findings.append(finding(rel, 1, "pep723-floor", requires or "(none)", 'set requires-python = ">=3.11"'))
119 if not info["has_test"]:
120 findings.append(finding(rel, 1, "test-missing", path.name, f"add scripts/tests/test_{path.stem}.py (unittest)"))
121 try:
122 tree = ast.parse(content)
123 except SyntaxError as err:
124 findings.append(finding(rel, err.lineno or 1, "syntax-error", str(err.msg), "fix the syntax"))
125 return info, findings
126 findings.extend(scan_ast(tree, rel))
127 return info, findings
130def scan_scripts(skill_root: Path) -> dict:
131 scripts_dir = skill_root / "scripts"
132 scripts: list[dict] = []
133 findings: list[dict] = []
134 if scripts_dir.is_dir():
135 for path in sorted(scripts_dir.glob("*.py")):
136 info, found = scan_script(path, scripts_dir)
137 scripts.append(info)
138 findings.extend(found)
139 findings.sort(key=lambda f: (f["path"], f["line"], f["rule"]))
140 return {"skill": skill_root.name, "scripts": scripts, "findings": findings}
143def main(argv: list[str] | None = None) -> int:
144 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
145 p.add_argument("skill", type=Path, help="skill folder to scan")
146 args = p.parse_args(argv)
147 if not args.skill.is_dir():
148 p.error(f"not a directory: {args.skill}")
149 result = scan_scripts(args.skill)
150 print(json.dumps(result, indent=2))
151 return 1 if result["findings"] else 0
154if __name__ == "__main__":
155 sys.exit(main())