#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # /// """scan_scripts: lint a skill's scripts/*.py for the repository's script conventions. Rules, each a `rule` value in the findings: pep723-missing no `# /// script` inline metadata block; `uv run` needs it to pick dependencies pep723-floor the block has no requires-python, or its floor is below 3.11 test-missing no scripts/tests/test_.py beside the script network-call imports a network module (urllib.request, requests, httpx, socket, http.client, aiohttp); a skill script should work offline unless its docstring says why it cannot model-id a string literal naming a model id (claude-*, gpt-*, gemini-*); users run any model, so a script never hardcodes or validates against one custom-io a string literal naming `_bmad/custom` or a `.user.toml` file; resolve_customization.py reads overrides and the bmad-customize skill writes them, so a skill's own script touches neither syntax-error the file does not parse A shebang is optional. Files under scripts/tests/ are not linted. Usage: scan_scripts.py Output, one JSON object on stdout: {"skill": str, "scripts": [{"path", "has_pep723", "floor", "has_test"}, ...], "findings": [{"path", "line", "rule", "text", "fix"}, ...]} Exit 0 when clean, 1 when there are findings, 2 on a usage error. """ from __future__ import annotations import argparse import ast import json import re import sys from pathlib import Path FLOOR = (3, 11) PEP723_RE = re.compile(r"^# /// script\n(?P(?:^#(?: .*)?\n)+?)^# ///$", re.MULTILINE) REQUIRES_RE = re.compile(r'^#\s*requires-python\s*=\s*"([^"]*)"', re.MULTILINE) VERSION_RE = re.compile(r">=\s*(\d+)\.(\d+)") # Pieces are joined so this file's own literals do not match the rule. CUSTOM_IO_RE = re.compile("|".join([r"_bmad/" + "custom", r"\.user\." + "toml"])) NETWORK_MODULES = {"urllib.request", "requests", "httpx", "socket", "http.client", "aiohttp"} # A model id carries a version digit; "claude-code" is a product name, not a model. MODEL_ID_RE = re.compile(r"\b(?:claude|gpt|gemini)-(?:[a-z]+-)*\d[a-z0-9.-]*\b", re.IGNORECASE) def finding(path: str, line: int, rule: str, text: str, fix: str) -> dict: return {"path": path, "line": line, "rule": rule, "text": text.strip()[:200], "fix": fix} def pep723_floor(content: str) -> tuple[bool, str | None]: """Return (has_block, requires-python value or None).""" match = PEP723_RE.search(content) if not match: return False, None requires = REQUIRES_RE.search(match.group("body")) return True, requires.group(1) if requires else None def floor_ok(requires: str) -> bool: match = VERSION_RE.search(requires) return bool(match) and (int(match.group(1)), int(match.group(2))) >= FLOOR def scan_ast(tree: ast.AST, rel: str) -> list[dict]: findings = [] docstring = tree.body[0].value if tree.body and isinstance(tree.body[0], ast.Expr) else None for node in ast.walk(tree): if node is docstring: continue names: list[str] = [] if isinstance(node, ast.Import): names = [alias.name for alias in node.names] elif isinstance(node, ast.ImportFrom) and node.module: names = [node.module] + [f"{node.module}.{alias.name}" for alias in node.names] for name in names: if name in NETWORK_MODULES or name.split(".")[0] in {"requests", "httpx", "socket", "aiohttp"}: findings.append( finding(rel, node.lineno, "network-call", name, "work offline, or say in the docstring why not") ) break if isinstance(node, ast.Constant) and isinstance(node.value, str): if CUSTOM_IO_RE.search(node.value): findings.append( finding( rel, node.lineno, "custom-io", node.value, "drop it: resolve_customization.py reads overrides and the bmad-customize skill writes them", ) ) match = MODEL_ID_RE.search(node.value) if match: findings.append( finding( rel, node.lineno, "model-id", match.group(0), "take the model from the caller, never a list" ) ) return findings def scan_script(path: Path, scripts_dir: Path) -> tuple[dict, list[dict]]: rel = f"scripts/{path.name}" content = path.read_text(encoding="utf-8", errors="replace") has_block, requires = pep723_floor(content) test_file = scripts_dir / "tests" / f"test_{path.stem}.py" info = {"path": rel, "has_pep723": has_block, "floor": requires, "has_test": test_file.is_file()} findings = [] if not has_block: findings.append( finding(rel, 1, "pep723-missing", path.name, 'add `# /// script` with requires-python = ">=3.11"') ) elif requires is None or not floor_ok(requires): findings.append(finding(rel, 1, "pep723-floor", requires or "(none)", 'set requires-python = ">=3.11"')) if not info["has_test"]: findings.append(finding(rel, 1, "test-missing", path.name, f"add scripts/tests/test_{path.stem}.py (unittest)")) try: tree = ast.parse(content) except SyntaxError as err: findings.append(finding(rel, err.lineno or 1, "syntax-error", str(err.msg), "fix the syntax")) return info, findings findings.extend(scan_ast(tree, rel)) return info, findings def scan_scripts(skill_root: Path) -> dict: scripts_dir = skill_root / "scripts" scripts: list[dict] = [] findings: list[dict] = [] if scripts_dir.is_dir(): for path in sorted(scripts_dir.glob("*.py")): info, found = scan_script(path, scripts_dir) scripts.append(info) findings.extend(found) findings.sort(key=lambda f: (f["path"], f["line"], f["rule"])) return {"skill": skill_root.name, "scripts": scripts, "findings": findings} def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("skill", type=Path, help="skill folder to scan") args = p.parse_args(argv) if not args.skill.is_dir(): p.error(f"not a directory: {args.skill}") result = scan_scripts(args.skill) print(json.dumps(result, indent=2)) return 1 if result["findings"] else 0 if __name__ == "__main__": sys.exit(main())