Files
calctext/.claude/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py
2026-03-16 19:54:53 -04:00

286 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Deterministic pre-pass for prompt craft scanner.
Extracts metrics and flagged patterns from SKILL.md and prompt files
so the LLM scanner can work from compact data instead of reading raw files.
Covers:
- SKILL.md line count and section inventory
- Overview section size
- Inline data detection (tables, fenced code blocks)
- Defensive padding pattern grep
- Meta-explanation pattern grep
- Back-reference detection ("as described above")
- Config header and progression condition presence per prompt
- File-level token estimates (chars / 4 rough approximation)
"""
# /// 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
# Defensive padding / filler patterns
WASTE_PATTERNS = [
(r'\b[Mm]ake sure (?:to|you)\b', 'defensive-padding', 'Defensive: "make sure to/you"'),
(r"\b[Dd]on'?t forget (?:to|that)\b", 'defensive-padding', "Defensive: \"don't forget\""),
(r'\b[Rr]emember (?:to|that)\b', 'defensive-padding', 'Defensive: "remember to/that"'),
(r'\b[Bb]e sure to\b', 'defensive-padding', 'Defensive: "be sure to"'),
(r'\b[Pp]lease ensure\b', 'defensive-padding', 'Defensive: "please ensure"'),
(r'\b[Ii]t is important (?:to|that)\b', 'defensive-padding', 'Defensive: "it is important"'),
(r'\b[Yy]ou are an AI\b', 'meta-explanation', 'Meta: "you are an AI"'),
(r'\b[Aa]s a language model\b', 'meta-explanation', 'Meta: "as a language model"'),
(r'\b[Aa]s an AI assistant\b', 'meta-explanation', 'Meta: "as an AI assistant"'),
(r'\b[Tt]his (?:workflow|skill|process) is designed to\b', 'meta-explanation', 'Meta: "this workflow is designed to"'),
(r'\b[Tt]he purpose of this (?:section|step) is\b', 'meta-explanation', 'Meta: "the purpose of this section is"'),
(r"\b[Ll]et'?s (?:think about|begin|start)\b", 'filler', "Filler: \"let's think/begin\""),
(r'\b[Nn]ow we(?:\'ll| will)\b', 'filler', "Filler: \"now we'll\""),
]
# Back-reference patterns (self-containment risk)
BACKREF_PATTERNS = [
(r'\bas described above\b', 'Back-reference: "as described above"'),
(r'\bper the overview\b', 'Back-reference: "per the overview"'),
(r'\bas mentioned (?:above|in|earlier)\b', 'Back-reference: "as mentioned above/in/earlier"'),
(r'\bsee (?:above|the overview)\b', 'Back-reference: "see above/the overview"'),
(r'\brefer to (?:the )?(?:above|overview|SKILL)\b', 'Back-reference: "refer to above/overview"'),
]
def count_tables(content: str) -> tuple[int, int]:
"""Count markdown tables and their total lines."""
table_count = 0
table_lines = 0
in_table = False
for line in content.split('\n'):
if '|' in line and re.match(r'^\s*\|', line):
if not in_table:
table_count += 1
in_table = True
table_lines += 1
else:
in_table = False
return table_count, table_lines
def count_fenced_blocks(content: str) -> tuple[int, int]:
"""Count fenced code blocks and their total lines."""
block_count = 0
block_lines = 0
in_block = False
for line in content.split('\n'):
if line.strip().startswith('```'):
if in_block:
in_block = False
else:
in_block = True
block_count += 1
elif in_block:
block_lines += 1
return block_count, block_lines
def extract_overview_size(content: str) -> int:
"""Count lines in the ## Overview section."""
lines = content.split('\n')
in_overview = False
overview_lines = 0
for line in lines:
if re.match(r'^##\s+Overview\b', line):
in_overview = True
continue
elif in_overview and re.match(r'^##\s', line):
break
elif in_overview:
overview_lines += 1
return overview_lines
def scan_file_patterns(filepath: Path, rel_path: str) -> dict:
"""Extract metrics and pattern matches from a single file."""
content = filepath.read_text(encoding='utf-8')
lines = content.split('\n')
line_count = len(lines)
# Token estimate (rough: chars / 4)
token_estimate = len(content) // 4
# Section inventory
sections = []
for i, line in enumerate(lines, 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})
# Tables and code blocks
table_count, table_lines = count_tables(content)
block_count, block_lines = count_fenced_blocks(content)
# Pattern matches
waste_matches = []
for pattern, category, label in WASTE_PATTERNS:
for m in re.finditer(pattern, content):
line_num = content[:m.start()].count('\n') + 1
waste_matches.append({
'line': line_num,
'category': category,
'pattern': label,
'context': lines[line_num - 1].strip()[:100],
})
backref_matches = []
for pattern, label in BACKREF_PATTERNS:
for m in re.finditer(pattern, content, re.IGNORECASE):
line_num = content[:m.start()].count('\n') + 1
backref_matches.append({
'line': line_num,
'pattern': label,
'context': lines[line_num - 1].strip()[:100],
})
# Config header
has_config_header = '{communication_language}' in content or '{document_output_language}' in content
# Progression condition
prog_keywords = ['progress', 'advance', 'move to', 'next stage',
'when complete', 'proceed to', 'transition', 'completion criteria']
has_progression = any(kw in content.lower() for kw in prog_keywords)
result = {
'file': rel_path,
'line_count': line_count,
'token_estimate': token_estimate,
'sections': sections,
'table_count': table_count,
'table_lines': table_lines,
'fenced_block_count': block_count,
'fenced_block_lines': block_lines,
'waste_patterns': waste_matches,
'back_references': backref_matches,
'has_config_header': has_config_header,
'has_progression': has_progression,
}
return result
def scan_prompt_metrics(skill_path: Path) -> dict:
"""Extract metrics from all prompt-relevant files."""
files_data = []
# SKILL.md
skill_md = skill_path / 'SKILL.md'
if skill_md.exists():
data = scan_file_patterns(skill_md, 'SKILL.md')
content = skill_md.read_text(encoding='utf-8')
data['overview_lines'] = extract_overview_size(content)
data['is_skill_md'] = True
files_data.append(data)
# Prompt files at skill root (non-SKILL.md .md files)
for f in sorted(skill_path.iterdir()):
if f.is_file() and f.suffix == '.md' and f.name != 'SKILL.md':
data = scan_file_patterns(f, f.name)
data['is_skill_md'] = False
files_data.append(data)
# Resources (just sizes, for progressive disclosure assessment)
resources_dir = skill_path / 'resources'
resource_sizes = {}
if resources_dir.exists():
for f in sorted(resources_dir.iterdir()):
if f.is_file() and f.suffix in ('.md', '.json', '.yaml', '.yml'):
content = f.read_text(encoding='utf-8')
resource_sizes[f.name] = {
'lines': len(content.split('\n')),
'tokens': len(content) // 4,
}
# Aggregate stats
total_waste = sum(len(f['waste_patterns']) for f in files_data)
total_backrefs = sum(len(f['back_references']) for f in files_data)
total_tokens = sum(f['token_estimate'] for f in files_data)
prompts_with_config = sum(1 for f in files_data if not f.get('is_skill_md') and f['has_config_header'])
prompts_with_progression = sum(1 for f in files_data if not f.get('is_skill_md') and f['has_progression'])
total_prompts = sum(1 for f in files_data if not f.get('is_skill_md'))
skill_md_data = next((f for f in files_data if f.get('is_skill_md')), None)
return {
'scanner': 'prompt-craft-prepass',
'script': 'prepass-prompt-metrics.py',
'version': '1.0.0',
'skill_path': str(skill_path),
'timestamp': datetime.now(timezone.utc).isoformat(),
'status': 'info',
'skill_md_summary': {
'line_count': skill_md_data['line_count'] if skill_md_data else 0,
'token_estimate': skill_md_data['token_estimate'] if skill_md_data else 0,
'overview_lines': skill_md_data.get('overview_lines', 0) if skill_md_data else 0,
'table_count': skill_md_data['table_count'] if skill_md_data else 0,
'table_lines': skill_md_data['table_lines'] if skill_md_data else 0,
'fenced_block_count': skill_md_data['fenced_block_count'] if skill_md_data else 0,
'fenced_block_lines': skill_md_data['fenced_block_lines'] if skill_md_data else 0,
'section_count': len(skill_md_data['sections']) if skill_md_data else 0,
},
'prompt_health': {
'total_prompts': total_prompts,
'prompts_with_config_header': prompts_with_config,
'prompts_with_progression': prompts_with_progression,
},
'aggregate': {
'total_files_scanned': len(files_data),
'total_token_estimate': total_tokens,
'total_waste_patterns': total_waste,
'total_back_references': total_backrefs,
},
'resource_sizes': resource_sizes,
'files': files_data,
}
def main() -> int:
parser = argparse.ArgumentParser(
description='Extract prompt craft metrics 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_prompt_metrics(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())