Instead of a CLI the user runs separately, this is now two files you drop into any repo: - CLAUDE.md: read automatically by Claude Code, teaches it to scan first, commit often, and leave handoff notes when usage runs low - scan.py: standalone script Claude runs itself to find errors via linters/tests/type-checkers, using zero AI tokens No workflow change for the user — they type in Claude Code as before. https://claude.ai/code/session_01Fzv8baXnEVVhnrffAb3Ucc
197 lines
5.9 KiB
Python
197 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Fast codebase scanner — finds errors using linters, tests, and type checkers.
|
|
|
|
Claude Code runs this script itself via bash. It uses zero AI tokens because
|
|
it's just running your project's existing tools and collecting the output.
|
|
|
|
Usage (run by Claude Code, not by you):
|
|
python scan.py [project_root]
|
|
|
|
Outputs a structured report to stdout that Claude can read and act on.
|
|
Auto-detects project type: Python, Node/TypeScript, Rust, Go.
|
|
"""
|
|
|
|
import subprocess
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def run(cmd, cwd, timeout=120):
|
|
"""Run a command, return (exit_code, output)."""
|
|
try:
|
|
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
out = r.stdout
|
|
if r.stderr:
|
|
out += "\n" + r.stderr
|
|
return r.returncode, out.strip()
|
|
except subprocess.TimeoutExpired:
|
|
return -1, f"TIMEOUT after {timeout}s: {' '.join(cmd)}"
|
|
except FileNotFoundError:
|
|
return -1, ""
|
|
|
|
|
|
def extract_files(output, root):
|
|
"""Pull file paths from linter output like 'path/file.py:10: error'."""
|
|
files = set()
|
|
for line in output.splitlines():
|
|
if ":" in line:
|
|
candidate = line.split(":")[0].strip().lstrip("./")
|
|
if candidate and (root / candidate).exists():
|
|
files.add(candidate)
|
|
return sorted(files)
|
|
|
|
|
|
def scan_python(root):
|
|
"""Run Python checks: pytest, ruff, mypy, syntax."""
|
|
sections = []
|
|
|
|
if not any((root / f).exists() for f in ["pyproject.toml", "setup.py", "requirements.txt"]):
|
|
return sections
|
|
|
|
# pytest
|
|
for cmd in [["python", "-m", "pytest", "--tb=short", "-q"], ["pytest", "--tb=short", "-q"]]:
|
|
code, out = run(cmd, root)
|
|
if code == -1 and not out:
|
|
continue
|
|
if code != 0 and out:
|
|
sections.append(("Test Failures", out))
|
|
break
|
|
|
|
# ruff
|
|
if shutil.which("ruff"):
|
|
code, out = run(["ruff", "check", "."], root)
|
|
if code != 0 and out:
|
|
sections.append(("Lint Errors (ruff)", out))
|
|
|
|
# mypy
|
|
if shutil.which("mypy"):
|
|
code, out = run(["mypy", ".", "--no-error-summary"], root, timeout=180)
|
|
if code != 0 and out:
|
|
sections.append(("Type Errors (mypy)", out))
|
|
|
|
# Syntax errors
|
|
syntax_errors = []
|
|
for py_file in list(root.rglob("*.py"))[:500]:
|
|
rel = py_file.relative_to(root)
|
|
if any(p in {".venv", "venv", "node_modules", "__pycache__", ".git", ".tox"} for p in rel.parts):
|
|
continue
|
|
code, out = run(["python", "-c", f"import ast; ast.parse(open('{py_file}').read())"], root)
|
|
if code != 0:
|
|
syntax_errors.append(f"{rel}: SyntaxError - {out.splitlines()[-1] if out else 'unknown'}")
|
|
if syntax_errors:
|
|
sections.append(("Syntax Errors", "\n".join(syntax_errors)))
|
|
|
|
return sections
|
|
|
|
|
|
def scan_node(root):
|
|
"""Run Node/TypeScript checks: test, eslint, tsc."""
|
|
sections = []
|
|
|
|
if not (root / "package.json").exists():
|
|
return sections
|
|
|
|
pkg = "yarn" if (root / "yarn.lock").exists() else "npm"
|
|
|
|
code, out = run([pkg, "test", "--", "--reporter=min"], root, timeout=180)
|
|
if code != 0 and out:
|
|
sections.append(("Test Failures", out))
|
|
|
|
if shutil.which("npx"):
|
|
code, out = run(["npx", "eslint", ".", "--format=compact", "--no-warn-ignored"], root, timeout=120)
|
|
if code != 0 and out:
|
|
sections.append(("Lint Errors (eslint)", out))
|
|
|
|
if (root / "tsconfig.json").exists():
|
|
code, out = run(["npx", "tsc", "--noEmit"], root, timeout=180)
|
|
if code != 0 and out:
|
|
sections.append(("TypeScript Errors", out))
|
|
|
|
return sections
|
|
|
|
|
|
def scan_rust(root):
|
|
"""Run Rust checks: cargo check, cargo test --no-run."""
|
|
sections = []
|
|
|
|
if not (root / "Cargo.toml").exists():
|
|
return sections
|
|
|
|
code, out = run(["cargo", "check", "--message-format=short"], root, timeout=300)
|
|
if code != 0 and out:
|
|
sections.append(("Build Errors (cargo)", out))
|
|
|
|
code, out = run(["cargo", "test", "--no-run"], root, timeout=300)
|
|
if code != 0 and out:
|
|
sections.append(("Test Compilation Errors", out))
|
|
|
|
return sections
|
|
|
|
|
|
def scan_go(root):
|
|
"""Run Go checks: build, vet, test."""
|
|
sections = []
|
|
|
|
if not (root / "go.mod").exists():
|
|
return sections
|
|
|
|
code, out = run(["go", "build", "./..."], root, timeout=180)
|
|
if code != 0 and out:
|
|
sections.append(("Build Errors (go)", out))
|
|
|
|
code, out = run(["go", "vet", "./..."], root, timeout=120)
|
|
if code != 0 and out:
|
|
sections.append(("Vet Warnings (go)", out))
|
|
|
|
code, out = run(["go", "test", "-short", "-count=1", "./..."], root, timeout=180)
|
|
if code != 0 and out:
|
|
sections.append(("Test Failures (go)", out))
|
|
|
|
return sections
|
|
|
|
|
|
def main():
|
|
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
|
|
|
|
if not root.is_dir():
|
|
print(f"Error: {root} is not a directory", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
all_sections = []
|
|
all_sections.extend(scan_python(root))
|
|
all_sections.extend(scan_node(root))
|
|
all_sections.extend(scan_rust(root))
|
|
all_sections.extend(scan_go(root))
|
|
|
|
if not all_sections:
|
|
print("SCAN COMPLETE: No errors found.")
|
|
sys.exit(0)
|
|
|
|
# Collect all files with issues
|
|
all_files = set()
|
|
for title, output in all_sections:
|
|
all_files.update(extract_files(output, root))
|
|
|
|
# Print structured report
|
|
print(f"SCAN COMPLETE: Found issues in {len(all_sections)} categories across {len(all_files)} files.\n")
|
|
|
|
for title, output in all_sections:
|
|
print(f"=== {title} ===")
|
|
lines = output.splitlines()
|
|
if len(lines) > 80:
|
|
print("(truncated to last 80 lines)")
|
|
print("\n".join(lines[-80:]))
|
|
else:
|
|
print(output)
|
|
print()
|
|
|
|
if all_files:
|
|
print("=== Files with Issues ===")
|
|
for f in sorted(all_files):
|
|
print(f" {f}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|