b0229b6332
Scans codebases for TODO/FIXME/HACK/XXX comments and generates Markdown notes in an Obsidian vault. Includes Zed editor integration that runs on file save, plus a shell wrapper for CLI usage. Supports customizable vault paths, project names, and output directories.
165 lines
3.5 KiB
Python
165 lines
3.5 KiB
Python
"""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*)" # comment prefix
|
|
r"(TODO|FIXME|HACK|XXX)" # tag
|
|
r"[\s:]*" # separator
|
|
r"(.*?)" # text
|
|
r"(?:\s*\*/\s*|\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
|