scan-path-standards.py 11 KB

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