309 lines
8.7 KiB
Python
Executable File
309 lines
8.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Validate that slash commands document their --flags in the frontmatter.
|
||
|
||
This pre-commit hook ensures that commands with --flag options in their body
|
||
have those flags documented in the frontmatter (description and argument_hint
|
||
fields) for proper autocomplete display in Claude Code.
|
||
|
||
Exit codes:
|
||
- 0: All flags documented OR no flags found OR not applicable
|
||
- 1: Warning - undocumented flags found (non-blocking)
|
||
- Never exits 2 (this is non-critical validation)
|
||
|
||
Run this as part of pre-commit to catch missing flag documentation.
|
||
|
||
Author: implementer agent
|
||
Date: 2025-12-14
|
||
Issue: GitHub #133 - Add pre-commit hook for command frontmatter flag validation
|
||
Related: Issue #131 - Fixed frontmatter for /align, /batch-implement, /create-issue, /sync
|
||
"""
|
||
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
|
||
# False positive flags that should be ignored
|
||
_FALSE_POSITIVE_FLAGS = frozenset([
|
||
"--help",
|
||
"--version",
|
||
"-h",
|
||
"-v",
|
||
"--flag", # Generic example flag
|
||
"--option", # Generic example option
|
||
"--example", # Generic example
|
||
"--your-flag", # Documentation placeholder
|
||
"--some-flag", # Documentation placeholder
|
||
])
|
||
|
||
|
||
def get_false_positive_flags() -> frozenset:
|
||
"""
|
||
Return set of flags that should be ignored (false positives).
|
||
|
||
These are common flags used in documentation examples that don't
|
||
need to be documented in frontmatter.
|
||
|
||
Returns:
|
||
Frozen set of flag strings to ignore
|
||
"""
|
||
return _FALSE_POSITIVE_FLAGS
|
||
|
||
|
||
def extract_frontmatter(content: str) -> Optional[str]:
|
||
"""
|
||
Extract YAML frontmatter from markdown content.
|
||
|
||
Frontmatter is content between --- markers at the start of the file.
|
||
|
||
Args:
|
||
content: Full markdown file content
|
||
|
||
Returns:
|
||
Frontmatter string (without the --- markers), or None if not found
|
||
"""
|
||
# Pattern: starts with ---, captures content (including empty) until next ---
|
||
# Allow for empty frontmatter (just two --- lines)
|
||
pattern = r'^---\s*\n(.*?)\n?---\s*\n'
|
||
match = re.search(pattern, content, re.DOTALL | re.MULTILINE)
|
||
|
||
if match:
|
||
return match.group(1)
|
||
return None
|
||
|
||
|
||
def remove_code_blocks(content: str) -> str:
|
||
"""
|
||
Remove code blocks from markdown content.
|
||
|
||
Removes both fenced code blocks (```...```) and inline code (`...`)
|
||
to prevent false positive flag detection from code examples.
|
||
|
||
Args:
|
||
content: Markdown content
|
||
|
||
Returns:
|
||
Content with code blocks removed
|
||
"""
|
||
# Remove fenced code blocks (``` blocks with optional language)
|
||
# Use non-greedy matching to handle multiple blocks
|
||
content = re.sub(r'```[^\n]*\n.*?```', '', content, flags=re.DOTALL)
|
||
|
||
# Remove inline code (`code`)
|
||
content = re.sub(r'`[^`]+`', '', content)
|
||
|
||
return content
|
||
|
||
|
||
def extract_flags_from_body(content: str) -> list[str]:
|
||
"""
|
||
Extract CLI flags (--flag-name) from markdown body.
|
||
|
||
Removes code blocks first to avoid false positives from examples.
|
||
Only extracts double-dash flags (--flag), not single-dash (-f).
|
||
|
||
Args:
|
||
content: Markdown body content (after frontmatter)
|
||
|
||
Returns:
|
||
List of unique flags found (e.g., ["--verbose", "--output"])
|
||
"""
|
||
if not content:
|
||
return []
|
||
|
||
# Remove code blocks to avoid false positives
|
||
clean_content = remove_code_blocks(content)
|
||
|
||
# Pattern: --word(-word)* with word boundary
|
||
# Matches: --verbose, --dry-run, --no-verify
|
||
pattern = r'--\w+(?:-\w+)*\b'
|
||
|
||
matches = re.findall(pattern, clean_content)
|
||
|
||
# Deduplicate and return as list
|
||
return list(set(matches))
|
||
|
||
|
||
def check_flags_in_frontmatter(flags: list[str], frontmatter: str) -> list[str]:
|
||
"""
|
||
Check which flags are missing from frontmatter.
|
||
|
||
Checks both description and argument_hint fields.
|
||
Filters out false positive flags (--help, --version, etc.).
|
||
|
||
Args:
|
||
flags: List of flags found in body
|
||
frontmatter: YAML frontmatter content
|
||
|
||
Returns:
|
||
List of flags that are missing from frontmatter
|
||
"""
|
||
if not flags or not frontmatter:
|
||
return []
|
||
|
||
false_positives = get_false_positive_flags()
|
||
missing = []
|
||
|
||
for flag in flags:
|
||
# Skip false positives
|
||
if flag in false_positives:
|
||
continue
|
||
|
||
# Check if flag appears anywhere in frontmatter
|
||
# (description or argument_hint fields)
|
||
if flag not in frontmatter:
|
||
missing.append(flag)
|
||
|
||
return sorted(missing)
|
||
|
||
|
||
def validate_command_file(filepath: Path) -> list[str]:
|
||
"""
|
||
Validate a command file for undocumented flags.
|
||
|
||
Checks if all --flags used in the body are documented in the
|
||
frontmatter (description or argument_hint fields).
|
||
|
||
Args:
|
||
filepath: Path to the command .md file
|
||
|
||
Returns:
|
||
List of warning messages (empty if all valid)
|
||
"""
|
||
warnings = []
|
||
|
||
try:
|
||
content = filepath.read_text(encoding='utf-8')
|
||
except Exception as e:
|
||
return [f"Could not read file: {e}"]
|
||
|
||
# Extract frontmatter
|
||
frontmatter = extract_frontmatter(content)
|
||
|
||
if frontmatter is None:
|
||
# Check if file has flags that need documentation
|
||
body_flags = extract_flags_from_body(content)
|
||
real_flags = [f for f in body_flags if f not in get_false_positive_flags()]
|
||
if real_flags:
|
||
return [f"No frontmatter found but file contains flags: {', '.join(real_flags)}"]
|
||
return []
|
||
|
||
# Get body content (everything after frontmatter)
|
||
# Find the end of frontmatter and get the rest
|
||
frontmatter_end = re.search(r'^---\s*\n.*?\n---\s*\n', content, re.DOTALL | re.MULTILINE)
|
||
if frontmatter_end:
|
||
body = content[frontmatter_end.end():]
|
||
else:
|
||
body = content
|
||
|
||
# Extract flags from body
|
||
flags = extract_flags_from_body(body)
|
||
|
||
if not flags:
|
||
return [] # No flags to validate
|
||
|
||
# Check which flags are missing from frontmatter
|
||
missing = check_flags_in_frontmatter(flags, frontmatter)
|
||
|
||
if missing:
|
||
warnings.append(f"Undocumented flags: {', '.join(missing)}")
|
||
|
||
return warnings
|
||
|
||
|
||
def main():
|
||
"""
|
||
Main entry point for the pre-commit hook.
|
||
|
||
Scans all command files in plugins/autonomous-dev/commands/
|
||
and reports any undocumented flags.
|
||
|
||
Exit codes:
|
||
- 0: All valid or not applicable
|
||
- 1: Warnings found (non-blocking)
|
||
"""
|
||
# Find commands directory relative to this script or cwd
|
||
# Script is at: plugins/autonomous-dev/hooks/validate_command_frontmatter_flags.py
|
||
# Commands are at: plugins/autonomous-dev/commands/
|
||
|
||
# Try relative to script first
|
||
script_dir = Path(__file__).parent
|
||
plugin_dir = script_dir.parent
|
||
commands_dir = plugin_dir / "commands"
|
||
|
||
# If not found, try relative to cwd (for testing)
|
||
if not commands_dir.exists():
|
||
cwd = Path.cwd()
|
||
commands_dir = cwd / "plugins" / "autonomous-dev" / "commands"
|
||
|
||
if not commands_dir.exists():
|
||
# Not applicable (not in a project with commands)
|
||
print("ℹ️ Commands directory not found, skipping validation")
|
||
sys.exit(0)
|
||
|
||
print("=" * 70)
|
||
print("COMMAND FRONTMATTER FLAG VALIDATION")
|
||
print("=" * 70)
|
||
print()
|
||
|
||
command_files = sorted(commands_dir.glob("*.md"))
|
||
|
||
if not command_files:
|
||
print("ℹ️ No command files found")
|
||
sys.exit(0)
|
||
|
||
valid = []
|
||
with_warnings = []
|
||
|
||
for filepath in command_files:
|
||
warnings = validate_command_file(filepath)
|
||
|
||
if not warnings:
|
||
valid.append(filepath.name)
|
||
print(f"✅ {filepath.name}")
|
||
else:
|
||
with_warnings.append((filepath.name, warnings))
|
||
print(f"⚠️ {filepath.name}")
|
||
for warning in warnings:
|
||
print(f" {warning}")
|
||
|
||
print()
|
||
print("=" * 70)
|
||
print(f"RESULTS: {len(valid)} valid, {len(with_warnings)} with warnings")
|
||
print("=" * 70)
|
||
|
||
if with_warnings:
|
||
print()
|
||
print("COMMANDS WITH UNDOCUMENTED FLAGS:")
|
||
print()
|
||
for name, warnings in with_warnings:
|
||
print(f" ⚠️ {name}")
|
||
for warning in warnings:
|
||
print(f" {warning}")
|
||
print()
|
||
|
||
print("TO FIX:")
|
||
print()
|
||
print(" Add missing flags to the frontmatter description or argument_hint.")
|
||
print()
|
||
print(" Example:")
|
||
print(' description: "Command with --flag1 and --flag2 options"')
|
||
print(' argument_hint: "--flag1 [--flag2]"')
|
||
print()
|
||
print(" See Issue #131 for examples of properly documented frontmatter.")
|
||
print()
|
||
|
||
# Exit 1 = warning (non-blocking)
|
||
sys.exit(1)
|
||
else:
|
||
print()
|
||
print("✅ ALL COMMANDS HAVE PROPERLY DOCUMENTED FLAGS!")
|
||
print()
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|