universal-ast-mapper 1.0.0 → 1.2.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 +21 -0
- package/dist/cli.js +35 -0
- package/dist/graph.js +5 -3
- package/dist/index.js +31 -0
- package/dist/resolver.js +8 -3
- package/dist/workspace.js +330 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ 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
99
|
ast-map trace-type <type> [dir] [alias: flow]
|
|
100
|
+
ast-map workspace [dir] [alias: ws]
|
|
100
101
|
ast-map search <pattern> [dir] [-m contains|exact|regex] [-k kind] [-e]
|
|
101
102
|
ast-map deps <file> [--scan <dir>]
|
|
102
103
|
ast-map top <dir> [-n 10]
|
|
@@ -325,6 +326,24 @@ Scan a file or directory for **named functions/methods with parameters that are
|
|
|
325
326
|
|
|
326
327
|
---
|
|
327
328
|
|
|
329
|
+
> **Monorepo note:** once a workspace is detected, `resolve_imports` and `build_symbol_graph` resolve cross-package imports (`@org/pkg`) to real source files and draw cross-package edges.
|
|
330
|
+
|
|
331
|
+
### `analyze_workspace`
|
|
332
|
+
**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.
|
|
333
|
+
|
|
334
|
+
```json
|
|
335
|
+
{
|
|
336
|
+
"tool": "npm", "packageCount": 3,
|
|
337
|
+
"packages": [ { "name": "@demo/a", "dir": "packages/a", "internalDeps": ["@demo/b"] } ],
|
|
338
|
+
"edges": [ { "from": "@demo/a", "to": "@demo/b" } ],
|
|
339
|
+
"packageCycles": []
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
**Params:** `path` (optional, defaults to root)
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
328
347
|
### `get_change_impact`
|
|
329
348
|
Given a file + symbol, reverse-traverse the import graph to compute **blast radius**.
|
|
330
349
|
|
|
@@ -586,6 +605,8 @@ Not part of the public API: the internal `src/` module layout and the generated
|
|
|
586
605
|
|
|
587
606
|
| Version | What changed |
|
|
588
607
|
|---------|--------------|
|
|
608
|
+
| **1.2.0** | **File-level cross-package resolution** — in a monorepo, bare imports of a workspace package (`@org/utils`, `@org/utils/sub`) now resolve to the actual source file (preferring `src/` over built `dist/`), so `resolve_imports` marks them in-project and `build_symbol_graph` draws cross-package edges. Builds on the v1.1.0 workspace discovery. |
|
|
609
|
+
| **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**. |
|
|
589
610
|
| **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
611
|
| **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
612
|
| **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. |
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { findDeadExports, findCircularDeps, getChangeImpact, getFileDeps, getTop
|
|
|
13
13
|
import { computeFileComplexity } from "./complexity.js";
|
|
14
14
|
import { findUnusedParams } from "./unused-params.js";
|
|
15
15
|
import { traceTypeInFile } from "./typeflow.js";
|
|
16
|
+
import { discoverWorkspace, findPackageCycles } from "./workspace.js";
|
|
16
17
|
import { buildCallGraph } from "./callgraph.js";
|
|
17
18
|
import { searchSymbols } from "./search.js";
|
|
18
19
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
@@ -369,6 +370,40 @@ program
|
|
|
369
370
|
}
|
|
370
371
|
console.log();
|
|
371
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
|
+
});
|
|
372
407
|
// ─── Command: trace-type ──────────────────────────────────────────────────────
|
|
373
408
|
program
|
|
374
409
|
.command("trace-type <type> [dir]")
|
package/dist/graph.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { resolveImportPath } from "./resolver.js";
|
|
3
|
+
import { resolveWorkspaceImportCached } from "./workspace.js";
|
|
3
4
|
import { buildCrossLangIndex, resolveCrossLangTarget, } from "./crosslang.js";
|
|
4
5
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
5
6
|
function collectSymbolNodes(symbols, parentId, file, nodes, edges) {
|
|
@@ -30,11 +31,12 @@ function isPathBasedLanguage(language) {
|
|
|
30
31
|
}
|
|
31
32
|
// Wire one TS/JS/Python-style relative import.
|
|
32
33
|
function wirePathImport(skel, imp, fromFileAbs, root, exportedSymbolMap, edges) {
|
|
33
|
-
if (!imp.from.startsWith("."))
|
|
34
|
-
return;
|
|
35
34
|
if (imp.isSideEffect)
|
|
36
35
|
return;
|
|
37
|
-
|
|
36
|
+
// Relative import → path resolve; bare specifier → monorepo workspace package.
|
|
37
|
+
const resolvedAbs = imp.from.startsWith(".")
|
|
38
|
+
? resolveImportPath(imp.from, fromFileAbs)
|
|
39
|
+
: resolveWorkspaceImportCached(imp.from, root);
|
|
38
40
|
if (!resolvedAbs)
|
|
39
41
|
return;
|
|
40
42
|
const resolvedRel = path.relative(root, resolvedAbs).split(path.sep).join("/");
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { searchSymbols } from "./search.js";
|
|
|
18
18
|
import { computeFileComplexity } from "./complexity.js";
|
|
19
19
|
import { findUnusedParams } from "./unused-params.js";
|
|
20
20
|
import { traceTypeInFile } from "./typeflow.js";
|
|
21
|
+
import { discoverWorkspace, findPackageCycles } from "./workspace.js";
|
|
21
22
|
/** Files may only be read inside this root (override with AST_MAP_ROOT). */
|
|
22
23
|
const ROOT = path.resolve(process.env.AST_MAP_ROOT ?? process.cwd());
|
|
23
24
|
function resolveInRoot(input) {
|
|
@@ -714,6 +715,36 @@ server.registerTool("trace_type", {
|
|
|
714
715
|
return errorText(describeError(err));
|
|
715
716
|
}
|
|
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
|
+
});
|
|
717
748
|
/* ─────────────────── tool: get_change_impact ───────────────────────────── */
|
|
718
749
|
server.registerTool("get_change_impact", {
|
|
719
750
|
title: "Get change impact (blast radius)",
|
package/dist/resolver.js
CHANGED
|
@@ -4,6 +4,7 @@ import { buildSkeleton, collectSourceFiles } from "./skeleton.js";
|
|
|
4
4
|
import { resolveOptions } from "./config.js";
|
|
5
5
|
import { findSymbol } from "./analysis.js";
|
|
6
6
|
import { buildCrossLangIndex, resolveCrossLangTarget, } from "./crosslang.js";
|
|
7
|
+
import { resolveWorkspaceImportCached } from "./workspace.js";
|
|
7
8
|
const SRC_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
|
|
8
9
|
function extractParams(sig) {
|
|
9
10
|
const start = sig.indexOf("(");
|
|
@@ -120,8 +121,12 @@ async function lookupSymbolInTarget(targetAbs, targetRel, symbol) {
|
|
|
120
121
|
return { found: false };
|
|
121
122
|
}
|
|
122
123
|
async function enrichRelativeImport(imp, fromAbs, root) {
|
|
123
|
-
const
|
|
124
|
-
|
|
124
|
+
const isBare = !imp.from.startsWith(".");
|
|
125
|
+
// Relative import → path resolve; bare specifier → try monorepo workspace.
|
|
126
|
+
let resolvedAbs = isBare ? null : resolveImportPath(imp.from, fromAbs);
|
|
127
|
+
if (!resolvedAbs && isBare)
|
|
128
|
+
resolvedAbs = resolveWorkspaceImportCached(imp.from, root);
|
|
129
|
+
const treatedExternal = isBare && !resolvedAbs;
|
|
125
130
|
const resolvedRel = resolvedAbs
|
|
126
131
|
? path.relative(root, resolvedAbs).split(path.sep).join("/")
|
|
127
132
|
: null;
|
|
@@ -132,7 +137,7 @@ async function enrichRelativeImport(imp, fromAbs, root) {
|
|
|
132
137
|
else if (resolvedAbs) {
|
|
133
138
|
enrichment = { found: true };
|
|
134
139
|
}
|
|
135
|
-
return assembleResolved(imp, resolvedAbs, resolvedRel,
|
|
140
|
+
return assembleResolved(imp, resolvedAbs, resolvedRel, treatedExternal, enrichment);
|
|
136
141
|
}
|
|
137
142
|
async function enrichCrossLangImport(imp, skel, fromAbs, root, index) {
|
|
138
143
|
const target = resolveCrossLangTarget(imp, skel, fromAbs, root, index);
|
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
}
|
|
208
|
+
/* ─── File-level cross-package import resolution ───────────────────────────── */
|
|
209
|
+
const SRC_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
210
|
+
const JS_TO_SRC = {
|
|
211
|
+
".js": [".ts", ".tsx", ".js"],
|
|
212
|
+
".jsx": [".tsx", ".jsx"],
|
|
213
|
+
".mjs": [".mts", ".mjs"],
|
|
214
|
+
".cjs": [".cts", ".cjs"],
|
|
215
|
+
};
|
|
216
|
+
function existsFileSync(p) {
|
|
217
|
+
try {
|
|
218
|
+
return fs.statSync(p).isFile();
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Resolve a candidate path (file, extensionless, or directory) to a real source
|
|
226
|
+
* file, preferring TS sources over the declared `.js` build output.
|
|
227
|
+
*/
|
|
228
|
+
function resolveSourceFile(candidate) {
|
|
229
|
+
const ext = path.extname(candidate).toLowerCase();
|
|
230
|
+
if (ext && JS_TO_SRC[ext]) {
|
|
231
|
+
const base = candidate.slice(0, candidate.length - ext.length);
|
|
232
|
+
for (const e of JS_TO_SRC[ext]) {
|
|
233
|
+
if (existsFileSync(base + e))
|
|
234
|
+
return base + e;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (existsFileSync(candidate))
|
|
238
|
+
return candidate;
|
|
239
|
+
for (const e of SRC_EXTS)
|
|
240
|
+
if (existsFileSync(candidate + e))
|
|
241
|
+
return candidate + e;
|
|
242
|
+
for (const e of SRC_EXTS) {
|
|
243
|
+
const idx = path.join(candidate, `index${e}`);
|
|
244
|
+
if (existsFileSync(idx))
|
|
245
|
+
return idx;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
/** Pick the source entry file of a package, preferring real source over dist. */
|
|
250
|
+
function packageEntry(pkgDir, pkg) {
|
|
251
|
+
const candidates = [];
|
|
252
|
+
// explicit source-ish fields first
|
|
253
|
+
for (const f of [pkg?.source, pkg?.module, pkg?.types, pkg?.typings, pkg?.main]) {
|
|
254
|
+
if (typeof f === "string")
|
|
255
|
+
candidates.push(f);
|
|
256
|
+
}
|
|
257
|
+
// exports["."] as a string or { source/import/default/types }
|
|
258
|
+
const dot = pkg?.exports?.["."] ?? pkg?.exports;
|
|
259
|
+
if (typeof dot === "string")
|
|
260
|
+
candidates.push(dot);
|
|
261
|
+
else if (dot && typeof dot === "object") {
|
|
262
|
+
for (const k of ["source", "import", "default", "types"]) {
|
|
263
|
+
if (typeof dot[k] === "string")
|
|
264
|
+
candidates.push(dot[k]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// conventional source roots
|
|
268
|
+
candidates.push("src/index.ts", "src/index.tsx", "src/index.js", "index.ts", "index.tsx", "index.js");
|
|
269
|
+
for (const c of candidates) {
|
|
270
|
+
const resolved = resolveSourceFile(path.resolve(pkgDir, c));
|
|
271
|
+
if (resolved)
|
|
272
|
+
return resolved;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Resolve a bare import specifier to an in-monorepo source file when it targets
|
|
278
|
+
* a workspace package. Handles exact package imports (`@org/utils`) and subpaths
|
|
279
|
+
* (`@org/utils/helpers`). Returns null for non-workspace (external) specifiers.
|
|
280
|
+
*/
|
|
281
|
+
export function resolveWorkspaceImport(importFrom, rootAbs, info) {
|
|
282
|
+
if (importFrom.startsWith("."))
|
|
283
|
+
return null; // relative, not a package import
|
|
284
|
+
let match = null;
|
|
285
|
+
let subpath = "";
|
|
286
|
+
for (const p of info.packages) {
|
|
287
|
+
if (importFrom === p.name) {
|
|
288
|
+
match = p;
|
|
289
|
+
subpath = "";
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
if (importFrom.startsWith(p.name + "/")) {
|
|
293
|
+
match = p;
|
|
294
|
+
subpath = importFrom.slice(p.name.length + 1);
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (!match)
|
|
299
|
+
return null;
|
|
300
|
+
const pkgDir = path.resolve(rootAbs, match.dir);
|
|
301
|
+
const pj = readJson(path.join(pkgDir, "package.json"));
|
|
302
|
+
const entry = packageEntry(pkgDir, pj);
|
|
303
|
+
if (!subpath)
|
|
304
|
+
return entry;
|
|
305
|
+
// Subpath import (`@org/utils/helpers`): resolve against the source root
|
|
306
|
+
// (the entry file's directory, typically `src/`) first, then the package dir.
|
|
307
|
+
const srcRoot = entry ? path.dirname(entry) : pkgDir;
|
|
308
|
+
return (resolveSourceFile(path.resolve(srcRoot, subpath)) ??
|
|
309
|
+
resolveSourceFile(path.resolve(pkgDir, subpath)));
|
|
310
|
+
}
|
|
311
|
+
/* ─── Cached per-root workspace index (for resolvers) ──────────────────────── */
|
|
312
|
+
const workspaceCache = new Map();
|
|
313
|
+
/** Discover (and cache) the workspace for a root, then resolve a package import. */
|
|
314
|
+
export function resolveWorkspaceImportCached(importFrom, rootAbs) {
|
|
315
|
+
if (importFrom.startsWith("."))
|
|
316
|
+
return null;
|
|
317
|
+
const key = path.resolve(rootAbs);
|
|
318
|
+
let info = workspaceCache.get(key);
|
|
319
|
+
if (!info) {
|
|
320
|
+
info = discoverWorkspace(key);
|
|
321
|
+
workspaceCache.set(key, info);
|
|
322
|
+
}
|
|
323
|
+
if (info.packages.length === 0)
|
|
324
|
+
return null;
|
|
325
|
+
return resolveWorkspaceImport(importFrom, key, info);
|
|
326
|
+
}
|
|
327
|
+
/** Test/debug hook: drop the cached workspace index. */
|
|
328
|
+
export function clearWorkspaceCache() {
|
|
329
|
+
workspaceCache.clear();
|
|
330
|
+
}
|
package/package.json
CHANGED