typegraph-mcp 0.9.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/.claude-plugin/plugin.json +17 -0
- package/.cursor-plugin/plugin.json +17 -0
- package/.mcp.json +10 -0
- package/LICENSE +21 -0
- package/README.md +451 -0
- package/benchmark.ts +735 -0
- package/check.ts +459 -0
- package/cli.ts +778 -0
- package/commands/check.md +23 -0
- package/commands/test.md +23 -0
- package/config.ts +50 -0
- package/gemini-extension.json +16 -0
- package/graph-queries.ts +462 -0
- package/hooks/hooks.json +15 -0
- package/module-graph.ts +507 -0
- package/package.json +39 -0
- package/scripts/ensure-deps.sh +34 -0
- package/server.ts +837 -0
- package/skills/code-exploration/SKILL.md +55 -0
- package/skills/dependency-audit/SKILL.md +50 -0
- package/skills/impact-analysis/SKILL.md +52 -0
- package/skills/refactor-safety/SKILL.md +50 -0
- package/skills/tool-selection/SKILL.md +79 -0
- package/smoke-test.ts +500 -0
- package/tsserver-client.ts +413 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run typegraph-mcp health checks to verify setup
|
|
3
|
+
argument-hint: [--verbose]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# TypeGraph Health Check
|
|
7
|
+
|
|
8
|
+
Run health checks to verify typegraph-mcp is correctly set up for this project.
|
|
9
|
+
|
|
10
|
+
## Instructions
|
|
11
|
+
|
|
12
|
+
1. Run the health check command:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx tsx ${CLAUDE_PLUGIN_ROOT}/cli.ts check
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Parse the output and report results:
|
|
19
|
+
- Count of passed/failed/warned checks
|
|
20
|
+
- For any failures, highlight the issue and the suggested fix
|
|
21
|
+
- If all checks pass, confirm typegraph-mcp is ready
|
|
22
|
+
|
|
23
|
+
3. The check verifies: Node.js version, tsx availability, TypeScript installation, tsconfig.json, MCP registration, dependencies, oxc-parser, oxc-resolver, tsserver, module graph, ESLint ignores, and .gitignore configuration.
|
package/commands/test.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run smoke tests to verify all 14 typegraph-mcp tools work
|
|
3
|
+
argument-hint: [--verbose]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# TypeGraph Smoke Test
|
|
7
|
+
|
|
8
|
+
Run smoke tests that exercise all 14 tools against the current project.
|
|
9
|
+
|
|
10
|
+
## Instructions
|
|
11
|
+
|
|
12
|
+
1. Run the smoke test command:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx tsx ${CLAUDE_PLUGIN_ROOT}/cli.ts test
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Parse the output and report results:
|
|
19
|
+
- Total passed/failed/skipped
|
|
20
|
+
- For any failures, report the tool name and what went wrong
|
|
21
|
+
- If all pass, confirm all 14 tools are working
|
|
22
|
+
|
|
23
|
+
3. Tests dynamically discover a suitable file in the project and exercise: module graph build, dependency_tree, dependents, import_cycles, shortest_path, subgraph, module_boundary, navbar, find_symbol, definition, references, type_info, navigate_to, blast_radius, module_exports, and trace_chain.
|
package/config.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared configuration for typegraph-mcp.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the project root detection logic used by server.ts, check.ts,
|
|
5
|
+
* and smoke-test.ts into a single module.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface TypegraphConfig {
|
|
13
|
+
/** Absolute path to the target project root */
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
/** Relative tsconfig path (e.g. "./tsconfig.json") */
|
|
16
|
+
tsconfigPath: string;
|
|
17
|
+
/** Absolute path to the typegraph-mcp tool directory */
|
|
18
|
+
toolDir: string;
|
|
19
|
+
/** Whether typegraph-mcp is embedded inside the project (e.g. plugins/typegraph-mcp/) */
|
|
20
|
+
toolIsEmbedded: boolean;
|
|
21
|
+
/** Path to tool dir — relative to projectRoot if embedded, else absolute */
|
|
22
|
+
toolRelPath: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Resolution ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve typegraph-mcp configuration from the tool directory location.
|
|
29
|
+
*
|
|
30
|
+
* Project root detection (three-level fallback):
|
|
31
|
+
* 1. TYPEGRAPH_PROJECT_ROOT env var (explicit override)
|
|
32
|
+
* 2. If toolDir is inside a `plugins/` directory, go up two levels
|
|
33
|
+
* 3. Otherwise, use cwd (standalone deployment, run from target project)
|
|
34
|
+
*/
|
|
35
|
+
export function resolveConfig(toolDir: string): TypegraphConfig {
|
|
36
|
+
const cwd = process.cwd();
|
|
37
|
+
|
|
38
|
+
const projectRoot = process.env["TYPEGRAPH_PROJECT_ROOT"]
|
|
39
|
+
? path.resolve(cwd, process.env["TYPEGRAPH_PROJECT_ROOT"])
|
|
40
|
+
: path.basename(path.dirname(toolDir)) === "plugins"
|
|
41
|
+
? path.resolve(toolDir, "../..")
|
|
42
|
+
: cwd;
|
|
43
|
+
|
|
44
|
+
const tsconfigPath = process.env["TYPEGRAPH_TSCONFIG"] || "./tsconfig.json";
|
|
45
|
+
|
|
46
|
+
const toolIsEmbedded = toolDir.startsWith(projectRoot + path.sep);
|
|
47
|
+
const toolRelPath = toolIsEmbedded ? path.relative(projectRoot, toolDir) : toolDir;
|
|
48
|
+
|
|
49
|
+
return { projectRoot, tsconfigPath, toolDir, toolIsEmbedded, toolRelPath };
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "typegraph",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Type-aware TypeScript navigation — 14 MCP tools for go-to-definition, find-references, dependency graphs, cycle detection, and impact analysis",
|
|
5
|
+
"mcpServers": {
|
|
6
|
+
"typegraph": {
|
|
7
|
+
"command": "npx",
|
|
8
|
+
"args": ["tsx", "${extensionPath}/server.ts"],
|
|
9
|
+
"cwd": "${extensionPath}",
|
|
10
|
+
"env": {
|
|
11
|
+
"TYPEGRAPH_PROJECT_ROOT": ".",
|
|
12
|
+
"TYPEGRAPH_TSCONFIG": "./tsconfig.json"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
package/graph-queries.ts
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph Queries — Pure traversal functions for the module import graph.
|
|
3
|
+
*
|
|
4
|
+
* All functions take ModuleGraph as first parameter and are side-effect free.
|
|
5
|
+
* Paths in results are absolute; callers convert to relative for tool output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ModuleGraph, ImportEdge } from "./module-graph.js";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function shouldIncludeEdge(edge: ImportEdge, includeTypeOnly: boolean): boolean {
|
|
15
|
+
if (!includeTypeOnly && edge.isTypeOnly) return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── 1. dependencyTree ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface DepTreeOpts {
|
|
22
|
+
depth?: number; // default: unlimited
|
|
23
|
+
includeTypeOnly?: boolean; // default: false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DepTreeResult {
|
|
27
|
+
root: string;
|
|
28
|
+
nodes: number;
|
|
29
|
+
files: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* BFS forward traversal from `file`. Returns transitive dependencies
|
|
34
|
+
* in breadth-first order (direct deps first, then their deps, etc.).
|
|
35
|
+
*/
|
|
36
|
+
export function dependencyTree(
|
|
37
|
+
graph: ModuleGraph,
|
|
38
|
+
file: string,
|
|
39
|
+
opts: DepTreeOpts = {}
|
|
40
|
+
): DepTreeResult {
|
|
41
|
+
const { depth = Infinity, includeTypeOnly = false } = opts;
|
|
42
|
+
const visited = new Set<string>();
|
|
43
|
+
const result: string[] = [];
|
|
44
|
+
|
|
45
|
+
// BFS
|
|
46
|
+
let frontier = [file];
|
|
47
|
+
visited.add(file);
|
|
48
|
+
let currentDepth = 0;
|
|
49
|
+
|
|
50
|
+
while (frontier.length > 0 && currentDepth < depth) {
|
|
51
|
+
const nextFrontier: string[] = [];
|
|
52
|
+
for (const f of frontier) {
|
|
53
|
+
const edges = graph.forward.get(f) ?? [];
|
|
54
|
+
for (const edge of edges) {
|
|
55
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
56
|
+
if (visited.has(edge.target)) continue;
|
|
57
|
+
visited.add(edge.target);
|
|
58
|
+
result.push(edge.target);
|
|
59
|
+
nextFrontier.push(edge.target);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
frontier = nextFrontier;
|
|
63
|
+
currentDepth++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { root: file, nodes: result.length, files: result };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── 2. dependents ──────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface DependentsOpts {
|
|
72
|
+
depth?: number; // default: unlimited
|
|
73
|
+
includeTypeOnly?: boolean; // default: false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DependentsResult {
|
|
77
|
+
root: string;
|
|
78
|
+
nodes: number;
|
|
79
|
+
directCount: number;
|
|
80
|
+
files: string[];
|
|
81
|
+
byPackage: Record<string, string[]>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Cache for package.json lookups — maps directory to package name */
|
|
85
|
+
const packageNameCache = new Map<string, string>();
|
|
86
|
+
|
|
87
|
+
function findPackageName(filePath: string): string {
|
|
88
|
+
let dir = path.dirname(filePath);
|
|
89
|
+
while (dir !== path.dirname(dir)) {
|
|
90
|
+
if (packageNameCache.has(dir)) return packageNameCache.get(dir)!;
|
|
91
|
+
const pkgJsonPath = path.join(dir, "package.json");
|
|
92
|
+
try {
|
|
93
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
94
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
95
|
+
const name = pkg.name ?? path.basename(dir);
|
|
96
|
+
packageNameCache.set(dir, name);
|
|
97
|
+
return name;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Skip unreadable package.json
|
|
101
|
+
}
|
|
102
|
+
dir = path.dirname(dir);
|
|
103
|
+
}
|
|
104
|
+
return "<root>";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* BFS reverse traversal from `file`. Returns files that (transitively) depend on this file.
|
|
109
|
+
* Groups results by nearest package.json ancestor.
|
|
110
|
+
*/
|
|
111
|
+
export function dependents(
|
|
112
|
+
graph: ModuleGraph,
|
|
113
|
+
file: string,
|
|
114
|
+
opts: DependentsOpts = {}
|
|
115
|
+
): DependentsResult {
|
|
116
|
+
const { depth = Infinity, includeTypeOnly = false } = opts;
|
|
117
|
+
const visited = new Set<string>();
|
|
118
|
+
const result: string[] = [];
|
|
119
|
+
let directCount = 0;
|
|
120
|
+
|
|
121
|
+
let frontier = [file];
|
|
122
|
+
visited.add(file);
|
|
123
|
+
let currentDepth = 0;
|
|
124
|
+
|
|
125
|
+
while (frontier.length > 0 && currentDepth < depth) {
|
|
126
|
+
const nextFrontier: string[] = [];
|
|
127
|
+
for (const f of frontier) {
|
|
128
|
+
const edges = graph.reverse.get(f) ?? [];
|
|
129
|
+
for (const edge of edges) {
|
|
130
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
131
|
+
if (visited.has(edge.target)) continue;
|
|
132
|
+
visited.add(edge.target);
|
|
133
|
+
result.push(edge.target);
|
|
134
|
+
if (currentDepth === 0) directCount++;
|
|
135
|
+
nextFrontier.push(edge.target);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
frontier = nextFrontier;
|
|
139
|
+
currentDepth++;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Group by package
|
|
143
|
+
const byPackage: Record<string, string[]> = {};
|
|
144
|
+
for (const f of result) {
|
|
145
|
+
const pkgName = findPackageName(f);
|
|
146
|
+
if (!byPackage[pkgName]) byPackage[pkgName] = [];
|
|
147
|
+
byPackage[pkgName]!.push(f);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { root: file, nodes: result.length, directCount, files: result, byPackage };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── 3. importCycles ────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export interface CycleOpts {
|
|
156
|
+
file?: string; // filter to cycles containing this file
|
|
157
|
+
package?: string; // filter to cycles within this directory
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface CycleResult {
|
|
161
|
+
count: number;
|
|
162
|
+
cycles: string[][];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Find import cycles using Tarjan's SCC algorithm.
|
|
167
|
+
* Returns strongly connected components with more than 1 node.
|
|
168
|
+
*/
|
|
169
|
+
export function importCycles(
|
|
170
|
+
graph: ModuleGraph,
|
|
171
|
+
opts: CycleOpts = {}
|
|
172
|
+
): CycleResult {
|
|
173
|
+
const { file, package: pkgDir } = opts;
|
|
174
|
+
|
|
175
|
+
// Tarjan's SCC
|
|
176
|
+
let index = 0;
|
|
177
|
+
const stack: string[] = [];
|
|
178
|
+
const onStack = new Set<string>();
|
|
179
|
+
const indices = new Map<string, number>();
|
|
180
|
+
const lowlinks = new Map<string, number>();
|
|
181
|
+
const sccs: string[][] = [];
|
|
182
|
+
|
|
183
|
+
function strongconnect(v: string): void {
|
|
184
|
+
indices.set(v, index);
|
|
185
|
+
lowlinks.set(v, index);
|
|
186
|
+
index++;
|
|
187
|
+
stack.push(v);
|
|
188
|
+
onStack.add(v);
|
|
189
|
+
|
|
190
|
+
const edges = graph.forward.get(v) ?? [];
|
|
191
|
+
for (const edge of edges) {
|
|
192
|
+
const w = edge.target;
|
|
193
|
+
if (!graph.files.has(w)) continue; // skip external
|
|
194
|
+
if (!indices.has(w)) {
|
|
195
|
+
strongconnect(w);
|
|
196
|
+
lowlinks.set(v, Math.min(lowlinks.get(v)!, lowlinks.get(w)!));
|
|
197
|
+
} else if (onStack.has(w)) {
|
|
198
|
+
lowlinks.set(v, Math.min(lowlinks.get(v)!, indices.get(w)!));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Root of SCC?
|
|
203
|
+
if (lowlinks.get(v) === indices.get(v)) {
|
|
204
|
+
const scc: string[] = [];
|
|
205
|
+
let w: string;
|
|
206
|
+
do {
|
|
207
|
+
w = stack.pop()!;
|
|
208
|
+
onStack.delete(w);
|
|
209
|
+
scc.push(w);
|
|
210
|
+
} while (w !== v);
|
|
211
|
+
if (scc.length > 1) {
|
|
212
|
+
sccs.push(scc);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const f of graph.files) {
|
|
218
|
+
if (!indices.has(f)) {
|
|
219
|
+
strongconnect(f);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Apply filters
|
|
224
|
+
let cycles = sccs;
|
|
225
|
+
if (file) {
|
|
226
|
+
cycles = cycles.filter((scc) => scc.includes(file));
|
|
227
|
+
}
|
|
228
|
+
if (pkgDir) {
|
|
229
|
+
const absPkgDir = path.resolve(pkgDir);
|
|
230
|
+
cycles = cycles.filter((scc) =>
|
|
231
|
+
scc.every((f) => f.startsWith(absPkgDir))
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { count: cycles.length, cycles };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── 4. shortestPath ────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
export interface PathOpts {
|
|
241
|
+
includeTypeOnly?: boolean; // default: false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface PathResult {
|
|
245
|
+
path: string[] | null;
|
|
246
|
+
hops: number;
|
|
247
|
+
chain: Array<{ file: string; imports: string[] }>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* BFS on forward graph from `from` to `to`.
|
|
252
|
+
* Returns the shortest import path with specifier names at each hop.
|
|
253
|
+
*/
|
|
254
|
+
export function shortestPath(
|
|
255
|
+
graph: ModuleGraph,
|
|
256
|
+
from: string,
|
|
257
|
+
to: string,
|
|
258
|
+
opts: PathOpts = {}
|
|
259
|
+
): PathResult {
|
|
260
|
+
const { includeTypeOnly = false } = opts;
|
|
261
|
+
|
|
262
|
+
if (from === to) {
|
|
263
|
+
return { path: [from], hops: 0, chain: [{ file: from, imports: [] }] };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// BFS with parent tracking
|
|
267
|
+
const visited = new Set<string>();
|
|
268
|
+
const parent = new Map<string, { from: string; specifiers: string[] }>();
|
|
269
|
+
visited.add(from);
|
|
270
|
+
let frontier = [from];
|
|
271
|
+
|
|
272
|
+
while (frontier.length > 0) {
|
|
273
|
+
const nextFrontier: string[] = [];
|
|
274
|
+
for (const f of frontier) {
|
|
275
|
+
const edges = graph.forward.get(f) ?? [];
|
|
276
|
+
for (const edge of edges) {
|
|
277
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
278
|
+
if (visited.has(edge.target)) continue;
|
|
279
|
+
visited.add(edge.target);
|
|
280
|
+
parent.set(edge.target, { from: f, specifiers: edge.specifiers });
|
|
281
|
+
|
|
282
|
+
if (edge.target === to) {
|
|
283
|
+
// Reconstruct path
|
|
284
|
+
const filePath: string[] = [to];
|
|
285
|
+
let current = to;
|
|
286
|
+
while (parent.has(current)) {
|
|
287
|
+
current = parent.get(current)!.from;
|
|
288
|
+
filePath.unshift(current);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const chain: Array<{ file: string; imports: string[] }> = [];
|
|
292
|
+
for (let i = 0; i < filePath.length; i++) {
|
|
293
|
+
const p = parent.get(filePath[i]!);
|
|
294
|
+
chain.push({
|
|
295
|
+
file: filePath[i]!,
|
|
296
|
+
imports: p?.specifiers ?? [],
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { path: filePath, hops: filePath.length - 1, chain };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
nextFrontier.push(edge.target);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
frontier = nextFrontier;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { path: null, hops: -1, chain: [] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── 5. subgraph ────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export interface SubgraphOpts {
|
|
315
|
+
depth?: number; // default: 1
|
|
316
|
+
direction?: "imports" | "dependents" | "both"; // default: "both"
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface SubgraphResult {
|
|
320
|
+
nodes: string[];
|
|
321
|
+
edges: Array<{
|
|
322
|
+
from: string;
|
|
323
|
+
to: string;
|
|
324
|
+
specifiers: string[];
|
|
325
|
+
isTypeOnly: boolean;
|
|
326
|
+
}>;
|
|
327
|
+
stats: { nodeCount: number; edgeCount: number };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Expand from seed files by `depth` hops in the specified direction.
|
|
332
|
+
* Returns the induced subgraph (all edges between discovered nodes).
|
|
333
|
+
*/
|
|
334
|
+
export function subgraph(
|
|
335
|
+
graph: ModuleGraph,
|
|
336
|
+
files: string[],
|
|
337
|
+
opts: SubgraphOpts = {}
|
|
338
|
+
): SubgraphResult {
|
|
339
|
+
const { depth = 1, direction = "both" } = opts;
|
|
340
|
+
const visited = new Set<string>(files);
|
|
341
|
+
|
|
342
|
+
let frontier = [...files];
|
|
343
|
+
for (let d = 0; d < depth && frontier.length > 0; d++) {
|
|
344
|
+
const nextFrontier: string[] = [];
|
|
345
|
+
for (const f of frontier) {
|
|
346
|
+
if (direction === "imports" || direction === "both") {
|
|
347
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
348
|
+
if (!visited.has(edge.target)) {
|
|
349
|
+
visited.add(edge.target);
|
|
350
|
+
nextFrontier.push(edge.target);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (direction === "dependents" || direction === "both") {
|
|
355
|
+
for (const edge of graph.reverse.get(f) ?? []) {
|
|
356
|
+
if (!visited.has(edge.target)) {
|
|
357
|
+
visited.add(edge.target);
|
|
358
|
+
nextFrontier.push(edge.target);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
frontier = nextFrontier;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Collect all edges between discovered nodes
|
|
367
|
+
const nodes = [...visited];
|
|
368
|
+
const edges: SubgraphResult["edges"] = [];
|
|
369
|
+
for (const f of nodes) {
|
|
370
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
371
|
+
if (visited.has(edge.target)) {
|
|
372
|
+
edges.push({
|
|
373
|
+
from: f,
|
|
374
|
+
to: edge.target,
|
|
375
|
+
specifiers: edge.specifiers,
|
|
376
|
+
isTypeOnly: edge.isTypeOnly,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { nodes, edges, stats: { nodeCount: nodes.length, edgeCount: edges.length } };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── 6. moduleBoundary ──────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
export interface BoundaryResult {
|
|
388
|
+
internalEdges: number;
|
|
389
|
+
incomingEdges: Array<{ from: string; to: string; specifiers: string[] }>;
|
|
390
|
+
outgoingEdges: Array<{ from: string; to: string; specifiers: string[] }>;
|
|
391
|
+
sharedDependencies: string[];
|
|
392
|
+
isolationScore: number; // 0–1
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Cut-line analysis for a set of files.
|
|
397
|
+
* Identifies incoming/outgoing edges and computes an isolation score.
|
|
398
|
+
*/
|
|
399
|
+
export function moduleBoundary(
|
|
400
|
+
graph: ModuleGraph,
|
|
401
|
+
files: string[]
|
|
402
|
+
): BoundaryResult {
|
|
403
|
+
const fileSet = new Set(files);
|
|
404
|
+
|
|
405
|
+
let internalEdges = 0;
|
|
406
|
+
const incomingEdges: BoundaryResult["incomingEdges"] = [];
|
|
407
|
+
const outgoingEdges: BoundaryResult["outgoingEdges"] = [];
|
|
408
|
+
const outgoingTargets = new Set<string>();
|
|
409
|
+
|
|
410
|
+
// Count internal edges and outgoing edges
|
|
411
|
+
for (const f of files) {
|
|
412
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
413
|
+
if (fileSet.has(edge.target)) {
|
|
414
|
+
internalEdges++;
|
|
415
|
+
} else {
|
|
416
|
+
outgoingEdges.push({
|
|
417
|
+
from: f,
|
|
418
|
+
to: edge.target,
|
|
419
|
+
specifiers: edge.specifiers,
|
|
420
|
+
});
|
|
421
|
+
outgoingTargets.add(edge.target);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Find incoming edges (files outside the set that import files in the set)
|
|
427
|
+
for (const f of files) {
|
|
428
|
+
for (const edge of graph.reverse.get(f) ?? []) {
|
|
429
|
+
if (!fileSet.has(edge.target)) {
|
|
430
|
+
incomingEdges.push({
|
|
431
|
+
from: edge.target,
|
|
432
|
+
to: f,
|
|
433
|
+
specifiers: edge.specifiers,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Shared dependencies: outgoing targets that are imported by multiple files in the set
|
|
440
|
+
const depCounts = new Map<string, number>();
|
|
441
|
+
for (const f of files) {
|
|
442
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
443
|
+
if (!fileSet.has(edge.target)) {
|
|
444
|
+
depCounts.set(edge.target, (depCounts.get(edge.target) ?? 0) + 1);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const sharedDependencies = [...depCounts.entries()]
|
|
449
|
+
.filter(([, count]) => count > 1)
|
|
450
|
+
.map(([dep]) => dep);
|
|
451
|
+
|
|
452
|
+
const total = internalEdges + incomingEdges.length + outgoingEdges.length;
|
|
453
|
+
const isolationScore = total === 0 ? 1 : internalEdges / total;
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
internalEdges,
|
|
457
|
+
incomingEdges,
|
|
458
|
+
outgoingEdges,
|
|
459
|
+
sharedDependencies,
|
|
460
|
+
isolationScore,
|
|
461
|
+
};
|
|
462
|
+
}
|