universal-ast-mapper 0.8.2 → 0.8.6
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 +53 -0
- package/dist/cli.js +136 -19
- package/dist/complexity.js +98 -0
- package/dist/extractors/typescript.js +153 -17
- package/dist/graph-analysis.js +36 -0
- package/dist/index.js +157 -2
- package/dist/unused-params.js +127 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,6 +93,9 @@ ast-map graph <dir> [-o graph.json]
|
|
|
93
93
|
ast-map validate <path> [--max-lines N] [--max-imports N] [--max-exports N]
|
|
94
94
|
ast-map dead <dir>
|
|
95
95
|
ast-map cycles <dir>
|
|
96
|
+
ast-map duplicates <dir> [alias: dupes]
|
|
97
|
+
ast-map complexity <path> [alias: cx] [--min N]
|
|
98
|
+
ast-map unused-params <path> [alias: unused]
|
|
96
99
|
ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
|
|
97
100
|
ast-map deps <file> [--scan <dir>]
|
|
98
101
|
ast-map top <dir> [-n 10]
|
|
@@ -260,6 +263,52 @@ Each cycle is canonicalised to avoid duplicates.
|
|
|
260
263
|
|
|
261
264
|
---
|
|
262
265
|
|
|
266
|
+
### `find_duplicate_symbols`
|
|
267
|
+
Scan a directory → find symbol names exported from **more than one file** (accidental collisions / parallel implementations). Each result lists every file + kind that declares the name.
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"duplicates": [
|
|
272
|
+
{ "symbol": "validate", "count": 2, "locations": [
|
|
273
|
+
{ "file": "src/a.ts", "kind": "function" },
|
|
274
|
+
{ "file": "src/b.ts", "kind": "function" }
|
|
275
|
+
]}
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Params:** `path`
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
### `get_complexity`
|
|
285
|
+
Compute **AST-based cyclomatic complexity** per function/method for a file or directory. Score = `1 + decision points` (if / for / while / case / catch / ternary / `&&` / `||`), with a rating: `low` (≤5), `moderate` (≤10), `high` (≤20), `very-high` (>20). Directory scans also return the highest-complexity **hotspots** across all files.
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
{
|
|
289
|
+
"file": "src/auth.ts",
|
|
290
|
+
"maxComplexity": 12,
|
|
291
|
+
"functions": [
|
|
292
|
+
{ "name": "validate", "complexity": 12, "rating": "high", "startLine": 8, "endLine": 40 }
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
**Params:** `path`
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
### `find_unused_params`
|
|
302
|
+
Scan a file or directory for **named functions/methods with parameters that are never used** in the body. Skips `_`-prefixed params (conventionally intentional), anonymous callbacks, and destructured bindings — and correctly treats object-shorthand (`{ id }`) as a use — to keep false positives near zero.
|
|
303
|
+
|
|
304
|
+
```json
|
|
305
|
+
{ "file": "src/x.ts", "functions": [ { "function": "greet", "line": 3, "unused": ["salutation"] } ] }
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**Params:** `path`
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
263
312
|
### `get_change_impact`
|
|
264
313
|
Given a file + symbol, reverse-traverse the import graph to compute **blast radius**.
|
|
265
314
|
|
|
@@ -482,6 +531,10 @@ src/
|
|
|
482
531
|
|
|
483
532
|
| Version | What changed |
|
|
484
533
|
|---------|--------------|
|
|
534
|
+
| **0.8.6** | **Unused parameter detection** — new `find_unused_params` MCP tool + `ast-map unused-params` (alias `unused`) CLI: named functions whose params are never referenced. Skips `_`-prefixed/destructured/anonymous and treats object-shorthand as a use (low false-positive). Server now 17 tools. |
|
|
535
|
+
| **0.8.5** | **Cyclomatic complexity** — new `get_complexity` MCP tool + `ast-map complexity` (alias `cx`, `--min N`) CLI: per-function AST-based complexity score (`1 + if/for/while/case/catch/ternary/&&/\|\|`) with low/moderate/high/very-high ratings and directory hotspots. Server now 16 tools. |
|
|
536
|
+
| **0.8.4** | **Duplicate symbol detection** — new `find_duplicate_symbols` MCP tool + `ast-map duplicates` (alias `dupes`) CLI command: finds symbol names exported from more than one file, with every file/kind that declares each name. |
|
|
537
|
+
| **0.8.3** | **TSX/React component props** — component symbols now carry extracted prop fields. PascalCase functions/arrows that return JSX or are typed `React.FC<P>`/`FC<P>` get `propsType` (named props type) + `props[]` (name, type, optional), resolved from same-file `interface`/`type` declarations or inline object types. Plus: MCP server now reports its real version from `package.json` (was hardcoded `0.5.3`). |
|
|
485
538
|
| **0.8.2** | **Swift cross-file wiring** — `import <Module>` resolves to that module's files (module = the `Sources/<Module>/` directory, else parent dir), wired into `build_symbol_graph` + `resolve_imports`. System modules (Foundation, UIKit, …) stay external. Completes cross-file graph/resolver support for all four v0.8.0 languages. |
|
|
486
539
|
| **0.8.1** | **Cross-file graph wiring for Kotlin & C/C++** — Kotlin FQCN/package index + C/C++ `#include` resolution (with header↔impl pairing) wired into `build_symbol_graph`, `resolve_imports`, and `get_call_graph`. Fixes a parse-cache rel-path leak (stale `.file` poisoned the cross-lang index → doubled paths) and Kotlin call-graph extraction (`function_declaration` name + field-less `call_expression`). |
|
|
487
540
|
| **0.8.0** | **4 new languages: C · C++ · Kotlin · Swift** — symbol extraction + imports parsing. C++ tracks access_specifier through class bodies. Kotlin handles `package`/`object`/`data class`. Swift handles `class`/`struct`/`enum` (all under `class_declaration`) and `protocol_declaration`. Ruby grammar in tree-sitter-wasms@0.1.13 is unstable — skipped. |
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,9 @@ import { supportedLanguages } from "./registry.js";
|
|
|
9
9
|
import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS } from "./analysis.js";
|
|
10
10
|
import { resolveFileImports } from "./resolver.js";
|
|
11
11
|
import { buildSymbolGraph } from "./graph.js";
|
|
12
|
-
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols } from "./graph-analysis.js";
|
|
12
|
+
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols, findDuplicateSymbols } from "./graph-analysis.js";
|
|
13
|
+
import { computeFileComplexity } from "./complexity.js";
|
|
14
|
+
import { findUnusedParams } from "./unused-params.js";
|
|
13
15
|
import { buildCallGraph } from "./callgraph.js";
|
|
14
16
|
import { searchSymbols } from "./search.js";
|
|
15
17
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
@@ -366,6 +368,121 @@ program
|
|
|
366
368
|
}
|
|
367
369
|
console.log();
|
|
368
370
|
});
|
|
371
|
+
// ─── Command: unused-params ───────────────────────────────────────────────────
|
|
372
|
+
program
|
|
373
|
+
.command("unused-params <path>")
|
|
374
|
+
.alias("unused")
|
|
375
|
+
.description("Find function parameters that are never used in the body")
|
|
376
|
+
.option("--json", "Output as JSON")
|
|
377
|
+
.action(async (inputPath, opts) => {
|
|
378
|
+
const { abs, rel } = resolveArg(inputPath);
|
|
379
|
+
const stat = fs.statSync(abs);
|
|
380
|
+
const results = [];
|
|
381
|
+
if (stat.isDirectory()) {
|
|
382
|
+
const sopts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
383
|
+
for (const file of collectSourceFiles(abs, sopts)) {
|
|
384
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
385
|
+
const r = await findUnusedParams(file, fileRel);
|
|
386
|
+
if (r && r.functions.length > 0)
|
|
387
|
+
results.push(r);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const r = await findUnusedParams(abs, rel);
|
|
392
|
+
if (!r)
|
|
393
|
+
die(`Unsupported file type: ${rel}`);
|
|
394
|
+
if (r.functions.length > 0)
|
|
395
|
+
results.push(r);
|
|
396
|
+
}
|
|
397
|
+
const rows = results.flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })));
|
|
398
|
+
if (opts.json)
|
|
399
|
+
return jsonOut({ path: rel, count: rows.length, functions: rows });
|
|
400
|
+
header(`Unused Parameters — ${rel}`);
|
|
401
|
+
if (rows.length === 0) {
|
|
402
|
+
console.log(indent(green("✓ No unused parameters found.")));
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
table(rows.map((f) => [f.function, yellow(f.unused.join(", ")), f.file]), [["Function", 26], ["Unused params", 28], ["File", 36]]);
|
|
406
|
+
const totalP = rows.reduce((a, f) => a + f.unused.length, 0);
|
|
407
|
+
console.log(`\n ${yellow(`${totalP} unused parameter(s)`)} in ${rows.length} function(s)`);
|
|
408
|
+
}
|
|
409
|
+
console.log();
|
|
410
|
+
});
|
|
411
|
+
// ─── Command: complexity ──────────────────────────────────────────────────────
|
|
412
|
+
program
|
|
413
|
+
.command("complexity <path>")
|
|
414
|
+
.alias("cx")
|
|
415
|
+
.description("Cyclomatic complexity per function (file or directory)")
|
|
416
|
+
.option("--json", "Output as JSON")
|
|
417
|
+
.option("--min <n>", "Only show functions with complexity >= n", (v) => parseInt(v, 10))
|
|
418
|
+
.action(async (inputPath, opts) => {
|
|
419
|
+
const { abs, rel } = resolveArg(inputPath);
|
|
420
|
+
const stat = fs.statSync(abs);
|
|
421
|
+
const min = opts.min ?? 1;
|
|
422
|
+
const fileResults = [];
|
|
423
|
+
if (stat.isDirectory()) {
|
|
424
|
+
const sopts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
425
|
+
for (const file of collectSourceFiles(abs, sopts)) {
|
|
426
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
427
|
+
const fc = await computeFileComplexity(file, fileRel);
|
|
428
|
+
if (fc)
|
|
429
|
+
fileResults.push(fc);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
const fc = await computeFileComplexity(abs, rel);
|
|
434
|
+
if (!fc)
|
|
435
|
+
die(`Unsupported file type: ${rel}`);
|
|
436
|
+
fileResults.push(fc);
|
|
437
|
+
}
|
|
438
|
+
const rows = fileResults
|
|
439
|
+
.flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })))
|
|
440
|
+
.filter((f) => f.complexity >= min)
|
|
441
|
+
.sort((a, b) => b.complexity - a.complexity);
|
|
442
|
+
if (opts.json)
|
|
443
|
+
return jsonOut({ path: rel, functionCount: rows.length, functions: rows });
|
|
444
|
+
header(`Cyclomatic Complexity — ${rel} ${dim(`(${fileResults.length} file(s))`)}`);
|
|
445
|
+
if (rows.length === 0) {
|
|
446
|
+
console.log(indent(green("✓ No functions found.")));
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
const colorFor = (r) => (r === "very-high" || r === "high" ? yellow : r === "moderate" ? bold : dim);
|
|
450
|
+
table(rows.slice(0, 40).map((f) => [String(f.complexity), colorFor(f.rating)(f.rating), f.name, f.file]), [["Cx", 4], ["Rating", 11], ["Function", 26], ["File", 38]]);
|
|
451
|
+
const high = rows.filter((f) => f.complexity > 10).length;
|
|
452
|
+
console.log(`\n ${rows.length} function(s)` + (high > 0 ? ` · ${yellow(`${high} above 10`)}` : ""));
|
|
453
|
+
}
|
|
454
|
+
console.log();
|
|
455
|
+
});
|
|
456
|
+
// ─── Command: duplicates ──────────────────────────────────────────────────────
|
|
457
|
+
program
|
|
458
|
+
.command("duplicates <dir>")
|
|
459
|
+
.alias("dupes")
|
|
460
|
+
.description("Find symbol names exported from more than one file")
|
|
461
|
+
.option("--json", "Output as JSON")
|
|
462
|
+
.action(async (inputPath, opts) => {
|
|
463
|
+
const { abs, rel } = resolveArg(inputPath);
|
|
464
|
+
if (!fs.statSync(abs).isDirectory())
|
|
465
|
+
die(`"${rel}" is not a directory`);
|
|
466
|
+
const skeletons = await gatherSkeletons(abs);
|
|
467
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
468
|
+
const duplicates = findDuplicateSymbols(graph);
|
|
469
|
+
if (opts.json)
|
|
470
|
+
return jsonOut({ directory: rel, scanned: skeletons.length, duplicateCount: duplicates.length, duplicates });
|
|
471
|
+
header(`Duplicate Symbols — ${rel}/ ${dim(`(${skeletons.length} files scanned)`)}`);
|
|
472
|
+
if (duplicates.length === 0) {
|
|
473
|
+
console.log(indent(green("✓ No duplicate exported symbols found.")));
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
for (const d of duplicates) {
|
|
477
|
+
console.log(indent(`${yellow(d.symbol)} ${dim(`— exported from ${d.count} files`)}`));
|
|
478
|
+
for (const loc of d.locations) {
|
|
479
|
+
console.log(indent(`${dim(col(loc.kind, 10))} ${loc.file}`, 5));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
console.log(`\n ${yellow(`${duplicates.length} duplicated name(s)`)}`);
|
|
483
|
+
}
|
|
484
|
+
console.log();
|
|
485
|
+
});
|
|
369
486
|
// ─── Command: cycles ──────────────────────────────────────────────────────────
|
|
370
487
|
program
|
|
371
488
|
.command("cycles <dir>")
|
|
@@ -592,24 +709,24 @@ program
|
|
|
592
709
|
.name("ast-map")
|
|
593
710
|
.description("CLI for universal-ast-mapper — structural code analysis tools")
|
|
594
711
|
.version("0.5.3")
|
|
595
|
-
.addHelpText("after", `
|
|
596
|
-
${bold("Examples:")}
|
|
597
|
-
ast-map langs
|
|
598
|
-
ast-map skeleton src/
|
|
599
|
-
ast-map symbol src/utils.ts sanitize --related
|
|
600
|
-
ast-map imports src/pages/login.tsx
|
|
601
|
-
ast-map graph src/ -o graph.json
|
|
602
|
-
ast-map validate src/
|
|
603
|
-
ast-map dead src/
|
|
604
|
-
ast-map cycles src/
|
|
605
|
-
ast-map search validateSession src/ --exported
|
|
606
|
-
ast-map deps src/lib/auth.ts --scan src/
|
|
607
|
-
ast-map top src/ -n 15
|
|
608
|
-
ast-map impact src/utils.ts sanitize --scan src/
|
|
609
|
-
ast-map calls src/utils.ts buildCallGraph --scan src/
|
|
610
|
-
|
|
611
|
-
${bold("Root:")}
|
|
612
|
-
Defaults to cwd. Override with AST_MAP_ROOT=<path> or run from your project root.
|
|
712
|
+
.addHelpText("after", `
|
|
713
|
+
${bold("Examples:")}
|
|
714
|
+
ast-map langs
|
|
715
|
+
ast-map skeleton src/
|
|
716
|
+
ast-map symbol src/utils.ts sanitize --related
|
|
717
|
+
ast-map imports src/pages/login.tsx
|
|
718
|
+
ast-map graph src/ -o graph.json
|
|
719
|
+
ast-map validate src/
|
|
720
|
+
ast-map dead src/
|
|
721
|
+
ast-map cycles src/
|
|
722
|
+
ast-map search validateSession src/ --exported
|
|
723
|
+
ast-map deps src/lib/auth.ts --scan src/
|
|
724
|
+
ast-map top src/ -n 15
|
|
725
|
+
ast-map impact src/utils.ts sanitize --scan src/
|
|
726
|
+
ast-map calls src/utils.ts buildCallGraph --scan src/
|
|
727
|
+
|
|
728
|
+
${bold("Root:")}
|
|
729
|
+
Defaults to cwd. Override with AST_MAP_ROOT=<path> or run from your project root.
|
|
613
730
|
`);
|
|
614
731
|
program.parseAsync(process.argv).catch(err => {
|
|
615
732
|
console.error(red("Fatal: ") + (err instanceof Error ? err.message : String(err)));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { parseSource } from "./parser.js";
|
|
3
|
+
import { detectLanguage } from "./registry.js";
|
|
4
|
+
import { buildSkeleton } from "./skeleton.js";
|
|
5
|
+
import { resolveOptions } from "./config.js";
|
|
6
|
+
// ─── Decision points ───────────────────────────────────────────────────────────
|
|
7
|
+
/**
|
|
8
|
+
* Node types that introduce a branch (each adds 1 to cyclomatic complexity).
|
|
9
|
+
* This is a deliberately broad cross-language union; languages that use a node
|
|
10
|
+
* type not listed here simply undercount rather than miscount.
|
|
11
|
+
*/
|
|
12
|
+
const DECISION_TYPES = new Set([
|
|
13
|
+
// conditionals
|
|
14
|
+
"if_statement", "if_expression", "elif_clause", "else_if_clause",
|
|
15
|
+
// loops
|
|
16
|
+
"for_statement", "for_in_statement", "for_of_statement", "enhanced_for_statement",
|
|
17
|
+
"for_expression", "while_statement", "while_expression", "do_statement", "loop_statement",
|
|
18
|
+
// switch / match arms (the default/else arm is intentionally excluded)
|
|
19
|
+
"switch_case", "expression_case", "type_case", "case_clause", "when_entry",
|
|
20
|
+
"when_clause", "match_arm", "case_statement",
|
|
21
|
+
// exception handlers
|
|
22
|
+
"catch_clause", "except_clause", "rescue_clause",
|
|
23
|
+
// ternary
|
|
24
|
+
"ternary_expression", "conditional_expression",
|
|
25
|
+
// python `and` / `or`
|
|
26
|
+
"boolean_operator",
|
|
27
|
+
]);
|
|
28
|
+
const FN_KINDS = new Set(["function", "method"]);
|
|
29
|
+
function rate(c) {
|
|
30
|
+
if (c <= 5)
|
|
31
|
+
return "low";
|
|
32
|
+
if (c <= 10)
|
|
33
|
+
return "moderate";
|
|
34
|
+
if (c <= 20)
|
|
35
|
+
return "high";
|
|
36
|
+
return "very-high";
|
|
37
|
+
}
|
|
38
|
+
/** Collect the start line of every decision point in the tree. */
|
|
39
|
+
function collectDecisionLines(node, out) {
|
|
40
|
+
const t = node.type;
|
|
41
|
+
if (DECISION_TYPES.has(t)) {
|
|
42
|
+
out.push(node.startPosition.row + 1);
|
|
43
|
+
}
|
|
44
|
+
else if (t === "binary_expression") {
|
|
45
|
+
// Short-circuit operators add a branch; arithmetic operators do not.
|
|
46
|
+
const op = node.childForFieldName("operator");
|
|
47
|
+
if (op && (op.text === "&&" || op.text === "||"))
|
|
48
|
+
out.push(node.startPosition.row + 1);
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
51
|
+
const c = node.namedChild(i);
|
|
52
|
+
if (c)
|
|
53
|
+
collectDecisionLines(c, out);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function flatten(symbols, acc = []) {
|
|
57
|
+
for (const s of symbols) {
|
|
58
|
+
acc.push(s);
|
|
59
|
+
flatten(s.children, acc);
|
|
60
|
+
}
|
|
61
|
+
return acc;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Compute cyclomatic complexity for every function/method in a file.
|
|
65
|
+
* Complexity is attributed by line range, so a function's score includes the
|
|
66
|
+
* control flow of any closures/nested functions declared inside it.
|
|
67
|
+
*/
|
|
68
|
+
export async function computeFileComplexity(absPath, relPath) {
|
|
69
|
+
const lang = detectLanguage(absPath);
|
|
70
|
+
if (!lang)
|
|
71
|
+
return null;
|
|
72
|
+
const source = fs.readFileSync(absPath, "utf8");
|
|
73
|
+
const root = await parseSource(lang.grammar, source);
|
|
74
|
+
const decisionLines = [];
|
|
75
|
+
collectDecisionLines(root, decisionLines);
|
|
76
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
77
|
+
const skel = await buildSkeleton(absPath, relPath, opts);
|
|
78
|
+
const functions = flatten(skel.symbols)
|
|
79
|
+
.filter((s) => FN_KINDS.has(s.kind))
|
|
80
|
+
.map((s) => {
|
|
81
|
+
const count = decisionLines.filter((l) => l >= s.range.startLine && l <= s.range.endLine).length;
|
|
82
|
+
const complexity = 1 + count;
|
|
83
|
+
return {
|
|
84
|
+
name: s.name,
|
|
85
|
+
kind: s.kind,
|
|
86
|
+
startLine: s.range.startLine,
|
|
87
|
+
endLine: s.range.endLine,
|
|
88
|
+
complexity,
|
|
89
|
+
rating: rate(complexity),
|
|
90
|
+
};
|
|
91
|
+
})
|
|
92
|
+
.sort((a, b) => b.complexity - a.complexity);
|
|
93
|
+
const maxComplexity = functions.reduce((m, f) => Math.max(m, f.complexity), 0);
|
|
94
|
+
const averageComplexity = functions.length === 0
|
|
95
|
+
? 0
|
|
96
|
+
: Math.round((functions.reduce((s, f) => s + f.complexity, 0) / functions.length) * 10) / 10;
|
|
97
|
+
return { file: skel.file, functions, maxComplexity, averageComplexity };
|
|
98
|
+
}
|
|
@@ -22,17 +22,14 @@ export function extractDirectivesTS(root, _source) {
|
|
|
22
22
|
}
|
|
23
23
|
return directives;
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
26
|
-
* Extractor shared by TypeScript, TSX and JavaScript.
|
|
27
|
-
* TS-only node types (interface/type/enum) simply never appear in JS sources.
|
|
28
|
-
*/
|
|
29
25
|
export function extractTypeScript(root, _source) {
|
|
30
|
-
|
|
26
|
+
const typeIndex = buildTypeIndex(root);
|
|
27
|
+
return collect(namedChildren(root), false, typeIndex);
|
|
31
28
|
}
|
|
32
|
-
function collect(nodes, exported) {
|
|
29
|
+
function collect(nodes, exported, typeIndex) {
|
|
33
30
|
const out = [];
|
|
34
31
|
for (const n of nodes) {
|
|
35
|
-
const res = handle(n, exported);
|
|
32
|
+
const res = handle(n, exported, typeIndex);
|
|
36
33
|
if (Array.isArray(res))
|
|
37
34
|
out.push(...res);
|
|
38
35
|
else if (res)
|
|
@@ -40,16 +37,16 @@ function collect(nodes, exported) {
|
|
|
40
37
|
}
|
|
41
38
|
return out;
|
|
42
39
|
}
|
|
43
|
-
function handle(node, exported) {
|
|
40
|
+
function handle(node, exported, typeIndex) {
|
|
44
41
|
switch (node.type) {
|
|
45
42
|
case "export_statement":
|
|
46
43
|
// `export <decl>` / `export default <decl>` — mark the inner declarations exported.
|
|
47
|
-
return collect(namedChildren(node), true);
|
|
44
|
+
return collect(namedChildren(node), true, typeIndex);
|
|
48
45
|
case "class_declaration":
|
|
49
46
|
case "abstract_class_declaration": {
|
|
50
47
|
const name = nameOf(node) ?? "(anonymous class)";
|
|
51
48
|
const body = node.childForFieldName("body");
|
|
52
|
-
const children = body ? collect(namedChildren(body), false) : [];
|
|
49
|
+
const children = body ? collect(namedChildren(body), false, typeIndex) : [];
|
|
53
50
|
return makeSymbol({
|
|
54
51
|
name,
|
|
55
52
|
kind: "class",
|
|
@@ -63,7 +60,7 @@ function handle(node, exported) {
|
|
|
63
60
|
case "interface_declaration": {
|
|
64
61
|
const name = nameOf(node) ?? "(anonymous interface)";
|
|
65
62
|
const body = node.childForFieldName("body");
|
|
66
|
-
const children = body ? collect(namedChildren(body), false) : [];
|
|
63
|
+
const children = body ? collect(namedChildren(body), false, typeIndex) : [];
|
|
67
64
|
return makeSymbol({
|
|
68
65
|
name,
|
|
69
66
|
kind: "interface",
|
|
@@ -78,7 +75,7 @@ function handle(node, exported) {
|
|
|
78
75
|
case "generator_function_declaration": {
|
|
79
76
|
const name = nameOf(node) ?? "(anonymous function)";
|
|
80
77
|
const body = node.childForFieldName("body");
|
|
81
|
-
|
|
78
|
+
const fnSym = makeSymbol({
|
|
82
79
|
name,
|
|
83
80
|
kind: "function",
|
|
84
81
|
node,
|
|
@@ -87,6 +84,8 @@ function handle(node, exported) {
|
|
|
87
84
|
exported,
|
|
88
85
|
doc: leadingComment(node),
|
|
89
86
|
});
|
|
87
|
+
attachComponentInfo(fnSym, node, null, name, typeIndex);
|
|
88
|
+
return fnSym;
|
|
90
89
|
}
|
|
91
90
|
case "type_alias_declaration":
|
|
92
91
|
return makeSymbol({
|
|
@@ -109,7 +108,7 @@ function handle(node, exported) {
|
|
|
109
108
|
});
|
|
110
109
|
case "lexical_declaration":
|
|
111
110
|
case "variable_declaration":
|
|
112
|
-
return fromVariableDeclaration(node, exported);
|
|
111
|
+
return fromVariableDeclaration(node, exported, typeIndex);
|
|
113
112
|
case "method_definition":
|
|
114
113
|
case "method_signature":
|
|
115
114
|
case "abstract_method_signature": {
|
|
@@ -148,7 +147,7 @@ function handle(node, exported) {
|
|
|
148
147
|
return null;
|
|
149
148
|
}
|
|
150
149
|
}
|
|
151
|
-
function fromVariableDeclaration(node, exported) {
|
|
150
|
+
function fromVariableDeclaration(node, exported, typeIndex) {
|
|
152
151
|
const out = [];
|
|
153
152
|
for (const decl of namedChildren(node)) {
|
|
154
153
|
if (decl.type !== "variable_declarator")
|
|
@@ -159,7 +158,7 @@ function fromVariableDeclaration(node, exported) {
|
|
|
159
158
|
continue;
|
|
160
159
|
if (value && (value.type === "arrow_function" || value.type === "function" || value.type === "function_expression")) {
|
|
161
160
|
const body = value.childForFieldName("body");
|
|
162
|
-
|
|
161
|
+
const arrowSym = makeSymbol({
|
|
163
162
|
name,
|
|
164
163
|
kind: "function",
|
|
165
164
|
node: decl,
|
|
@@ -167,12 +166,14 @@ function fromVariableDeclaration(node, exported) {
|
|
|
167
166
|
signature: headerSignature(value, body),
|
|
168
167
|
exported,
|
|
169
168
|
doc: leadingComment(node),
|
|
170
|
-
})
|
|
169
|
+
});
|
|
170
|
+
attachComponentInfo(arrowSym, value, decl, name, typeIndex);
|
|
171
|
+
out.push(arrowSym);
|
|
171
172
|
}
|
|
172
173
|
else if (value && (value.type === "class_expression" || value.type === "class")) {
|
|
173
174
|
// const MyClass = class { ... }
|
|
174
175
|
const body = value.childForFieldName("body");
|
|
175
|
-
const children = body ? collect(namedChildren(body), false) : [];
|
|
176
|
+
const children = body ? collect(namedChildren(body), false, typeIndex) : [];
|
|
176
177
|
out.push(makeSymbol({
|
|
177
178
|
name,
|
|
178
179
|
kind: "class",
|
|
@@ -318,3 +319,138 @@ function memberVisibility(node) {
|
|
|
318
319
|
return "private";
|
|
319
320
|
return "public";
|
|
320
321
|
}
|
|
322
|
+
// ─── React/TSX component prop extraction ──────────────────────────────────────
|
|
323
|
+
const JSX_NODES = new Set(["jsx_element", "jsx_self_closing_element", "jsx_fragment"]);
|
|
324
|
+
function firstNamed(node) {
|
|
325
|
+
return node.namedChildCount > 0 ? node.namedChild(0) : null;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Index every top-level (and exported) interface / object-type alias by name,
|
|
329
|
+
* mapping it to its prop fields. Used to resolve a component's named props type
|
|
330
|
+
* (e.g. `ButtonProps`) back to its individual props.
|
|
331
|
+
*/
|
|
332
|
+
function buildTypeIndex(root) {
|
|
333
|
+
const idx = new Map();
|
|
334
|
+
const visit = (nodes) => {
|
|
335
|
+
for (const n of nodes) {
|
|
336
|
+
if (n.type === "export_statement") {
|
|
337
|
+
visit(namedChildren(n));
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (n.type === "interface_declaration") {
|
|
341
|
+
const name = nameOf(n);
|
|
342
|
+
const body = n.childForFieldName("body");
|
|
343
|
+
if (name && body)
|
|
344
|
+
idx.set(name, propsFromMembers(body));
|
|
345
|
+
}
|
|
346
|
+
else if (n.type === "type_alias_declaration") {
|
|
347
|
+
const name = nameOf(n);
|
|
348
|
+
const val = n.childForFieldName("value");
|
|
349
|
+
if (name && val && val.type === "object_type")
|
|
350
|
+
idx.set(name, propsFromMembers(val));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
visit(namedChildren(root));
|
|
355
|
+
return idx;
|
|
356
|
+
}
|
|
357
|
+
/** Read `property_signature` members out of an interface_body / object_type. */
|
|
358
|
+
function propsFromMembers(container) {
|
|
359
|
+
const props = [];
|
|
360
|
+
for (const m of namedChildren(container)) {
|
|
361
|
+
if (m.type !== "property_signature")
|
|
362
|
+
continue;
|
|
363
|
+
const nameNode = m.childForFieldName("name");
|
|
364
|
+
if (!nameNode)
|
|
365
|
+
continue;
|
|
366
|
+
const info = { name: nameNode.text };
|
|
367
|
+
const typeAnn = m.childForFieldName("type");
|
|
368
|
+
const typeNode = typeAnn ? firstNamed(typeAnn) : null;
|
|
369
|
+
if (typeNode)
|
|
370
|
+
info.type = typeNode.text.replace(/\s+/g, " ").trim();
|
|
371
|
+
const colon = m.text.indexOf(":");
|
|
372
|
+
const head = colon >= 0 ? m.text.slice(0, colon) : m.text;
|
|
373
|
+
if (head.includes("?"))
|
|
374
|
+
info.optional = true;
|
|
375
|
+
props.push(info);
|
|
376
|
+
}
|
|
377
|
+
return props;
|
|
378
|
+
}
|
|
379
|
+
/** Walk a function body looking for any JSX node (marks it a React component). */
|
|
380
|
+
function returnsJSX(node) {
|
|
381
|
+
if (!node)
|
|
382
|
+
return false;
|
|
383
|
+
let found = false;
|
|
384
|
+
const walk = (n) => {
|
|
385
|
+
if (found)
|
|
386
|
+
return;
|
|
387
|
+
if (JSX_NODES.has(n.type)) {
|
|
388
|
+
found = true;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
for (let i = 0; i < n.namedChildCount; i++) {
|
|
392
|
+
const c = n.namedChild(i);
|
|
393
|
+
if (c)
|
|
394
|
+
walk(c);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
walk(node);
|
|
398
|
+
return found;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* If `typeNode` is `FC<P>` / `React.FC<P>` / `FunctionComponent<P>` (or the
|
|
402
|
+
* React-qualified form), return the first type argument node (the props type).
|
|
403
|
+
*/
|
|
404
|
+
function fcTypeArgument(typeNode) {
|
|
405
|
+
if (!typeNode || typeNode.type !== "generic_type")
|
|
406
|
+
return null;
|
|
407
|
+
const base = typeNode.childForFieldName("name");
|
|
408
|
+
const baseText = base ? base.text : "";
|
|
409
|
+
if (!/(^|\.)(FC|FunctionComponent)$/.test(baseText))
|
|
410
|
+
return null;
|
|
411
|
+
for (let i = 0; i < typeNode.namedChildCount; i++) {
|
|
412
|
+
const c = typeNode.namedChild(i);
|
|
413
|
+
if (c && c.type === "type_arguments")
|
|
414
|
+
return firstNamed(c);
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Detect a React component (PascalCase + returns JSX, or typed as FC) and
|
|
420
|
+
* attach its props. `funcNode` is the function/arrow; `declNode` is the
|
|
421
|
+
* variable_declarator when the component is `const X: React.FC<P> = ...`.
|
|
422
|
+
*/
|
|
423
|
+
function attachComponentInfo(sym, funcNode, declNode, name, idx) {
|
|
424
|
+
if (!/^[A-Z]/.test(name))
|
|
425
|
+
return; // components are PascalCase
|
|
426
|
+
let propsTypeNode = null;
|
|
427
|
+
let fc = false;
|
|
428
|
+
if (declNode) {
|
|
429
|
+
const ta = declNode.childForFieldName("type");
|
|
430
|
+
const arg = ta ? fcTypeArgument(firstNamed(ta)) : null;
|
|
431
|
+
if (arg) {
|
|
432
|
+
propsTypeNode = arg;
|
|
433
|
+
fc = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (!fc && !returnsJSX(funcNode.childForFieldName("body")))
|
|
437
|
+
return; // not a component
|
|
438
|
+
if (!propsTypeNode) {
|
|
439
|
+
const params = funcNode.childForFieldName("parameters");
|
|
440
|
+
const first = params ? firstNamed(params) : null; // required/optional_parameter
|
|
441
|
+
const ta = first ? first.childForFieldName("type") : null;
|
|
442
|
+
if (ta)
|
|
443
|
+
propsTypeNode = firstNamed(ta);
|
|
444
|
+
}
|
|
445
|
+
if (!propsTypeNode)
|
|
446
|
+
return; // component, but untyped props — nothing to extract
|
|
447
|
+
if (propsTypeNode.type === "object_type") {
|
|
448
|
+
sym.props = propsFromMembers(propsTypeNode);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const typeName = propsTypeNode.text.replace(/\s+/g, " ").trim();
|
|
452
|
+
sym.propsType = typeName;
|
|
453
|
+
const resolved = idx.get(typeName);
|
|
454
|
+
if (resolved)
|
|
455
|
+
sym.props = resolved;
|
|
456
|
+
}
|
package/dist/graph-analysis.js
CHANGED
|
@@ -241,3 +241,39 @@ export function getTopSymbols(graph, limit = 10) {
|
|
|
241
241
|
}
|
|
242
242
|
return results.sort((a, b) => b.importCount - a.importCount).slice(0, limit);
|
|
243
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Find symbol names that are exported from more than one file. These are often
|
|
246
|
+
* accidental collisions (copy-paste, parallel implementations) that make a
|
|
247
|
+
* codebase harder to navigate and can cause the wrong import to be auto-suggested.
|
|
248
|
+
*
|
|
249
|
+
* Only exported symbols are considered, and a name must appear in at least two
|
|
250
|
+
* distinct files to count as a duplicate.
|
|
251
|
+
*/
|
|
252
|
+
export function findDuplicateSymbols(graph) {
|
|
253
|
+
const byName = new Map();
|
|
254
|
+
for (const node of graph.nodes) {
|
|
255
|
+
if (node.nodeType !== "symbol")
|
|
256
|
+
continue;
|
|
257
|
+
const sym = node;
|
|
258
|
+
if (!sym.exported)
|
|
259
|
+
continue;
|
|
260
|
+
const arr = byName.get(sym.symbol) ?? [];
|
|
261
|
+
arr.push(sym);
|
|
262
|
+
byName.set(sym.symbol, arr);
|
|
263
|
+
}
|
|
264
|
+
const out = [];
|
|
265
|
+
for (const [name, syms] of byName) {
|
|
266
|
+
// Collapse to one location per file (a file may declare the name once).
|
|
267
|
+
const perFile = new Map();
|
|
268
|
+
for (const s of syms)
|
|
269
|
+
if (!perFile.has(s.file))
|
|
270
|
+
perFile.set(s.file, s);
|
|
271
|
+
if (perFile.size < 2)
|
|
272
|
+
continue;
|
|
273
|
+
const locations = [...perFile.values()]
|
|
274
|
+
.map((s) => ({ file: s.file, kind: s.kind, nodeId: s.id }))
|
|
275
|
+
.sort((a, b) => a.file.localeCompare(b.file));
|
|
276
|
+
out.push({ symbol: name, count: perFile.size, locations });
|
|
277
|
+
}
|
|
278
|
+
return out.sort((a, b) => b.count - a.count || a.symbol.localeCompare(b.symbol));
|
|
279
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { resolveOptions, loadProjectConfig } from "./config.js";
|
|
8
9
|
import { buildSkeleton, collectSourceFiles, UnsupportedLanguageError, } from "./skeleton.js";
|
|
9
10
|
import { renderHtml, renderCombinedHtml } from "./html.js";
|
|
@@ -11,9 +12,11 @@ import { supportedLanguages } from "./registry.js";
|
|
|
11
12
|
import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMissingTryCatch, checkGeneralRules, GENERAL_RULE_DEFAULTS, } from "./analysis.js";
|
|
12
13
|
import { resolveFileImports } from "./resolver.js";
|
|
13
14
|
import { buildSymbolGraph } from "./graph.js";
|
|
14
|
-
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols } from "./graph-analysis.js";
|
|
15
|
+
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols, findDuplicateSymbols } from "./graph-analysis.js";
|
|
15
16
|
import { buildCallGraph } from "./callgraph.js";
|
|
16
17
|
import { searchSymbols } from "./search.js";
|
|
18
|
+
import { computeFileComplexity } from "./complexity.js";
|
|
19
|
+
import { findUnusedParams } from "./unused-params.js";
|
|
17
20
|
/** Files may only be read inside this root (override with AST_MAP_ROOT). */
|
|
18
21
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
19
22
|
function resolveInRoot(input) {
|
|
@@ -44,9 +47,19 @@ function errorText(message) {
|
|
|
44
47
|
content: [{ type: "text", text: message }],
|
|
45
48
|
};
|
|
46
49
|
}
|
|
50
|
+
/** Read the package version at runtime so it never drifts from package.json. */
|
|
51
|
+
const PKG_VERSION = (() => {
|
|
52
|
+
try {
|
|
53
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "..", "package.json"), "utf8")).version;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return "0.0.0";
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
47
60
|
const server = new McpServer({
|
|
48
61
|
name: "universal-ast-mapper",
|
|
49
|
-
version:
|
|
62
|
+
version: PKG_VERSION,
|
|
50
63
|
});
|
|
51
64
|
/* ----------------------- tool: list_supported_languages ----------------------- */
|
|
52
65
|
server.registerTool("list_supported_languages", {
|
|
@@ -512,6 +525,148 @@ server.registerTool("find_circular_deps", {
|
|
|
512
525
|
return errorText(describeError(err));
|
|
513
526
|
}
|
|
514
527
|
});
|
|
528
|
+
/* ─────────────────── tool: find_duplicate_symbols ──────────────────────── */
|
|
529
|
+
server.registerTool("find_duplicate_symbols", {
|
|
530
|
+
title: "Find duplicate exported symbols",
|
|
531
|
+
description: "Scan a directory and return symbol names that are exported from more than one file. " +
|
|
532
|
+
"These are often accidental collisions (copy-paste, parallel implementations) that make " +
|
|
533
|
+
"a codebase harder to navigate. Each result lists every file/kind that declares the name.",
|
|
534
|
+
inputSchema: {
|
|
535
|
+
path: z
|
|
536
|
+
.string()
|
|
537
|
+
.describe("Directory to scan, relative to project root or absolute within it."),
|
|
538
|
+
},
|
|
539
|
+
}, async ({ path: input }) => {
|
|
540
|
+
try {
|
|
541
|
+
const { abs, rel } = resolveInRoot(input);
|
|
542
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
543
|
+
return errorText(`"${input}" is not a directory. find_duplicate_symbols requires a directory.`);
|
|
544
|
+
}
|
|
545
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
546
|
+
const files = collectSourceFiles(abs, opts);
|
|
547
|
+
const skeletons = [];
|
|
548
|
+
const errors = [];
|
|
549
|
+
for (const file of files) {
|
|
550
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
551
|
+
try {
|
|
552
|
+
skeletons.push(await buildSkeleton(file, fileRel, opts));
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
errors.push({ file: fileRel, error: describeError(err) });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const graph = buildSymbolGraph(skeletons, ROOT);
|
|
559
|
+
const duplicates = findDuplicateSymbols(graph);
|
|
560
|
+
return jsonText({
|
|
561
|
+
directory: rel.split(path.sep).join("/"),
|
|
562
|
+
scanned: files.length,
|
|
563
|
+
duplicateCount: duplicates.length,
|
|
564
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
565
|
+
duplicates,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
return errorText(describeError(err));
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
/* ─────────────────── tool: get_complexity ──────────────────────────────── */
|
|
573
|
+
server.registerTool("get_complexity", {
|
|
574
|
+
title: "Get cyclomatic complexity per function",
|
|
575
|
+
description: "Compute AST-based cyclomatic complexity for every function/method in a FILE or DIRECTORY. " +
|
|
576
|
+
"Each function gets a score (1 + decision points: if / for / while / case / catch / ternary / && / ||) " +
|
|
577
|
+
"and a rating (low <=5, moderate <=10, high <=20, very-high >20). For a directory, returns per-file " +
|
|
578
|
+
"results plus the highest-complexity hotspots across the scan.",
|
|
579
|
+
inputSchema: {
|
|
580
|
+
path: z.string().describe("File or directory, relative to project root or absolute within it."),
|
|
581
|
+
},
|
|
582
|
+
}, async ({ path: input }) => {
|
|
583
|
+
try {
|
|
584
|
+
const { abs, rel } = resolveInRoot(input);
|
|
585
|
+
const stat = fs.statSync(abs);
|
|
586
|
+
if (stat.isDirectory()) {
|
|
587
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
588
|
+
const files = collectSourceFiles(abs, opts);
|
|
589
|
+
const results = [];
|
|
590
|
+
const errors = [];
|
|
591
|
+
for (const file of files) {
|
|
592
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
593
|
+
try {
|
|
594
|
+
const fc = await computeFileComplexity(file, fileRel);
|
|
595
|
+
if (fc)
|
|
596
|
+
results.push(fc);
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
errors.push({ file: fileRel, error: describeError(err) });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const hotspots = results
|
|
603
|
+
.flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })))
|
|
604
|
+
.sort((a, b) => b.complexity - a.complexity)
|
|
605
|
+
.slice(0, 15);
|
|
606
|
+
return jsonText({
|
|
607
|
+
directory: rel.split(path.sep).join("/"),
|
|
608
|
+
scanned: files.length,
|
|
609
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
610
|
+
hotspots,
|
|
611
|
+
files: results,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
const fc = await computeFileComplexity(abs, rel.split(path.sep).join("/"));
|
|
615
|
+
if (!fc)
|
|
616
|
+
return errorText(`Unsupported file type: ${input}`);
|
|
617
|
+
return jsonText(fc);
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
return errorText(describeError(err));
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
/* ─────────────────── tool: find_unused_params ──────────────────────────── */
|
|
624
|
+
server.registerTool("find_unused_params", {
|
|
625
|
+
title: "Find unused function parameters",
|
|
626
|
+
description: "Scan a FILE or DIRECTORY for named functions/methods that declare parameters never " +
|
|
627
|
+
"referenced in their body. Skips `_`-prefixed params (conventionally intentional), " +
|
|
628
|
+
"anonymous callbacks, and destructured bindings to avoid false positives.",
|
|
629
|
+
inputSchema: {
|
|
630
|
+
path: z.string().describe("File or directory, relative to project root or absolute within it."),
|
|
631
|
+
},
|
|
632
|
+
}, async ({ path: input }) => {
|
|
633
|
+
try {
|
|
634
|
+
const { abs, rel } = resolveInRoot(input);
|
|
635
|
+
const stat = fs.statSync(abs);
|
|
636
|
+
if (stat.isDirectory()) {
|
|
637
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
638
|
+
const files = collectSourceFiles(abs, opts);
|
|
639
|
+
const results = [];
|
|
640
|
+
const errors = [];
|
|
641
|
+
for (const file of files) {
|
|
642
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
643
|
+
try {
|
|
644
|
+
const r = await findUnusedParams(file, fileRel);
|
|
645
|
+
if (r && r.functions.length > 0)
|
|
646
|
+
results.push(r);
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
errors.push({ file: fileRel, error: describeError(err) });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const unusedParamCount = results.reduce((sum, r) => sum + r.functions.reduce((a, f) => a + f.unused.length, 0), 0);
|
|
653
|
+
return jsonText({
|
|
654
|
+
directory: rel.split(path.sep).join("/"),
|
|
655
|
+
scanned: files.length,
|
|
656
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
657
|
+
unusedParamCount,
|
|
658
|
+
files: results,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
const r = await findUnusedParams(abs, rel.split(path.sep).join("/"));
|
|
662
|
+
if (!r)
|
|
663
|
+
return errorText(`Unsupported file type: ${input}`);
|
|
664
|
+
return jsonText(r);
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
return errorText(describeError(err));
|
|
668
|
+
}
|
|
669
|
+
});
|
|
515
670
|
/* ─────────────────── tool: get_change_impact ───────────────────────────── */
|
|
516
671
|
server.registerTool("get_change_impact", {
|
|
517
672
|
title: "Get change impact (blast radius)",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { parseSource } from "./parser.js";
|
|
3
|
+
import { detectLanguage } from "./registry.js";
|
|
4
|
+
// Named function-like nodes across the supported languages. Anonymous arrows /
|
|
5
|
+
// lambdas are intentionally skipped: they're usually callbacks where an unused
|
|
6
|
+
// parameter is required by the caller's signature (event handlers, map indices).
|
|
7
|
+
const FN_TYPES = new Set([
|
|
8
|
+
"function_declaration",
|
|
9
|
+
"generator_function_declaration",
|
|
10
|
+
"function_definition", // Python / C / C++
|
|
11
|
+
"async_function_definition", // Python
|
|
12
|
+
"method_definition", // TS/JS class member
|
|
13
|
+
"method_declaration", // Go / Java / C#
|
|
14
|
+
"constructor_declaration", // Java / C#
|
|
15
|
+
"function_item", // Rust
|
|
16
|
+
]);
|
|
17
|
+
const PARAM_CONTAINERS = new Set([
|
|
18
|
+
"formal_parameters", "parameters", "parameter_list", "function_value_parameters",
|
|
19
|
+
]);
|
|
20
|
+
const ID_TYPES = new Set(["identifier", "simple_identifier"]);
|
|
21
|
+
// Identifier-like nodes that count as a *usage* of a name. Includes object
|
|
22
|
+
// shorthand (`{ foo }` references `foo`), which is ubiquitous in JS/TS.
|
|
23
|
+
const USE_TYPES = new Set([
|
|
24
|
+
"identifier", "simple_identifier",
|
|
25
|
+
"shorthand_property_identifier", "shorthand_property_identifier_pattern",
|
|
26
|
+
]);
|
|
27
|
+
// Binding shapes we do NOT try to resolve to a single name (avoid false positives).
|
|
28
|
+
const SKIP_PARAM = /splat|rest|spread|object_pattern|array_pattern|tuple_pattern|object_type/;
|
|
29
|
+
function fnName(node) {
|
|
30
|
+
const nm = node.childForFieldName("name");
|
|
31
|
+
if (nm)
|
|
32
|
+
return nm.text;
|
|
33
|
+
// Kotlin/Swift function_declaration: name is the first simple_identifier child.
|
|
34
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
35
|
+
const c = node.namedChild(i);
|
|
36
|
+
if (c && c.type === "simple_identifier")
|
|
37
|
+
return c.text;
|
|
38
|
+
}
|
|
39
|
+
return "(anonymous)";
|
|
40
|
+
}
|
|
41
|
+
function paramsNode(fn) {
|
|
42
|
+
const p = fn.childForFieldName("parameters");
|
|
43
|
+
if (p)
|
|
44
|
+
return p;
|
|
45
|
+
for (let i = 0; i < fn.namedChildCount; i++) {
|
|
46
|
+
const c = fn.namedChild(i);
|
|
47
|
+
if (c && PARAM_CONTAINERS.has(c.type))
|
|
48
|
+
return c;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** Best-effort binding names for one parameter node (may be empty when unsure). */
|
|
53
|
+
function paramNames(p) {
|
|
54
|
+
if (ID_TYPES.has(p.type))
|
|
55
|
+
return [p.text];
|
|
56
|
+
if (SKIP_PARAM.test(p.type))
|
|
57
|
+
return [];
|
|
58
|
+
const pat = p.childForFieldName("pattern");
|
|
59
|
+
if (pat)
|
|
60
|
+
return ID_TYPES.has(pat.type) ? [pat.text] : [];
|
|
61
|
+
const nm = p.childForFieldName("name");
|
|
62
|
+
if (nm && ID_TYPES.has(nm.type))
|
|
63
|
+
return [nm.text];
|
|
64
|
+
// Go: `a, b int` → several identifier children before the type.
|
|
65
|
+
const ids = [];
|
|
66
|
+
for (let i = 0; i < p.namedChildCount; i++) {
|
|
67
|
+
const c = p.namedChild(i);
|
|
68
|
+
if (c && c.type === "identifier")
|
|
69
|
+
ids.push(c.text);
|
|
70
|
+
}
|
|
71
|
+
return ids;
|
|
72
|
+
}
|
|
73
|
+
/** Collect every bare identifier reference in the subtree (not member/field names). */
|
|
74
|
+
function collectIdentifierUses(node, out) {
|
|
75
|
+
if (USE_TYPES.has(node.type))
|
|
76
|
+
out.add(node.text);
|
|
77
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
78
|
+
const c = node.namedChild(i);
|
|
79
|
+
if (c)
|
|
80
|
+
collectIdentifierUses(c, out);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function unusedInFunction(fn) {
|
|
84
|
+
const pnode = paramsNode(fn);
|
|
85
|
+
const body = fn.childForFieldName("body");
|
|
86
|
+
if (!pnode || !body)
|
|
87
|
+
return [];
|
|
88
|
+
const names = [];
|
|
89
|
+
for (let i = 0; i < pnode.namedChildCount; i++) {
|
|
90
|
+
const p = pnode.namedChild(i);
|
|
91
|
+
if (p)
|
|
92
|
+
names.push(...paramNames(p));
|
|
93
|
+
}
|
|
94
|
+
if (names.length === 0)
|
|
95
|
+
return [];
|
|
96
|
+
const used = new Set();
|
|
97
|
+
collectIdentifierUses(body, used);
|
|
98
|
+
// Skip `_`-prefixed (conventionally intentional) and `this`/`self`.
|
|
99
|
+
return names.filter((n) => n !== "_" && !n.startsWith("_") && n !== "this" && n !== "self" && !used.has(n));
|
|
100
|
+
}
|
|
101
|
+
export async function findUnusedParams(absPath, relPath) {
|
|
102
|
+
const lang = detectLanguage(absPath);
|
|
103
|
+
if (!lang)
|
|
104
|
+
return null;
|
|
105
|
+
const source = fs.readFileSync(absPath, "utf8");
|
|
106
|
+
const root = await parseSource(lang.grammar, source);
|
|
107
|
+
const functions = [];
|
|
108
|
+
const walk = (node) => {
|
|
109
|
+
if (FN_TYPES.has(node.type)) {
|
|
110
|
+
const unused = unusedInFunction(node);
|
|
111
|
+
if (unused.length > 0) {
|
|
112
|
+
functions.push({
|
|
113
|
+
function: fnName(node),
|
|
114
|
+
line: node.startPosition.row + 1,
|
|
115
|
+
unused,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
120
|
+
const c = node.namedChild(i);
|
|
121
|
+
if (c)
|
|
122
|
+
walk(c);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
walk(root);
|
|
126
|
+
return { file: relPath, functions };
|
|
127
|
+
}
|
package/package.json
CHANGED