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:
Claude
2026-03-29 22:48:58 +00:00
parent 2d4ead1e3f
commit e211583214
4 changed files with 529 additions and 67 deletions
+134 -3
View File
@@ -5,7 +5,8 @@ import sys
from pathlib import Path
from .analyzer import analyze_codebase
from .decomposer import TaskChunk, decompose_by_phase, decompose_by_component, STRATEGIES
from .decomposer import TaskChunk, decompose_by_phase, decompose_by_component
from .scanner import scan_codebase
from .tracker import save_plan, load_plan, mark_done, get_next_chunk, get_status_summary, TRACKER_DIR
@@ -54,8 +55,8 @@ def cmd_plan(args: argparse.Namespace) -> None:
print(f"\nPlan saved to: {plan_file}")
print(f"Prompts saved to: {root / TRACKER_DIR / 'prompts'}/")
print(f"\nTo start, copy the prompt from chunk_01.md into Claude Code.")
print(f"After each chunk, run: ctc done <chunk_id>")
print("\nTo start, copy the prompt from chunk_01.md into Claude Code.")
print("After each chunk, run: ctc done <chunk_id>")
def cmd_status(args: argparse.Namespace) -> None:
@@ -128,6 +129,74 @@ def cmd_show(args: argparse.Namespace) -> None:
sys.exit(1)
def cmd_scan(args: argparse.Namespace) -> None:
"""Scan the codebase for errors, then output a ready-to-paste prompt."""
root = Path(args.project).resolve()
if not root.is_dir():
print(f"Error: {root} is not a directory.", file=sys.stderr)
sys.exit(1)
print(f"Scanning {root}...", file=sys.stderr)
result = scan_codebase(root)
task = args.task or ""
prompt = result.to_prompt(task)
output_file = root / ".claude-chunks" / "scan_results.md"
output_file.parent.mkdir(exist_ok=True)
output_file.write_text(prompt)
if args.copy:
try:
import subprocess
process = subprocess.Popen(
_get_clipboard_cmd(),
stdin=subprocess.PIPE,
)
process.communicate(prompt.encode())
print("Scan complete! Prompt copied to clipboard.", file=sys.stderr)
print(f"Also saved to: {output_file}", file=sys.stderr)
except (FileNotFoundError, OSError):
print(f"Scan complete! Saved to: {output_file}", file=sys.stderr)
print(prompt)
else:
print(f"Scan complete! Saved to: {output_file}", file=sys.stderr)
print(file=sys.stderr)
print(prompt)
# Summary stats to stderr
n_err = len(result.errors)
n_warn = len(result.warnings)
n_files = len(set(result.files_with_issues))
print(f"\nFound: {n_err} errors, {n_warn} warnings across {n_files} files", file=sys.stderr)
def cmd_init(args: argparse.Namespace) -> None:
"""Initialize a CLAUDE.md in the target project to teach Claude Code chunking."""
root = Path(args.project).resolve()
claude_md = root / "CLAUDE.md"
if claude_md.exists() and not args.force:
print(f"CLAUDE.md already exists at {claude_md}. Use --force to overwrite.", file=sys.stderr)
sys.exit(1)
template = _get_claude_md_template()
# If CLAUDE.md exists and --force, append rather than overwrite
if claude_md.exists() and args.force:
existing = claude_md.read_text()
if "# Usage Efficiency" not in existing:
claude_md.write_text(existing.rstrip() + "\n\n" + template)
print(f"Appended chunking instructions to {claude_md}")
else:
print("CLAUDE.md already has chunking instructions.")
else:
claude_md.write_text(template)
print(f"Created {claude_md}")
print("Claude Code will now follow usage-efficient chunking practices in this repo.")
def _get_clipboard_cmd() -> list[str]:
"""Get the platform-appropriate clipboard command."""
import platform
@@ -160,6 +229,57 @@ def _parse_manual_chunks(filepath: str, task: str) -> list[dict]:
return chunks
def _get_claude_md_template() -> str:
return '''# Usage Efficiency
When working on large tasks in this codebase, follow these practices to minimize
token usage and avoid running out of session capacity.
## Before Starting a Large Task
1. Check if a pre-scan exists: look for `.claude-chunks/scan_results.md`
2. If it exists, read it FIRST — it contains linter/test/build errors already identified
3. Use those results to target your work. Do NOT re-read the entire codebase.
## Chunking Strategy
For any task that will touch more than 5 files or require significant analysis:
1. **Plan first, implement second.** Before writing code, list:
- Which files need to change
- What order to change them in
- How to verify each change works
2. **Work in commits.** Make a commit after each logical unit of work. This creates
save points if the session ends unexpectedly.
3. **Stay focused.** Only read files directly relevant to the current task. Don't
explore the codebase "to understand it better" — that burns tokens fast.
4. **Use tools efficiently:**
- Use `grep`/`rg` to find what you need instead of reading whole files
- Read specific line ranges instead of entire files when possible
- Don't re-read files you've already read in this session
## If the Task Is Too Large
If you realize mid-task that you won't finish in this session:
1. Commit everything you've done so far
2. Create a file called `.claude-chunks/handoff.md` with:
- What was completed
- What remains to be done
- Any gotchas or context the next session needs
3. Tell the user to run `ctc status` to see progress
## Error Fixing Workflow
When asked to find and fix errors in the codebase:
1. First check for `.claude-chunks/scan_results.md` — it may already have the errors
2. If not, ask the user to run `ctc scan` first (it's faster than AI-powered search)
3. Focus on the specific files and line numbers from the scan results
4. Don't read unrelated files
'''
def main() -> None:
parser = argparse.ArgumentParser(
prog="ctc",
@@ -184,6 +304,15 @@ def main() -> None:
help="File with one chunk description per line (for manual strategy)",
)
# scan
p_scan = sub.add_parser("scan", help="Scan codebase for errors and generate a prompt")
p_scan.add_argument("task", nargs="?", default="", help="Optional: what you're trying to fix")
p_scan.add_argument("-c", "--copy", action="store_true", help="Copy prompt to clipboard")
# init
p_init = sub.add_parser("init", help="Add CLAUDE.md with chunking instructions to a project")
p_init.add_argument("--force", action="store_true", help="Overwrite/append to existing CLAUDE.md")
# status
sub.add_parser("status", help="Show progress on the current plan")
@@ -203,6 +332,8 @@ def main() -> None:
commands = {
"plan": cmd_plan,
"scan": cmd_scan,
"init": cmd_init,
"status": cmd_status,
"done": cmd_done,
"next": cmd_next,
+1 -1
View File
@@ -42,7 +42,7 @@ class TaskChunk:
if self.depends_on:
deps = ", ".join(f"Task {d}" for d in self.depends_on)
lines.append(f"## Prerequisites")
lines.append("## Prerequisites")
lines.append(f"This task depends on: {deps}. Those changes should already be committed.")
lines.append("")
+291
View File
@@ -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}")