initial commit
This commit is contained in:
313
.claude/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py
Executable file
313
.claude/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py
Executable file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic pre-pass for execution efficiency scanner.
|
||||
|
||||
Extracts dependency graph data and execution patterns from a BMad skill
|
||||
so the LLM scanner can evaluate efficiency from compact structured data.
|
||||
|
||||
Covers:
|
||||
- Dependency graph from bmad-manifest.json (after, before arrays)
|
||||
- Circular dependency detection
|
||||
- Transitive dependency redundancy
|
||||
- Parallelizable stage groups (independent nodes)
|
||||
- Sequential pattern detection in prompts (numbered Read/Grep/Glob steps)
|
||||
- Subagent-from-subagent detection
|
||||
"""
|
||||
|
||||
# /// 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
|
||||
|
||||
|
||||
def detect_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
|
||||
"""Detect circular dependencies in a directed graph using DFS."""
|
||||
cycles = []
|
||||
visited = set()
|
||||
path = []
|
||||
path_set = set()
|
||||
|
||||
def dfs(node: str) -> None:
|
||||
if node in path_set:
|
||||
cycle_start = path.index(node)
|
||||
cycles.append(path[cycle_start:] + [node])
|
||||
return
|
||||
if node in visited:
|
||||
return
|
||||
visited.add(node)
|
||||
path.append(node)
|
||||
path_set.add(node)
|
||||
for neighbor in graph.get(node, []):
|
||||
dfs(neighbor)
|
||||
path.pop()
|
||||
path_set.discard(node)
|
||||
|
||||
for node in graph:
|
||||
dfs(node)
|
||||
|
||||
return cycles
|
||||
|
||||
|
||||
def find_transitive_redundancy(graph: dict[str, list[str]]) -> list[dict]:
|
||||
"""Find cases where A declares dependency on C, but A->B->C already exists."""
|
||||
redundancies = []
|
||||
|
||||
def get_transitive(node: str, visited: set | None = None) -> set[str]:
|
||||
if visited is None:
|
||||
visited = set()
|
||||
for dep in graph.get(node, []):
|
||||
if dep not in visited:
|
||||
visited.add(dep)
|
||||
get_transitive(dep, visited)
|
||||
return visited
|
||||
|
||||
for node, direct_deps in graph.items():
|
||||
for dep in direct_deps:
|
||||
# Check if dep is reachable through other direct deps
|
||||
other_deps = [d for d in direct_deps if d != dep]
|
||||
for other in other_deps:
|
||||
transitive = get_transitive(other)
|
||||
if dep in transitive:
|
||||
redundancies.append({
|
||||
'node': node,
|
||||
'redundant_dep': dep,
|
||||
'already_via': other,
|
||||
'issue': f'"{node}" declares "{dep}" as dependency, but already reachable via "{other}"',
|
||||
})
|
||||
|
||||
return redundancies
|
||||
|
||||
|
||||
def find_parallel_groups(graph: dict[str, list[str]], all_nodes: set[str]) -> list[list[str]]:
|
||||
"""Find groups of nodes that have no dependencies on each other (can run in parallel)."""
|
||||
# Nodes with no incoming edges from other nodes in the set
|
||||
independent_groups = []
|
||||
|
||||
# Simple approach: find all nodes at each "level" of the DAG
|
||||
remaining = set(all_nodes)
|
||||
while remaining:
|
||||
# Nodes whose dependencies are all satisfied (not in remaining)
|
||||
ready = set()
|
||||
for node in remaining:
|
||||
deps = set(graph.get(node, []))
|
||||
if not deps & remaining:
|
||||
ready.add(node)
|
||||
if not ready:
|
||||
break # Circular dependency, can't proceed
|
||||
if len(ready) > 1:
|
||||
independent_groups.append(sorted(ready))
|
||||
remaining -= ready
|
||||
|
||||
return independent_groups
|
||||
|
||||
|
||||
def scan_sequential_patterns(filepath: Path, rel_path: str) -> list[dict]:
|
||||
"""Detect sequential operation patterns that could be parallel."""
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
patterns = []
|
||||
|
||||
# Sequential numbered steps with Read/Grep/Glob
|
||||
tool_steps = re.findall(
|
||||
r'^\s*\d+\.\s+.*?\b(Read|Grep|Glob|read|grep|glob)\b.*$',
|
||||
content, re.MULTILINE
|
||||
)
|
||||
if len(tool_steps) >= 3:
|
||||
patterns.append({
|
||||
'file': rel_path,
|
||||
'type': 'sequential-tool-calls',
|
||||
'count': len(tool_steps),
|
||||
'issue': f'{len(tool_steps)} sequential tool call steps found — check if independent calls can be parallel',
|
||||
})
|
||||
|
||||
# "Read all files" / "for each" loop patterns
|
||||
loop_patterns = [
|
||||
(r'[Rr]ead all (?:files|documents|prompts)', 'read-all'),
|
||||
(r'[Ff]or each (?:file|document|prompt|stage)', 'for-each-loop'),
|
||||
(r'[Aa]nalyze each', 'analyze-each'),
|
||||
(r'[Ss]can (?:through|all|each)', 'scan-all'),
|
||||
(r'[Rr]eview (?:all|each)', 'review-all'),
|
||||
]
|
||||
for pattern, ptype in loop_patterns:
|
||||
matches = re.findall(pattern, content)
|
||||
if matches:
|
||||
patterns.append({
|
||||
'file': rel_path,
|
||||
'type': ptype,
|
||||
'count': len(matches),
|
||||
'issue': f'"{matches[0]}" pattern found — consider parallel subagent delegation',
|
||||
})
|
||||
|
||||
# Subagent spawning from subagent (impossible)
|
||||
if re.search(r'(?i)spawn.*subagent|launch.*subagent|create.*subagent', content):
|
||||
# Check if this file IS a subagent (non-SKILL.md, non-numbered prompt at root)
|
||||
if rel_path != 'SKILL.md' and not re.match(r'^\d+-', rel_path):
|
||||
patterns.append({
|
||||
'file': rel_path,
|
||||
'type': 'subagent-chain-violation',
|
||||
'count': 1,
|
||||
'issue': 'Subagent file references spawning other subagents — subagents cannot spawn subagents',
|
||||
})
|
||||
|
||||
return patterns
|
||||
|
||||
|
||||
def scan_execution_deps(skill_path: Path) -> dict:
|
||||
"""Run all deterministic execution efficiency checks."""
|
||||
# Parse manifest for dependency graph
|
||||
dep_graph: dict[str, list[str]] = {}
|
||||
prefer_after: dict[str, list[str]] = {}
|
||||
all_stages: set[str] = set()
|
||||
manifest_found = False
|
||||
|
||||
for manifest_path in [
|
||||
skill_path / 'bmad-manifest.json',
|
||||
]:
|
||||
if manifest_path.exists():
|
||||
manifest_found = True
|
||||
try:
|
||||
data = json.loads(manifest_path.read_text(encoding='utf-8'))
|
||||
if isinstance(data, dict):
|
||||
# Single manifest
|
||||
name = data.get('name', manifest_path.stem)
|
||||
all_stages.add(name)
|
||||
# New unified format uses per-capability fields
|
||||
caps = data.get('capabilities', [])
|
||||
for cap in caps:
|
||||
cap_name = cap.get('name', name)
|
||||
# 'after' = hard/soft dependencies (things that should run before this)
|
||||
dep_graph[cap_name] = cap.get('after', []) or []
|
||||
# 'before' = downstream consumers (things this should run before)
|
||||
prefer_after[cap_name] = cap.get('before', []) or []
|
||||
all_stages.add(cap_name)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
break
|
||||
|
||||
# Also check for stage-level prompt files at skill root
|
||||
for f in sorted(skill_path.iterdir()):
|
||||
if f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md':
|
||||
all_stages.add(f.stem)
|
||||
|
||||
# Cycle detection
|
||||
cycles = detect_cycles(dep_graph)
|
||||
|
||||
# Transitive redundancy
|
||||
redundancies = find_transitive_redundancy(dep_graph)
|
||||
|
||||
# Parallel groups
|
||||
parallel_groups = find_parallel_groups(dep_graph, all_stages)
|
||||
|
||||
# Sequential pattern detection across all prompt and agent files at root
|
||||
sequential_patterns = []
|
||||
for f in sorted(skill_path.iterdir()):
|
||||
if f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md':
|
||||
patterns = scan_sequential_patterns(f, f.name)
|
||||
sequential_patterns.extend(patterns)
|
||||
|
||||
# Also scan SKILL.md
|
||||
skill_md = skill_path / 'SKILL.md'
|
||||
if skill_md.exists():
|
||||
sequential_patterns.extend(scan_sequential_patterns(skill_md, 'SKILL.md'))
|
||||
|
||||
# Build issues from deterministic findings
|
||||
issues = []
|
||||
for cycle in cycles:
|
||||
issues.append({
|
||||
'severity': 'critical',
|
||||
'category': 'circular-dependency',
|
||||
'issue': f'Circular dependency detected: {" → ".join(cycle)}',
|
||||
})
|
||||
for r in redundancies:
|
||||
issues.append({
|
||||
'severity': 'medium',
|
||||
'category': 'dependency-bloat',
|
||||
'issue': r['issue'],
|
||||
})
|
||||
for p in sequential_patterns:
|
||||
severity = 'critical' if p['type'] == 'subagent-chain-violation' else 'medium'
|
||||
issues.append({
|
||||
'file': p['file'],
|
||||
'severity': severity,
|
||||
'category': p['type'],
|
||||
'issue': p['issue'],
|
||||
})
|
||||
|
||||
by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
|
||||
for issue in issues:
|
||||
sev = issue['severity']
|
||||
if sev in by_severity:
|
||||
by_severity[sev] += 1
|
||||
|
||||
status = 'pass'
|
||||
if by_severity['critical'] > 0:
|
||||
status = 'fail'
|
||||
elif by_severity['medium'] > 0:
|
||||
status = 'warning'
|
||||
|
||||
return {
|
||||
'scanner': 'execution-efficiency-prepass',
|
||||
'script': 'prepass-execution-deps.py',
|
||||
'version': '1.0.0',
|
||||
'skill_path': str(skill_path),
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'status': status,
|
||||
'dependency_graph': {
|
||||
'manifest_found': manifest_found,
|
||||
'stages': sorted(all_stages),
|
||||
'hard_dependencies': dep_graph,
|
||||
'soft_dependencies': prefer_after,
|
||||
'cycles': cycles,
|
||||
'transitive_redundancies': redundancies,
|
||||
'parallel_groups': parallel_groups,
|
||||
},
|
||||
'sequential_patterns': sequential_patterns,
|
||||
'issues': issues,
|
||||
'summary': {
|
||||
'total_issues': len(issues),
|
||||
'by_severity': by_severity,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Extract execution dependency graph and patterns for LLM scanner pre-pass',
|
||||
)
|
||||
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_execution_deps(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 __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user