"""Fast codebase scanner - finds errors/issues WITHOUT using AI tokens. Runs linters, tests, and pattern matching to identify problems before you even open Claude Code. Feed the results to Claude so it doesn't waste usage reading 10,000 lines to find the bug itself. """ import subprocess import shutil from dataclasses import dataclass, field from pathlib import Path @dataclass class ScanResult: """Results from a codebase scan.""" errors: list[str] = field(default_factory=list) # actual errors found warnings: list[str] = field(default_factory=list) files_with_issues: list[str] = field(default_factory=list) test_output: str = "" lint_output: str = "" build_output: str = "" raw_sections: dict[str, str] = field(default_factory=dict) def to_prompt(self, task: str = "") -> str: """Generate a prompt you can paste into Claude Code.""" lines = [] if task: lines.append(f"# Task: {task}") lines.append("") lines.append("# Pre-scan Results") lines.append("The following issues were found by automated scanning (linters, tests, grep).") lines.append("Use these results to focus your work — don't re-read the entire codebase.") lines.append("") if self.errors: lines.append(f"## Errors ({len(self.errors)})") for e in self.errors: lines.append(e) lines.append("") if self.warnings: lines.append(f"## Warnings ({len(self.warnings)})") for w in self.warnings[:20]: # cap at 20 to keep prompt manageable lines.append(w) if len(self.warnings) > 20: lines.append(f"... and {len(self.warnings) - 20} more warnings") lines.append("") if self.test_output: lines.append("## Test Output") lines.append("```") # Only include the tail — failures are at the end test_lines = self.test_output.splitlines() if len(test_lines) > 60: lines.append("... (truncated, showing last 60 lines)") lines.extend(test_lines[-60:]) else: lines.extend(test_lines) lines.append("```") lines.append("") if self.lint_output: lines.append("## Lint Output") lines.append("```") lint_lines = self.lint_output.splitlines() if len(lint_lines) > 60: lines.append("... (truncated, showing last 60 lines)") lines.extend(lint_lines[-60:]) else: lines.extend(lint_lines) lines.append("```") lines.append("") if self.build_output: lines.append("## Build Output") lines.append("```") build_lines = self.build_output.splitlines() if len(build_lines) > 60: lines.append("... (truncated, showing last 60 lines)") lines.extend(build_lines[-60:]) else: lines.extend(build_lines) lines.append("```") lines.append("") if self.files_with_issues: lines.append("## Files with Issues") for f in sorted(set(self.files_with_issues)): lines.append(f"- `{f}`") lines.append("") lines.extend([ "## Instructions", "- Fix the errors above. Start with the files listed in 'Files with Issues'.", "- Read only the files relevant to the errors — don't explore the whole codebase.", "- Commit your fixes when done.", ]) return "\n".join(lines) def _run(cmd: list[str], cwd: Path, timeout: int = 120) -> tuple[int, str]: """Run a command and capture output. Returns (exit_code, combined_output).""" try: result = subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, ) output = result.stdout if result.stderr: output += "\n" + result.stderr return result.returncode, output.strip() except subprocess.TimeoutExpired: return -1, f"Command timed out after {timeout}s: {' '.join(cmd)}" except FileNotFoundError: return -1, "" def _extract_file_paths(output: str, root: Path) -> list[str]: """Extract file paths from linter/compiler output lines like 'path/to/file.py:10: error'.""" files = [] for line in output.splitlines(): if ":" in line: candidate = line.split(":")[0].strip() if candidate and (root / candidate).exists(): files.append(candidate) return files def scan_codebase(root: Path) -> ScanResult: """Run all available scanners against the codebase.""" root = root.resolve() result = ScanResult() # Detect project type and run appropriate tools _scan_python(root, result) _scan_node(root, result) _scan_rust(root, result) _scan_go(root, result) _scan_generic_patterns(root, result) return result def _scan_python(root: Path, result: ScanResult) -> None: """Run Python-specific checks.""" has_python = ( (root / "pyproject.toml").exists() or (root / "setup.py").exists() or (root / "requirements.txt").exists() ) if not has_python: return # pytest for cmd in [["python", "-m", "pytest", "--tb=short", "-q"], ["pytest", "--tb=short", "-q"]]: code, output = _run(cmd, root) if code == -1 and not output: continue if code != 0 and output: result.test_output = output result.files_with_issues.extend(_extract_file_paths(output, root)) break # ruff (fast linter) if shutil.which("ruff"): code, output = _run(["ruff", "check", "."], root) if code != 0 and output: result.lint_output = output result.files_with_issues.extend(_extract_file_paths(output, root)) # mypy if shutil.which("mypy"): code, output = _run(["mypy", ".", "--no-error-summary"], root, timeout=180) if code != 0 and output: for line in output.splitlines(): if ": error:" in line: result.errors.append(line) elif ": warning:" in line: result.warnings.append(line) result.files_with_issues.extend(_extract_file_paths(output, root)) # Python syntax errors (fast, no deps needed) code, output = _run(["python", "-m", "py_compile", "--help"], root) py_files = list(root.rglob("*.py")) for py_file in py_files[:500]: # cap to avoid huge repos rel = py_file.relative_to(root) if any(part in {".venv", "venv", "node_modules", "__pycache__", ".git"} for part in rel.parts): continue code, output = _run(["python", "-c", f"import ast; ast.parse(open('{py_file}').read())"], root) if code != 0: result.errors.append(f"{rel}: SyntaxError") result.files_with_issues.append(str(rel)) def _scan_node(root: Path, result: ScanResult) -> None: """Run Node.js-specific checks.""" if not (root / "package.json").exists(): return # npm test or yarn test pkg_manager = "yarn" if (root / "yarn.lock").exists() else "npm" code, output = _run([pkg_manager, "test", "--", "--reporter=min"], root, timeout=180) if code != 0 and output: result.test_output = output result.files_with_issues.extend(_extract_file_paths(output, root)) # eslint if shutil.which("npx"): code, output = _run(["npx", "eslint", ".", "--format=compact", "--no-warn-ignored"], root, timeout=120) if code != 0 and output: result.lint_output = output result.files_with_issues.extend(_extract_file_paths(output, root)) # TypeScript check if (root / "tsconfig.json").exists(): code, output = _run(["npx", "tsc", "--noEmit"], root, timeout=180) if code != 0 and output: result.build_output = output for line in output.splitlines(): if ": error " in line: result.errors.append(line) result.files_with_issues.extend(_extract_file_paths(output, root)) def _scan_rust(root: Path, result: ScanResult) -> None: """Run Rust-specific checks.""" if not (root / "Cargo.toml").exists(): return code, output = _run(["cargo", "check", "--message-format=short"], root, timeout=300) if code != 0 and output: result.build_output = output for line in output.splitlines(): if "error" in line.lower(): result.errors.append(line) result.files_with_issues.extend(_extract_file_paths(output, root)) code, output = _run(["cargo", "test", "--no-run"], root, timeout=300) if code != 0 and output: result.test_output = output def _scan_go(root: Path, result: ScanResult) -> None: """Run Go-specific checks.""" if not (root / "go.mod").exists(): return code, output = _run(["go", "build", "./..."], root, timeout=180) if code != 0 and output: result.build_output = output result.files_with_issues.extend(_extract_file_paths(output, root)) code, output = _run(["go", "vet", "./..."], root, timeout=120) if code != 0 and output: for line in output.splitlines(): result.warnings.append(line) result.files_with_issues.extend(_extract_file_paths(output, root)) code, output = _run(["go", "test", "-short", "-count=1", "./..."], root, timeout=180) if code != 0 and output: result.test_output = output def _scan_generic_patterns(root: Path, result: ScanResult) -> None: """Grep for common error patterns across any codebase.""" patterns = [ ("TODO.*FIXME", "TODO/FIXME markers"), ("HACK", "HACK markers"), ("BUG", "BUG markers"), ("XXX", "XXX markers"), ] if not shutil.which("grep"): return for pattern, label in patterns: code, output = _run( ["grep", "-rn", "--include=*.py", "--include=*.js", "--include=*.ts", "--include=*.tsx", "--include=*.jsx", "--include=*.go", "--include=*.rs", "--include=*.java", "--include=*.rb", "--include=*.cpp", "--include=*.c", "-E", pattern, "."], root, ) if code == 0 and output: lines = output.splitlines() for line in lines[:10]: result.warnings.append(f"[{label}] {line}")