| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- #!/usr/bin/env python3
- """Deterministic path standards scanner for BMad skills.
- Validates all .md and .json files against BMad path conventions:
- 1. {project-root} for any project-scope path (not just _bmad)
- 2. Bare _bmad references must have {project-root} prefix
- 3. Config variables used directly — no double-prefix with {project-root}
- 4. ./ only for same-folder references — never ./subdir/ cross-directory
- 5. No ../ parent directory references
- 6. No absolute paths
- 7. Memory paths must use {project-root}/_bmad/memory/{skillName}/
- 8. Frontmatter allows only name and description
- 9. No .md files at skill root except SKILL.md
- """
- # /// script
- # requires-python = ">=3.9"
- # ///
- from __future__ import annotations
- import argparse
- import json
- import re
- import sys
- from datetime import datetime, timezone
- from pathlib import Path
- # Patterns to detect
- # Double-prefix: {project-root}/{config-variable} — config vars already contain project-root
- DOUBLE_PREFIX_RE = re.compile(r'\{project-root\}/\{[^}]+\}')
- # Bare _bmad without {project-root} prefix — match _bmad at word boundary
- # but not when preceded by {project-root}/
- BARE_BMAD_RE = re.compile(r'(?<!\{project-root\}/)_bmad[/\s]')
- # Absolute paths
- ABSOLUTE_PATH_RE = re.compile(r'(?:^|[\s"`\'(])(/(?:Users|home|opt|var|tmp|etc|usr)/\S+)', re.MULTILINE)
- HOME_PATH_RE = re.compile(r'(?:^|[\s"`\'(])(~/\S+)', re.MULTILINE)
- # Parent directory reference (still invalid)
- RELATIVE_DOT_RE = re.compile(r'(?:^|[\s"`\'(])(\.\./\S+)', re.MULTILINE)
- # Cross-directory ./ — ./subdir/ is wrong because ./ means same folder only
- CROSS_DIR_DOT_SLASH_RE = re.compile(r'(?:^|[\s"`\'(])\./(?:references|scripts|assets)/\S+', re.MULTILINE)
- # Memory path pattern: should use {project-root}/_bmad/memory/
- MEMORY_PATH_RE = re.compile(r'_bmad/memory/\S+')
- VALID_MEMORY_PATH_RE = re.compile(r'\{project-root\}/_bmad/memory/[\w-]+/')
- # Fenced code block detection (to skip examples showing wrong patterns)
- FENCE_RE = re.compile(r'^```', re.MULTILINE)
- # Valid frontmatter keys
- VALID_FRONTMATTER_KEYS = {'name', 'description'}
- def is_in_fenced_block(content: str, pos: int) -> bool:
- """Check if a position is inside a fenced code block."""
- fences = [m.start() for m in FENCE_RE.finditer(content[:pos])]
- # Odd number of fences before pos means we're inside a block
- return len(fences) % 2 == 1
- def get_line_number(content: str, pos: int) -> int:
- """Get 1-based line number for a position in content."""
- return content[:pos].count('\n') + 1
- def check_frontmatter(content: str, filepath: Path) -> list[dict]:
- """Validate SKILL.md frontmatter contains only allowed keys."""
- findings = []
- if filepath.name != 'SKILL.md':
- return findings
- if not content.startswith('---'):
- findings.append({
- 'file': filepath.name,
- 'line': 1,
- 'severity': 'critical',
- 'category': 'frontmatter',
- 'title': 'SKILL.md missing frontmatter block',
- 'detail': 'SKILL.md must start with --- frontmatter containing name and description',
- 'action': 'Add frontmatter with name and description fields',
- })
- return findings
- # Find closing ---
- end = content.find('\n---', 3)
- if end == -1:
- findings.append({
- 'file': filepath.name,
- 'line': 1,
- 'severity': 'critical',
- 'category': 'frontmatter',
- 'title': 'SKILL.md frontmatter block not closed',
- 'detail': 'Missing closing --- for frontmatter',
- 'action': 'Add closing --- after frontmatter fields',
- })
- return findings
- frontmatter = content[4:end]
- for i, line in enumerate(frontmatter.split('\n'), start=2):
- line = line.strip()
- if not line or line.startswith('#'):
- continue
- if ':' in line:
- key = line.split(':', 1)[0].strip()
- if key not in VALID_FRONTMATTER_KEYS:
- findings.append({
- 'file': filepath.name,
- 'line': i,
- 'severity': 'high',
- 'category': 'frontmatter',
- 'title': f'Invalid frontmatter key: {key}',
- 'detail': f'Only {", ".join(sorted(VALID_FRONTMATTER_KEYS))} are allowed in frontmatter',
- 'action': f'Remove {key} from frontmatter — use as content field in SKILL.md body instead',
- })
- return findings
- def check_root_md_files(skill_path: Path) -> list[dict]:
- """Check that no .md files exist at skill root except SKILL.md."""
- findings = []
- for md_file in skill_path.glob('*.md'):
- if md_file.name != 'SKILL.md':
- findings.append({
- 'file': md_file.name,
- 'line': 0,
- 'severity': 'high',
- 'category': 'structure',
- 'title': f'Prompt file at skill root: {md_file.name}',
- 'detail': 'All progressive disclosure content must be in ./references/ — only SKILL.md belongs at root',
- 'action': f'Move {md_file.name} to references/{md_file.name}',
- })
- return findings
- def scan_file(filepath: Path, skip_fenced: bool = True) -> list[dict]:
- """Scan a single file for path standard violations."""
- findings = []
- content = filepath.read_text(encoding='utf-8')
- rel_path = filepath.name
- checks = [
- (DOUBLE_PREFIX_RE, 'double-prefix', 'critical',
- 'Double-prefix: {project-root}/{variable} — config variables already contain {project-root} at runtime'),
- (ABSOLUTE_PATH_RE, 'absolute-path', 'high',
- 'Absolute path found — not portable across machines'),
- (HOME_PATH_RE, 'absolute-path', 'high',
- 'Home directory path (~/) found — environment-specific'),
- (RELATIVE_DOT_RE, 'relative-prefix', 'high',
- 'Parent directory reference (../) found — fragile, breaks with reorganization'),
- (CROSS_DIR_DOT_SLASH_RE, 'cross-dir-dot-slash', 'high',
- 'Cross-directory ./ reference — ./ means same folder only; use bare skill-root relative path (e.g., references/foo.md not ./references/foo.md)'),
- ]
- for pattern, category, severity, message in checks:
- for match in pattern.finditer(content):
- pos = match.start()
- if skip_fenced and is_in_fenced_block(content, pos):
- continue
- line_num = get_line_number(content, pos)
- line_content = content.split('\n')[line_num - 1].strip()
- findings.append({
- 'file': rel_path,
- 'line': line_num,
- 'severity': severity,
- 'category': category,
- 'title': message,
- 'detail': line_content[:120],
- 'action': '',
- })
- # Bare _bmad check — more nuanced, need to avoid false positives
- # inside {project-root}/_bmad which is correct
- for match in BARE_BMAD_RE.finditer(content):
- pos = match.start()
- if skip_fenced and is_in_fenced_block(content, pos):
- continue
- start = max(0, pos - 30)
- before = content[start:pos]
- if '{project-root}/' in before:
- continue
- line_num = get_line_number(content, pos)
- line_content = content.split('\n')[line_num - 1].strip()
- findings.append({
- 'file': rel_path,
- 'line': line_num,
- 'severity': 'high',
- 'category': 'bare-bmad',
- 'title': 'Bare _bmad reference without {project-root} prefix',
- 'detail': line_content[:120],
- 'action': '',
- })
- # Memory path check — memory paths should use {project-root}/_bmad/memory/{skillName}/
- for match in MEMORY_PATH_RE.finditer(content):
- pos = match.start()
- if skip_fenced and is_in_fenced_block(content, pos):
- continue
- start = max(0, pos - 20)
- before = content[start:pos]
- if '{project-root}/' not in before:
- line_num = get_line_number(content, pos)
- line_content = content.split('\n')[line_num - 1].strip()
- findings.append({
- 'file': rel_path,
- 'line': line_num,
- 'severity': 'high',
- 'category': 'memory-path',
- 'title': 'Memory path missing {project-root} prefix — use {project-root}/_bmad/memory/',
- 'detail': line_content[:120],
- 'action': '',
- })
- return findings
- def scan_skill(skill_path: Path, skip_fenced: bool = True) -> dict:
- """Scan all .md and .json files in a skill directory."""
- all_findings = []
- # Check for .md files at root that aren't SKILL.md
- all_findings.extend(check_root_md_files(skill_path))
- # Check SKILL.md frontmatter
- skill_md = skill_path / 'SKILL.md'
- if skill_md.exists():
- content = skill_md.read_text(encoding='utf-8')
- all_findings.extend(check_frontmatter(content, skill_md))
- # Find all .md and .json files
- md_files = sorted(list(skill_path.rglob('*.md')) + list(skill_path.rglob('*.json')))
- if not md_files:
- print(f"Warning: No .md or .json files found in {skill_path}", file=sys.stderr)
- files_scanned = []
- for md_file in md_files:
- rel = md_file.relative_to(skill_path)
- files_scanned.append(str(rel))
- file_findings = scan_file(md_file, skip_fenced)
- for f in file_findings:
- f['file'] = str(rel)
- all_findings.extend(file_findings)
- # Build summary
- by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
- by_category = {
- 'double_prefix': 0,
- 'bare_bmad': 0,
- 'absolute_path': 0,
- 'relative_prefix': 0,
- 'cross_dir_dot_slash': 0,
- 'memory_path': 0,
- 'frontmatter': 0,
- 'structure': 0,
- }
- for f in all_findings:
- sev = f['severity']
- if sev in by_severity:
- by_severity[sev] += 1
- cat = f['category'].replace('-', '_')
- if cat in by_category:
- by_category[cat] += 1
- return {
- 'scanner': 'path-standards',
- 'script': 'scan-path-standards.py',
- 'version': '3.0.0',
- 'skill_path': str(skill_path),
- 'timestamp': datetime.now(timezone.utc).isoformat(),
- 'files_scanned': files_scanned,
- 'status': 'pass' if not all_findings else 'fail',
- 'findings': all_findings,
- 'assessments': {},
- 'summary': {
- 'total_findings': len(all_findings),
- 'by_severity': by_severity,
- 'by_category': by_category,
- 'assessment': 'Path standards scan complete',
- },
- }
- def main() -> int:
- parser = argparse.ArgumentParser(
- description='Scan BMad skill for path standard violations',
- )
- parser.add_argument(
- 'skill_path',
- type=Path,
- help='Path to the skill directory to scan',
- )
- parser.add_argument(
- '--output', '-o',
- type=Path,
- help='Write JSON output to file instead of stdout',
- )
- parser.add_argument(
- '--include-fenced',
- action='store_true',
- help='Also check inside fenced code blocks (by default they are skipped)',
- )
- args = parser.parse_args()
- if not args.skill_path.is_dir():
- print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
- return 2
- result = scan_skill(args.skill_path, skip_fenced=not args.include_fenced)
- output = json.dumps(result, indent=2)
- if args.output:
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(output)
- print(f"Results written to {args.output}", file=sys.stderr)
- else:
- print(output)
- return 0 if result['status'] == 'pass' else 1
- if __name__ == '__main__':
- sys.exit(main())
|