Files
claude-smart-usage/claude_task_chunker/scanner.py
T
Claude e211583214 Add codebase scanner, CLAUDE.md init, and usage-focused workflow
- 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
2026-03-29 22:48:58 +00:00

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}")