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
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
"""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}")
|
||||
Reference in New Issue
Block a user