pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
451 lines (450 loc) • 15.4 kB
JavaScript
/**
* Complexity Metrics Client for pi-lens
*
* Language-agnostic AST-based code complexity metrics, computed over the shared
* tree-sitter client (#402 — no `typescript` compiler dependency). Supported
* grammars are keyed in LANGUAGE_NODES (JS/TS, Python, Go, Rust today; adding a
* language is one table entry).
*
* Tracks: max nesting depth, function length, cyclomatic + cognitive complexity,
* maintainability index (Halstead-free), LOC/comments, code entropy, and AI-slop
* indicators. These are silent metrics surfaced in the session summary.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { firstChildOfType, parseTreeSitterRoot, resolveTreeSitterLanguage, walk, } from "./tree-sitter-shared.js";
// JS/TS/JSX share one grammar shape.
const JSTS = {
functionLike: new Set([
"function_declaration",
"method_definition",
"function_expression",
"arrow_function",
"generator_function",
"generator_function_declaration",
]),
nesting: new Set([
"if_statement",
"while_statement",
"for_statement",
"for_in_statement",
"switch_statement",
"function_declaration",
"function_expression",
"arrow_function",
"method_definition",
"class_declaration",
"try_statement",
"catch_clause",
]),
decision: new Set([
"if_statement",
"while_statement",
"for_statement",
"for_in_statement",
"switch_case",
"ternary_expression",
]),
cognitive: new Set([
"if_statement",
"while_statement",
"for_statement",
"for_in_statement",
"switch_statement",
"switch_case",
"ternary_expression",
"catch_clause",
]),
logicalOpNodes: new Set(),
logicalBinaryOps: new Set(["&&", "||", "??"]),
tryNode: "try_statement",
nameChildTypes: ["identifier", "property_identifier"],
};
const PYTHON = {
functionLike: new Set(["function_definition", "lambda"]),
nesting: new Set([
"if_statement",
"for_statement",
"while_statement",
"match_statement",
"function_definition",
"class_definition",
"try_statement",
"except_clause",
"elif_clause",
]),
decision: new Set([
"if_statement",
"elif_clause",
"for_statement",
"while_statement",
"case_clause",
"conditional_expression",
]),
cognitive: new Set([
"if_statement",
"elif_clause",
"for_statement",
"while_statement",
"match_statement",
"case_clause",
"conditional_expression",
"except_clause",
]),
logicalOpNodes: new Set(["boolean_operator"]),
logicalBinaryOps: new Set(),
tryNode: "try_statement",
nameChildTypes: ["identifier"],
};
const GO = {
functionLike: new Set([
"function_declaration",
"method_declaration",
"func_literal",
]),
nesting: new Set([
"if_statement",
"for_statement",
"expression_switch_statement",
"type_switch_statement",
"select_statement",
"function_declaration",
"method_declaration",
]),
decision: new Set([
"if_statement",
"for_statement",
"expression_case",
"type_case",
"communication_case",
]),
cognitive: new Set([
"if_statement",
"for_statement",
"expression_switch_statement",
"type_switch_statement",
"select_statement",
"expression_case",
"type_case",
"communication_case",
]),
logicalOpNodes: new Set(),
logicalBinaryOps: new Set(["&&", "||"]),
tryNode: undefined, // Go has no try/catch (error returns)
nameChildTypes: ["identifier", "field_identifier"],
};
const RUST = {
functionLike: new Set(["function_item", "closure_expression"]),
nesting: new Set([
"if_expression",
"while_expression",
"for_expression",
"loop_expression",
"match_expression",
"function_item",
]),
decision: new Set([
"if_expression",
"while_expression",
"for_expression",
"loop_expression",
"match_arm",
]),
cognitive: new Set([
"if_expression",
"while_expression",
"for_expression",
"loop_expression",
"match_expression",
"match_arm",
]),
logicalOpNodes: new Set(),
logicalBinaryOps: new Set(["&&", "||"]),
tryNode: undefined, // Rust uses Result/? — no try/catch
nameChildTypes: ["identifier"],
};
const LANGUAGE_NODES = {
typescript: JSTS,
tsx: JSTS,
javascript: JSTS,
python: PYTHON,
go: GO,
rust: RUST,
};
const COMMENT_TYPES = new Set(["comment", "line_comment", "block_comment"]);
// --- Metric helpers (module-level, node-config-driven) ---
function isLogicalOp(node, nodes) {
if (nodes.logicalOpNodes.has(node.type))
return true;
if (nodes.logicalBinaryOps.size > 0 && node.type === "binary_expression") {
return (node.children ?? []).some((c) => c && nodes.logicalBinaryOps.has(c.type));
}
return false;
}
/** Cyclomatic contribution of a subtree: decision points + logical operators. */
function subtreeCyclomatic(root, nodes) {
let cc = 0;
walk(root, (n) => {
if (nodes.decision.has(n.type))
cc++;
if (isLogicalOp(n, nodes))
cc++;
});
return cc;
}
/** Cognitive complexity (SonarSource-style: base + nesting penalty). */
function subtreeCognitive(node, nesting, nodes) {
let complexity = 0;
if (nodes.cognitive.has(node.type))
complexity += 1 + nesting;
// Labeled break/continue add complexity.
if ((node.type === "break_statement" || node.type === "continue_statement") &&
(node.children ?? []).some((c) => c &&
(c.type === "statement_identifier" ||
c.type === "identifier" ||
c.type === "label_name" ||
c.type === "label"))) {
complexity += 1 + nesting;
}
if (isLogicalOp(node, nodes))
complexity += 1;
const childNesting = nodes.nesting.has(node.type) ? nesting + 1 : nesting;
for (const child of node.children ?? []) {
if (child)
complexity += subtreeCognitive(child, childNesting, nodes);
}
return complexity;
}
function subtreeMaxNesting(node, currentDepth, nodes) {
let maxDepth = currentDepth;
if (nodes.nesting.has(node.type)) {
currentDepth++;
maxDepth = Math.max(maxDepth, currentDepth);
}
for (const child of node.children ?? []) {
if (child) {
maxDepth = Math.max(maxDepth, subtreeMaxNesting(child, currentDepth, nodes));
}
}
return maxDepth;
}
function functionName(fnNode, nodes) {
for (const t of nodes.nameChildTypes) {
const id = firstChildOfType(fnNode, t);
if (id)
return id.text;
}
return undefined;
}
function collectFunctionMetrics(root, nodes) {
const functions = [];
const visit = (node, nestingLevel) => {
if (nodes.functionLike.has(node.type)) {
const startLine = node.startPosition.row;
const endLine = node.endPosition.row;
functions.push({
name: functionName(node, nodes) ?? `<anonymous@L${startLine + 1}>`,
line: startLine + 1,
length: endLine - startLine + 1,
cyclomatic: subtreeCyclomatic(node, nodes),
cognitive: subtreeCognitive(node, nestingLevel, nodes),
nestingDepth: subtreeMaxNesting(node, 0, nodes),
});
}
const newNesting = nodes.nesting.has(node.type)
? nestingLevel + 1
: nestingLevel;
for (const child of node.children ?? []) {
if (child)
visit(child, newNesting);
}
};
visit(root, 0);
return functions;
}
function countTryCatch(root, nodes) {
if (!nodes.tryNode)
return 0;
let count = 0;
walk(root, (n) => {
if (n.type === nodes.tryNode)
count++;
});
return count;
}
function countLines(content, root) {
const lines = content.split(/\r?\n/);
const commentLineSet = new Set();
walk(root, (n) => {
if (COMMENT_TYPES.has(n.type)) {
for (let l = n.startPosition.row; l <= n.endPosition.row; l++) {
commentLineSet.add(l);
}
}
});
const codeLines = lines.filter((line, i) => {
if (line.trim().length === 0)
return false;
if (!commentLineSet.has(i))
return true;
// Line has a comment — keep it only if code remains after stripping it.
const stripped = line
.replace(/\/\/.*$/, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/#.*$/, "")
.trim();
return stripped.length > 0;
}).length;
return { codeLines, commentLines: commentLineSet.size };
}
const AI_COMMENT_PATTERNS = [
/(?:🔍|✅|📝|🔧|🐛|⚠️|🚀|💡|🎯|📌|🏷️|🔑|🏗️|🧪|🗑️|🔄|♻️|📋|🔖|📊|💬|🔥|💎|⭐|🌟|🎨|🛠️)/u,
/(?:\/\/|#)\s*(Initialize|Setup|Clean up|Create|Define|Check if|Handle|Process|Validate|Return|Get|Set|Add|Remove|Update|Fetch)\b/i,
/(?:\/\/|#)\s*(This function|This method|This code|Here we|Now we)\b/i,
/\/\*\*?\s*(Overview|Summary|Description|Example|Usage)\s*\*?\//i,
];
function countAICommentPatterns(sourceText) {
let count = 0;
for (const line of sourceText.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith("//") ||
trimmed.startsWith("/*") ||
trimmed.startsWith("*") ||
trimmed.startsWith("#")) {
for (const pattern of AI_COMMENT_PATTERNS) {
if (pattern.test(line)) {
count++;
break;
}
}
}
}
return count;
}
function calculateCodeEntropy(sourceText) {
const tokens = sourceText
.replace(/\/\/.*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/["'`][^"'`]*["'`]/g, "STR")
.replace(/\b\d+(\.\d+)?\b/g, "NUM")
.split(/[\s\n\r\t,;:()[\]{}=<>!&|+\-*/%^~?]+/)
.filter((t) => t.length > 0);
if (tokens.length === 0)
return 0;
const freq = new Map();
for (const token of tokens)
freq.set(token, (freq.get(token) || 0) + 1);
let entropy = 0;
for (const count of freq.values()) {
const p = count / tokens.length;
if (p > 0)
entropy -= p * Math.log2(p);
}
return entropy;
}
/**
* Maintainability index, Halstead-free variant:
* MI = max(0, (171 - 0.23·Cyclomatic - 16.2·ln(LOC)) · 100/171) + comment bonus.
*/
function calculateMaintainabilityIndex(cyclomatic, loc, comments) {
if (loc === 0)
return 100;
const lnLOC = Math.log(loc);
let mi = ((171 - 0.23 * cyclomatic - 16.2 * lnLOC) * 100) / 171;
const commentBonus = Math.min(10, (comments / loc) * 50);
mi += commentBonus;
return Math.max(0, Math.min(100, mi));
}
function calculateMaxParams(functions) {
// Estimate from average function length (kept from the original heuristic).
return Math.min(10, Math.max(2, Math.round(functions.reduce((a, f) => a + f.length, 0) /
Math.max(1, functions.length) /
5)));
}
function countSingleUseFunctions(functions) {
return functions.filter((f) => f.length < 10 &&
f.cyclomatic <= 2 &&
/^(get|set|check|is|has|validate|format|parse|convert|create|make)/i.test(f.name)).length;
}
// --- Client ---
export class ComplexityClient {
log;
constructor(verbose = false) {
this.log = verbose
? (msg) => console.error(`[complexity] ${msg}`)
: () => { };
}
/** True if the file's grammar has a complexity node mapping. */
isSupportedFile(filePath) {
const languageId = resolveTreeSitterLanguage(filePath);
return Boolean(languageId && languageId in LANGUAGE_NODES);
}
/** Analyze complexity metrics for a file (null if unsupported / unparseable). */
async analyzeFile(filePath) {
const absolutePath = path.resolve(filePath);
const languageId = resolveTreeSitterLanguage(absolutePath);
const nodes = languageId ? LANGUAGE_NODES[languageId] : undefined;
if (!nodes)
return null;
let content;
let root;
try {
if (!fs.existsSync(absolutePath))
return null;
content = fs.readFileSync(absolutePath, "utf-8");
root = await parseTreeSitterRoot(absolutePath, content);
}
catch (err) {
this.log(`Read/parse error for ${filePath}: ${err.message}`);
return null;
}
if (!root)
return null;
try {
return this.computeMetrics(absolutePath, content, root, nodes);
}
catch (err) {
this.log(`Analysis error for ${filePath}: ${err.message}`);
return null;
}
}
computeMetrics(absolutePath, content, root, nodes) {
const { codeLines, commentLines } = countLines(content, root);
const functions = collectFunctionMetrics(root, nodes);
const maxNestingDepth = subtreeMaxNesting(root, 0, nodes);
const cognitive = subtreeCognitive(root, 0, nodes);
const funcStats = this.aggregateFunctionStats(functions);
return {
filePath: path.relative(process.cwd(), absolutePath),
maxNestingDepth,
avgFunctionLength: funcStats.avgLength,
maxFunctionLength: funcStats.maxLength,
functionCount: functions.length,
cyclomaticComplexity: funcStats.avgCyclomatic,
maxCyclomaticComplexity: funcStats.maxCyclomatic,
cognitiveComplexity: cognitive,
maintainabilityIndex: Math.round(calculateMaintainabilityIndex(funcStats.avgCyclomatic, codeLines, commentLines) * 10) / 10,
linesOfCode: codeLines,
commentLines,
codeEntropy: Math.round(calculateCodeEntropy(content) * 100) / 100,
maxParamsInFunction: calculateMaxParams(functions),
aiCommentPatterns: countAICommentPatterns(content),
singleUseFunctions: countSingleUseFunctions(functions),
tryCatchCount: countTryCatch(root, nodes),
};
}
aggregateFunctionStats(functions) {
if (functions.length === 0) {
return { avgLength: 0, maxLength: 0, avgCyclomatic: 1, maxCyclomatic: 1 };
}
const lengths = functions.map((f) => f.length);
const cyclomatics = functions.map((f) => f.cyclomatic);
const sum = (arr) => arr.reduce((a, b) => a + b, 0);
return {
avgLength: Math.round(sum(lengths) / lengths.length),
maxLength: Math.max(...lengths),
avgCyclomatic: Math.max(1, Math.round(sum(cyclomatics) / cyclomatics.length)),
maxCyclomatic: Math.max(1, Math.max(...cyclomatics)),
};
}
}