trashclaw / plugins /code_stats.py
elyanlabs's picture
Source snapshot of github.com/Scottcjn/trashclaw@6dce9ed
53b2ac9 verified
Raw History Blame Contribute Delete
5.8 kB
# SPDX-License-Identifier: MIT
"""
Code Stats Plugin for TrashClaw
Analyze a codebase directory and return statistics:
lines of code, file counts by language, largest files,
TODO/FIXME counts, and complexity indicators.
Useful for understanding a project before diving in.
Zero external dependencies — pure stdlib.
Plugin contract:
TOOL_DEF = dict with name, description, parameters (OpenAI function schema)
run(**kwargs) -> str (called with the parsed arguments)
"""
import os
import re
from collections import defaultdict
TOOL_DEF = {
"name": "code_stats",
"description": "Analyze a codebase directory: lines of code by language, file counts, largest files, TODO/FIXME counts. Useful for understanding a project's scope before working on it.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory to analyze (default: current directory)"
},
"top_n": {
"type": "integer",
"description": "Number of largest files to show (default: 10)"
}
},
"required": []
}
}
# Extension -> language mapping
LANG_MAP = {
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
".tsx": "TypeScript (JSX)", ".jsx": "JavaScript (JSX)",
".rs": "Rust", ".go": "Go", ".rb": "Ruby", ".java": "Java",
".c": "C", ".h": "C Header", ".cpp": "C++", ".hpp": "C++ Header",
".cs": "C#", ".php": "PHP", ".swift": "Swift", ".kt": "Kotlin",
".lua": "Lua", ".sh": "Shell", ".bash": "Shell", ".zsh": "Shell",
".sql": "SQL", ".html": "HTML", ".css": "CSS", ".scss": "SCSS",
".md": "Markdown", ".json": "JSON", ".yaml": "YAML", ".yml": "YAML",
".toml": "TOML", ".xml": "XML", ".r": "R", ".jl": "Julia",
".pl": "Perl", ".ex": "Elixir", ".exs": "Elixir",
".zig": "Zig", ".nim": "Nim", ".v": "V", ".dart": "Dart",
}
SKIP_DIRS = {
".git", "node_modules", "__pycache__", ".venv", "venv",
"target", "build", "dist", ".next", ".cache", "vendor",
".tox", ".mypy_cache", ".pytest_cache", "coverage",
}
SKIP_EXTENSIONS = {".min.js", ".min.css", ".map", ".lock"}
def _is_binary(filepath, sample_size=8192):
"""Quick check if a file is binary."""
try:
with open(filepath, "rb") as f:
chunk = f.read(sample_size)
if b"\x00" in chunk:
return True
except Exception:
return True
return False
def run(path: str = ".", top_n: int = 10, **kwargs) -> str:
"""Analyze codebase and return formatted statistics."""
path = os.path.expanduser(path)
if not os.path.isabs(path):
path = os.path.abspath(path)
if not os.path.isdir(path):
return f"Error: Not a directory: {path}"
lang_lines = defaultdict(int)
lang_files = defaultdict(int)
file_sizes = [] # (lines, path)
total_files = 0
total_lines = 0
todo_count = 0
fixme_count = 0
hack_count = 0
todo_pattern = re.compile(r"\bTODO\b", re.IGNORECASE)
fixme_pattern = re.compile(r"\bFIXME\b", re.IGNORECASE)
hack_pattern = re.compile(r"\bHACK\b", re.IGNORECASE)
for root, dirs, files in os.walk(path):
# Skip hidden and vendor dirs
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
for fname in files:
filepath = os.path.join(root, fname)
_, ext = os.path.splitext(fname)
if ext in SKIP_EXTENSIONS or any(
fname.endswith(skip_ext) for skip_ext in SKIP_EXTENSIONS
):
continue
lang = LANG_MAP.get(ext)
if not lang:
continue
if _is_binary(filepath):
continue
try:
with open(filepath, "r", errors="replace") as f:
lines = f.readlines()
except Exception:
continue
line_count = len(lines)
total_files += 1
total_lines += line_count
lang_lines[lang] += line_count
lang_files[lang] += 1
rel_path = os.path.relpath(filepath, path)
file_sizes.append((line_count, rel_path))
# Count markers
for line in lines:
if todo_pattern.search(line):
todo_count += 1
if fixme_pattern.search(line):
fixme_count += 1
if hack_pattern.search(line):
hack_count += 1
if total_files == 0:
return f"No recognized source files found in {path}"
# Build output
out = []
out.append(f"=== Code Stats: {os.path.basename(path)} ===\n")
out.append(f"Total: {total_files} files, {total_lines:,} lines\n")
# Language breakdown (sorted by lines)
out.append("Language Breakdown:")
sorted_langs = sorted(lang_lines.items(), key=lambda x: -x[1])
for lang, lines in sorted_langs:
pct = (lines / total_lines) * 100 if total_lines else 0
bar = "█" * int(pct / 3)
out.append(f" {lang:<20} {lines:>8,} lines ({lang_files[lang]} files) {bar} {pct:.1f}%")
# Largest files
out.append(f"\nTop {top_n} Largest Files:")
file_sizes.sort(key=lambda x: -x[0])
for lines, fpath in file_sizes[:top_n]:
out.append(f" {lines:>6,} lines {fpath}")
# Markers
markers_total = todo_count + fixme_count + hack_count
if markers_total > 0:
out.append(f"\nCode Markers:")
if todo_count:
out.append(f" TODO: {todo_count}")
if fixme_count:
out.append(f" FIXME: {fixme_count}")
if hack_count:
out.append(f" HACK: {hack_count}")
return "\n".join(out)