universal-ast-mapper 0.8.4 → 1.0.0

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 CHANGED
@@ -94,6 +94,9 @@ ast-map validate <path> [--max-lines N] [--max-imports N] [--max-expo
94
94
  ast-map dead <dir>
95
95
  ast-map cycles <dir>
96
96
  ast-map duplicates <dir> [alias: dupes]
97
+ ast-map complexity <path> [alias: cx] [--min N]
98
+ ast-map unused-params <path> [alias: unused]
99
+ ast-map trace-type <type> [dir] [alias: flow]
97
100
  ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
98
101
  ast-map deps <file> [--scan <dir>]
99
102
  ast-map top <dir> [-n 10]
@@ -279,6 +282,49 @@ Scan a directory → find symbol names exported from **more than one file** (acc
279
282
 
280
283
  ---
281
284
 
285
+ ### `get_complexity`
286
+ 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.
287
+
288
+ ```json
289
+ {
290
+ "file": "src/auth.ts",
291
+ "maxComplexity": 12,
292
+ "functions": [
293
+ { "name": "validate", "complexity": 12, "rating": "high", "startLine": 8, "endLine": 40 }
294
+ ]
295
+ }
296
+ ```
297
+
298
+ **Params:** `path`
299
+
300
+ ---
301
+
302
+ ### `find_unused_params`
303
+ 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.
304
+
305
+ ```json
306
+ { "file": "src/x.ts", "functions": [ { "function": "greet", "line": 3, "unused": ["salutation"] } ] }
307
+ ```
308
+
309
+ **Params:** `path`
310
+
311
+ ---
312
+
313
+ ### `trace_type`
314
+ **Scoped type-flow tracing.** Find everywhere a named type flows through a directory — function **parameters** and **return types**, typed **variables**, and class **fields**. AST-based (no full type inference), so it tracks where a type is *named* in signatures; works best for TS/Python but resolves return/param types in any language that annotates them.
315
+
316
+ ```json
317
+ {
318
+ "type": "Inventory",
319
+ "byRole": { "param": 3, "return": 2, "variable": 1, "field": 1 },
320
+ "refs": [ { "file": "src/svc.ts", "symbol": "make", "role": "return", "line": 4 } ]
321
+ }
322
+ ```
323
+
324
+ **Params:** `type`, `path`
325
+
326
+ ---
327
+
282
328
  ### `get_change_impact`
283
329
  Given a file + symbol, reverse-traverse the import graph to compute **blast radius**.
284
330
 
@@ -497,10 +543,54 @@ src/
497
543
 
498
544
  ---
499
545
 
546
+ ## GitHub Action — architecture gate in CI
547
+
548
+ Use AST-MCP as a CI check with the bundled composite action (`action.yml`):
549
+
550
+ ```yaml
551
+ # .github/workflows/architecture.yml
552
+ name: Architecture
553
+ on: [pull_request]
554
+ jobs:
555
+ validate:
556
+ runs-on: ubuntu-latest
557
+ steps:
558
+ - uses: actions/checkout@v4
559
+ - uses: actions/setup-node@v4
560
+ with: { node-version: "20" }
561
+ - uses: 6ixthxense/AST-MCP@v1.0.0
562
+ with:
563
+ path: src
564
+ max-lines: "400"
565
+ max-imports: "20"
566
+ max-exports: "15"
567
+ ```
568
+
569
+ The action runs `ast-map validate` and fails the job on threshold violations. You can also call any CLI command directly with `npx -p universal-ast-mapper ast-map <command>`.
570
+
571
+ ---
572
+
573
+ ## Stability (1.0)
574
+
575
+ As of **v1.0.0**, the public surface is stable across the `1.x` line:
576
+
577
+ - **MCP tool names and input schemas** — no breaking changes; new tools and new *optional* inputs may be added.
578
+ - **CLI commands and flags** — stable; new commands/flags may be added.
579
+ - **Skeleton JSON** — `schemaVersion` follows additive-compatible evolution; new *optional* fields (e.g. `props`, `decorators`) may appear without a major bump.
580
+
581
+ Not part of the public API: the internal `src/` module layout and the generated HTML markup.
582
+
583
+ ---
584
+
500
585
  ## Changelog
501
586
 
502
587
  | Version | What changed |
503
588
  |---------|--------------|
589
+ | **1.0.0** | **Stable release.** Locks the public API (MCP tool names + schemas, CLI surface) for the 1.x line. Adds a **GitHub Action** (`action.yml`) to run `ast-map validate` as a CI architecture gate, plus a project CI workflow. Caps a 12-language engine with 18 MCP tools / 17 CLI commands spanning skeletons, dependency graphs, and deep analysis (dead code · cycles · impact · complexity · duplicates · unused params · decorators · type flow). |
590
+ | **0.9.0** | **Scoped type-flow tracing** — new `trace_type` MCP tool + `ast-map trace-type` (alias `flow`) CLI: follow a named type through function params, return types, typed variables, and class fields across a directory. Completes the deeper-analysis suite (dead code · cycles · impact · complexity · duplicates · unused params · type flow). **18 MCP tools**. |
591
+ | **0.8.7** | **Python decorators in the call graph** — function/method symbols now carry a `decorators` field (`@router.get("/x")` → `router.get("/x")`), surfaced in skeletons (outline + full) and in `get_call_graph`. Traces framework wiring like FastAPI/Flask routes and `@staticmethod`/`@property` stacks to their handler. |
592
+ | **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. |
593
+ | **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. |
504
594
  | **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. |
505
595
  | **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`). |
506
596
  | **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. |
package/dist/callgraph.js CHANGED
@@ -243,6 +243,17 @@ async function fileCallsSymbol(fileAbs, funcName) {
243
243
  return false;
244
244
  }
245
245
  // ─── Public API ───────────────────────────────────────────────────────────────
246
+ /** Recursively find the first symbol with the given name and return its decorators. */
247
+ function findDecorators(symbols, name) {
248
+ for (const s of symbols) {
249
+ if (s.name === name && s.decorators && s.decorators.length > 0)
250
+ return s.decorators;
251
+ const nested = findDecorators(s.children, name);
252
+ if (nested)
253
+ return nested;
254
+ }
255
+ return undefined;
256
+ }
246
257
  export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
247
258
  const langEntry = detectLanguage(filePath);
248
259
  if (!langEntry)
@@ -427,6 +438,7 @@ export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
427
438
  }
428
439
  }
429
440
  }
441
+ const decorators = findDecorators(skel.symbols, funcName);
430
442
  return {
431
443
  file: relPath,
432
444
  function: funcName,
@@ -434,6 +446,7 @@ export async function buildCallGraph(filePath, funcName, root, allSkeletons) {
434
446
  startLine: funcNode.startPosition.row + 1,
435
447
  endLine: funcNode.endPosition.row + 1,
436
448
  },
449
+ ...(decorators ? { decorators } : {}),
437
450
  calls,
438
451
  calledBy,
439
452
  };
package/dist/cli.js CHANGED
@@ -10,6 +10,9 @@ import { findSymbol, findRelatedSymbols, findServerImports, isApiRoute, findMiss
10
10
  import { resolveFileImports } from "./resolver.js";
11
11
  import { buildSymbolGraph } from "./graph.js";
12
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";
15
+ import { traceTypeInFile } from "./typeflow.js";
13
16
  import { buildCallGraph } from "./callgraph.js";
14
17
  import { searchSymbols } from "./search.js";
15
18
  const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
@@ -366,6 +369,124 @@ program
366
369
  }
367
370
  console.log();
368
371
  });
372
+ // ─── Command: trace-type ──────────────────────────────────────────────────────
373
+ program
374
+ .command("trace-type <type> [dir]")
375
+ .alias("flow")
376
+ .description("Trace a type through params, returns, variables and fields")
377
+ .option("--json", "Output as JSON")
378
+ .action(async (typeName, dir, opts) => {
379
+ const { abs, rel } = resolveArg(dir ?? ".");
380
+ if (!fs.statSync(abs).isDirectory())
381
+ die(`"${rel}" is not a directory`);
382
+ const sopts = resolveOptions({ detail: "outline", emitHtml: false });
383
+ const refs = [];
384
+ for (const file of collectSourceFiles(abs, sopts)) {
385
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
386
+ refs.push(...(await traceTypeInFile(file, fileRel, typeName)));
387
+ }
388
+ if (opts.json)
389
+ return jsonOut({ type: typeName, dir: rel, refCount: refs.length, refs });
390
+ header(`Type Flow: ${bold(typeName)} — ${rel}/ ${dim(`(${refs.length} ref(s))`)}`);
391
+ if (refs.length === 0) {
392
+ console.log(indent(dim(`No references to type "${typeName}" found in signatures.`)));
393
+ }
394
+ else {
395
+ const roleColor = (r) => (r === "return" ? green : r === "param" ? yellow : dim);
396
+ table(refs.map((r) => [
397
+ roleColor(r.role)(r.role),
398
+ r.symbol + (r.detail ? `(${r.detail})` : ""),
399
+ `:${r.line}`,
400
+ r.file,
401
+ ]), [["Role", 9], ["Symbol", 24], ["Line", 6], ["File", 34]]);
402
+ }
403
+ console.log();
404
+ });
405
+ // ─── Command: unused-params ───────────────────────────────────────────────────
406
+ program
407
+ .command("unused-params <path>")
408
+ .alias("unused")
409
+ .description("Find function parameters that are never used in the body")
410
+ .option("--json", "Output as JSON")
411
+ .action(async (inputPath, opts) => {
412
+ const { abs, rel } = resolveArg(inputPath);
413
+ const stat = fs.statSync(abs);
414
+ const results = [];
415
+ if (stat.isDirectory()) {
416
+ const sopts = resolveOptions({ detail: "outline", emitHtml: false });
417
+ for (const file of collectSourceFiles(abs, sopts)) {
418
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
419
+ const r = await findUnusedParams(file, fileRel);
420
+ if (r && r.functions.length > 0)
421
+ results.push(r);
422
+ }
423
+ }
424
+ else {
425
+ const r = await findUnusedParams(abs, rel);
426
+ if (!r)
427
+ die(`Unsupported file type: ${rel}`);
428
+ if (r.functions.length > 0)
429
+ results.push(r);
430
+ }
431
+ const rows = results.flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })));
432
+ if (opts.json)
433
+ return jsonOut({ path: rel, count: rows.length, functions: rows });
434
+ header(`Unused Parameters — ${rel}`);
435
+ if (rows.length === 0) {
436
+ console.log(indent(green("✓ No unused parameters found.")));
437
+ }
438
+ else {
439
+ table(rows.map((f) => [f.function, yellow(f.unused.join(", ")), f.file]), [["Function", 26], ["Unused params", 28], ["File", 36]]);
440
+ const totalP = rows.reduce((a, f) => a + f.unused.length, 0);
441
+ console.log(`\n ${yellow(`${totalP} unused parameter(s)`)} in ${rows.length} function(s)`);
442
+ }
443
+ console.log();
444
+ });
445
+ // ─── Command: complexity ──────────────────────────────────────────────────────
446
+ program
447
+ .command("complexity <path>")
448
+ .alias("cx")
449
+ .description("Cyclomatic complexity per function (file or directory)")
450
+ .option("--json", "Output as JSON")
451
+ .option("--min <n>", "Only show functions with complexity >= n", (v) => parseInt(v, 10))
452
+ .action(async (inputPath, opts) => {
453
+ const { abs, rel } = resolveArg(inputPath);
454
+ const stat = fs.statSync(abs);
455
+ const min = opts.min ?? 1;
456
+ const fileResults = [];
457
+ if (stat.isDirectory()) {
458
+ const sopts = resolveOptions({ detail: "outline", emitHtml: false });
459
+ for (const file of collectSourceFiles(abs, sopts)) {
460
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
461
+ const fc = await computeFileComplexity(file, fileRel);
462
+ if (fc)
463
+ fileResults.push(fc);
464
+ }
465
+ }
466
+ else {
467
+ const fc = await computeFileComplexity(abs, rel);
468
+ if (!fc)
469
+ die(`Unsupported file type: ${rel}`);
470
+ fileResults.push(fc);
471
+ }
472
+ const rows = fileResults
473
+ .flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })))
474
+ .filter((f) => f.complexity >= min)
475
+ .sort((a, b) => b.complexity - a.complexity);
476
+ if (opts.json)
477
+ return jsonOut({ path: rel, functionCount: rows.length, functions: rows });
478
+ header(`Cyclomatic Complexity — ${rel} ${dim(`(${fileResults.length} file(s))`)}`);
479
+ if (rows.length === 0) {
480
+ console.log(indent(green("✓ No functions found.")));
481
+ }
482
+ else {
483
+ const colorFor = (r) => (r === "very-high" || r === "high" ? yellow : r === "moderate" ? bold : dim);
484
+ 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]]);
485
+ const high = rows.filter((f) => f.complexity > 10).length;
486
+ console.log(`\n ${rows.length} function(s)` + (high > 0 ? ` · ${yellow(`${high} above 10`)}` : ""));
487
+ }
488
+ console.log();
489
+ });
369
490
  // ─── Command: duplicates ──────────────────────────────────────────────────────
370
491
  program
371
492
  .command("duplicates <dir>")
@@ -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
+ }
@@ -49,6 +49,8 @@ export function toOutline(symbols) {
49
49
  };
50
50
  if (s.exported !== undefined)
51
51
  out.exported = s.exported;
52
+ if (s.decorators)
53
+ out.decorators = s.decorators;
52
54
  return out;
53
55
  });
54
56
  }
@@ -15,7 +15,18 @@ function collect(nodes, insideClass) {
15
15
  function handle(node, insideClass) {
16
16
  if (node.type === "decorated_definition") {
17
17
  const inner = innerDefinition(node);
18
- return inner ? handle(inner, insideClass) : null;
18
+ if (!inner)
19
+ return null;
20
+ const sym = handle(inner, insideClass);
21
+ if (sym) {
22
+ const decs = namedChildren(node)
23
+ .filter((c) => c.type === "decorator")
24
+ .map((d) => d.text.replace(/^@\s*/, "").replace(/\s+/g, " ").trim())
25
+ .filter((t) => t.length > 0);
26
+ if (decs.length > 0)
27
+ sym.decorators = decs;
28
+ }
29
+ return sym;
19
30
  }
20
31
  if (node.type === "class_definition") {
21
32
  const name = nameOf(node) ?? "(class)";
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ import { buildSymbolGraph } from "./graph.js";
15
15
  import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols, findDuplicateSymbols } from "./graph-analysis.js";
16
16
  import { buildCallGraph } from "./callgraph.js";
17
17
  import { searchSymbols } from "./search.js";
18
+ import { computeFileComplexity } from "./complexity.js";
19
+ import { findUnusedParams } from "./unused-params.js";
20
+ import { traceTypeInFile } from "./typeflow.js";
18
21
  /** Files may only be read inside this root (override with AST_MAP_ROOT). */
19
22
  const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
20
23
  function resolveInRoot(input) {
@@ -567,6 +570,150 @@ server.registerTool("find_duplicate_symbols", {
567
570
  return errorText(describeError(err));
568
571
  }
569
572
  });
573
+ /* ─────────────────── tool: get_complexity ──────────────────────────────── */
574
+ server.registerTool("get_complexity", {
575
+ title: "Get cyclomatic complexity per function",
576
+ description: "Compute AST-based cyclomatic complexity for every function/method in a FILE or DIRECTORY. " +
577
+ "Each function gets a score (1 + decision points: if / for / while / case / catch / ternary / && / ||) " +
578
+ "and a rating (low <=5, moderate <=10, high <=20, very-high >20). For a directory, returns per-file " +
579
+ "results plus the highest-complexity hotspots across the scan.",
580
+ inputSchema: {
581
+ path: z.string().describe("File or directory, relative to project root or absolute within it."),
582
+ },
583
+ }, async ({ path: input }) => {
584
+ try {
585
+ const { abs, rel } = resolveInRoot(input);
586
+ const stat = fs.statSync(abs);
587
+ if (stat.isDirectory()) {
588
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
589
+ const files = collectSourceFiles(abs, opts);
590
+ const results = [];
591
+ const errors = [];
592
+ for (const file of files) {
593
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
594
+ try {
595
+ const fc = await computeFileComplexity(file, fileRel);
596
+ if (fc)
597
+ results.push(fc);
598
+ }
599
+ catch (err) {
600
+ errors.push({ file: fileRel, error: describeError(err) });
601
+ }
602
+ }
603
+ const hotspots = results
604
+ .flatMap((r) => r.functions.map((f) => ({ file: r.file, ...f })))
605
+ .sort((a, b) => b.complexity - a.complexity)
606
+ .slice(0, 15);
607
+ return jsonText({
608
+ directory: rel.split(path.sep).join("/"),
609
+ scanned: files.length,
610
+ ...(errors.length > 0 ? { errors } : {}),
611
+ hotspots,
612
+ files: results,
613
+ });
614
+ }
615
+ const fc = await computeFileComplexity(abs, rel.split(path.sep).join("/"));
616
+ if (!fc)
617
+ return errorText(`Unsupported file type: ${input}`);
618
+ return jsonText(fc);
619
+ }
620
+ catch (err) {
621
+ return errorText(describeError(err));
622
+ }
623
+ });
624
+ /* ─────────────────── tool: find_unused_params ──────────────────────────── */
625
+ server.registerTool("find_unused_params", {
626
+ title: "Find unused function parameters",
627
+ description: "Scan a FILE or DIRECTORY for named functions/methods that declare parameters never " +
628
+ "referenced in their body. Skips `_`-prefixed params (conventionally intentional), " +
629
+ "anonymous callbacks, and destructured bindings to avoid false positives.",
630
+ inputSchema: {
631
+ path: z.string().describe("File or directory, relative to project root or absolute within it."),
632
+ },
633
+ }, async ({ path: input }) => {
634
+ try {
635
+ const { abs, rel } = resolveInRoot(input);
636
+ const stat = fs.statSync(abs);
637
+ if (stat.isDirectory()) {
638
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
639
+ const files = collectSourceFiles(abs, opts);
640
+ const results = [];
641
+ const errors = [];
642
+ for (const file of files) {
643
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
644
+ try {
645
+ const r = await findUnusedParams(file, fileRel);
646
+ if (r && r.functions.length > 0)
647
+ results.push(r);
648
+ }
649
+ catch (err) {
650
+ errors.push({ file: fileRel, error: describeError(err) });
651
+ }
652
+ }
653
+ const unusedParamCount = results.reduce((sum, r) => sum + r.functions.reduce((a, f) => a + f.unused.length, 0), 0);
654
+ return jsonText({
655
+ directory: rel.split(path.sep).join("/"),
656
+ scanned: files.length,
657
+ ...(errors.length > 0 ? { errors } : {}),
658
+ unusedParamCount,
659
+ files: results,
660
+ });
661
+ }
662
+ const r = await findUnusedParams(abs, rel.split(path.sep).join("/"));
663
+ if (!r)
664
+ return errorText(`Unsupported file type: ${input}`);
665
+ return jsonText(r);
666
+ }
667
+ catch (err) {
668
+ return errorText(describeError(err));
669
+ }
670
+ });
671
+ /* ─────────────────── tool: trace_type ──────────────────────────────────── */
672
+ server.registerTool("trace_type", {
673
+ title: "Trace a type through the code",
674
+ description: "Find everywhere a named type flows through a directory: function parameters and return " +
675
+ "types, typed variables, and class fields. A scoped, AST-based type-flow view (best for " +
676
+ "TS/Python) \u2014 no full type inference, so it tracks where the type is *named* in signatures.",
677
+ inputSchema: {
678
+ type: z.string().describe('Type name to trace, e.g. "Inventory".'),
679
+ path: z.string().describe("Directory to scan, relative to project root or absolute within it."),
680
+ },
681
+ }, async ({ type: typeName, path: input }) => {
682
+ try {
683
+ const { abs, rel } = resolveInRoot(input);
684
+ if (!fs.statSync(abs).isDirectory()) {
685
+ return errorText(`"${input}" is not a directory. trace_type requires a directory.`);
686
+ }
687
+ const opts = resolveOptions({ detail: "outline", emitHtml: false });
688
+ const files = collectSourceFiles(abs, opts);
689
+ const refs = [];
690
+ const errors = [];
691
+ for (const file of files) {
692
+ const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
693
+ try {
694
+ refs.push(...(await traceTypeInFile(file, fileRel, typeName)));
695
+ }
696
+ catch (err) {
697
+ errors.push({ file: fileRel, error: describeError(err) });
698
+ }
699
+ }
700
+ const byRole = { param: 0, return: 0, variable: 0, field: 0 };
701
+ for (const r of refs)
702
+ byRole[r.role]++;
703
+ return jsonText({
704
+ type: typeName,
705
+ directory: rel.split(path.sep).join("/"),
706
+ scanned: files.length,
707
+ refCount: refs.length,
708
+ byRole,
709
+ ...(errors.length > 0 ? { errors } : {}),
710
+ refs,
711
+ });
712
+ }
713
+ catch (err) {
714
+ return errorText(describeError(err));
715
+ }
716
+ });
570
717
  /* ─────────────────── tool: get_change_impact ───────────────────────────── */
571
718
  server.registerTool("get_change_impact", {
572
719
  title: "Get change impact (blast radius)",
@@ -0,0 +1,124 @@
1
+ import fs from "node:fs";
2
+ import { parseSource } from "./parser.js";
3
+ import { detectLanguage } from "./registry.js";
4
+ const FN_TYPES = new Set([
5
+ "function_declaration", "generator_function_declaration", "function_definition",
6
+ "async_function_definition", "method_definition", "method_declaration",
7
+ "constructor_declaration", "function_item",
8
+ ]);
9
+ const ID_TYPES = new Set(["identifier", "simple_identifier"]);
10
+ const TYPE_ID_TYPES = new Set(["type_identifier", "identifier"]);
11
+ const PARAM_CONTAINERS = new Set([
12
+ "formal_parameters", "parameters", "parameter_list", "function_value_parameters",
13
+ ]);
14
+ function fnName(node) {
15
+ const nm = node.childForFieldName("name");
16
+ if (nm)
17
+ return nm.text;
18
+ for (let i = 0; i < node.namedChildCount; i++) {
19
+ const c = node.namedChild(i);
20
+ if (c && c.type === "simple_identifier")
21
+ return c.text;
22
+ }
23
+ return "(anonymous)";
24
+ }
25
+ /** Does this type-annotation subtree reference the bare type name `name`? */
26
+ function typeRefsName(node, name) {
27
+ if (!node)
28
+ return false;
29
+ let hit = false;
30
+ const walk = (n) => {
31
+ if (hit)
32
+ return;
33
+ if (TYPE_ID_TYPES.has(n.type) && n.text === name) {
34
+ hit = true;
35
+ return;
36
+ }
37
+ for (let i = 0; i < n.namedChildCount; i++) {
38
+ const c = n.namedChild(i);
39
+ if (c)
40
+ walk(c);
41
+ }
42
+ };
43
+ walk(node);
44
+ return hit;
45
+ }
46
+ function paramName(p) {
47
+ if (ID_TYPES.has(p.type))
48
+ return p.text;
49
+ const pat = p.childForFieldName("pattern");
50
+ if (pat && ID_TYPES.has(pat.type))
51
+ return pat.text;
52
+ const nm = p.childForFieldName("name");
53
+ if (nm && ID_TYPES.has(nm.type))
54
+ return nm.text;
55
+ return undefined;
56
+ }
57
+ function paramsNode(fn) {
58
+ const p = fn.childForFieldName("parameters");
59
+ if (p)
60
+ return p;
61
+ for (let i = 0; i < fn.namedChildCount; i++) {
62
+ const c = fn.namedChild(i);
63
+ if (c && PARAM_CONTAINERS.has(c.type))
64
+ return c;
65
+ }
66
+ return null;
67
+ }
68
+ export async function traceTypeInFile(absPath, relPath, typeName) {
69
+ const lang = detectLanguage(absPath);
70
+ if (!lang)
71
+ return [];
72
+ const source = fs.readFileSync(absPath, "utf8");
73
+ const root = await parseSource(lang.grammar, source);
74
+ const refs = [];
75
+ const walk = (node) => {
76
+ if (FN_TYPES.has(node.type)) {
77
+ const name = fnName(node);
78
+ // return type
79
+ const rt = node.childForFieldName("return_type");
80
+ if (typeRefsName(rt, typeName)) {
81
+ refs.push({ file: relPath, symbol: name, role: "return", line: node.startPosition.row + 1 });
82
+ }
83
+ // params
84
+ const pnode = paramsNode(node);
85
+ if (pnode) {
86
+ for (let i = 0; i < pnode.namedChildCount; i++) {
87
+ const p = pnode.namedChild(i);
88
+ if (!p)
89
+ continue;
90
+ const ty = p.childForFieldName("type");
91
+ if (typeRefsName(ty, typeName)) {
92
+ const pn = paramName(p);
93
+ refs.push({
94
+ file: relPath, symbol: name, role: "param",
95
+ ...(pn ? { detail: pn } : {}),
96
+ line: p.startPosition.row + 1,
97
+ });
98
+ }
99
+ }
100
+ }
101
+ }
102
+ else if (node.type === "variable_declarator") {
103
+ const ty = node.childForFieldName("type");
104
+ const nm = node.childForFieldName("name");
105
+ if (ty && nm && typeRefsName(ty, typeName)) {
106
+ refs.push({ file: relPath, symbol: nm.text, role: "variable", line: node.startPosition.row + 1 });
107
+ }
108
+ }
109
+ else if (node.type === "public_field_definition" || node.type === "field_definition") {
110
+ const ty = node.childForFieldName("type");
111
+ const nm = node.childForFieldName("name");
112
+ if (ty && nm && typeRefsName(ty, typeName)) {
113
+ refs.push({ file: relPath, symbol: nm.text, role: "field", line: node.startPosition.row + 1 });
114
+ }
115
+ }
116
+ for (let i = 0; i < node.namedChildCount; i++) {
117
+ const c = node.namedChild(i);
118
+ if (c)
119
+ walk(c);
120
+ }
121
+ };
122
+ walk(root);
123
+ return refs;
124
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-ast-mapper",
3
- "version": "0.8.4",
3
+ "version": "1.0.0",
4
4
  "description": "MCP server that maps source files into a normalized code skeleton (JSON + HTML) using tree-sitter.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",