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

scan_paths.py

180 lines · skills/bmad-toolsmith/scripts/scan_paths.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""scan_paths: lint a skill's Markdown files for path conventions.
6
7Rules, each a `rule` value in the findings:
8 bare-script-call `uv run scripts/x.py` or `uv run ./scripts/x.py`; a skill's own scripts run as
9 `uv run {skill-root}/scripts/x.py` because the agent's working directory is the project
10 installed-path `{installed_path}` or an `installed_path` definition, a pre-skill leftover
11 absolute-path /Users/, /home/, C:\\ or ~/ paths, which do not travel between machines
12 cross-skill-ref a path into another skill's folder (skills/<other>/..., .claude/skills/<other>/..., or
13 a ../ path that leaves this skill); to use another skill, invoke it by name
14 missing-file a backticked skill-relative path (`references/x.md`) that does not exist from the file's
15 folder or the skill root, when its first folder does exist (otherwise it is prose);
16 files under assets/ and files named sample-* are not checked, they describe emitted output
17 old-module-format a `module.yaml` or `module-help.csv` mention; modules are described by bmod.toml now
18 python-call `python x.py`, `python3 -m x` or `pip install`; every script runs as `uv run <path>`,
19 which reads the script's PEP 723 header for its dependencies
21Fenced code blocks are checked for bare-script-call, installed-path, absolute-path, old-module-format and python-call
22(a wrong example teaches the wrong call) and skipped for the two reference-resolution rules.
24Usage:
25 scan_paths.py <skill-folder> [--allow RULE ...]
27--allow suppresses a rule by name, for a skill that documents the old format on purpose
28(`--allow old-module-format`).
30Output, one JSON object on stdout:
31 {"skill": str, "files_scanned": int, "findings": [{"path", "line", "rule", "text", "fix"}, ...]}
32Exit 0 when clean, 1 when there are findings, 2 on a usage error.
33"""
35from __future__ import annotations
37import argparse
38import json
39import re
40import sys
41from pathlib import Path
43SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv"}
44EXAMPLE_DIR = "assets"
45EXAMPLE_PREFIX = "sample-"
47BARE_SCRIPT_RE = re.compile(r"\buv\s+run\b[^`\n]*?\s(?:\./)?scripts/\S+")
48INSTALLED_PATH_RE = re.compile(r"\{installed_path\}|\binstalled_path\s*[:=]")
49ABS_PATH_RE = re.compile(r"(?:/Users/|/home/|\b[A-Za-z]:[\\/]|(?<![\w.])~/)\S*")
50OLD_FORMAT_RE = re.compile(r"\bmodule\.yaml\b|\bmodule-help\.csv\b")
51PYTHON_CALL_RE = re.compile(r"(?<![\w/.-])(?:python3?|pip3?)\s+(?:-m\s+\S+|\S+\.py\b|install\b)")
52BACKTICK_REF_RE = re.compile(r"`([^`\s]+/[^`\s]+\.(?:md|yaml|yml|toml|json|csv|txt|xml|py|html))`")
53SKILL_DIR_RE = re.compile(r"(?:^|/)(?:skills|\.claude/skills|\.agents/skills|_bmad)/([a-z0-9][a-z0-9-]*)/")
54# Runtime folders under _bmad that are not skills.
55RULES = {
56 "bare-script-call",
57 "installed-path",
58 "absolute-path",
59 "cross-skill-ref",
60 "missing-file",
61 "old-module-format",
62 "python-call",
64BMAD_RUNTIME_DIRS = {"scripts", "config", "custom", "memory", "render", "_config", "knowledge"}
67def finding(path: str, line: int, rule: str, text: str, fix: str) -> dict:
68 return {"path": path, "line": line, "rule": rule, "text": text.strip()[:200], "fix": fix}
71def blank_fences(content: str) -> str:
72 """Blank fenced code blocks but keep their newlines so line numbers stay aligned."""
73 return re.sub(r"```.*?```", lambda m: re.sub(r"[^\n]", "", m.group(0)), content, flags=re.DOTALL)
76def line_of(content: str, offset: int) -> int:
77 return content.count("\n", 0, offset) + 1
80def iter_md_files(skill_root: Path):
81 for path in sorted(skill_root.rglob("*.md")):
82 parts = path.relative_to(skill_root).parts
83 if any(part in SKIP_DIRS or part.startswith(".") for part in parts):
84 continue
85 yield path
88def is_example(rel: Path) -> bool:
89 return EXAMPLE_DIR in rel.parts[:-1] or rel.name.startswith(EXAMPLE_PREFIX)
92def scan_regex_rules(content: str, rel: str) -> list[dict]:
93 findings = []
94 for regex, rule, fix in (
95 (BARE_SCRIPT_RE, "bare-script-call", "write `uv run {skill-root}/scripts/<name>.py`"),
96 (INSTALLED_PATH_RE, "installed-path", "remove installed_path; use a path relative to this file"),
97 (ABS_PATH_RE, "absolute-path", "use {project-root}, {skill-root} or a config value"),
98 (OLD_FORMAT_RE, "old-module-format", "describe the module in bmod.toml; see the migrate mode"),
99 (
100 PYTHON_CALL_RE,
101 "python-call",
102 "run it as `uv run <path>`; dependencies come from the script's PEP 723 header",
103 ),
104 ):
105 for match in regex.finditer(content):
106 findings.append(finding(rel, line_of(content, match.start()), rule, match.group(0), fix))
107 return findings
110def scan_references(content: str, rel: Path, skill_root: Path) -> list[dict]:
111 findings = []
112 stripped = blank_fences(content)
113 skill_name = skill_root.name
114 file_dir = (skill_root / rel).parent
115 for match in BACKTICK_REF_RE.finditer(stripped):
116 raw = match.group(1)
117 line = line_of(stripped, match.start())
118 other = SKILL_DIR_RE.search(raw)
119 if other and other.group(1) != skill_name and other.group(1) not in BMAD_RUNTIME_DIRS:
120 findings.append(
121 finding(
122 rel.as_posix(),
123 line,
124 "cross-skill-ref",
125 raw,
126 f'do not reach into `{other.group(1)}`; write "invoke the `{other.group(1)}` skill"',
127 )
128 )
129 continue
130 if any(ch in raw for ch in "*<{"):
131 continue
132 if raw.startswith("../"):
133 target = (file_dir / raw).resolve()
134 if skill_root.resolve() not in target.parents and target != skill_root.resolve():
135 findings.append(finding(rel.as_posix(), line, "cross-skill-ref", raw, "a skill's files stay inside it"))
136 continue
137 if raw.startswith(("/", "./", "_bmad/", "@")) or is_example(rel):
138 continue
139 roots = [file_dir, skill_root]
140 if any((root / raw).exists() for root in roots):
141 continue
142 first_dir = raw.split("/")[0]
143 if any((root / first_dir).is_dir() for root in roots):
144 findings.append(
145 finding(rel.as_posix(), line, "missing-file", raw, "fix the path or remove the dead reference")
146 )
147 return findings
150def scan_skill(skill_root: Path, allow: tuple[str, ...] = ()) -> dict:
151 findings: list[dict] = []
152 count = 0
153 for path in iter_md_files(skill_root):
154 count += 1
155 rel = path.relative_to(skill_root)
156 content = path.read_text(encoding="utf-8", errors="replace")
157 findings.extend(scan_regex_rules(content, rel.as_posix()))
158 findings.extend(scan_references(content, rel, skill_root))
159 findings = [f for f in findings if f["rule"] not in allow]
160 findings.sort(key=lambda f: (f["path"], f["line"], f["rule"]))
161 return {"skill": skill_root.name, "files_scanned": count, "findings": findings}
164def main(argv: list[str] | None = None) -> int:
165 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
166 p.add_argument("skill", type=Path, help="skill folder to scan")
167 p.add_argument("--allow", action="append", default=[], metavar="RULE", help="suppress a rule by name (repeatable)")
168 args = p.parse_args(argv)
169 if not args.skill.is_dir():
170 p.error(f"not a directory: {args.skill}")
171 unknown = sorted(set(args.allow) - RULES)
172 if unknown:
173 p.error(f"unknown rule(s): {', '.join(unknown)}; rules: {', '.join(sorted(RULES))}")
174 result = scan_skill(args.skill, tuple(args.allow))
175 print(json.dumps(result, indent=2))
176 return 1 if result["findings"] else 0
179if __name__ == "__main__":
180 sys.exit(main())