Initial commit
This commit is contained in:
346
skills/documentation-management/tools/validate-commands.py
Executable file
346
skills/documentation-management/tools/validate-commands.py
Executable file
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate command examples and code blocks in markdown documentation.
|
||||
|
||||
Purpose: Extract code blocks from markdown files and validate syntax/formatting.
|
||||
Author: Generated by documentation methodology experiment
|
||||
Version: 1.0
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Dict
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeBlock:
|
||||
"""Represents a code block found in markdown."""
|
||||
language: str
|
||||
content: str
|
||||
line_number: int
|
||||
file_path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of validating a code block."""
|
||||
code_block: CodeBlock
|
||||
is_valid: bool
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
class MarkdownValidator:
|
||||
"""Extract and validate code blocks from markdown files."""
|
||||
|
||||
def __init__(self):
|
||||
self.supported_validators = {
|
||||
'bash': self._validate_bash,
|
||||
'sh': self._validate_bash,
|
||||
'shell': self._validate_bash,
|
||||
'python': self._validate_python,
|
||||
'go': self._validate_go,
|
||||
'json': self._validate_json,
|
||||
'yaml': self._validate_yaml,
|
||||
'yml': self._validate_yaml,
|
||||
}
|
||||
|
||||
def extract_code_blocks(self, file_path: Path) -> List[CodeBlock]:
|
||||
"""Extract all code blocks from markdown file."""
|
||||
code_blocks = []
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
lines = content.split('\n')
|
||||
|
||||
in_code_block = False
|
||||
current_language = ""
|
||||
current_content = []
|
||||
start_line = 0
|
||||
|
||||
for line_num, line in enumerate(lines, start=1):
|
||||
# Match code block start (```language)
|
||||
start_match = re.match(r'^```(\w+)?', line)
|
||||
if start_match and not in_code_block:
|
||||
in_code_block = True
|
||||
current_language = start_match.group(1) or ''
|
||||
current_content = []
|
||||
start_line = line_num
|
||||
continue
|
||||
|
||||
# Match code block end (```)
|
||||
if line.startswith('```') and in_code_block:
|
||||
code_blocks.append(CodeBlock(
|
||||
language=current_language,
|
||||
content='\n'.join(current_content),
|
||||
line_number=start_line,
|
||||
file_path=file_path
|
||||
))
|
||||
in_code_block = False
|
||||
current_language = ""
|
||||
current_content = []
|
||||
continue
|
||||
|
||||
# Accumulate code block content
|
||||
if in_code_block:
|
||||
current_content.append(line)
|
||||
|
||||
return code_blocks
|
||||
|
||||
def validate_code_block(self, code_block: CodeBlock) -> ValidationResult:
|
||||
"""Validate a single code block based on its language."""
|
||||
if not code_block.language:
|
||||
# No language specified, skip validation
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=True,
|
||||
error_message=""
|
||||
)
|
||||
|
||||
language = code_block.language.lower()
|
||||
|
||||
if language not in self.supported_validators:
|
||||
# Language not supported for validation, skip
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=True,
|
||||
error_message=f"Validation not supported for language: {language}"
|
||||
)
|
||||
|
||||
validator = self.supported_validators[language]
|
||||
return validator(code_block)
|
||||
|
||||
def _validate_bash(self, code_block: CodeBlock) -> ValidationResult:
|
||||
"""Validate bash/shell syntax using shellcheck or basic parsing."""
|
||||
# Check for common bash syntax errors
|
||||
content = code_block.content
|
||||
|
||||
# Skip if it's just comments or examples (not executable)
|
||||
lines = [line.strip() for line in content.split('\n') if line.strip()]
|
||||
if all(line.startswith('#') or not line for line in lines):
|
||||
return ValidationResult(code_block=code_block, is_valid=True)
|
||||
|
||||
# Check for unmatched quotes
|
||||
single_quotes = content.count("'") - content.count("\\'")
|
||||
double_quotes = content.count('"') - content.count('\\"')
|
||||
|
||||
if single_quotes % 2 != 0:
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message="Unmatched single quote"
|
||||
)
|
||||
|
||||
if double_quotes % 2 != 0:
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message="Unmatched double quote"
|
||||
)
|
||||
|
||||
# Check for unmatched braces/brackets
|
||||
if content.count('{') != content.count('}'):
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message="Unmatched curly braces"
|
||||
)
|
||||
|
||||
if content.count('[') != content.count(']'):
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message="Unmatched square brackets"
|
||||
)
|
||||
|
||||
if content.count('(') != content.count(')'):
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message="Unmatched parentheses"
|
||||
)
|
||||
|
||||
# Try shellcheck if available
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['shellcheck', '-'],
|
||||
input=content.encode('utf-8'),
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error = result.stdout.decode('utf-8')
|
||||
# Extract first meaningful error
|
||||
error_lines = [l for l in error.split('\n') if l.strip() and not l.startswith('In -')]
|
||||
error_msg = error_lines[0] if error_lines else "Shellcheck validation failed"
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message=f"shellcheck: {error_msg}"
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
# shellcheck not available or timed out, basic validation passed
|
||||
pass
|
||||
|
||||
return ValidationResult(code_block=code_block, is_valid=True)
|
||||
|
||||
def _validate_python(self, code_block: CodeBlock) -> ValidationResult:
|
||||
"""Validate Python syntax using ast.parse."""
|
||||
import ast
|
||||
|
||||
try:
|
||||
ast.parse(code_block.content)
|
||||
return ValidationResult(code_block=code_block, is_valid=True)
|
||||
except SyntaxError as e:
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message=f"Python syntax error: {e.msg} at line {e.lineno}"
|
||||
)
|
||||
|
||||
def _validate_go(self, code_block: CodeBlock) -> ValidationResult:
|
||||
"""Validate Go syntax using gofmt."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['gofmt', '-e'],
|
||||
input=code_block.content.encode('utf-8'),
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.decode('utf-8')
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message=f"gofmt: {error.strip()}"
|
||||
)
|
||||
return ValidationResult(code_block=code_block, is_valid=True)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
# gofmt not available, skip validation
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=True,
|
||||
error_message="gofmt not available"
|
||||
)
|
||||
|
||||
def _validate_json(self, code_block: CodeBlock) -> ValidationResult:
|
||||
"""Validate JSON syntax."""
|
||||
import json
|
||||
|
||||
try:
|
||||
json.loads(code_block.content)
|
||||
return ValidationResult(code_block=code_block, is_valid=True)
|
||||
except json.JSONDecodeError as e:
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message=f"JSON error: {e.msg} at line {e.lineno}"
|
||||
)
|
||||
|
||||
def _validate_yaml(self, code_block: CodeBlock) -> ValidationResult:
|
||||
"""Validate YAML syntax."""
|
||||
try:
|
||||
import yaml
|
||||
yaml.safe_load(code_block.content)
|
||||
return ValidationResult(code_block=code_block, is_valid=True)
|
||||
except ImportError:
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=True,
|
||||
error_message="PyYAML not installed, skipping validation"
|
||||
)
|
||||
except yaml.YAMLError as e:
|
||||
return ValidationResult(
|
||||
code_block=code_block,
|
||||
is_valid=False,
|
||||
error_message=f"YAML error: {str(e)}"
|
||||
)
|
||||
|
||||
def validate_file(self, file_path: Path) -> List[ValidationResult]:
|
||||
"""Extract and validate all code blocks in a file."""
|
||||
code_blocks = self.extract_code_blocks(file_path)
|
||||
results = []
|
||||
|
||||
for code_block in code_blocks:
|
||||
result = self.validate_code_block(code_block)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def print_results(results: List[ValidationResult], verbose: bool = False):
|
||||
"""Print validation results."""
|
||||
total_blocks = len(results)
|
||||
valid_blocks = sum(1 for r in results if r.is_valid)
|
||||
invalid_blocks = total_blocks - valid_blocks
|
||||
|
||||
if verbose or invalid_blocks > 0:
|
||||
for result in results:
|
||||
if not result.is_valid:
|
||||
print(f"❌ {result.code_block.file_path}:{result.code_block.line_number}")
|
||||
print(f" Language: {result.code_block.language}")
|
||||
print(f" Error: {result.error_message}")
|
||||
print()
|
||||
elif verbose:
|
||||
print(f"✅ {result.code_block.file_path}:{result.code_block.line_number} ({result.code_block.language})")
|
||||
|
||||
print(f"\nValidation Summary:")
|
||||
print(f" Total code blocks: {total_blocks}")
|
||||
print(f" Valid: {valid_blocks}")
|
||||
print(f" Invalid: {invalid_blocks}")
|
||||
|
||||
if invalid_blocks == 0:
|
||||
print("\n✅ All code blocks validated successfully!")
|
||||
else:
|
||||
print(f"\n❌ {invalid_blocks} code block(s) have validation errors")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Validate code blocks in markdown documentation'
|
||||
)
|
||||
parser.add_argument(
|
||||
'files',
|
||||
nargs='+',
|
||||
type=Path,
|
||||
help='Markdown files to validate'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-v', '--verbose',
|
||||
action='store_true',
|
||||
help='Show all validation results (not just errors)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
validator = MarkdownValidator()
|
||||
all_results = []
|
||||
|
||||
for file_path in args.files:
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {file_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not file_path.suffix == '.md':
|
||||
print(f"Warning: Skipping non-markdown file: {file_path}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
results = validator.validate_file(file_path)
|
||||
all_results.extend(results)
|
||||
|
||||
print_results(all_results, verbose=args.verbose)
|
||||
|
||||
# Exit with error code if any validation failed
|
||||
if any(not r.is_valid for r in all_results):
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
185
skills/documentation-management/tools/validate-links.py
Executable file
185
skills/documentation-management/tools/validate-links.py
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
validate-links.py - Validate markdown links in documentation
|
||||
|
||||
Usage:
|
||||
./validate-links.py [file.md] # Check one file
|
||||
./validate-links.py [directory] # Check all .md files
|
||||
|
||||
Exit codes:
|
||||
0 - All links valid
|
||||
1 - One or more broken links found
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Colors
|
||||
RED = '\033[0;31m'
|
||||
GREEN = '\033[0;32m'
|
||||
YELLOW = '\033[1;33m'
|
||||
NC = '\033[0m'
|
||||
|
||||
# Counters
|
||||
total_links = 0
|
||||
valid_links = 0
|
||||
broken_links = 0
|
||||
broken_list = []
|
||||
|
||||
|
||||
def heading_to_anchor(heading):
|
||||
"""Convert heading text to GitHub-style anchor"""
|
||||
# Remove markdown formatting
|
||||
heading = re.sub(r'[`*_]', '', heading)
|
||||
# Lowercase and replace spaces with hyphens
|
||||
anchor = heading.lower().replace(' ', '-')
|
||||
# Remove non-alphanumeric except hyphens
|
||||
anchor = re.sub(r'[^a-z0-9-]', '', anchor)
|
||||
return anchor
|
||||
|
||||
|
||||
def check_anchor(file_path, anchor):
|
||||
"""Check if anchor exists in file"""
|
||||
# Remove leading #
|
||||
anchor = anchor.lstrip('#')
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
# Match heading lines
|
||||
match = re.match(r'^(#+)\s+(.+)$', line)
|
||||
if match:
|
||||
heading_text = match.group(2).strip()
|
||||
heading_anchor = heading_to_anchor(heading_text)
|
||||
if heading_anchor == anchor.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_link(file_path, link_text, link_url):
|
||||
"""Validate a single link"""
|
||||
global total_links, valid_links, broken_links
|
||||
|
||||
total_links += 1
|
||||
|
||||
# Skip external links
|
||||
if link_url.startswith(('http://', 'https://')):
|
||||
valid_links += 1
|
||||
return True
|
||||
|
||||
# Handle anchor-only links
|
||||
if link_url.startswith('#'):
|
||||
if check_anchor(file_path, link_url):
|
||||
valid_links += 1
|
||||
return True
|
||||
else:
|
||||
broken_links += 1
|
||||
broken_list.append(f"{file_path}: [{link_text}]({link_url}) - Anchor not found")
|
||||
return False
|
||||
|
||||
# Handle file links (with or without anchor)
|
||||
link_file = link_url
|
||||
link_anchor = None
|
||||
if '#' in link_url:
|
||||
link_file, link_anchor = link_url.split('#', 1)
|
||||
link_anchor = '#' + link_anchor
|
||||
|
||||
# Resolve relative path
|
||||
current_dir = os.path.dirname(file_path)
|
||||
if link_file.startswith('/'):
|
||||
# Absolute path from repo root (not supported in this simple version)
|
||||
resolved_path = link_file
|
||||
else:
|
||||
# Relative path
|
||||
resolved_path = os.path.join(current_dir, link_file)
|
||||
|
||||
# Normalize path
|
||||
resolved_path = os.path.normpath(resolved_path)
|
||||
|
||||
# Check file exists
|
||||
if not os.path.isfile(resolved_path):
|
||||
broken_links += 1
|
||||
broken_list.append(f"{file_path}: [{link_text}]({link_url}) - File not found: {resolved_path}")
|
||||
return False
|
||||
|
||||
# Check anchor if present
|
||||
if link_anchor:
|
||||
if check_anchor(resolved_path, link_anchor):
|
||||
valid_links += 1
|
||||
return True
|
||||
else:
|
||||
broken_links += 1
|
||||
broken_list.append(f"{file_path}: [{link_text}]({link_url}) - Anchor not found in {resolved_path}")
|
||||
return False
|
||||
|
||||
valid_links += 1
|
||||
return True
|
||||
|
||||
|
||||
def validate_file(file_path):
|
||||
"""Validate all links in a markdown file"""
|
||||
print(f"{YELLOW}Checking:{NC} {file_path}")
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find all markdown links: [text](url)
|
||||
link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
|
||||
for match in re.finditer(link_pattern, content):
|
||||
link_text = match.group(1)
|
||||
link_url = match.group(2)
|
||||
validate_link(file_path, link_text, link_url)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function"""
|
||||
if len(sys.argv) < 2:
|
||||
target = '.'
|
||||
else:
|
||||
target = sys.argv[1]
|
||||
|
||||
print(f"{YELLOW}Link Validation Tool{NC}")
|
||||
print("====================")
|
||||
print("")
|
||||
|
||||
target_path = Path(target)
|
||||
|
||||
if not target_path.exists():
|
||||
print(f"{RED}Error:{NC} {target} not found")
|
||||
sys.exit(2)
|
||||
|
||||
if target_path.is_file():
|
||||
if target_path.suffix != '.md':
|
||||
print(f"{RED}Error:{NC} Not a markdown file: {target}")
|
||||
sys.exit(2)
|
||||
validate_file(str(target_path))
|
||||
elif target_path.is_dir():
|
||||
for md_file in target_path.rglob('*.md'):
|
||||
validate_file(str(md_file))
|
||||
else:
|
||||
print(f"{RED}Error:{NC} {target} is neither a file nor directory")
|
||||
sys.exit(2)
|
||||
|
||||
# Summary
|
||||
print("")
|
||||
print("====================")
|
||||
print(f"{YELLOW}Summary{NC}")
|
||||
print("====================")
|
||||
print(f"Total links: {total_links}")
|
||||
print(f"{GREEN}Valid:{NC} {valid_links}")
|
||||
print(f"{RED}Broken:{NC} {broken_links}")
|
||||
|
||||
if broken_links > 0:
|
||||
print("")
|
||||
print("Details:")
|
||||
for broken in broken_list:
|
||||
print(f"{RED} ✗{NC} {broken}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"{GREEN}✓ All links valid!{NC}")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user