#!/usr/bin/env python3 """ Analyze Project Structure
Analyzes project directory structure against CODITECT standards. Identifies stray files, naming violations, missing READMEs, and calculates production readiness score.
Usage: python analyze-project-structure.py [path] [--json] [--verbose]
Examples: python analyze-project-structure.py # Analyze current directory python analyze-project-structure.py /path/to/project # Analyze specific path python analyze-project-structure.py --json # Output as JSON """
import argparse import json import os import re import sys from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Tuple
ANSI color codes
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 Issue: """Represents a structure issue.""" severity: str # critical, high, medium, low category: str # structure, naming, documentation, etc. message: str path: str fix: Optional[str] = None
@dataclass class AnalysisResult: """Results of structure analysis.""" score: int = 0 grade: str = "F" issues: List[Issue] = field(default_factory=list) readme_coverage: float = 0.0 naming_compliance: float = 0.0 stray_files: List[str] = field(default_factory=list) missing_readmes: List[str] = field(default_factory=list) total_directories: int = 0 total_files: int = 0
CODITECT naming patterns
NAMING_PATTERNS = { 'directories': r'^[a-z][a-z0-9-]$|^[a-z][a-z0-9]$|^.[a-z][a-z0-9-]$', 'markdown': r'^[A-Z][A-Z0-9-].md$|^[a-z][a-z0-9-].md$', 'python': r'^[a-z][a-z0-9_].py$|^[a-z]+.py$', 'typescript': r'^[a-z][a-z0-9-].(ts|tsx)$|^[A-Z][a-zA-Z0-9].(tsx)$', 'shell': r'^[a-z][a-z0-9-].sh$', 'json': r'^[a-z][a-z0-9-].json$|^[a-z][a-z0-9.-]*.json$', }
Required files at root
REQUIRED_ROOT_FILES = ['README.md', 'LICENSE', '.gitignore'] CODITECT_ROOT_FILES = ['CLAUDE.md']
Stray file patterns (shouldn't be at root)
STRAY_PATTERNS = [ r'..log$', r'..tmp$', r'..bak$', r'.~$', r'^CHECKPOINT-.*.md$', ]
Directories that should exist
EXPECTED_DIRECTORIES = ['docs', 'scripts']
def check_naming(name: str, is_dir: bool, extension: str = '') -> bool: """Check if name follows CODITECT naming conventions.""" if is_dir: return bool(re.match(NAMING_PATTERNS['directories'], name))
ext_patterns = {
'.md': NAMING_PATTERNS['markdown'],
'.py': NAMING_PATTERNS['python'],
'.ts': NAMING_PATTERNS['typescript'],
'.tsx': NAMING_PATTERNS['typescript'],
'.sh': NAMING_PATTERNS['shell'],
'.json': NAMING_PATTERNS['json'],
}
pattern = ext_patterns.get(extension, r'^[a-z][a-z0-9._-]*$')
return bool(re.match(pattern, name))
def is_stray_file(name: str) -> bool: """Check if file is a stray file that shouldn't be at root.""" for pattern in STRAY_PATTERNS: if re.match(pattern, name): return True return False
def calculate_grade(score: int) -> str: """Calculate letter grade from score.""" if score >= 95: return 'A+' if score >= 90: return 'A' if score >= 85: return 'A-' if score >= 80: return 'B+' if score >= 75: return 'B' if score >= 70: return 'C+' if score >= 65: return 'C' if score >= 60: return 'D' return 'F'
def analyze_directory(path: Path, depth: int = 0, max_depth: int = 10) -> AnalysisResult: """Analyze a directory and its contents.""" result = AnalysisResult()
if depth > max_depth:
return result
try:
items = list(path.iterdir())
except PermissionError:
return result
dirs = [i for i in items if i.is_dir() and not i.name.startswith('.git')]
files = [i for i in items if i.is_file()]
result.total_directories = len(dirs)
result.total_files = len(files)
# Check for README
has_readme = any(f.name == 'README.md' for f in files)
if not has_readme and depth < 4: # Only check up to 4 levels deep
result.missing_readmes.append(str(path))
result.issues.append(Issue(
severity='medium',
category='documentation',
message=f'Missing README.md',
path=str(path),
fix=f'Create README.md in {path}'
))
# Check root-specific requirements
if depth == 0:
for required in REQUIRED_ROOT_FILES:
if not any(f.name == required for f in files):
result.issues.append(Issue(
severity='high',
category='structure',
message=f'Missing required file: {required}',
path=str(path),
fix=f'Create {required}'
))
for required in CODITECT_ROOT_FILES:
if not any(f.name == required for f in files):
result.issues.append(Issue(
severity='critical',
category='structure',
message=f'Missing CODITECT file: {required}',
path=str(path),
fix=f'Create {required} following CODITECT standards'
))
# Check for stray files at root
for f in files:
if is_stray_file(f.name):
result.stray_files.append(f.name)
result.issues.append(Issue(
severity='medium',
category='organization',
message=f'Stray file at root: {f.name}',
path=str(f),
fix=f'Move {f.name} to appropriate directory or delete'
))
# Check naming conventions
naming_violations = 0
total_checked = 0
for d in dirs:
if d.name.startswith('.'):
continue
total_checked += 1
if not check_naming(d.name, is_dir=True):
naming_violations += 1
result.issues.append(Issue(
severity='low',
category='naming',
message=f'Directory naming violation: {d.name}',
path=str(d),
fix=f'Rename to kebab-case: {d.name.lower().replace("_", "-")}'
))
for f in files:
total_checked += 1
ext = f.suffix
if not check_naming(f.name, is_dir=False, extension=ext):
naming_violations += 1
result.issues.append(Issue(
severity='low',
category='naming',
message=f'File naming violation: {f.name}',
path=str(f),
fix=f'Rename following naming conventions'
))
if total_checked > 0:
result.naming_compliance = 1 - (naming_violations / total_checked)
# Recurse into subdirectories
for d in dirs:
if d.name.startswith('.') or d.name in ['node_modules', 'venv', '.venv', '__pycache__']:
continue
sub_result = analyze_directory(d, depth + 1, max_depth)
result.issues.extend(sub_result.issues)
result.missing_readmes.extend(sub_result.missing_readmes)
result.stray_files.extend(sub_result.stray_files)
result.total_directories += sub_result.total_directories
result.total_files += sub_result.total_files
return result
def calculate_score(result: AnalysisResult) -> int: """Calculate production readiness score.""" score = 100
# Deduct for issues
severity_deductions = {
'critical': 15,
'high': 8,
'medium': 3,
'low': 1
}
for issue in result.issues:
score -= severity_deductions.get(issue.severity, 1)
# Minimum score is 0
return max(0, score)
def print_report(result: AnalysisResult, verbose: bool = False): """Print analysis report to console.""" print(f"\n{Colors.BOLD}{'='*60}{Colors.RESET}") print(f"{Colors.BOLD} PROJECT STRUCTURE ANALYSIS REPORT{Colors.RESET}") print(f"{Colors.BOLD}{'='*60}{Colors.RESET}\n")
# Score and grade
grade_color = Colors.GREEN if result.score >= 80 else (Colors.YELLOW if result.score >= 60 else Colors.RED)
print(f" {Colors.BOLD}Score:{Colors.RESET} {grade_color}{result.score}/100{Colors.RESET}")
print(f" {Colors.BOLD}Grade:{Colors.RESET} {grade_color}{result.grade}{Colors.RESET}")
print()
# Summary
critical = len([i for i in result.issues if i.severity == 'critical'])
high = len([i for i in result.issues if i.severity == 'high'])
medium = len([i for i in result.issues if i.severity == 'medium'])
low = len([i for i in result.issues if i.severity == 'low'])
print(f" {Colors.BOLD}Issues Found:{Colors.RESET}")
if critical: print(f" {Colors.RED}Critical: {critical}{Colors.RESET}")
if high: print(f" {Colors.YELLOW}High: {high}{Colors.RESET}")
if medium: print(f" {Colors.CYAN}Medium: {medium}{Colors.RESET}")
if low: print(f" Low: {low}")
print()
# Metrics
print(f" {Colors.BOLD}Metrics:{Colors.RESET}")
print(f" Directories: {result.total_directories}")
print(f" Files: {result.total_files}")
print(f" Naming Compliance: {result.naming_compliance*100:.1f}%")
print(f" Missing READMEs: {len(result.missing_readmes)}")
print(f" Stray Files: {len(result.stray_files)}")
print()
if verbose:
# Critical issues
critical_issues = [i for i in result.issues if i.severity == 'critical']
if critical_issues:
print(f" {Colors.RED}{Colors.BOLD}CRITICAL ISSUES:{Colors.RESET}")
for issue in critical_issues:
print(f" • {issue.message}")
if issue.fix:
print(f" Fix: {issue.fix}")
print()
# High priority issues
high_issues = [i for i in result.issues if i.severity == 'high']
if high_issues:
print(f" {Colors.YELLOW}{Colors.BOLD}HIGH PRIORITY:{Colors.RESET}")
for issue in high_issues:
print(f" • {issue.message}")
print()
# Stray files
if result.stray_files:
print(f" {Colors.CYAN}{Colors.BOLD}STRAY FILES:{Colors.RESET}")
for f in result.stray_files[:10]:
print(f" • {f}")
if len(result.stray_files) > 10:
print(f" ... and {len(result.stray_files) - 10} more")
print()
# Recommendations
print(f" {Colors.BOLD}RECOMMENDATIONS:{Colors.RESET}")
if critical:
print(f" 1. {Colors.RED}Fix critical issues immediately{Colors.RESET}")
if len(result.missing_readmes) > 0:
print(f" 2. Generate missing READMEs with /readme-gen")
if len(result.stray_files) > 0:
print(f" 3. Move stray files to appropriate directories")
if result.naming_compliance < 1.0:
print(f" 4. Fix naming violations with /naming-check --fix")
print()
print(f"{Colors.BOLD}{'='*60}{Colors.RESET}\n")
def main(): parser = argparse.ArgumentParser( description='Analyze project structure against CODITECT standards' ) parser.add_argument('path', nargs='?', default='.', help='Path to analyze') parser.add_argument('--json', action='store_true', help='Output as JSON') parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') parser.add_argument('--check', action='store_true', help='Exit with error if score < 80')
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)
# Run analysis
result = analyze_directory(path)
result.score = calculate_score(result)
result.grade = calculate_grade(result.score)
# Calculate README coverage
if result.total_directories > 0:
dirs_with_readme = result.total_directories - len(result.missing_readmes)
result.readme_coverage = dirs_with_readme / result.total_directories
# Output
if args.json:
output = {
'score': result.score,
'grade': result.grade,
'readme_coverage': result.readme_coverage,
'naming_compliance': result.naming_compliance,
'total_directories': result.total_directories,
'total_files': result.total_files,
'stray_files': result.stray_files,
'missing_readmes': result.missing_readmes,
'issues': [
{
'severity': i.severity,
'category': i.category,
'message': i.message,
'path': i.path,
'fix': i.fix
}
for i in result.issues
]
}
print(json.dumps(output, indent=2))
else:
print_report(result, args.verbose)
# Exit with error if check mode and score too low
if args.check and result.score < 80:
sys.exit(1)
sys.exit(0)
if name == 'main': main()