/**
* 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.";
}
});
}