| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475 |
- #!/usr/bin/env python3
- """Deterministic pre-pass for workflow integrity scanner.
- Extracts structural metadata from a BMad skill that the LLM scanner
- can use instead of reading all files itself. Covers:
- - Frontmatter parsing and validation
- - Section inventory (H2/H3 headers)
- - Template artifact detection
- - Stage file cross-referencing
- - Stage numbering validation
- - Config header detection in prompts
- - Language/directness pattern grep
- - On Exit / Exiting section detection (invalid)
- """
- # /// 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
- # Template artifacts that should NOT appear in finalized skills
- TEMPLATE_ARTIFACTS = [
- r'\{if-complex-workflow\}', r'\{/if-complex-workflow\}',
- r'\{if-simple-workflow\}', r'\{/if-simple-workflow\}',
- r'\{if-simple-utility\}', r'\{/if-simple-utility\}',
- r'\{if-module\}', r'\{/if-module\}',
- r'\{if-headless\}', r'\{/if-headless\}',
- r'\{displayName\}', r'\{skillName\}',
- ]
- # Runtime variables that ARE expected (not artifacts)
- RUNTIME_VARS = {
- '{user_name}', '{communication_language}', '{document_output_language}',
- '{project-root}', '{output_folder}', '{planning_artifacts}',
- }
- # Directness anti-patterns
- DIRECTNESS_PATTERNS = [
- (r'\byou should\b', 'Suggestive "you should" — use direct imperative'),
- (r'\bplease\b(?! note)', 'Polite "please" — use direct imperative'),
- (r'\bhandle appropriately\b', 'Ambiguous "handle appropriately" — specify how'),
- (r'\bwhen ready\b', 'Vague "when ready" — specify testable condition'),
- ]
- # Invalid sections
- INVALID_SECTIONS = [
- (r'^##\s+On\s+Exit\b', 'On Exit section found — no exit hooks exist in the system, this will never run'),
- (r'^##\s+Exiting\b', 'Exiting section found — no exit hooks exist in the system, this will never run'),
- ]
- def parse_frontmatter(content: str) -> tuple[dict | None, list[dict]]:
- """Parse YAML frontmatter and validate."""
- findings = []
- fm_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
- if not fm_match:
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'critical', 'category': 'frontmatter',
- 'issue': 'No YAML frontmatter found',
- })
- return None, findings
- try:
- # Frontmatter is YAML-like key: value pairs — parse manually
- fm = {}
- for line in fm_match.group(1).strip().split('\n'):
- line = line.strip()
- if not line or line.startswith('#'):
- continue
- if ':' in line:
- key, _, value = line.partition(':')
- fm[key.strip()] = value.strip().strip('"').strip("'")
- except Exception as e:
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'critical', 'category': 'frontmatter',
- 'issue': f'Invalid frontmatter: {e}',
- })
- return None, findings
- if not isinstance(fm, dict):
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'critical', 'category': 'frontmatter',
- 'issue': 'Frontmatter is not a YAML mapping',
- })
- return None, findings
- # name check
- name = fm.get('name')
- if not name:
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'critical', 'category': 'frontmatter',
- 'issue': 'Missing "name" field in frontmatter',
- })
- elif not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name):
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'high', 'category': 'frontmatter',
- 'issue': f'Name "{name}" is not kebab-case',
- })
- # bmad- prefix check removed — bmad- is reserved for official BMad creations only
- # description check
- desc = fm.get('description')
- if not desc:
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'high', 'category': 'frontmatter',
- 'issue': 'Missing "description" field in frontmatter',
- })
- elif 'Use when' not in desc and 'use when' not in desc:
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'medium', 'category': 'frontmatter',
- 'issue': 'Description missing "Use when..." trigger phrase',
- })
- # Extra fields check
- allowed = {'name', 'description', 'menu-code'}
- extra = set(fm.keys()) - allowed
- if extra:
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'low', 'category': 'frontmatter',
- 'issue': f'Extra frontmatter fields: {", ".join(sorted(extra))}',
- })
- return fm, findings
- def extract_sections(content: str) -> list[dict]:
- """Extract all H2 headers with line numbers."""
- sections = []
- for i, line in enumerate(content.split('\n'), 1):
- m = re.match(r'^(#{2,3})\s+(.+)$', line)
- if m:
- sections.append({
- 'level': len(m.group(1)),
- 'title': m.group(2).strip(),
- 'line': i,
- })
- return sections
- def check_required_sections(sections: list[dict]) -> list[dict]:
- """Check for required and invalid sections."""
- findings = []
- h2_titles = [s['title'] for s in sections if s['level'] == 2]
- if 'Overview' not in h2_titles:
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'high', 'category': 'sections',
- 'issue': 'Missing ## Overview section',
- })
- if 'On Activation' not in h2_titles:
- findings.append({
- 'file': 'SKILL.md', 'line': 1,
- 'severity': 'high', 'category': 'sections',
- 'issue': 'Missing ## On Activation section',
- })
- # Invalid sections
- for s in sections:
- if s['level'] == 2:
- for pattern, message in INVALID_SECTIONS:
- if re.match(pattern, f"## {s['title']}"):
- findings.append({
- 'file': 'SKILL.md', 'line': s['line'],
- 'severity': 'high', 'category': 'invalid-section',
- 'issue': message,
- })
- return findings
- def find_template_artifacts(filepath: Path, rel_path: str) -> list[dict]:
- """Scan for orphaned template substitution artifacts."""
- findings = []
- content = filepath.read_text(encoding='utf-8')
- for pattern in TEMPLATE_ARTIFACTS:
- for m in re.finditer(pattern, content):
- matched = m.group()
- if matched in RUNTIME_VARS:
- continue
- line_num = content[:m.start()].count('\n') + 1
- findings.append({
- 'file': rel_path, 'line': line_num,
- 'severity': 'high', 'category': 'artifacts',
- 'issue': f'Orphaned template artifact: {matched}',
- 'fix': 'Resolve or remove this template conditional/placeholder',
- })
- return findings
- def cross_reference_stages(skill_path: Path, skill_content: str) -> tuple[dict, list[dict]]:
- """Cross-reference stage files between SKILL.md and numbered prompt files at skill root."""
- findings = []
- # Get actual numbered prompt files at skill root (exclude SKILL.md)
- actual_files = set()
- for f in skill_path.iterdir():
- if f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md' and re.match(r'^\d+-', f.name):
- actual_files.add(f.name)
- # Find stage references in SKILL.md — look for both old prompts/ style and new root style
- referenced = set()
- # Match `prompts/XX-name.md` (legacy) or bare `XX-name.md` references
- ref_pattern = re.compile(r'(?:prompts/)?(\d+-[^\s)`]+\.md)')
- for m in ref_pattern.finditer(skill_content):
- referenced.add(m.group(1))
- # Missing files (referenced but don't exist)
- missing = referenced - actual_files
- for f in sorted(missing):
- findings.append({
- 'file': 'SKILL.md', 'line': 0,
- 'severity': 'critical', 'category': 'missing-stage',
- 'issue': f'Referenced stage file does not exist: {f}',
- })
- # Orphaned files (exist but not referenced)
- orphaned = actual_files - referenced
- for f in sorted(orphaned):
- findings.append({
- 'file': f, 'line': 0,
- 'severity': 'medium', 'category': 'naming',
- 'issue': f'Stage file exists but not referenced in SKILL.md: {f}',
- })
- # Stage numbering check
- numbered = []
- for f in sorted(actual_files):
- m = re.match(r'^(\d+)-(.+)\.md$', f)
- if m:
- numbered.append((int(m.group(1)), f))
- if numbered:
- numbered.sort()
- nums = [n[0] for n in numbered]
- expected = list(range(nums[0], nums[0] + len(nums)))
- if nums != expected:
- gaps = set(expected) - set(nums)
- if gaps:
- findings.append({
- 'file': skill_path.name, 'line': 0,
- 'severity': 'medium', 'category': 'naming',
- 'issue': f'Stage numbering has gaps: missing {sorted(gaps)}',
- })
- stage_summary = {
- 'total_stages': len(actual_files),
- 'referenced': sorted(referenced),
- 'actual': sorted(actual_files),
- 'missing_stages': sorted(missing),
- 'orphaned_stages': sorted(orphaned),
- }
- return stage_summary, findings
- def check_prompt_basics(skill_path: Path) -> tuple[list[dict], list[dict]]:
- """Check each prompt file for config header and progression conditions."""
- findings = []
- prompt_details = []
- # Look for numbered prompt files at skill root
- prompt_files = sorted(
- f for f in skill_path.iterdir()
- if f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md' and re.match(r'^\d+-', f.name)
- )
- if not prompt_files:
- return prompt_details, findings
- for f in prompt_files:
- content = f.read_text(encoding='utf-8')
- rel_path = f.name
- detail = {'file': f.name, 'has_config_header': False, 'has_progression': False}
- # Config header check
- if '{communication_language}' in content or '{document_output_language}' in content:
- detail['has_config_header'] = True
- else:
- findings.append({
- 'file': rel_path, 'line': 1,
- 'severity': 'medium', 'category': 'config-header',
- 'issue': 'No config header with language variables found',
- })
- # Progression condition check (look for progression-related keywords near end)
- lower = content.lower()
- prog_keywords = ['progress', 'advance', 'move to', 'next stage', 'when complete',
- 'proceed to', 'transition', 'completion criteria']
- if any(kw in lower for kw in prog_keywords):
- detail['has_progression'] = True
- else:
- findings.append({
- 'file': rel_path, 'line': len(content.split('\n')),
- 'severity': 'high', 'category': 'progression',
- 'issue': 'No progression condition keywords found',
- })
- # Directness checks
- for pattern, message in DIRECTNESS_PATTERNS:
- for m in re.finditer(pattern, content, re.IGNORECASE):
- line_num = content[:m.start()].count('\n') + 1
- findings.append({
- 'file': rel_path, 'line': line_num,
- 'severity': 'low', 'category': 'language',
- 'issue': message,
- })
- # Template artifacts
- findings.extend(find_template_artifacts(f, rel_path))
- prompt_details.append(detail)
- return prompt_details, findings
- def detect_workflow_type(skill_content: str, has_prompts: bool) -> str:
- """Detect workflow type from SKILL.md content."""
- has_stage_refs = bool(re.search(r'(?:prompts/)?\d+-\S+\.md', skill_content))
- has_routing = bool(re.search(r'(?i)(rout|stage|branch|path)', skill_content))
- if has_stage_refs or (has_prompts and has_routing):
- return 'complex'
- elif re.search(r'(?m)^\d+\.\s', skill_content):
- return 'simple-workflow'
- else:
- return 'simple-utility'
- def scan_workflow_integrity(skill_path: Path) -> dict:
- """Run all deterministic workflow integrity checks."""
- all_findings = []
- # Read SKILL.md
- skill_md = skill_path / 'SKILL.md'
- if not skill_md.exists():
- return {
- 'scanner': 'workflow-integrity-prepass',
- 'script': 'prepass-workflow-integrity.py',
- 'version': '1.0.0',
- 'skill_path': str(skill_path),
- 'timestamp': datetime.now(timezone.utc).isoformat(),
- 'status': 'fail',
- 'issues': [{'file': 'SKILL.md', 'line': 1, 'severity': 'critical',
- 'category': 'missing-file', 'issue': 'SKILL.md does not exist'}],
- 'summary': {'total_issues': 1, 'by_severity': {'critical': 1, 'high': 0, 'medium': 0, 'low': 0}},
- }
- skill_content = skill_md.read_text(encoding='utf-8')
- # Frontmatter
- frontmatter, fm_findings = parse_frontmatter(skill_content)
- all_findings.extend(fm_findings)
- # Sections
- sections = extract_sections(skill_content)
- section_findings = check_required_sections(sections)
- all_findings.extend(section_findings)
- # Template artifacts in SKILL.md
- all_findings.extend(find_template_artifacts(skill_md, 'SKILL.md'))
- # Directness checks in SKILL.md
- for pattern, message in DIRECTNESS_PATTERNS:
- for m in re.finditer(pattern, skill_content, re.IGNORECASE):
- line_num = skill_content[:m.start()].count('\n') + 1
- all_findings.append({
- 'file': 'SKILL.md', 'line': line_num,
- 'severity': 'low', 'category': 'language',
- 'issue': message,
- })
- # Workflow type
- has_prompts = any(
- f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md' and re.match(r'^\d+-', f.name)
- for f in skill_path.iterdir()
- )
- workflow_type = detect_workflow_type(skill_content, has_prompts)
- # Stage cross-reference
- stage_summary, stage_findings = cross_reference_stages(skill_path, skill_content)
- all_findings.extend(stage_findings)
- # Prompt basics
- prompt_details, prompt_findings = check_prompt_basics(skill_path)
- all_findings.extend(prompt_findings)
- # Build severity summary
- by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
- for f in all_findings:
- sev = f['severity']
- if sev in by_severity:
- by_severity[sev] += 1
- status = 'pass'
- if by_severity['critical'] > 0:
- status = 'fail'
- elif by_severity['high'] > 0:
- status = 'warning'
- return {
- 'scanner': 'workflow-integrity-prepass',
- 'script': 'prepass-workflow-integrity.py',
- 'version': '1.0.0',
- 'skill_path': str(skill_path),
- 'timestamp': datetime.now(timezone.utc).isoformat(),
- 'status': status,
- 'metadata': {
- 'frontmatter': frontmatter,
- 'sections': sections,
- 'workflow_type': workflow_type,
- },
- 'stage_summary': stage_summary,
- 'prompt_details': prompt_details,
- 'issues': all_findings,
- 'summary': {
- 'total_issues': len(all_findings),
- 'by_severity': by_severity,
- },
- }
- def main() -> int:
- parser = argparse.ArgumentParser(
- description='Deterministic pre-pass for workflow integrity scanning',
- )
- 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',
- )
- 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_workflow_integrity(args.skill_path)
- 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())
|