#!/usr/bin/env python3 """ Enforce Naming Conventions
Validates and optionally fixes file/directory naming against CODITECT standards.
Usage: python enforce-naming-conventions.py [path] [--fix] [--check]
Examples: python enforce-naming-conventions.py # Check current directory python enforce-naming-conventions.py --fix # Fix violations python enforce-naming-conventions.py --check # Exit non-zero on violations """
import argparse import os import re import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple
ANSI colors
Shared Colors module (consolidates 36 duplicate definitions)
_script_dir = Path(file).parent sys.path.insert(0, str(_script_dir / "core")) from colors import Colors
class Violation: """Represents a naming violation.""" path: Path current_name: str expected_name: str rule: str is_dir: bool
Naming rules
RULES = { 'directory': { 'pattern': r'^[a-z][a-z0-9-]$', 'description': 'kebab-case', 'transform': lambda s: s.lower().replace('', '-').replace(' ', '-') }, 'python_package': { 'pattern': r'^[a-z][a-z0-9]$', 'description': 'snake_case', 'transform': lambda s: s.lower().replace('-', '').replace(' ', '') }, 'markdown_standard': { 'pattern': r'^(README|CLAUDE|LICENSE|CHANGELOG|CONTRIBUTING|CODE_OF_CONDUCT).md$', 'description': 'UPPERCASE.md', 'transform': lambda s: s.upper().replace('.MD', '.md') }, 'markdown_doc': { 'pattern': r'^[A-Z][A-Z0-9-].md$', 'description': 'UPPER-KEBAB-CASE.md', 'transform': lambda s: s.upper().replace('', '-').replace(' ', '-') }, 'python_file': { 'pattern': r'^[a-z][a-z0-9].py$', 'description': 'snake_case.py', 'transform': lambda s: s.lower().replace('-', '').replace(' ', '') }, 'typescript': { 'pattern': r'^[a-z][a-z0-9-].ts$', 'description': 'kebab-case.ts', 'transform': lambda s: s.lower().replace('_', '-').replace(' ', '-') }, 'react_component': { 'pattern': r'^[A-Z][a-zA-Z0-9].tsx$', 'description': 'PascalCase.tsx', 'transform': lambda s: ''.join(w.capitalize() for w in re.split(r'[-\s]', s.replace('.tsx', ''))) + '.tsx' }, 'shell': { 'pattern': r'^[a-z][a-z0-9-]*.sh$', 'description': 'kebab-case.sh', 'transform': lambda s: s.lower().replace('', '-').replace(' ', '-') }, 'json_config': { 'pattern': r'^[a-z][a-z0-9.-]*.json$', 'description': 'kebab-case.json', 'transform': lambda s: s.lower().replace('_', '-').replace(' ', '-') } }
Standard docs that must be uppercase
STANDARD_DOCS = ['readme.md', 'claude.md', 'license', 'license.md', 'changelog.md', 'contributing.md', 'code_of_conduct.md']
def get_rule_for_file(path: Path) -> Optional[Tuple[str, dict]]: """Determine which naming rule applies to a file.""" name = path.name.lower() suffix = path.suffix.lower()
# Standard docs
if name in STANDARD_DOCS:
return 'markdown_standard', RULES['markdown_standard']
# By extension
if suffix == '.md':
return 'markdown_doc', RULES['markdown_doc']
elif suffix == '.py':
return 'python_file', RULES['python_file']
elif suffix == '.ts':
return 'typescript', RULES['typescript']
elif suffix == '.tsx':
return 'react_component', RULES['react_component']
elif suffix == '.sh':
return 'shell', RULES['shell']
elif suffix == '.json':
return 'json_config', RULES['json_config']
return None
def check_name(path: Path) -> Optional[Violation]: """Check if a file/directory name follows conventions.""" name = path.name
# Skip hidden files/dirs and special cases
if name.startswith('.') or name.startswith('__'):
return None
if path.is_dir():
# Check if it's a Python package (has __init__.py)
is_python_pkg = (path / '__init__.py').exists()
rule_name = 'python_package' if is_python_pkg else 'directory'
rule = RULES[rule_name]
if not re.match(rule['pattern'], name):
expected = rule['transform'](name)
return Violation(
path=path,
current_name=name,
expected_name=expected,
rule=rule['description'],
is_dir=True
)
else:
rule_info = get_rule_for_file(path)
if rule_info:
rule_name, rule = rule_info
if not re.match(rule['pattern'], name):
expected = rule['transform'](name)
return Violation(
path=path,
current_name=name,
expected_name=expected,
rule=rule['description'],
is_dir=False
)
return None
def scan_directory(path: Path, recursive: bool = True) -> List[Violation]: """Scan directory for naming violations.""" violations = []
skip_dirs = {'.git', 'node_modules', 'venv', '.venv', '__pycache__', '.pytest_cache'}
try:
items = list(path.iterdir())
except PermissionError:
return violations
for item in items:
if item.name in skip_dirs:
continue
violation = check_name(item)
if violation:
violations.append(violation)
if recursive and item.is_dir() and not item.name.startswith('.'):
violations.extend(scan_directory(item, recursive))
return violations
def fix_violation(violation: Violation, dry_run: bool = False) -> bool: """Fix a naming violation using git mv.""" new_path = violation.path.parent / violation.expected_name
if new_path.exists():
print(f" {Colors.RED}Cannot fix: {new_path} already exists{Colors.RESET}")
return False
if dry_run:
print(f" Would rename: {violation.current_name} → {violation.expected_name}")
return True
try:
# Try git mv first
result = subprocess.run(
['git', 'mv', str(violation.path), str(new_path)],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f" {Colors.GREEN}Fixed:{Colors.RESET} {violation.current_name} → {violation.expected_name}")
return True
else:
# Fall back to regular rename
violation.path.rename(new_path)
print(f" {Colors.GREEN}Renamed:{Colors.RESET} {violation.current_name} → {violation.expected_name}")
return True
except Exception as e:
print(f" {Colors.RED}Error fixing {violation.current_name}: {e}{Colors.RESET}")
return False
def print_report(violations: List[Violation], total_checked: int): """Print violations report.""" compliance = 1 - (len(violations) / total_checked) if total_checked > 0 else 1.0
print(f"\n{Colors.BOLD}{'='*60}{Colors.RESET}")
print(f"{Colors.BOLD} NAMING CONVENTION REPORT{Colors.RESET}")
print(f"{Colors.BOLD}{'='*60}{Colors.RESET}\n")
color = Colors.GREEN if compliance >= 0.95 else (Colors.YELLOW if compliance >= 0.8 else Colors.RED)
print(f" {Colors.BOLD}Compliance:{Colors.RESET} {color}{compliance*100:.1f}%{Colors.RESET}")
print(f" {Colors.BOLD}Violations:{Colors.RESET} {len(violations)}")
print()
if violations:
# Group by rule
by_rule: Dict[str, List[Violation]] = {}
for v in violations:
by_rule.setdefault(v.rule, []).append(v)
for rule, rule_violations in by_rule.items():
print(f" {Colors.CYAN}{Colors.BOLD}{rule} violations ({len(rule_violations)}):{Colors.RESET}")
for v in rule_violations[:5]: # Show first 5
print(f" {v.current_name} → {v.expected_name}")
if len(rule_violations) > 5:
print(f" ... and {len(rule_violations) - 5} more")
print()
print(f"{Colors.BOLD}{'='*60}{Colors.RESET}\n")
def main(): parser = argparse.ArgumentParser( description='Validate and fix naming conventions' ) parser.add_argument('path', nargs='?', default='.', help='Path to check') parser.add_argument('--fix', action='store_true', help='Fix violations') parser.add_argument('--dry-run', action='store_true', help='Show what would be fixed') parser.add_argument('--check', action='store_true', help='Exit non-zero on violations') parser.add_argument('--json', action='store_true', help='Output as JSON')
args = parser.parse_args()
path = Path(args.path).resolve()
if not path.exists():
print(f"Error: Path does not exist: {path}", file=sys.stderr)
sys.exit(1)
# Scan for violations
violations = scan_directory(path)
# Count total items checked (rough estimate)
total_items = sum(1 for _ in path.rglob('*') if not any(
p in str(_) for p in ['.git', 'node_modules', 'venv', '__pycache__']
))
if args.json:
output = {
'compliance': 1 - (len(violations) / total_items) if total_items > 0 else 1.0,
'violations': [
{
'path': str(v.path),
'current': v.current_name,
'expected': v.expected_name,
'rule': v.rule,
'is_directory': v.is_dir
}
for v in violations
]
}
print(json.dumps(output, indent=2))
elif args.fix or args.dry_run:
print(f"\n{Colors.BOLD}Fixing naming violations...{Colors.RESET}\n")
fixed = 0
for v in violations:
if fix_violation(v, dry_run=args.dry_run):
fixed += 1
print(f"\n{Colors.GREEN}Fixed {fixed} of {len(violations)} violations{Colors.RESET}\n")
else:
print_report(violations, total_items)
if args.check and violations:
sys.exit(1)
sys.exit(0)
if name == 'main': main()