pi-lens 1.3.6 → 1.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/clients/ast-grep-client.ts +97 -0
- package/clients/complexity-client.test.ts +2 -0
- package/clients/complexity-client.ts +51 -0
- package/index.ts +21 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ Example:
|
|
|
73
73
|
| `/lens-dead-code` | Find unused exports/files/dependencies (requires knip) |
|
|
74
74
|
| `/lens-deps` | Circular dependency scan (requires madge) |
|
|
75
75
|
| `/lens-format [file\|--all]` | Apply Biome formatting |
|
|
76
|
-
| `/lens-
|
|
76
|
+
| `/lens-booboo [path]` | Code review: design smells + complexity metrics |
|
|
77
77
|
|
|
78
78
|
### On-demand tools
|
|
79
79
|
|
|
@@ -200,6 +200,103 @@ export class AstGrepClient {
|
|
|
200
200
|
return { matches: result.matches, applied: apply, error: result.error };
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Find similar functions by comparing normalized AST structure
|
|
205
|
+
*/
|
|
206
|
+
async findSimilarFunctions(dir: string, lang: string = "typescript"): Promise<Array<{ pattern: string; functions: Array<{ name: string; file: string; line: number }> }>> {
|
|
207
|
+
if (!this.isAvailable()) return [];
|
|
208
|
+
|
|
209
|
+
const tmpDir = require("node:os").tmpdir();
|
|
210
|
+
const ts = Date.now();
|
|
211
|
+
const ruleDir = require("node:path").join(tmpDir, `pi-lens-similar-${ts}`);
|
|
212
|
+
const rulesSubdir = require("node:path").join(ruleDir, "rules");
|
|
213
|
+
const ruleFile = require("node:path").join(rulesSubdir, "find-functions.yml");
|
|
214
|
+
const configFile = require("node:path").join(ruleDir, ".sgconfig.yml");
|
|
215
|
+
|
|
216
|
+
require("node:fs").mkdirSync(rulesSubdir, { recursive: true });
|
|
217
|
+
require("node:fs").writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
|
|
218
|
+
require("node:fs").writeFileSync(ruleFile, `id: find-functions
|
|
219
|
+
language: ${lang}
|
|
220
|
+
rule:
|
|
221
|
+
kind: function_declaration
|
|
222
|
+
severity: info
|
|
223
|
+
message: found
|
|
224
|
+
`);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const result = spawnSync("npx", [
|
|
228
|
+
"sg", "scan",
|
|
229
|
+
"--config", configFile,
|
|
230
|
+
"--json",
|
|
231
|
+
dir,
|
|
232
|
+
], {
|
|
233
|
+
encoding: "utf-8",
|
|
234
|
+
timeout: 30000,
|
|
235
|
+
shell: true,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const output = result.stdout || result.stderr || "";
|
|
239
|
+
if (!output.trim()) return [];
|
|
240
|
+
|
|
241
|
+
const items = JSON.parse(output);
|
|
242
|
+
const matches = Array.isArray(items) ? items : [items];
|
|
243
|
+
|
|
244
|
+
// Normalize each function by removing identifiers
|
|
245
|
+
const normalized = new Map<string, Array<{ name: string; file: string; line: number }>>();
|
|
246
|
+
|
|
247
|
+
for (const item of matches) {
|
|
248
|
+
const text = item.text || "";
|
|
249
|
+
const nameMatch = text.match(/function\s+(\w+)/);
|
|
250
|
+
if (!nameMatch || !nameMatch[1]) continue;
|
|
251
|
+
|
|
252
|
+
// Normalize by replacing function name with FN, parameters with P1..Pn, and removing specific values
|
|
253
|
+
let normalizedText = text
|
|
254
|
+
.replace(/function\s+\w+/, "function FN")
|
|
255
|
+
.replace(/\bconst\b|\blet\b|\bvar\b/g, "VAR")
|
|
256
|
+
.replace(/["'].*?["']/g, "STR")
|
|
257
|
+
.replace(/`[^`]*`/g, "TMPL")
|
|
258
|
+
.replace(/\b\d+\b/g, "NUM")
|
|
259
|
+
.replace(/\btrue\b|\bfalse\b/g, "BOOL")
|
|
260
|
+
.replace(/\/\/.*/g, "")
|
|
261
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
262
|
+
.replace(/\s+/g, " ")
|
|
263
|
+
.trim();
|
|
264
|
+
|
|
265
|
+
// Extract just the body structure
|
|
266
|
+
const bodyMatch = normalizedText.match(/\{(.*)\}/);
|
|
267
|
+
const body = bodyMatch ? bodyMatch[1].trim() : normalizedText;
|
|
268
|
+
|
|
269
|
+
// Use first 200 chars as signature
|
|
270
|
+
const signature = body.slice(0, 200);
|
|
271
|
+
|
|
272
|
+
if (!normalized.has(signature)) {
|
|
273
|
+
normalized.set(signature, []);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const line = item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0;
|
|
277
|
+
normalized.get(signature)!.push({
|
|
278
|
+
name: nameMatch[1],
|
|
279
|
+
file: item.file,
|
|
280
|
+
line: line + 1,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Return groups with more than one function
|
|
285
|
+
const result_groups: Array<{ pattern: string; functions: Array<{ name: string; file: string; line: number }> }> = [];
|
|
286
|
+
for (const [pattern, functions] of normalized) {
|
|
287
|
+
if (functions.length > 1) {
|
|
288
|
+
result_groups.push({ pattern, functions });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return result_groups;
|
|
293
|
+
} catch {
|
|
294
|
+
return [];
|
|
295
|
+
} finally {
|
|
296
|
+
try { require("node:fs").rmSync(ruleDir, { recursive: true, force: true }); } catch {}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
203
300
|
/**
|
|
204
301
|
* Scan for exported function names in a directory
|
|
205
302
|
*/
|
|
@@ -238,6 +238,7 @@ function long(): number {
|
|
|
238
238
|
maintainabilityIndex: 75,
|
|
239
239
|
linesOfCode: 100,
|
|
240
240
|
commentLines: 10,
|
|
241
|
+
codeEntropy: 0.5,
|
|
241
242
|
};
|
|
242
243
|
|
|
243
244
|
const formatted = client.formatMetrics(metrics);
|
|
@@ -259,6 +260,7 @@ function long(): number {
|
|
|
259
260
|
maintainabilityIndex: 25,
|
|
260
261
|
linesOfCode: 500,
|
|
261
262
|
commentLines: 10,
|
|
263
|
+
codeEntropy: 0.5,
|
|
262
264
|
};
|
|
263
265
|
|
|
264
266
|
const formatted = client.formatMetrics(metrics);
|
|
@@ -34,6 +34,7 @@ export interface FileComplexity {
|
|
|
34
34
|
maintainabilityIndex: number; // 0-100
|
|
35
35
|
linesOfCode: number;
|
|
36
36
|
commentLines: number;
|
|
37
|
+
codeEntropy: number; // Shannon entropy (0-1, lower = more predictable)
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export interface FunctionMetrics {
|
|
@@ -195,6 +196,9 @@ export class ComplexityClient {
|
|
|
195
196
|
commentLines
|
|
196
197
|
);
|
|
197
198
|
|
|
199
|
+
// Code Entropy (Shannon entropy of code tokens)
|
|
200
|
+
const codeEntropy = this.calculateCodeEntropy(content);
|
|
201
|
+
|
|
198
202
|
return {
|
|
199
203
|
filePath: path.relative(process.cwd(), absolutePath),
|
|
200
204
|
maxNestingDepth,
|
|
@@ -208,6 +212,7 @@ export class ComplexityClient {
|
|
|
208
212
|
maintainabilityIndex: Math.round(maintainabilityIndex * 10) / 10,
|
|
209
213
|
linesOfCode: codeLines,
|
|
210
214
|
commentLines,
|
|
215
|
+
codeEntropy: Math.round(codeEntropy * 1000) / 1000,
|
|
211
216
|
};
|
|
212
217
|
} catch (err: any) {
|
|
213
218
|
this.log(`Analysis error for ${filePath}: ${err.message}`);
|
|
@@ -242,6 +247,13 @@ export class ComplexityClient {
|
|
|
242
247
|
parts.push(` Max nesting: ${metrics.maxNestingDepth} levels (consider extracting)`);
|
|
243
248
|
}
|
|
244
249
|
|
|
250
|
+
// Code entropy (0-1, lower = more predictable)
|
|
251
|
+
if (metrics.codeEntropy > 0.8) {
|
|
252
|
+
parts.push(` Entropy: ${metrics.codeEntropy} (high — code may be unpredictable/AI-generated)`);
|
|
253
|
+
} else if (metrics.codeEntropy < 0.3) {
|
|
254
|
+
parts.push(` Entropy: ${metrics.codeEntropy} (low — repetitive patterns detected)`);
|
|
255
|
+
}
|
|
256
|
+
|
|
245
257
|
// Function length
|
|
246
258
|
if (metrics.maxFunctionLength > 50) {
|
|
247
259
|
parts.push(` Longest function: ${metrics.maxFunctionLength} lines (avg: ${metrics.avgFunctionLength})`);
|
|
@@ -491,6 +503,45 @@ export class ComplexityClient {
|
|
|
491
503
|
return totalOps * Math.log2(uniqueOps);
|
|
492
504
|
}
|
|
493
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Calculate Shannon entropy of code tokens
|
|
508
|
+
* Measures predictability/uniformity of code
|
|
509
|
+
* 0 = completely uniform, 1 = maximum entropy
|
|
510
|
+
*/
|
|
511
|
+
private calculateCodeEntropy(sourceText: string): number {
|
|
512
|
+
// Tokenize by splitting on whitespace and common delimiters
|
|
513
|
+
const tokens = sourceText
|
|
514
|
+
.replace(/\/\/.*/g, "") // Remove single-line comments
|
|
515
|
+
.replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments
|
|
516
|
+
.replace(/["'`][^"'`]*["'`]/g, "STR") // Normalize strings
|
|
517
|
+
.replace(/\b\d+(\.\d+)?\b/g, "NUM") // Normalize numbers
|
|
518
|
+
.split(/[\s\n\r\t,;:()[\]{}=<>!&|+\-*/%^~?]+/)
|
|
519
|
+
.filter(t => t.length > 0);
|
|
520
|
+
|
|
521
|
+
if (tokens.length === 0) return 0;
|
|
522
|
+
|
|
523
|
+
// Count token frequencies
|
|
524
|
+
const freq = new Map<string, number>();
|
|
525
|
+
for (const token of tokens) {
|
|
526
|
+
freq.set(token, (freq.get(token) || 0) + 1);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Calculate Shannon entropy: H = -sum(p * log2(p))
|
|
530
|
+
let entropy = 0;
|
|
531
|
+
for (const count of freq.values()) {
|
|
532
|
+
const p = count / tokens.length;
|
|
533
|
+
if (p > 0) {
|
|
534
|
+
entropy -= p * Math.log2(p);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Normalize to 0-1 range
|
|
539
|
+
const maxEntropy = Math.log2(freq.size);
|
|
540
|
+
if (maxEntropy === 0) return 0;
|
|
541
|
+
|
|
542
|
+
return Math.min(1, entropy / maxEntropy);
|
|
543
|
+
}
|
|
544
|
+
|
|
494
545
|
private isKeyword(text: string): boolean {
|
|
495
546
|
const keywords = new Set([
|
|
496
547
|
"if", "else", "for", "while", "do", "switch", "case", "break", "continue",
|
package/index.ts
CHANGED
|
@@ -215,9 +215,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
215
215
|
},
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
pi.registerCommand("lens-
|
|
218
|
+
pi.registerCommand("lens-booboo", {
|
|
219
219
|
description:
|
|
220
|
-
"Code review: design smells + complexity metrics. Usage: /lens-
|
|
220
|
+
"Code review: design smells + complexity metrics. Usage: /lens-booboo [path]",
|
|
221
221
|
handler: async (args, ctx) => {
|
|
222
222
|
const targetPath = args.trim() || ctx.cwd || process.cwd();
|
|
223
223
|
ctx.ui.notify("🔍 Running code review...", "info");
|
|
@@ -286,7 +286,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
286
286
|
}
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
// Part 2:
|
|
289
|
+
// Part 2: Similar functions (advanced duplicate detection)
|
|
290
|
+
if (astGrepClient.isAvailable()) {
|
|
291
|
+
const similarGroups = await astGrepClient.findSimilarFunctions(targetPath, "typescript");
|
|
292
|
+
if (similarGroups.length > 0) {
|
|
293
|
+
let report = `[Similar Functions] ${similarGroups.length} group(s) of structurally similar functions:\n`;
|
|
294
|
+
for (const group of similarGroups.slice(0, 5)) {
|
|
295
|
+
report += ` Pattern: ${group.functions.map(f => f.name).join(", ")}\n`;
|
|
296
|
+
for (const fn of group.functions) {
|
|
297
|
+
report += ` ${fn.name} (${path.basename(fn.file)}:${fn.line})\n`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (similarGroups.length > 5) {
|
|
301
|
+
report += ` ... and ${similarGroups.length - 5} more groups\n`;
|
|
302
|
+
}
|
|
303
|
+
parts.push(report);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Part 3: Complexity metrics
|
|
290
308
|
const results: import("./clients/complexity-client.js").FileComplexity[] = [];
|
|
291
309
|
|
|
292
310
|
const scanDir = (dir: string) => {
|
package/package.json
CHANGED