- ctc scan: runs linters/tests/type-checkers to find errors without burning AI tokens. Auto-detects Python, Node, Rust, Go projects. - ctc init: drops a CLAUDE.md into any repo teaching Claude Code to work efficiently (chunk work, commit often, read scan results first). - Updated README with the actual workflow: scan -> paste -> fix. https://claude.ai/code/session_01Fzv8baXnEVVhnrffAb3Ucc
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""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}")
|