/** * pi-metrics — Code Metrics & Health * Cyclomatic complexity, lines of code, file composition, tech debt indicators. * * /metrics scan → full code metrics report * /metrics loc → lines of code by language * /metrics complexity → cyclomatic complexity for a file * /metrics largest → largest files */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join, extname, relative } from "node:path"; const RST = "\x1b[0m", B = "\x1b[1m", D = "\x1b[2m"; const G = "\x1b[32m", R = "\x1b[31m", Y = "\x1b[33m", C = "\x1b[36m"; const LANG_MAP: Record = { ".ts": "TypeScript", ".tsx": "TypeScript", ".js": "JavaScript", ".jsx": "JavaScript", ".py": "Python", ".go": "Go", ".rs": "Rust", ".java": "Java", ".rb": "Ruby", ".php": "PHP", ".c": "C", ".cpp": "C++", ".h": "C/C++ Header", ".cs": "C#", ".swift": "Swift", ".kt": "Kotlin", ".scala": "Scala", ".css": "CSS", ".scss": "SCSS", ".html": "HTML", ".vue": "Vue", ".svelte": "Svelte", ".sql": "SQL", ".sh": "Shell", ".bash": "Shell", ".yaml": "YAML", ".yml": "YAML", ".json": "JSON", ".toml": "TOML", ".md": "Markdown", ".mdx": "MDX", }; const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv", "vendor", "target", ".cache"]); interface FileMetrics { path: string; lang: string; lines: number; codeLines: number; blankLines: number; commentLines: number; complexity: number; bytes: number; } interface LangMetrics { lang: string; files: number; lines: number; codeLines: number; commentLines: number; bytes: number; } function analyzeFile(filePath: string, basePath: string): FileMetrics | null { const ext = extname(filePath); const lang = LANG_MAP[ext]; if (!lang) return null; try { const content = readFileSync(filePath, "utf-8"); const lines = content.split("\n"); let codeLines = 0, blankLines = 0, commentLines = 0, complexity = 1; let inBlock = false; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) { blankLines++; continue; } if (inBlock) { commentLines++; if (trimmed.includes("*/")) inBlock = false; continue; } if (trimmed.startsWith("/*")) { commentLines++; inBlock = !trimmed.includes("*/"); continue; } if (trimmed.startsWith("//") || trimmed.startsWith("#") && !trimmed.startsWith("#!")) { commentLines++; continue; } codeLines++; // Cyclomatic complexity: count decision points const decisions = (trimmed.match(/\b(if|else if|elif|for|while|case|catch|&&|\|\||\?)\b/g) || []).length; complexity += decisions; } return { path: relative(basePath, filePath), lang, lines: lines.length, codeLines, blankLines, commentLines, complexity, bytes: statSync(filePath).size, }; } catch { return null; } } function scanDir(dir: string, maxFiles = 5000): FileMetrics[] { const results: FileMetrics[] = []; function walk(d: string) { if (results.length >= maxFiles) return; try { for (const entry of readdirSync(d, { withFileTypes: true })) { if (results.length >= maxFiles) return; const full = join(d, entry.name); if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) walk(full); else if (entry.isFile()) { const m = analyzeFile(full, dir); if (m) results.push(m); } } } catch {} } walk(dir); return results; } function groupByLang(files: FileMetrics[]): LangMetrics[] { const groups: Record = {}; for (const f of files) { if (!groups[f.lang]) groups[f.lang] = { lang: f.lang, files: 0, lines: 0, codeLines: 0, commentLines: 0, bytes: 0 }; const g = groups[f.lang]; g.files++; g.lines += f.lines; g.codeLines += f.codeLines; g.commentLines += f.commentLines; g.bytes += f.bytes; } return Object.values(groups).sort((a, b) => b.codeLines - a.codeLines); } export default function (pi: ExtensionAPI) { pi.registerCommand("metrics", { description: "Code metrics: /metrics scan|loc|complexity|largest", handler: async (args, ctx) => { const parts = (args || "").trim().split(/\s+/); const cmd = parts[0] || "scan"; const target = parts[1] || "."; if (cmd === "scan" || cmd === "loc") { if (!existsSync(target)) return `${R}Not found:${RST} ${target}`; const files = scanDir(target); if (!files.length) return `${Y}No source files found.${RST}`; const langs = groupByLang(files); const totalLines = files.reduce((s, f) => s + f.lines, 0); const totalCode = files.reduce((s, f) => s + f.codeLines, 0); const totalComment = files.reduce((s, f) => s + f.commentLines, 0); const totalBytes = files.reduce((s, f) => s + f.bytes, 0); let out = `${B}${C}📊 Code Metrics${RST} — ${target}\n\n`; out += ` Files: ${G}${files.length}${RST} Lines: ${totalLines.toLocaleString()} Code: ${totalCode.toLocaleString()} Comments: ${totalComment.toLocaleString()}\n`; out += ` Size: ${(totalBytes / 1024 / 1024).toFixed(1)} MB Comment ratio: ${totalCode ? Math.round(totalComment / totalCode * 100) : 0}%\n\n`; out += ` ${B}By Language:${RST}\n`; for (const l of langs) { const pct = Math.round(l.codeLines / totalCode * 100); const bar = "█".repeat(Math.round(pct / 5)) + "░".repeat(20 - Math.round(pct / 5)); out += ` ${l.lang.padEnd(15)} ${bar} ${String(pct).padStart(3)}% ${l.codeLines.toLocaleString().padStart(8)} lines ${String(l.files).padStart(4)} files\n`; } // Tech debt indicators const bigFiles = files.filter(f => f.codeLines > 500).length; const complexFiles = files.filter(f => f.complexity > 30).length; const noComments = files.filter(f => f.codeLines > 50 && f.commentLines === 0).length; if (bigFiles || complexFiles || noComments) { out += `\n ${Y}${B}Tech Debt Indicators:${RST}\n`; if (bigFiles) out += ` ${Y}⚠ ${bigFiles} files > 500 lines${RST}\n`; if (complexFiles) out += ` ${Y}⚠ ${complexFiles} files with complexity > 30${RST}\n`; if (noComments) out += ` ${Y}⚠ ${noComments} files > 50 lines with zero comments${RST}\n`; } return out; } if (cmd === "complexity") { if (!target || !existsSync(target)) return `${Y}Usage:${RST} /metrics complexity `; const m = analyzeFile(target, "."); if (!m) return `${R}Cannot analyze:${RST} ${target}`; const cc = m.complexity; const color = cc <= 10 ? G : cc <= 20 ? Y : R; const rating = cc <= 10 ? "Low" : cc <= 20 ? "Moderate" : cc <= 50 ? "High" : "Very High"; return `${B}${C}📊 Complexity${RST} — ${target}\n\n Cyclomatic: ${color}${B}${cc}${RST} (${rating})\n Lines: ${m.lines} Code: ${m.codeLines} Comments: ${m.commentLines} Blank: ${m.blankLines}`; } if (cmd === "largest") { if (!existsSync(target)) return `${R}Not found:${RST} ${target}`; const files = scanDir(target).sort((a, b) => b.codeLines - a.codeLines).slice(0, 20); let out = `${B}${C}📊 Largest Files${RST}\n\n`; for (const f of files) { const color = f.codeLines > 500 ? R : f.codeLines > 200 ? Y : D; out += ` ${color}${String(f.codeLines).padStart(6)}${RST} lines ${f.path}\n`; } return out; } return `${B}${C}📊 Metrics${RST}\n /metrics scan — full report\n /metrics loc — lines by language\n /metrics complexity — cyclomatic complexity\n /metrics largest — biggest files`; } }); pi.registerTool({ name: "metrics_scan", description: "Scan a project for code metrics: lines by language, complexity, tech debt indicators.", parameters: Type.Object({ path: Type.String({ description: "Project directory" }) }), execute: async (p) => { const files = scanDir(p.path); const langs = groupByLang(files); return JSON.stringify({ files: files.length, languages: langs, techDebt: { bigFiles: files.filter(f => f.codeLines > 500).length, complexFiles: files.filter(f => f.complexity > 30).length, noComments: files.filter(f => f.codeLines > 50 && f.commentLines === 0).length, }}, null, 2); } }); pi.registerTool({ name: "metrics_complexity", description: "Get cyclomatic complexity and line counts for a specific file.", parameters: Type.Object({ path: Type.String({ description: "File path" }) }), execute: async (p) => { const m = analyzeFile(p.path, "."); return m ? JSON.stringify(m, null, 2) : "Cannot analyze file."; } }); }