Files
gh-overlord-z-claudeshack/skills/wizard/scripts/audit_docs.py
2025-11-30 08:46:50 +08:00

361 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""
Documentation Wizard - Documentation Audit Tool
Detects outdated, missing, or inconsistent documentation across ClaudeShack.
Usage:
# Full audit
python audit_docs.py
# Audit specific file
python audit_docs.py --file README.md
# Check for broken links
python audit_docs.py --check-links
# Detect missing documentation
python audit_docs.py --missing
Environment Variables:
WIZARD_VERBOSE: Set to '1' for detailed output
"""
import os
import sys
import json
import argparse
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple
import re
def find_skills() -> List[Path]:
"""Find all skill directories."""
skills_dir = Path('skills')
if not skills_dir.exists():
return []
return [d for d in skills_dir.iterdir() if d.is_dir() and not d.name.startswith('.')]
def check_skill_documentation(skill_path: Path) -> Dict[str, Any]:
"""Check if a skill has required documentation.
Args:
skill_path: Path to skill directory
Returns:
Dictionary with audit results
"""
issues = []
warnings = []
# Check for SKILL.md
skill_md = skill_path / 'SKILL.md'
if not skill_md.exists():
issues.append(f"Missing SKILL.md")
else:
# Check frontmatter
try:
with open(skill_md, 'r', encoding='utf-8') as f:
content = f.read()
if not content.startswith('---'):
issues.append("SKILL.md missing YAML frontmatter")
elif 'name:' not in content[:500] or 'description:' not in content[:500]:
issues.append("SKILL.md frontmatter missing name or description")
except (OSError, IOError, UnicodeDecodeError):
issues.append("SKILL.md cannot be read")
# Check for Scripts directory
scripts_dir = skill_path / 'Scripts'
if scripts_dir.exists():
# Check for Scripts README
scripts_readme = scripts_dir / 'README.md'
if not scripts_readme.exists():
warnings.append("Scripts directory missing README.md")
# Count Python scripts
python_scripts = list(scripts_dir.glob('*.py'))
if len(python_scripts) == 0:
warnings.append("Scripts directory has no Python files")
else:
warnings.append("No Scripts directory (might be documentation-only skill)")
return {
'skill': skill_path.name,
'path': str(skill_path),
'issues': issues,
'warnings': warnings,
'has_skill_md': skill_md.exists(),
'has_scripts': scripts_dir.exists(),
'script_count': len(list(scripts_dir.glob('*.py'))) if scripts_dir.exists() else 0
}
def check_readme_mentions_skills(readme_path: Path, skills: List[Path]) -> List[str]:
"""Check if README mentions all skills.
Args:
readme_path: Path to README.md
skills: List of skill paths
Returns:
List of skills not mentioned in README
"""
if not readme_path.exists():
return []
try:
with open(readme_path, 'r', encoding='utf-8') as f:
readme_content = f.read().lower()
except (OSError, IOError, UnicodeDecodeError):
return []
missing = []
for skill in skills:
skill_name = skill.name
if skill_name.lower() not in readme_content:
missing.append(skill_name)
return missing
def check_broken_links(file_path: Path) -> List[Dict[str, Any]]:
"""Check for broken relative links in markdown file.
Args:
file_path: Path to markdown file
Returns:
List of broken links with details
"""
broken = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except (OSError, IOError, UnicodeDecodeError):
return []
# Find markdown links: [text](path)
link_pattern = r'\[([^\]]+)\]\(([^\)]+)\)'
matches = re.finditer(link_pattern, content)
for match in matches:
link_text = match.group(1)
link_path = match.group(2)
# Skip external links
if link_path.startswith(('http://', 'https://', 'mailto:', '#')):
continue
# Resolve relative path
resolved = file_path.parent / link_path
if not resolved.exists():
broken.append({
'text': link_text,
'path': link_path,
'resolved': str(resolved),
'line': content[:match.start()].count('\n') + 1
})
return broken
def audit_documentation() -> Dict[str, Any]:
"""Perform full documentation audit.
Returns:
Audit results dictionary
"""
results = {
'skills': [],
'main_readme': {},
'contributing': {},
'broken_links': [],
'summary': {
'total_skills': 0,
'skills_with_issues': 0,
'total_issues': 0,
'total_warnings': 0,
'missing_from_readme': []
}
}
# Audit skills
skills = find_skills()
results['summary']['total_skills'] = len(skills)
for skill in skills:
audit = check_skill_documentation(skill)
results['skills'].append(audit)
if audit['issues']:
results['summary']['skills_with_issues'] += 1
results['summary']['total_issues'] += len(audit['issues'])
results['summary']['total_warnings'] += len(audit['warnings'])
# Check README
readme_path = Path('README.md')
if readme_path.exists():
missing = check_readme_mentions_skills(readme_path, skills)
results['summary']['missing_from_readme'] = missing
# Check for broken links in README
broken = check_broken_links(readme_path)
if broken:
results['broken_links'].append({
'file': 'README.md',
'links': broken
})
else:
results['main_readme']['missing'] = True
# Check CONTRIBUTING.md
contributing_path = Path('CONTRIBUTING.md')
if contributing_path.exists():
broken = check_broken_links(contributing_path)
if broken:
results['broken_links'].append({
'file': 'CONTRIBUTING.md',
'links': broken
})
else:
results['contributing']['missing'] = True
return results
def format_audit_report(results: Dict[str, Any]) -> str:
"""Format audit results as readable report.
Args:
results: Audit results
Returns:
Formatted report string
"""
lines = []
lines.append("=" * 70)
lines.append("DOCUMENTATION WIZARD - AUDIT REPORT")
lines.append("=" * 70)
lines.append("")
# Summary
summary = results['summary']
lines.append("SUMMARY")
lines.append("-" * 70)
lines.append(f"Total Skills: {summary['total_skills']}")
lines.append(f"Skills with Issues: {summary['skills_with_issues']}")
lines.append(f"Total Issues: {summary['total_issues']}")
lines.append(f"Total Warnings: {summary['total_warnings']}")
lines.append("")
# Skills not in README
if summary['missing_from_readme']:
lines.append("⚠️ Skills Not Mentioned in README:")
for skill in summary['missing_from_readme']:
lines.append(f" - {skill}")
lines.append("")
# Skill-specific issues
if summary['skills_with_issues'] > 0:
lines.append("SKILL ISSUES")
lines.append("-" * 70)
for skill_audit in results['skills']:
if skill_audit['issues'] or skill_audit['warnings']:
lines.append(f"\n{skill_audit['skill']}:")
for issue in skill_audit['issues']:
lines.append(f"{issue}")
for warning in skill_audit['warnings']:
lines.append(f" ⚠️ {warning}")
lines.append("")
# Broken links
if results['broken_links']:
lines.append("BROKEN LINKS")
lines.append("-" * 70)
for file_links in results['broken_links']:
lines.append(f"\n{file_links['file']}:")
for link in file_links['links']:
lines.append(f" Line {link['line']}: [{link['text']}]({link['path']})")
lines.append(f" Resolved to: {link['resolved']} (NOT FOUND)")
lines.append("")
# Overall status
lines.append("=" * 70)
if summary['total_issues'] == 0 and not results['broken_links']:
lines.append("✅ DOCUMENTATION AUDIT PASSED - No critical issues found")
else:
lines.append(f"❌ DOCUMENTATION AUDIT FAILED - {summary['total_issues']} issues found")
if summary['total_warnings'] > 0:
lines.append(f"⚠️ {summary['total_warnings']} warnings (non-critical)")
lines.append("=" * 70)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description='Documentation Wizard - Audit documentation for issues',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
'--file',
help='Audit specific file only'
)
parser.add_argument(
'--check-links',
action='store_true',
help='Check for broken links only'
)
parser.add_argument(
'--missing',
action='store_true',
help='Check for missing documentation only'
)
parser.add_argument(
'--json',
action='store_true',
help='Output as JSON'
)
args = parser.parse_args()
# Run audit
results = audit_documentation()
# Output
if args.json:
print(json.dumps(results, indent=2))
else:
report = format_audit_report(results)
print(report)
# Exit code
if results['summary']['total_issues'] > 0 or results['broken_links']:
sys.exit(1)
else:
sys.exit(0)
if __name__ == '__main__':
main()