"""TODO scanner for source code files.""" import re from dataclasses import dataclass from pathlib import Path from typing import Dict, List @dataclass class TodoItem: file: Path line: int tag: str text: str # Regex for common comment prefixes followed by TODO/FIXME/HACK/XXX _TODO_RE = re.compile( r"(?:^\s*(?:#|//|/\*|\*|\s*)?$", # optional block-comment / html close re.IGNORECASE, ) # File extensions to scan _SCAN_EXTENSIONS = { ".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".c", ".cc", ".cpp", ".h", ".hpp", ".rs", ".go", ".rb", ".sh", ".bash", ".zsh", ".pl", ".pm", ".swift", ".kt", ".scala", ".r", ".lua", ".php", ".cs", ".fs", ".fsx", ".md", ".yaml", ".yml", ".json", ".toml", ".ini", ".cfg", ".conf", ".dockerfile", ".makefile", ".mk", } _SKIP_DIRS = { ".git", ".hg", ".svn", "__pycache__", ".pytest_cache", ".mypy_cache", "node_modules", "vendor", "target", "build", "dist", ".next", ".nuxt", ".terraform", ".venv", "venv", "env", ".idea", ".vscode", ".vs", "out", "coverage", "site-packages", "egg-info", } def _should_scan(path: Path) -> bool: ext = path.suffix.lower() if ext in _SCAN_EXTENSIONS: return True if path.name.lower().startswith("dockerfile"): return True if path.name.lower().startswith("makefile"): return True return False def scan_directory(project_path: Path) -> List[TodoItem]: """Recursively scan a directory for TODO comments.""" todos: List[TodoItem] = [] for entry in project_path.rglob("*"): if not entry.is_file(): continue # Skip hidden dirs try: relative = entry.relative_to(project_path) except ValueError: relative = entry if any( part in _SKIP_DIRS or part.startswith(".") for part in relative.parts[:-1] ): continue if not _should_scan(entry): continue todos.extend(_scan_file(entry, project_path)) # Sort by file path then line number todos.sort(key=lambda t: (str(t.file), t.line)) return todos def _scan_file(file_path: Path, project_path: Path) -> List[TodoItem]: todos: List[TodoItem] = [] try: with open(file_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() except (OSError, UnicodeDecodeError): return todos for lineno, raw_line in enumerate(lines, start=1): match = _TODO_RE.search(raw_line) if match: tag = match.group(1).upper() text = match.group(2).strip() if text: rel_file = file_path.relative_to(project_path) todos.append(TodoItem(file=rel_file, line=lineno, tag=tag, text=text)) return todos def group_by_file(todos: List[TodoItem]) -> Dict[Path, List[TodoItem]]: """Group TODO items by their file path.""" groups: Dict[Path, List[TodoItem]] = {} for t in todos: groups.setdefault(t.file, []).append(t) return groups def count_tags(todos: List[TodoItem]) -> Dict[str, int]: """Count occurrences of each tag.""" counts: Dict[str, int] = {} for t in todos: counts[t.tag] = counts.get(t.tag, 0) + 1 return counts