"""Codebase analyzer - gathers structure and context for task decomposition.""" import os import subprocess from dataclasses import dataclass, field from pathlib import Path @dataclass class CodebaseInfo: """Summary of a codebase's structure and key files.""" root: Path languages: dict[str, int] = field(default_factory=dict) # extension -> file count total_files: int = 0 total_lines: int = 0 tree: list[str] = field(default_factory=list) # directory tree (paths) key_files: list[str] = field(default_factory=list) # config, entry points, etc. git_branch: str = "" recent_commits: list[str] = field(default_factory=list) def summary(self) -> str: lines = [f"Codebase: {self.root.name}"] if self.git_branch: lines.append(f"Branch: {self.git_branch}") lines.append(f"Files: {self.total_files} | Lines: ~{self.total_lines}") if self.languages: top = sorted(self.languages.items(), key=lambda x: -x[1])[:5] lines.append("Languages: " + ", ".join(f"{ext} ({n})" for ext, n in top)) if self.key_files: lines.append("Key files: " + ", ".join(self.key_files[:10])) return "\n".join(lines) IGNORE_DIRS = { ".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".next", ".nuxt", "target", "vendor", ".tox", ".mypy_cache", ".pytest_cache", "coverage", ".cache", "env", ".env", ".eggs", "*.egg-info", } KEY_FILE_NAMES = { "package.json", "pyproject.toml", "setup.py", "setup.cfg", "Cargo.toml", "go.mod", "Makefile", "CMakeLists.txt", "Dockerfile", "docker-compose.yml", "docker-compose.yaml", ".env.example", "requirements.txt", "tsconfig.json", "webpack.config.js", "vite.config.ts", "vite.config.js", "next.config.js", "tailwind.config.js", "CLAUDE.md", } def analyze_codebase(root: Path) -> CodebaseInfo: """Walk the codebase and gather structural info.""" info = CodebaseInfo(root=root.resolve()) # Git info try: info.git_branch = subprocess.check_output( ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=root, stderr=subprocess.DEVNULL, text=True, ).strip() except (subprocess.CalledProcessError, FileNotFoundError): pass try: log = subprocess.check_output( ["git", "log", "--oneline", "-10"], cwd=root, stderr=subprocess.DEVNULL, text=True, ).strip() if log: info.recent_commits = log.splitlines() except (subprocess.CalledProcessError, FileNotFoundError): pass # Walk files languages: dict[str, int] = {} total_lines = 0 for dirpath, dirnames, filenames in os.walk(root): # Prune ignored directories dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS] rel_dir = os.path.relpath(dirpath, root) for fname in filenames: full = os.path.join(dirpath, fname) rel = os.path.join(rel_dir, fname) if rel_dir != "." else fname info.tree.append(rel) info.total_files += 1 # Check key files if fname in KEY_FILE_NAMES: info.key_files.append(rel) # Language stats ext = Path(fname).suffix.lower() if ext: languages[ext] = languages.get(ext, 0) + 1 # Line count (skip binary) try: with open(full, "r", errors="ignore") as f: total_lines += sum(1 for _ in f) except (OSError, UnicodeDecodeError): pass info.languages = languages info.total_lines = total_lines return info