prepass-workflow-integrity.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. #!/usr/bin/env python3
  2. """Deterministic pre-pass for workflow integrity scanner.
  3. Extracts structural metadata from a BMad skill that the LLM scanner
  4. can use instead of reading all files itself. Covers:
  5. - Frontmatter parsing and validation
  6. - Section inventory (H2/H3 headers)
  7. - Template artifact detection
  8. - Stage file cross-referencing
  9. - Stage numbering validation
  10. - Config header detection in prompts
  11. - Language/directness pattern grep
  12. - On Exit / Exiting section detection (invalid)
  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. # Template artifacts that should NOT appear in finalized skills
  25. TEMPLATE_ARTIFACTS = [
  26. r'\{if-complex-workflow\}', r'\{/if-complex-workflow\}',
  27. r'\{if-simple-workflow\}', r'\{/if-simple-workflow\}',
  28. r'\{if-simple-utility\}', r'\{/if-simple-utility\}',
  29. r'\{if-module\}', r'\{/if-module\}',
  30. r'\{if-headless\}', r'\{/if-headless\}',
  31. r'\{displayName\}', r'\{skillName\}',
  32. ]
  33. # Runtime variables that ARE expected (not artifacts)
  34. RUNTIME_VARS = {
  35. '{user_name}', '{communication_language}', '{document_output_language}',
  36. '{project-root}', '{output_folder}', '{planning_artifacts}',
  37. }
  38. # Directness anti-patterns
  39. DIRECTNESS_PATTERNS = [
  40. (r'\byou should\b', 'Suggestive "you should" — use direct imperative'),
  41. (r'\bplease\b(?! note)', 'Polite "please" — use direct imperative'),
  42. (r'\bhandle appropriately\b', 'Ambiguous "handle appropriately" — specify how'),
  43. (r'\bwhen ready\b', 'Vague "when ready" — specify testable condition'),
  44. ]
  45. # Invalid sections
  46. INVALID_SECTIONS = [
  47. (r'^##\s+On\s+Exit\b', 'On Exit section found — no exit hooks exist in the system, this will never run'),
  48. (r'^##\s+Exiting\b', 'Exiting section found — no exit hooks exist in the system, this will never run'),
  49. ]
  50. def parse_frontmatter(content: str) -> tuple[dict | None, list[dict]]:
  51. """Parse YAML frontmatter and validate."""
  52. findings = []
  53. fm_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
  54. if not fm_match:
  55. findings.append({
  56. 'file': 'SKILL.md', 'line': 1,
  57. 'severity': 'critical', 'category': 'frontmatter',
  58. 'issue': 'No YAML frontmatter found',
  59. })
  60. return None, findings
  61. try:
  62. # Frontmatter is YAML-like key: value pairs — parse manually
  63. fm = {}
  64. for line in fm_match.group(1).strip().split('\n'):
  65. line = line.strip()
  66. if not line or line.startswith('#'):
  67. continue
  68. if ':' in line:
  69. key, _, value = line.partition(':')
  70. fm[key.strip()] = value.strip().strip('"').strip("'")
  71. except Exception as e:
  72. findings.append({
  73. 'file': 'SKILL.md', 'line': 1,
  74. 'severity': 'critical', 'category': 'frontmatter',
  75. 'issue': f'Invalid frontmatter: {e}',
  76. })
  77. return None, findings
  78. if not isinstance(fm, dict):
  79. findings.append({
  80. 'file': 'SKILL.md', 'line': 1,
  81. 'severity': 'critical', 'category': 'frontmatter',
  82. 'issue': 'Frontmatter is not a YAML mapping',
  83. })
  84. return None, findings
  85. # name check
  86. name = fm.get('name')
  87. if not name:
  88. findings.append({
  89. 'file': 'SKILL.md', 'line': 1,
  90. 'severity': 'critical', 'category': 'frontmatter',
  91. 'issue': 'Missing "name" field in frontmatter',
  92. })
  93. elif not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name):
  94. findings.append({
  95. 'file': 'SKILL.md', 'line': 1,
  96. 'severity': 'high', 'category': 'frontmatter',
  97. 'issue': f'Name "{name}" is not kebab-case',
  98. })
  99. # bmad- prefix check removed — bmad- is reserved for official BMad creations only
  100. # description check
  101. desc = fm.get('description')
  102. if not desc:
  103. findings.append({
  104. 'file': 'SKILL.md', 'line': 1,
  105. 'severity': 'high', 'category': 'frontmatter',
  106. 'issue': 'Missing "description" field in frontmatter',
  107. })
  108. elif 'Use when' not in desc and 'use when' not in desc:
  109. findings.append({
  110. 'file': 'SKILL.md', 'line': 1,
  111. 'severity': 'medium', 'category': 'frontmatter',
  112. 'issue': 'Description missing "Use when..." trigger phrase',
  113. })
  114. # Extra fields check
  115. allowed = {'name', 'description', 'menu-code'}
  116. extra = set(fm.keys()) - allowed
  117. if extra:
  118. findings.append({
  119. 'file': 'SKILL.md', 'line': 1,
  120. 'severity': 'low', 'category': 'frontmatter',
  121. 'issue': f'Extra frontmatter fields: {", ".join(sorted(extra))}',
  122. })
  123. return fm, findings
  124. def extract_sections(content: str) -> list[dict]:
  125. """Extract all H2 headers with line numbers."""
  126. sections = []
  127. for i, line in enumerate(content.split('\n'), 1):
  128. m = re.match(r'^(#{2,3})\s+(.+)$', line)
  129. if m:
  130. sections.append({
  131. 'level': len(m.group(1)),
  132. 'title': m.group(2).strip(),
  133. 'line': i,
  134. })
  135. return sections
  136. def check_required_sections(sections: list[dict]) -> list[dict]:
  137. """Check for required and invalid sections."""
  138. findings = []
  139. h2_titles = [s['title'] for s in sections if s['level'] == 2]
  140. if 'Overview' not in h2_titles:
  141. findings.append({
  142. 'file': 'SKILL.md', 'line': 1,
  143. 'severity': 'high', 'category': 'sections',
  144. 'issue': 'Missing ## Overview section',
  145. })
  146. if 'On Activation' not in h2_titles:
  147. findings.append({
  148. 'file': 'SKILL.md', 'line': 1,
  149. 'severity': 'high', 'category': 'sections',
  150. 'issue': 'Missing ## On Activation section',
  151. })
  152. # Invalid sections
  153. for s in sections:
  154. if s['level'] == 2:
  155. for pattern, message in INVALID_SECTIONS:
  156. if re.match(pattern, f"## {s['title']}"):
  157. findings.append({
  158. 'file': 'SKILL.md', 'line': s['line'],
  159. 'severity': 'high', 'category': 'invalid-section',
  160. 'issue': message,
  161. })
  162. return findings
  163. def find_template_artifacts(filepath: Path, rel_path: str) -> list[dict]:
  164. """Scan for orphaned template substitution artifacts."""
  165. findings = []
  166. content = filepath.read_text(encoding='utf-8')
  167. for pattern in TEMPLATE_ARTIFACTS:
  168. for m in re.finditer(pattern, content):
  169. matched = m.group()
  170. if matched in RUNTIME_VARS:
  171. continue
  172. line_num = content[:m.start()].count('\n') + 1
  173. findings.append({
  174. 'file': rel_path, 'line': line_num,
  175. 'severity': 'high', 'category': 'artifacts',
  176. 'issue': f'Orphaned template artifact: {matched}',
  177. 'fix': 'Resolve or remove this template conditional/placeholder',
  178. })
  179. return findings
  180. def cross_reference_stages(skill_path: Path, skill_content: str) -> tuple[dict, list[dict]]:
  181. """Cross-reference stage files between SKILL.md and numbered prompt files at skill root."""
  182. findings = []
  183. # Get actual numbered prompt files at skill root (exclude SKILL.md)
  184. actual_files = set()
  185. for f in skill_path.iterdir():
  186. if f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md' and re.match(r'^\d+-', f.name):
  187. actual_files.add(f.name)
  188. # Find stage references in SKILL.md — look for both old prompts/ style and new root style
  189. referenced = set()
  190. # Match `prompts/XX-name.md` (legacy) or bare `XX-name.md` references
  191. ref_pattern = re.compile(r'(?:prompts/)?(\d+-[^\s)`]+\.md)')
  192. for m in ref_pattern.finditer(skill_content):
  193. referenced.add(m.group(1))
  194. # Missing files (referenced but don't exist)
  195. missing = referenced - actual_files
  196. for f in sorted(missing):
  197. findings.append({
  198. 'file': 'SKILL.md', 'line': 0,
  199. 'severity': 'critical', 'category': 'missing-stage',
  200. 'issue': f'Referenced stage file does not exist: {f}',
  201. })
  202. # Orphaned files (exist but not referenced)
  203. orphaned = actual_files - referenced
  204. for f in sorted(orphaned):
  205. findings.append({
  206. 'file': f, 'line': 0,
  207. 'severity': 'medium', 'category': 'naming',
  208. 'issue': f'Stage file exists but not referenced in SKILL.md: {f}',
  209. })
  210. # Stage numbering check
  211. numbered = []
  212. for f in sorted(actual_files):
  213. m = re.match(r'^(\d+)-(.+)\.md$', f)
  214. if m:
  215. numbered.append((int(m.group(1)), f))
  216. if numbered:
  217. numbered.sort()
  218. nums = [n[0] for n in numbered]
  219. expected = list(range(nums[0], nums[0] + len(nums)))
  220. if nums != expected:
  221. gaps = set(expected) - set(nums)
  222. if gaps:
  223. findings.append({
  224. 'file': skill_path.name, 'line': 0,
  225. 'severity': 'medium', 'category': 'naming',
  226. 'issue': f'Stage numbering has gaps: missing {sorted(gaps)}',
  227. })
  228. stage_summary = {
  229. 'total_stages': len(actual_files),
  230. 'referenced': sorted(referenced),
  231. 'actual': sorted(actual_files),
  232. 'missing_stages': sorted(missing),
  233. 'orphaned_stages': sorted(orphaned),
  234. }
  235. return stage_summary, findings
  236. def check_prompt_basics(skill_path: Path) -> tuple[list[dict], list[dict]]:
  237. """Check each prompt file for config header and progression conditions."""
  238. findings = []
  239. prompt_details = []
  240. # Look for numbered prompt files at skill root
  241. prompt_files = sorted(
  242. f for f in skill_path.iterdir()
  243. if f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md' and re.match(r'^\d+-', f.name)
  244. )
  245. if not prompt_files:
  246. return prompt_details, findings
  247. for f in prompt_files:
  248. content = f.read_text(encoding='utf-8')
  249. rel_path = f.name
  250. detail = {'file': f.name, 'has_config_header': False, 'has_progression': False}
  251. # Config header check
  252. if '{communication_language}' in content or '{document_output_language}' in content:
  253. detail['has_config_header'] = True
  254. else:
  255. findings.append({
  256. 'file': rel_path, 'line': 1,
  257. 'severity': 'medium', 'category': 'config-header',
  258. 'issue': 'No config header with language variables found',
  259. })
  260. # Progression condition check (look for progression-related keywords near end)
  261. lower = content.lower()
  262. prog_keywords = ['progress', 'advance', 'move to', 'next stage', 'when complete',
  263. 'proceed to', 'transition', 'completion criteria']
  264. if any(kw in lower for kw in prog_keywords):
  265. detail['has_progression'] = True
  266. else:
  267. findings.append({
  268. 'file': rel_path, 'line': len(content.split('\n')),
  269. 'severity': 'high', 'category': 'progression',
  270. 'issue': 'No progression condition keywords found',
  271. })
  272. # Directness checks
  273. for pattern, message in DIRECTNESS_PATTERNS:
  274. for m in re.finditer(pattern, content, re.IGNORECASE):
  275. line_num = content[:m.start()].count('\n') + 1
  276. findings.append({
  277. 'file': rel_path, 'line': line_num,
  278. 'severity': 'low', 'category': 'language',
  279. 'issue': message,
  280. })
  281. # Template artifacts
  282. findings.extend(find_template_artifacts(f, rel_path))
  283. prompt_details.append(detail)
  284. return prompt_details, findings
  285. def detect_workflow_type(skill_content: str, has_prompts: bool) -> str:
  286. """Detect workflow type from SKILL.md content."""
  287. has_stage_refs = bool(re.search(r'(?:prompts/)?\d+-\S+\.md', skill_content))
  288. has_routing = bool(re.search(r'(?i)(rout|stage|branch|path)', skill_content))
  289. if has_stage_refs or (has_prompts and has_routing):
  290. return 'complex'
  291. elif re.search(r'(?m)^\d+\.\s', skill_content):
  292. return 'simple-workflow'
  293. else:
  294. return 'simple-utility'
  295. def scan_workflow_integrity(skill_path: Path) -> dict:
  296. """Run all deterministic workflow integrity checks."""
  297. all_findings = []
  298. # Read SKILL.md
  299. skill_md = skill_path / 'SKILL.md'
  300. if not skill_md.exists():
  301. return {
  302. 'scanner': 'workflow-integrity-prepass',
  303. 'script': 'prepass-workflow-integrity.py',
  304. 'version': '1.0.0',
  305. 'skill_path': str(skill_path),
  306. 'timestamp': datetime.now(timezone.utc).isoformat(),
  307. 'status': 'fail',
  308. 'issues': [{'file': 'SKILL.md', 'line': 1, 'severity': 'critical',
  309. 'category': 'missing-file', 'issue': 'SKILL.md does not exist'}],
  310. 'summary': {'total_issues': 1, 'by_severity': {'critical': 1, 'high': 0, 'medium': 0, 'low': 0}},
  311. }
  312. skill_content = skill_md.read_text(encoding='utf-8')
  313. # Frontmatter
  314. frontmatter, fm_findings = parse_frontmatter(skill_content)
  315. all_findings.extend(fm_findings)
  316. # Sections
  317. sections = extract_sections(skill_content)
  318. section_findings = check_required_sections(sections)
  319. all_findings.extend(section_findings)
  320. # Template artifacts in SKILL.md
  321. all_findings.extend(find_template_artifacts(skill_md, 'SKILL.md'))
  322. # Directness checks in SKILL.md
  323. for pattern, message in DIRECTNESS_PATTERNS:
  324. for m in re.finditer(pattern, skill_content, re.IGNORECASE):
  325. line_num = skill_content[:m.start()].count('\n') + 1
  326. all_findings.append({
  327. 'file': 'SKILL.md', 'line': line_num,
  328. 'severity': 'low', 'category': 'language',
  329. 'issue': message,
  330. })
  331. # Workflow type
  332. has_prompts = any(
  333. f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md' and re.match(r'^\d+-', f.name)
  334. for f in skill_path.iterdir()
  335. )
  336. workflow_type = detect_workflow_type(skill_content, has_prompts)
  337. # Stage cross-reference
  338. stage_summary, stage_findings = cross_reference_stages(skill_path, skill_content)
  339. all_findings.extend(stage_findings)
  340. # Prompt basics
  341. prompt_details, prompt_findings = check_prompt_basics(skill_path)
  342. all_findings.extend(prompt_findings)
  343. # Build severity summary
  344. by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
  345. for f in all_findings:
  346. sev = f['severity']
  347. if sev in by_severity:
  348. by_severity[sev] += 1
  349. status = 'pass'
  350. if by_severity['critical'] > 0:
  351. status = 'fail'
  352. elif by_severity['high'] > 0:
  353. status = 'warning'
  354. return {
  355. 'scanner': 'workflow-integrity-prepass',
  356. 'script': 'prepass-workflow-integrity.py',
  357. 'version': '1.0.0',
  358. 'skill_path': str(skill_path),
  359. 'timestamp': datetime.now(timezone.utc).isoformat(),
  360. 'status': status,
  361. 'metadata': {
  362. 'frontmatter': frontmatter,
  363. 'sections': sections,
  364. 'workflow_type': workflow_type,
  365. },
  366. 'stage_summary': stage_summary,
  367. 'prompt_details': prompt_details,
  368. 'issues': all_findings,
  369. 'summary': {
  370. 'total_issues': len(all_findings),
  371. 'by_severity': by_severity,
  372. },
  373. }
  374. def main() -> int:
  375. parser = argparse.ArgumentParser(
  376. description='Deterministic pre-pass for workflow integrity scanning',
  377. )
  378. parser.add_argument(
  379. 'skill_path',
  380. type=Path,
  381. help='Path to the skill directory to scan',
  382. )
  383. parser.add_argument(
  384. '--output', '-o',
  385. type=Path,
  386. help='Write JSON output to file instead of stdout',
  387. )
  388. args = parser.parse_args()
  389. if not args.skill_path.is_dir():
  390. print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
  391. return 2
  392. result = scan_workflow_integrity(args.skill_path)
  393. output = json.dumps(result, indent=2)
  394. if args.output:
  395. args.output.parent.mkdir(parents=True, exist_ok=True)
  396. args.output.write_text(output)
  397. print(f"Results written to {args.output}", file=sys.stderr)
  398. else:
  399. print(output)
  400. return 0 if result['status'] == 'pass' else 1
  401. if __name__ == '__main__':
  402. sys.exit(main())