universal-ast-mapper 0.8.6 → 1.1.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 +76 -0
- package/dist/callgraph.js +13 -0
- package/dist/cli.js +69 -0
- package/dist/extractors/common.js +2 -0
- package/dist/extractors/python.js +12 -1
- package/dist/index.js +78 -0
- package/dist/typeflow.js +124 -0
- package/dist/workspace.js +207 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,6 +96,8 @@ ast-map cycles <dir>
|
|
|
96
96
|
ast-map duplicates <dir> [alias: dupes]
|
|
97
97
|
ast-map complexity <path> [alias: cx] [--min N]
|
|
98
98
|
ast-map unused-params <path> [alias: unused]
|
|
99
|
+
ast-map trace-type <type> [dir] [alias: flow]
|
|
100
|
+
ast-map workspace [dir] [alias: ws]
|
|
99
101
|
ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
|
|
100
102
|
ast-map deps <file> [--scan <dir>]
|
|
101
103
|
ast-map top <dir> [-n 10]
|
|
@@ -309,6 +311,37 @@ Scan a file or directory for **named functions/methods with parameters that are
|
|
|
309
311
|
|
|
310
312
|
---
|
|
311
313
|
|
|
314
|
+
### `trace_type`
|
|
315
|
+
**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.
|
|
316
|
+
|
|
317
|
+
```json
|
|
318
|
+
{
|
|
319
|
+
"type": "Inventory",
|
|
320
|
+
"byRole": { "param": 3, "return": 2, "variable": 1, "field": 1 },
|
|
321
|
+
"refs": [ { "file": "src/svc.ts", "symbol": "make", "role": "return", "line": 4 } ]
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
**Params:** `type`, `path`
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
### `analyze_workspace`
|
|
330
|
+
**Monorepo support.** Discover the packages in a JS/TS monorepo (npm/yarn `workspaces`, `pnpm-workspace.yaml`, or `lerna.json`) and the dependency edges between them. Returns each package's name, directory, and workspace-internal dependencies, plus any circular dependencies between packages.
|
|
331
|
+
|
|
332
|
+
```json
|
|
333
|
+
{
|
|
334
|
+
"tool": "npm", "packageCount": 3,
|
|
335
|
+
"packages": [ { "name": "@demo/a", "dir": "packages/a", "internalDeps": ["@demo/b"] } ],
|
|
336
|
+
"edges": [ { "from": "@demo/a", "to": "@demo/b" } ],
|
|
337
|
+
"packageCycles": []
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Params:** `path` (optional, defaults to root)
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
312
345
|
### `get_change_impact`
|
|
313
346
|
Given a file + symbol, reverse-traverse the import graph to compute **blast radius**.
|
|
314
347
|
|
|
@@ -527,10 +560,53 @@ src/
|
|
|
527
560
|
|
|
528
561
|
---
|
|
529
562
|
|
|
563
|
+
## GitHub Action — architecture gate in CI
|
|
564
|
+
|
|
565
|
+
Use AST-MCP as a CI check with the bundled composite action (`action.yml`):
|
|
566
|
+
|
|
567
|
+
```yaml
|
|
568
|
+
# .github/workflows/architecture.yml
|
|
569
|
+
name: Architecture
|
|
570
|
+
on: [pull_request]
|
|
571
|
+
jobs:
|
|
572
|
+
validate:
|
|
573
|
+
runs-on: ubuntu-latest
|
|
574
|
+
steps:
|
|
575
|
+
- uses: actions/checkout@v4
|
|
576
|
+
- uses: actions/setup-node@v4
|
|
577
|
+
with: { node-version: "20" }
|
|
578
|
+
- uses: 6ixthxense/AST-MCP@v1.0.0
|
|
579
|
+
with:
|
|
580
|
+
path: src
|
|
581
|
+
max-lines: "400"
|
|
582
|
+
max-imports: "20"
|
|
583
|
+
max-exports: "15"
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
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>`.
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## Stability (1.0)
|
|
591
|
+
|
|
592
|
+
As of **v1.0.0**, the public surface is stable across the `1.x` line:
|
|
593
|
+
|
|
594
|
+
- **MCP tool names and input schemas** — no breaking changes; new tools and new *optional* inputs may be added.
|
|
595
|
+
- **CLI commands and flags** — stable; new commands/flags may be added.
|
|
596
|
+
- **Skeleton JSON** — `schemaVersion` follows additive-compatible evolution; new *optional* fields (e.g. `props`, `decorators`) may appear without a major bump.
|
|
597
|
+
|
|
598
|
+
Not part of the public API: the internal `src/` module layout and the generated HTML markup.
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
530
602
|
## Changelog
|
|
531
603
|
|
|
532
604
|
| Version | What changed |
|
|
533
605
|
|---------|--------------|
|
|
606
|
+
| **1.1.0** | **Monorepo support** — new `analyze_workspace` MCP tool + `ast-map workspace` (alias `ws`) CLI: discovers packages from npm/yarn `workspaces`, `pnpm-workspace.yaml`, or `lerna.json`, maps internal package dependencies, and flags circular package deps. **19 MCP tools**. |
|
|
607
|
+
| **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). |
|
|
608
|
+
| **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**. |
|
|
609
|
+
| **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. |
|
|
534
610
|
| **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
611
|
| **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
612
|
| **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. |
|
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
|
@@ -12,6 +12,8 @@ import { buildSymbolGraph } from "./graph.js";
|
|
|
12
12
|
import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTopSymbols, findDuplicateSymbols } from "./graph-analysis.js";
|
|
13
13
|
import { computeFileComplexity } from "./complexity.js";
|
|
14
14
|
import { findUnusedParams } from "./unused-params.js";
|
|
15
|
+
import { traceTypeInFile } from "./typeflow.js";
|
|
16
|
+
import { discoverWorkspace, findPackageCycles } from "./workspace.js";
|
|
15
17
|
import { buildCallGraph } from "./callgraph.js";
|
|
16
18
|
import { searchSymbols } from "./search.js";
|
|
17
19
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
@@ -368,6 +370,73 @@ program
|
|
|
368
370
|
}
|
|
369
371
|
console.log();
|
|
370
372
|
});
|
|
373
|
+
// ─── Command: workspace ───────────────────────────────────────────────────────
|
|
374
|
+
program
|
|
375
|
+
.command("workspace [dir]")
|
|
376
|
+
.alias("ws")
|
|
377
|
+
.description("Discover monorepo packages and their internal dependency graph")
|
|
378
|
+
.option("--json", "Output as JSON")
|
|
379
|
+
.action(async (dir, opts) => {
|
|
380
|
+
const { abs, rel } = resolveArg(dir ?? ".");
|
|
381
|
+
if (!fs.statSync(abs).isDirectory())
|
|
382
|
+
die(`"${rel}" is not a directory`);
|
|
383
|
+
const info = discoverWorkspace(abs);
|
|
384
|
+
const cycles = findPackageCycles(info);
|
|
385
|
+
if (opts.json) {
|
|
386
|
+
return jsonOut({ root: rel, tool: info.tool, packageCount: info.packages.length, packages: info.packages, edges: info.edges, packageCycles: cycles });
|
|
387
|
+
}
|
|
388
|
+
header(`Workspace — ${rel}/ ${dim(`(${info.tool}, ${info.packages.length} package(s))`)}`);
|
|
389
|
+
if (info.packages.length === 0) {
|
|
390
|
+
console.log(indent(dim("No workspace packages found (no workspaces/pnpm-workspace.yaml/lerna.json).")));
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
table(info.packages.map((p) => [
|
|
394
|
+
p.name,
|
|
395
|
+
p.dir,
|
|
396
|
+
p.internalDeps.length > 0 ? yellow(`→ ${p.internalDeps.join(", ")}`) : dim("(no internal deps)"),
|
|
397
|
+
]), [["Package", 24], ["Dir", 22], ["Internal deps", 34]]);
|
|
398
|
+
if (cycles.length > 0) {
|
|
399
|
+
console.log(`\n${indent(bold(yellow("Circular package dependencies:")))}`);
|
|
400
|
+
for (const c of cycles)
|
|
401
|
+
console.log(indent(`${yellow("↻")} ${c.join(dim(" → "))}`));
|
|
402
|
+
}
|
|
403
|
+
console.log(`\n ${info.edges.length} internal edge(s)` + (cycles.length ? ` · ${yellow(`${cycles.length} cycle(s)`)}` : ""));
|
|
404
|
+
}
|
|
405
|
+
console.log();
|
|
406
|
+
});
|
|
407
|
+
// ─── Command: trace-type ──────────────────────────────────────────────────────
|
|
408
|
+
program
|
|
409
|
+
.command("trace-type <type> [dir]")
|
|
410
|
+
.alias("flow")
|
|
411
|
+
.description("Trace a type through params, returns, variables and fields")
|
|
412
|
+
.option("--json", "Output as JSON")
|
|
413
|
+
.action(async (typeName, dir, opts) => {
|
|
414
|
+
const { abs, rel } = resolveArg(dir ?? ".");
|
|
415
|
+
if (!fs.statSync(abs).isDirectory())
|
|
416
|
+
die(`"${rel}" is not a directory`);
|
|
417
|
+
const sopts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
418
|
+
const refs = [];
|
|
419
|
+
for (const file of collectSourceFiles(abs, sopts)) {
|
|
420
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
421
|
+
refs.push(...(await traceTypeInFile(file, fileRel, typeName)));
|
|
422
|
+
}
|
|
423
|
+
if (opts.json)
|
|
424
|
+
return jsonOut({ type: typeName, dir: rel, refCount: refs.length, refs });
|
|
425
|
+
header(`Type Flow: ${bold(typeName)} — ${rel}/ ${dim(`(${refs.length} ref(s))`)}`);
|
|
426
|
+
if (refs.length === 0) {
|
|
427
|
+
console.log(indent(dim(`No references to type "${typeName}" found in signatures.`)));
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
const roleColor = (r) => (r === "return" ? green : r === "param" ? yellow : dim);
|
|
431
|
+
table(refs.map((r) => [
|
|
432
|
+
roleColor(r.role)(r.role),
|
|
433
|
+
r.symbol + (r.detail ? `(${r.detail})` : ""),
|
|
434
|
+
`:${r.line}`,
|
|
435
|
+
r.file,
|
|
436
|
+
]), [["Role", 9], ["Symbol", 24], ["Line", 6], ["File", 34]]);
|
|
437
|
+
}
|
|
438
|
+
console.log();
|
|
439
|
+
});
|
|
371
440
|
// ─── Command: unused-params ───────────────────────────────────────────────────
|
|
372
441
|
program
|
|
373
442
|
.command("unused-params <path>")
|
|
@@ -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
|
-
|
|
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
|
@@ -17,6 +17,8 @@ import { buildCallGraph } from "./callgraph.js";
|
|
|
17
17
|
import { searchSymbols } from "./search.js";
|
|
18
18
|
import { computeFileComplexity } from "./complexity.js";
|
|
19
19
|
import { findUnusedParams } from "./unused-params.js";
|
|
20
|
+
import { traceTypeInFile } from "./typeflow.js";
|
|
21
|
+
import { discoverWorkspace, findPackageCycles } from "./workspace.js";
|
|
20
22
|
/** Files may only be read inside this root (override with AST_MAP_ROOT). */
|
|
21
23
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
22
24
|
function resolveInRoot(input) {
|
|
@@ -667,6 +669,82 @@ server.registerTool("find_unused_params", {
|
|
|
667
669
|
return errorText(describeError(err));
|
|
668
670
|
}
|
|
669
671
|
});
|
|
672
|
+
/* ─────────────────── tool: trace_type ──────────────────────────────────── */
|
|
673
|
+
server.registerTool("trace_type", {
|
|
674
|
+
title: "Trace a type through the code",
|
|
675
|
+
description: "Find everywhere a named type flows through a directory: function parameters and return " +
|
|
676
|
+
"types, typed variables, and class fields. A scoped, AST-based type-flow view (best for " +
|
|
677
|
+
"TS/Python) \u2014 no full type inference, so it tracks where the type is *named* in signatures.",
|
|
678
|
+
inputSchema: {
|
|
679
|
+
type: z.string().describe('Type name to trace, e.g. "Inventory".'),
|
|
680
|
+
path: z.string().describe("Directory to scan, relative to project root or absolute within it."),
|
|
681
|
+
},
|
|
682
|
+
}, async ({ type: typeName, path: input }) => {
|
|
683
|
+
try {
|
|
684
|
+
const { abs, rel } = resolveInRoot(input);
|
|
685
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
686
|
+
return errorText(`"${input}" is not a directory. trace_type requires a directory.`);
|
|
687
|
+
}
|
|
688
|
+
const opts = resolveOptions({ detail: "outline", emitHtml: false });
|
|
689
|
+
const files = collectSourceFiles(abs, opts);
|
|
690
|
+
const refs = [];
|
|
691
|
+
const errors = [];
|
|
692
|
+
for (const file of files) {
|
|
693
|
+
const fileRel = path.relative(ROOT, file).split(path.sep).join("/");
|
|
694
|
+
try {
|
|
695
|
+
refs.push(...(await traceTypeInFile(file, fileRel, typeName)));
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
errors.push({ file: fileRel, error: describeError(err) });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const byRole = { param: 0, return: 0, variable: 0, field: 0 };
|
|
702
|
+
for (const r of refs)
|
|
703
|
+
byRole[r.role]++;
|
|
704
|
+
return jsonText({
|
|
705
|
+
type: typeName,
|
|
706
|
+
directory: rel.split(path.sep).join("/"),
|
|
707
|
+
scanned: files.length,
|
|
708
|
+
refCount: refs.length,
|
|
709
|
+
byRole,
|
|
710
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
711
|
+
refs,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
return errorText(describeError(err));
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
/* ─────────────────── tool: analyze_workspace ───────────────────────────── */
|
|
719
|
+
server.registerTool("analyze_workspace", {
|
|
720
|
+
title: "Analyze a monorepo workspace",
|
|
721
|
+
description: "Discover the packages in a JS/TS monorepo (npm/yarn `workspaces`, pnpm-workspace.yaml, or " +
|
|
722
|
+
"lerna.json) and the dependency edges between them. Returns each package's name, directory, " +
|
|
723
|
+
"and workspace-internal dependencies, plus any circular dependencies between packages.",
|
|
724
|
+
inputSchema: {
|
|
725
|
+
path: z.string().optional().describe("Workspace root directory. Defaults to the project root."),
|
|
726
|
+
},
|
|
727
|
+
}, async ({ path: input }) => {
|
|
728
|
+
try {
|
|
729
|
+
const { abs, rel } = resolveInRoot(input ?? ".");
|
|
730
|
+
if (!fs.statSync(abs).isDirectory()) {
|
|
731
|
+
return errorText(`"${input}" is not a directory. analyze_workspace requires a directory.`);
|
|
732
|
+
}
|
|
733
|
+
const info = discoverWorkspace(abs);
|
|
734
|
+
const cycles = findPackageCycles(info);
|
|
735
|
+
return jsonText({
|
|
736
|
+
root: rel.split(path.sep).join("/") || ".",
|
|
737
|
+
tool: info.tool,
|
|
738
|
+
packageCount: info.packages.length,
|
|
739
|
+
packages: info.packages,
|
|
740
|
+
edges: info.edges,
|
|
741
|
+
packageCycles: cycles,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
return errorText(describeError(err));
|
|
746
|
+
}
|
|
747
|
+
});
|
|
670
748
|
/* ─────────────────── tool: get_change_impact ───────────────────────────── */
|
|
671
749
|
server.registerTool("get_change_impact", {
|
|
672
750
|
title: "Get change impact (blast radius)",
|
package/dist/typeflow.js
ADDED
|
@@ -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,207 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage"]);
|
|
4
|
+
function readJson(file) {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Minimal `packages:` list reader for pnpm-workspace.yaml (no YAML dep). */
|
|
13
|
+
function readPnpmPatterns(file) {
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = fs.readFileSync(file, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const out = [];
|
|
22
|
+
let inPackages = false;
|
|
23
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
24
|
+
const line = raw.replace(/#.*$/, "");
|
|
25
|
+
if (/^packages\s*:/.test(line)) {
|
|
26
|
+
inPackages = true;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (inPackages) {
|
|
30
|
+
const m = line.match(/^\s*-\s*['"]?([^'"]+?)['"]?\s*$/);
|
|
31
|
+
if (m)
|
|
32
|
+
out.push(m[1].trim());
|
|
33
|
+
else if (/^\S/.test(line))
|
|
34
|
+
break; // next top-level key
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** Expand a workspace glob pattern to package directories containing package.json. */
|
|
40
|
+
function expandPattern(rootAbs, pattern) {
|
|
41
|
+
const clean = pattern.replace(/\/+$/, "");
|
|
42
|
+
const dirs = [];
|
|
43
|
+
const hasPkg = (dir) => fs.existsSync(path.join(dir, "package.json"));
|
|
44
|
+
if (!clean.includes("*")) {
|
|
45
|
+
const abs = path.resolve(rootAbs, clean);
|
|
46
|
+
if (hasPkg(abs))
|
|
47
|
+
dirs.push(abs);
|
|
48
|
+
return dirs;
|
|
49
|
+
}
|
|
50
|
+
if (clean.endsWith("/**")) {
|
|
51
|
+
const base = path.resolve(rootAbs, clean.slice(0, -3));
|
|
52
|
+
walkForPackages(base, dirs, 6);
|
|
53
|
+
return dirs;
|
|
54
|
+
}
|
|
55
|
+
// `prefix/*` — immediate subdirectories.
|
|
56
|
+
if (clean.endsWith("/*")) {
|
|
57
|
+
const base = path.resolve(rootAbs, clean.slice(0, -2));
|
|
58
|
+
let entries = [];
|
|
59
|
+
try {
|
|
60
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return dirs;
|
|
64
|
+
}
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
if (e.isDirectory() && !IGNORE_DIRS.has(e.name) && hasPkg(path.join(base, e.name))) {
|
|
67
|
+
dirs.push(path.join(base, e.name));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return dirs;
|
|
71
|
+
}
|
|
72
|
+
// Fallback: bounded recursive scan under the non-glob prefix.
|
|
73
|
+
const prefix = clean.split("*")[0];
|
|
74
|
+
walkForPackages(path.resolve(rootAbs, prefix), dirs, 6);
|
|
75
|
+
return dirs;
|
|
76
|
+
}
|
|
77
|
+
function walkForPackages(base, out, depth) {
|
|
78
|
+
if (depth < 0)
|
|
79
|
+
return;
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (fs.existsSync(path.join(base, "package.json")))
|
|
88
|
+
out.push(base);
|
|
89
|
+
for (const e of entries) {
|
|
90
|
+
if (e.isDirectory() && !IGNORE_DIRS.has(e.name)) {
|
|
91
|
+
walkForPackages(path.join(base, e.name), out, depth - 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function depNames(pkg) {
|
|
96
|
+
const out = new Set();
|
|
97
|
+
for (const key of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
98
|
+
const d = pkg?.[key];
|
|
99
|
+
if (d && typeof d === "object")
|
|
100
|
+
for (const k of Object.keys(d))
|
|
101
|
+
out.add(k);
|
|
102
|
+
}
|
|
103
|
+
return [...out];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Discover the packages in a JS/TS monorepo and the dependency edges between
|
|
107
|
+
* them. Supports npm/yarn `workspaces`, pnpm-workspace.yaml, and lerna.json.
|
|
108
|
+
*/
|
|
109
|
+
export function discoverWorkspace(rootAbs) {
|
|
110
|
+
const rootPkg = readJson(path.join(rootAbs, "package.json"));
|
|
111
|
+
let tool = "none";
|
|
112
|
+
const patterns = [];
|
|
113
|
+
// npm / yarn workspaces
|
|
114
|
+
const ws = rootPkg?.workspaces;
|
|
115
|
+
if (Array.isArray(ws)) {
|
|
116
|
+
patterns.push(...ws);
|
|
117
|
+
tool = "npm";
|
|
118
|
+
}
|
|
119
|
+
else if (ws && Array.isArray(ws.packages)) {
|
|
120
|
+
patterns.push(...ws.packages);
|
|
121
|
+
tool = "npm";
|
|
122
|
+
}
|
|
123
|
+
// pnpm
|
|
124
|
+
const pnpmFile = path.join(rootAbs, "pnpm-workspace.yaml");
|
|
125
|
+
if (fs.existsSync(pnpmFile)) {
|
|
126
|
+
patterns.push(...readPnpmPatterns(pnpmFile));
|
|
127
|
+
tool = "pnpm";
|
|
128
|
+
}
|
|
129
|
+
// lerna
|
|
130
|
+
const lerna = readJson(path.join(rootAbs, "lerna.json"));
|
|
131
|
+
if (lerna && Array.isArray(lerna.packages)) {
|
|
132
|
+
patterns.push(...lerna.packages);
|
|
133
|
+
if (tool === "none")
|
|
134
|
+
tool = "lerna";
|
|
135
|
+
}
|
|
136
|
+
// Dedupe patterns, expand to package dirs.
|
|
137
|
+
const dirSet = new Set();
|
|
138
|
+
for (const p of [...new Set(patterns)]) {
|
|
139
|
+
for (const d of expandPattern(rootAbs, p))
|
|
140
|
+
dirSet.add(d);
|
|
141
|
+
}
|
|
142
|
+
const packages = [];
|
|
143
|
+
const nameToPkg = new Map();
|
|
144
|
+
const pending = [];
|
|
145
|
+
for (const dirAbs of [...dirSet].sort()) {
|
|
146
|
+
const pj = readJson(path.join(dirAbs, "package.json"));
|
|
147
|
+
if (!pj || !pj.name)
|
|
148
|
+
continue;
|
|
149
|
+
const rel = path.relative(rootAbs, dirAbs).split(path.sep).join("/");
|
|
150
|
+
const allDeps = depNames(pj);
|
|
151
|
+
const wp = { name: pj.name, dir: rel || ".", internalDeps: [], allDeps };
|
|
152
|
+
packages.push(wp);
|
|
153
|
+
nameToPkg.set(pj.name, wp);
|
|
154
|
+
pending.push({ pkg: wp, deps: allDeps });
|
|
155
|
+
}
|
|
156
|
+
// Resolve internal deps now that all package names are known.
|
|
157
|
+
const edges = [];
|
|
158
|
+
for (const { pkg, deps } of pending) {
|
|
159
|
+
for (const d of deps) {
|
|
160
|
+
if (nameToPkg.has(d) && d !== pkg.name) {
|
|
161
|
+
pkg.internalDeps.push(d);
|
|
162
|
+
edges.push({ from: pkg.name, to: d });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
pkg.internalDeps.sort();
|
|
166
|
+
}
|
|
167
|
+
return { root: rootAbs, tool, packages, edges };
|
|
168
|
+
}
|
|
169
|
+
/** Detect circular dependencies among workspace packages (package-level). */
|
|
170
|
+
export function findPackageCycles(info) {
|
|
171
|
+
const adj = new Map();
|
|
172
|
+
for (const p of info.packages)
|
|
173
|
+
adj.set(p.name, p.internalDeps.slice());
|
|
174
|
+
const color = new Map();
|
|
175
|
+
for (const k of adj.keys())
|
|
176
|
+
color.set(k, "white");
|
|
177
|
+
const cycles = [];
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
const path = [];
|
|
180
|
+
const dfs = (node) => {
|
|
181
|
+
color.set(node, "gray");
|
|
182
|
+
path.push(node);
|
|
183
|
+
for (const next of adj.get(node) ?? []) {
|
|
184
|
+
const c = color.get(next);
|
|
185
|
+
if (c === "gray") {
|
|
186
|
+
const start = path.indexOf(next);
|
|
187
|
+
const raw = path.slice(start);
|
|
188
|
+
const min = raw.reduce((b, n, i) => (n < raw[b] ? i : b), 0);
|
|
189
|
+
const canon = [...raw.slice(min), ...raw.slice(0, min)];
|
|
190
|
+
const key = canon.join(">");
|
|
191
|
+
if (!seen.has(key)) {
|
|
192
|
+
seen.add(key);
|
|
193
|
+
cycles.push([...canon, canon[0]]);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else if (c === "white") {
|
|
197
|
+
dfs(next);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
path.pop();
|
|
201
|
+
color.set(node, "black");
|
|
202
|
+
};
|
|
203
|
+
for (const k of adj.keys())
|
|
204
|
+
if (color.get(k) === "white")
|
|
205
|
+
dfs(k);
|
|
206
|
+
return cycles;
|
|
207
|
+
}
|
package/package.json
CHANGED