scan-path-standards.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. #!/usr/bin/env python3
  2. """Deterministic path standards scanner for BMad skills.
  3. Validates all .md and .json files against BMad path conventions:
  4. 1. {project-root} for any project-scope path (not just _bmad)
  5. 2. Bare _bmad references must have {project-root} prefix
  6. 3. Config variables used directly — no double-prefix with {project-root}
  7. 4. ./ only for same-folder references — never ./subdir/ cross-directory
  8. 5. No ../ parent directory references
  9. 6. No absolute paths
  10. 7. Memory paths must use {project-root}/_bmad/memory/{skillName}/
  11. 8. Frontmatter allows only name and description
  12. 9. No .md files at skill root except SKILL.md
  13. """
  14. # /// script
  15. # requires-python = ">=3.9"
  16. # ///
  17. from __future__ import annotations
  18. import argparse
  19. import json
  20. import re
  21. import sys
  22. from datetime import datetime, timezone
  23. from pathlib import Path
  24. # Patterns to detect
  25. # Double-prefix: {project-root}/{config-variable} — config vars already contain project-root
  26. DOUBLE_PREFIX_RE = re.compile(r'\{project-root\}/\{[^}]+\}')
  27. # Bare _bmad without {project-root} prefix — match _bmad at word boundary
  28. # but not when preceded by {project-root}/
  29. BARE_BMAD_RE = re.compile(r'(?<!\{project-root\}/)_bmad[/\s]')
  30. # Absolute paths
  31. ABSOLUTE_PATH_RE = re.compile(r'(?:^|[\s"`\'(])(/(?:Users|home|opt|var|tmp|etc|usr)/\S+)', re.MULTILINE)
  32. HOME_PATH_RE = re.compile(r'(?:^|[\s"`\'(])(~/\S+)', re.MULTILINE)
  33. # Parent directory reference (still invalid)
  34. RELATIVE_DOT_RE = re.compile(r'(?:^|[\s"`\'(])(\.\./\S+)', re.MULTILINE)
  35. # Cross-directory ./ — ./subdir/ is wrong because ./ means same folder only
  36. CROSS_DIR_DOT_SLASH_RE = re.compile(r'(?:^|[\s"`\'(])\./(?:references|scripts|assets)/\S+', re.MULTILINE)
  37. # Memory path pattern: should use {project-root}/_bmad/memory/
  38. MEMORY_PATH_RE = re.compile(r'_bmad/memory/\S+')
  39. VALID_MEMORY_PATH_RE = re.compile(r'\{project-root\}/_bmad/memory/[\w-]+/')
  40. # Fenced code block detection (to skip examples showing wrong patterns)
  41. FENCE_RE = re.compile(r'^```', re.MULTILINE)
  42. # Valid frontmatter keys
  43. VALID_FRONTMATTER_KEYS = {'name', 'description'}
  44. def is_in_fenced_block(content: str, pos: int) -> bool:
  45. """Check if a position is inside a fenced code block."""
  46. fences = [m.start() for m in FENCE_RE.finditer(content[:pos])]
  47. # Odd number of fences before pos means we're inside a block
  48. return len(fences) % 2 == 1
  49. def get_line_number(content: str, pos: int) -> int:
  50. """Get 1-based line number for a position in content."""
  51. return content[:pos].count('\n') + 1
  52. def check_frontmatter(content: str, filepath: Path) -> list[dict]:
  53. """Validate SKILL.md frontmatter contains only allowed keys."""
  54. findings = []
  55. if filepath.name != 'SKILL.md':
  56. return findings
  57. if not content.startswith('---'):
  58. findings.append({
  59. 'file': filepath.name,
  60. 'line': 1,
  61. 'severity': 'critical',
  62. 'category': 'frontmatter',
  63. 'title': 'SKILL.md missing frontmatter block',
  64. 'detail': 'SKILL.md must start with --- frontmatter containing name and description',
  65. 'action': 'Add frontmatter with name and description fields',
  66. })
  67. return findings
  68. # Find closing ---
  69. end = content.find('\n---', 3)
  70. if end == -1:
  71. findings.append({
  72. 'file': filepath.name,
  73. 'line': 1,
  74. 'severity': 'critical',
  75. 'category': 'frontmatter',
  76. 'title': 'SKILL.md frontmatter block not closed',
  77. 'detail': 'Missing closing --- for frontmatter',
  78. 'action': 'Add closing --- after frontmatter fields',
  79. })
  80. return findings
  81. frontmatter = content[4:end]
  82. for i, line in enumerate(frontmatter.split('\n'), start=2):
  83. line = line.strip()
  84. if not line or line.startswith('#'):
  85. continue
  86. if ':' in line:
  87. key = line.split(':', 1)[0].strip()
  88. if key not in VALID_FRONTMATTER_KEYS:
  89. findings.append({
  90. 'file': filepath.name,
  91. 'line': i,
  92. 'severity': 'high',
  93. 'category': 'frontmatter',
  94. 'title': f'Invalid frontmatter key: {key}',
  95. 'detail': f'Only {", ".join(sorted(VALID_FRONTMATTER_KEYS))} are allowed in frontmatter',
  96. 'action': f'Remove {key} from frontmatter — use as content field in SKILL.md body instead',
  97. })
  98. return findings
  99. def check_root_md_files(skill_path: Path) -> list[dict]:
  100. """Check that no .md files exist at skill root except SKILL.md."""
  101. findings = []
  102. for md_file in skill_path.glob('*.md'):
  103. if md_file.name != 'SKILL.md':
  104. findings.append({
  105. 'file': md_file.name,
  106. 'line': 0,
  107. 'severity': 'high',
  108. 'category': 'structure',
  109. 'title': f'Prompt file at skill root: {md_file.name}',
  110. 'detail': 'All progressive disclosure content must be in ./references/ — only SKILL.md belongs at root',
  111. 'action': f'Move {md_file.name} to references/{md_file.name}',
  112. })
  113. return findings
  114. def scan_file(filepath: Path, skip_fenced: bool = True) -> list[dict]:
  115. """Scan a single file for path standard violations."""
  116. findings = []
  117. content = filepath.read_text(encoding='utf-8')
  118. rel_path = filepath.name
  119. checks = [
  120. (DOUBLE_PREFIX_RE, 'double-prefix', 'critical',
  121. 'Double-prefix: {project-root}/{variable} — config variables already contain {project-root} at runtime'),
  122. (ABSOLUTE_PATH_RE, 'absolute-path', 'high',
  123. 'Absolute path found — not portable across machines'),
  124. (HOME_PATH_RE, 'absolute-path', 'high',
  125. 'Home directory path (~/) found — environment-specific'),
  126. (RELATIVE_DOT_RE, 'relative-prefix', 'high',
  127. 'Parent directory reference (../) found — fragile, breaks with reorganization'),
  128. (CROSS_DIR_DOT_SLASH_RE, 'cross-dir-dot-slash', 'high',
  129. 'Cross-directory ./ reference — ./ means same folder only; use bare skill-root relative path (e.g., references/foo.md not ./references/foo.md)'),
  130. ]
  131. for pattern, category, severity, message in checks:
  132. for match in pattern.finditer(content):
  133. pos = match.start()
  134. if skip_fenced and is_in_fenced_block(content, pos):
  135. continue
  136. line_num = get_line_number(content, pos)
  137. line_content = content.split('\n')[line_num - 1].strip()
  138. findings.append({
  139. 'file': rel_path,
  140. 'line': line_num,
  141. 'severity': severity,
  142. 'category': category,
  143. 'title': message,
  144. 'detail': line_content[:120],
  145. 'action': '',
  146. })
  147. # Bare _bmad check — more nuanced, need to avoid false positives
  148. # inside {project-root}/_bmad which is correct
  149. for match in BARE_BMAD_RE.finditer(content):
  150. pos = match.start()
  151. if skip_fenced and is_in_fenced_block(content, pos):
  152. continue
  153. start = max(0, pos - 30)
  154. before = content[start:pos]
  155. if '{project-root}/' in before:
  156. continue
  157. line_num = get_line_number(content, pos)
  158. line_content = content.split('\n')[line_num - 1].strip()
  159. findings.append({
  160. 'file': rel_path,
  161. 'line': line_num,
  162. 'severity': 'high',
  163. 'category': 'bare-bmad',
  164. 'title': 'Bare _bmad reference without {project-root} prefix',
  165. 'detail': line_content[:120],
  166. 'action': '',
  167. })
  168. # Memory path check — memory paths should use {project-root}/_bmad/memory/{skillName}/
  169. for match in MEMORY_PATH_RE.finditer(content):
  170. pos = match.start()
  171. if skip_fenced and is_in_fenced_block(content, pos):
  172. continue
  173. start = max(0, pos - 20)
  174. before = content[start:pos]
  175. if '{project-root}/' not in before:
  176. line_num = get_line_number(content, pos)
  177. line_content = content.split('\n')[line_num - 1].strip()
  178. findings.append({
  179. 'file': rel_path,
  180. 'line': line_num,
  181. 'severity': 'high',
  182. 'category': 'memory-path',
  183. 'title': 'Memory path missing {project-root} prefix — use {project-root}/_bmad/memory/',
  184. 'detail': line_content[:120],
  185. 'action': '',
  186. })
  187. return findings
  188. def scan_skill(skill_path: Path, skip_fenced: bool = True) -> dict:
  189. """Scan all .md and .json files in a skill directory."""
  190. all_findings = []
  191. # Check for .md files at root that aren't SKILL.md
  192. all_findings.extend(check_root_md_files(skill_path))
  193. # Check SKILL.md frontmatter
  194. skill_md = skill_path / 'SKILL.md'
  195. if skill_md.exists():
  196. content = skill_md.read_text(encoding='utf-8')
  197. all_findings.extend(check_frontmatter(content, skill_md))
  198. # Find all .md and .json files
  199. md_files = sorted(list(skill_path.rglob('*.md')) + list(skill_path.rglob('*.json')))
  200. if not md_files:
  201. print(f"Warning: No .md or .json files found in {skill_path}", file=sys.stderr)
  202. files_scanned = []
  203. for md_file in md_files:
  204. rel = md_file.relative_to(skill_path)
  205. files_scanned.append(str(rel))
  206. file_findings = scan_file(md_file, skip_fenced)
  207. for f in file_findings:
  208. f['file'] = str(rel)
  209. all_findings.extend(file_findings)
  210. # Build summary
  211. by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
  212. by_category = {
  213. 'double_prefix': 0,
  214. 'bare_bmad': 0,
  215. 'absolute_path': 0,
  216. 'relative_prefix': 0,
  217. 'cross_dir_dot_slash': 0,
  218. 'memory_path': 0,
  219. 'frontmatter': 0,
  220. 'structure': 0,
  221. }
  222. for f in all_findings:
  223. sev = f['severity']
  224. if sev in by_severity:
  225. by_severity[sev] += 1
  226. cat = f['category'].replace('-', '_')
  227. if cat in by_category:
  228. by_category[cat] += 1
  229. return {
  230. 'scanner': 'path-standards',
  231. 'script': 'scan-path-standards.py',
  232. 'version': '3.0.0',
  233. 'skill_path': str(skill_path),
  234. 'timestamp': datetime.now(timezone.utc).isoformat(),
  235. 'files_scanned': files_scanned,
  236. 'status': 'pass' if not all_findings else 'fail',
  237. 'findings': all_findings,
  238. 'assessments': {},
  239. 'summary': {
  240. 'total_findings': len(all_findings),
  241. 'by_severity': by_severity,
  242. 'by_category': by_category,
  243. 'assessment': 'Path standards scan complete',
  244. },
  245. }
  246. def main() -> int:
  247. parser = argparse.ArgumentParser(
  248. description='Scan BMad skill for path standard violations',
  249. )
  250. parser.add_argument(
  251. 'skill_path',
  252. type=Path,
  253. help='Path to the skill directory to scan',
  254. )
  255. parser.add_argument(
  256. '--output', '-o',
  257. type=Path,
  258. help='Write JSON output to file instead of stdout',
  259. )
  260. parser.add_argument(
  261. '--include-fenced',
  262. action='store_true',
  263. help='Also check inside fenced code blocks (by default they are skipped)',
  264. )
  265. args = parser.parse_args()
  266. if not args.skill_path.is_dir():
  267. print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
  268. return 2
  269. result = scan_skill(args.skill_path, skip_fenced=not args.include_fenced)
  270. output = json.dumps(result, indent=2)
  271. if args.output:
  272. args.output.parent.mkdir(parents=True, exist_ok=True)
  273. args.output.write_text(output)
  274. print(f"Results written to {args.output}", file=sys.stderr)
  275. else:
  276. print(output)
  277. return 0 if result['status'] == 'pass' else 1
  278. if __name__ == '__main__':
  279. sys.exit(main())