@kridaydave/code-mapper 1.0.0 → 1.0.1
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/CHANGELOG.md +31 -0
- package/README.md +1 -0
- package/bin/code-mapper.mjs +86 -0
- package/dist/graph/GraphAnalyzer.js +32 -65
- package/dist/graph/GraphAnalyzer.js.map +1 -1
- package/dist/graph/GraphBuilder.js +18 -45
- package/dist/graph/GraphBuilder.js.map +1 -1
- package/dist/index.js +100 -23
- package/dist/index.js.map +1 -1
- package/dist/mcp/cache.js +8 -17
- package/dist/mcp/cache.js.map +1 -1
- package/dist/mcp/resources.js +5 -1
- package/dist/mcp/resources.js.map +1 -1
- package/dist/mcp/tools.js +190 -35
- package/dist/mcp/tools.js.map +1 -1
- package/dist/parser/ComplexityAnalyzer.js +19 -2
- package/dist/parser/ComplexityAnalyzer.js.map +1 -1
- package/dist/parser/FileAnalyzer.js +8 -30
- package/dist/parser/FileAnalyzer.js.map +1 -1
- package/dist/parser/ProjectParser.js +8 -5
- package/dist/parser/ProjectParser.js.map +1 -1
- package/dist/parser/ProjectParser.test.js +1 -17
- package/dist/parser/ProjectParser.test.js.map +1 -1
- package/dist/tui/index.js +239 -0
- package/dist/tui/index.js.map +1 -0
- package/package.json +82 -35
- package/AGENTS.md +0 -174
- package/docs/PHASE2_PLAN.md +0 -435
- package/fixtures/test-project/calculator.ts +0 -28
- package/fixtures/test-project/index.ts +0 -2
- package/fixtures/test-project/math.ts +0 -11
- package/src/graph/Graph.test.ts +0 -222
- package/src/graph/GraphAnalyzer.ts +0 -502
- package/src/graph/GraphBuilder.ts +0 -258
- package/src/graph/types.ts +0 -42
- package/src/index.ts +0 -38
- package/src/mcp/cache.ts +0 -89
- package/src/mcp/resources.ts +0 -137
- package/src/mcp/tools.test.ts +0 -104
- package/src/mcp/tools.ts +0 -529
- package/src/parser/ComplexityAnalyzer.ts +0 -275
- package/src/parser/FileAnalyzer.ts +0 -215
- package/src/parser/ProjectParser.test.ts +0 -96
- package/src/parser/ProjectParser.ts +0 -172
- package/src/parser/types.ts +0 -77
- package/src/types/graphology-pagerank.d.ts +0 -20
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -15
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
const require = createRequire(import.meta.url);
|
|
3
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
4
|
-
const Graph: typeof import("graphology").default = require("graphology");
|
|
5
|
-
import type { AbstractGraph, Attributes } from "graphology-types";
|
|
6
|
-
import { ParseResult } from "../parser/types.js";
|
|
7
|
-
import { GraphNode, GraphEdge } from "./types.js";
|
|
8
|
-
|
|
9
|
-
type CodeGraph = AbstractGraph<Attributes, Attributes, Attributes>;
|
|
10
|
-
|
|
11
|
-
export class GraphBuilder {
|
|
12
|
-
build(parseResult: ParseResult): { graph: CodeGraph; nodes: GraphNode[]; edges: GraphEdge[] } {
|
|
13
|
-
const graph = new Graph({ multi: true });
|
|
14
|
-
const nodes: GraphNode[] = [];
|
|
15
|
-
const edges: GraphEdge[] = [];
|
|
16
|
-
|
|
17
|
-
const classLookup = new Map<string, { filePath: string; name: string; lineNumber: number }>();
|
|
18
|
-
for (const fileInfo of parseResult.files) {
|
|
19
|
-
for (const cls of fileInfo.classes) {
|
|
20
|
-
classLookup.set(fileInfo.filePath + "::" + cls.name, { filePath: fileInfo.filePath, name: cls.name, lineNumber: cls.lineNumber });
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const fileNodeIds = new Map<string, string>();
|
|
25
|
-
|
|
26
|
-
// Phase 1: Create file nodes
|
|
27
|
-
for (const fileInfo of parseResult.files) {
|
|
28
|
-
const fileId = `file:${fileInfo.filePath}`;
|
|
29
|
-
fileNodeIds.set(fileInfo.relativePath, fileId);
|
|
30
|
-
fileNodeIds.set(fileInfo.filePath, fileId);
|
|
31
|
-
|
|
32
|
-
graph.addNode(fileId, {
|
|
33
|
-
kind: "file",
|
|
34
|
-
label: fileInfo.relativePath,
|
|
35
|
-
filePath: fileInfo.filePath,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
nodes.push({
|
|
39
|
-
id: fileId,
|
|
40
|
-
kind: "file",
|
|
41
|
-
label: fileInfo.relativePath,
|
|
42
|
-
filePath: fileInfo.filePath,
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Phase 2: Create function and class nodes, add containment edges
|
|
47
|
-
for (const fileInfo of parseResult.files) {
|
|
48
|
-
const fileId = `file:${fileInfo.filePath}`;
|
|
49
|
-
|
|
50
|
-
for (const fn of fileInfo.functions) {
|
|
51
|
-
const fnId = `fn:${fileInfo.filePath}:${fn.name}:${fn.lineNumber}`;
|
|
52
|
-
graph.addNode(fnId, {
|
|
53
|
-
kind: "function",
|
|
54
|
-
label: fn.name,
|
|
55
|
-
filePath: fileInfo.filePath,
|
|
56
|
-
lineNumber: fn.lineNumber,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
graph.addEdge(fileId, fnId, {
|
|
60
|
-
kind: "contains",
|
|
61
|
-
label: `contains ${fn.name}`,
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
edges.push({
|
|
65
|
-
source: fileId,
|
|
66
|
-
target: fnId,
|
|
67
|
-
kind: "contains",
|
|
68
|
-
label: `contains ${fn.name}`,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
nodes.push({
|
|
72
|
-
id: fnId,
|
|
73
|
-
kind: "function",
|
|
74
|
-
label: fn.name,
|
|
75
|
-
filePath: fileInfo.filePath,
|
|
76
|
-
lineNumber: fn.lineNumber,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
for (const cls of fileInfo.classes) {
|
|
81
|
-
const clsId = `class:${fileInfo.filePath}:${cls.name}:${cls.lineNumber}`;
|
|
82
|
-
graph.addNode(clsId, {
|
|
83
|
-
kind: "class",
|
|
84
|
-
label: cls.name,
|
|
85
|
-
filePath: fileInfo.filePath,
|
|
86
|
-
lineNumber: cls.lineNumber,
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
graph.addEdge(fileId, clsId, {
|
|
90
|
-
kind: "contains",
|
|
91
|
-
label: `contains ${cls.name}`,
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
edges.push({
|
|
95
|
-
source: fileId,
|
|
96
|
-
target: clsId,
|
|
97
|
-
kind: "contains",
|
|
98
|
-
label: `contains ${cls.name}`,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
nodes.push({
|
|
102
|
-
id: clsId,
|
|
103
|
-
kind: "class",
|
|
104
|
-
label: cls.name,
|
|
105
|
-
filePath: fileInfo.filePath,
|
|
106
|
-
lineNumber: cls.lineNumber,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Phase 3: Build import edges between files
|
|
112
|
-
for (const fileInfo of parseResult.files) {
|
|
113
|
-
const sourceFileId = `file:${fileInfo.filePath}`;
|
|
114
|
-
|
|
115
|
-
for (const imp of fileInfo.imports) {
|
|
116
|
-
const targetPath = this.resolveImportPath(fileInfo.filePath, imp.moduleSpecifier, parseResult);
|
|
117
|
-
if (targetPath) {
|
|
118
|
-
const targetFileId = `file:${targetPath}`;
|
|
119
|
-
if (graph.hasNode(targetFileId)) {
|
|
120
|
-
const edgeLabel = imp.namedImports.length > 0
|
|
121
|
-
? `imports {${imp.namedImports.join(", ")}}`
|
|
122
|
-
: imp.defaultImport
|
|
123
|
-
? `imports ${imp.defaultImport}`
|
|
124
|
-
: "imports module";
|
|
125
|
-
|
|
126
|
-
if (!graph.hasEdge(sourceFileId, targetFileId)) {
|
|
127
|
-
graph.addEdge(sourceFileId, targetFileId, {
|
|
128
|
-
kind: "imports",
|
|
129
|
-
label: edgeLabel,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
edges.push({
|
|
133
|
-
source: sourceFileId,
|
|
134
|
-
target: targetFileId,
|
|
135
|
-
kind: "imports",
|
|
136
|
-
label: edgeLabel,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Phase 4: Build extends/implements edges for classes
|
|
145
|
-
for (const fileInfo of parseResult.files) {
|
|
146
|
-
for (const cls of fileInfo.classes) {
|
|
147
|
-
const clsId = `class:${fileInfo.filePath}:${cls.name}:${cls.lineNumber}`;
|
|
148
|
-
|
|
149
|
-
if (cls.extends) {
|
|
150
|
-
// Try to find the parent class in the codebase
|
|
151
|
-
const parentName = cls.extends.split("<")[0].trim(); // Handle generics
|
|
152
|
-
const parentClass = this.findClassByName(parentName, parseResult, classLookup);
|
|
153
|
-
if (parentClass) {
|
|
154
|
-
const parentId = `class:${parentClass.filePath}:${parentClass.name}:${parentClass.lineNumber}`;
|
|
155
|
-
if (graph.hasNode(parentId)) {
|
|
156
|
-
graph.addEdge(clsId, parentId, {
|
|
157
|
-
kind: "extends",
|
|
158
|
-
label: `extends ${parentClass.name}`,
|
|
159
|
-
});
|
|
160
|
-
edges.push({
|
|
161
|
-
source: clsId,
|
|
162
|
-
target: parentId,
|
|
163
|
-
kind: "extends",
|
|
164
|
-
label: `extends ${parentClass.name}`,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
for (const impl of cls.implements) {
|
|
171
|
-
const implName = impl.split("<")[0].trim();
|
|
172
|
-
const implClass = this.findClassByName(implName, parseResult, classLookup);
|
|
173
|
-
if (implClass) {
|
|
174
|
-
const implId = `class:${implClass.filePath}:${implClass.name}:${implClass.lineNumber}`;
|
|
175
|
-
if (graph.hasNode(implId)) {
|
|
176
|
-
graph.addEdge(clsId, implId, {
|
|
177
|
-
kind: "implements",
|
|
178
|
-
label: `implements ${implClass.name}`,
|
|
179
|
-
});
|
|
180
|
-
edges.push({
|
|
181
|
-
source: clsId,
|
|
182
|
-
target: implId,
|
|
183
|
-
kind: "implements",
|
|
184
|
-
label: `implements ${implClass.name}`,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return { graph, nodes, edges };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private resolveImportPath(fromFile: string, moduleSpecifier: string, parseResult: ParseResult): string | null {
|
|
196
|
-
// Skip external packages (no leading . or /)
|
|
197
|
-
if (!moduleSpecifier.startsWith(".") && !moduleSpecifier.startsWith("/")) {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Strip .js/.jsx/.ts/.tsx extension from the specifier
|
|
202
|
-
let specifier = moduleSpecifier;
|
|
203
|
-
for (const ext of [".js", ".jsx", ".ts", ".tsx"]) {
|
|
204
|
-
if (specifier.endsWith(ext)) {
|
|
205
|
-
specifier = specifier.slice(0, -ext.length);
|
|
206
|
-
break;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Resolve relative path using forward-slash logic (ts-morph always uses /)
|
|
211
|
-
const fromDir = fromFile.replace(/\\/g, "/").replace(/\/[^/]+$/, "");
|
|
212
|
-
const parts = [...(fromDir === "" ? [] : fromDir.split("/")), ...specifier.split("/")];
|
|
213
|
-
const resolved: string[] = [];
|
|
214
|
-
for (const part of parts) {
|
|
215
|
-
if (part === "..") {
|
|
216
|
-
if (resolved.length > 0) {
|
|
217
|
-
resolved.pop();
|
|
218
|
-
}
|
|
219
|
-
// else: excessive ".." — ignore, prevents escaping root
|
|
220
|
-
} else if (part !== "." && part !== "") {
|
|
221
|
-
resolved.push(part);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// Reconstruct with the drive prefix if present
|
|
225
|
-
let resolvedPath = resolved.join("/");
|
|
226
|
-
// If original fromFile had a Windows drive letter, preserve it
|
|
227
|
-
const fromFileNormalized = fromFile.replace(/\\/g, "/");
|
|
228
|
-
const driveMatch = fromFileNormalized.match(/^([A-Za-z]:\/)/);
|
|
229
|
-
if (driveMatch && !resolvedPath.startsWith(driveMatch[1])) {
|
|
230
|
-
resolvedPath = driveMatch[1] + resolvedPath;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Try exact match first
|
|
234
|
-
if (parseResult.files.some(f => f.filePath === resolvedPath)) {
|
|
235
|
-
return resolvedPath;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Try adding extensions
|
|
239
|
-
const extensions = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
|
|
240
|
-
for (const ext of extensions) {
|
|
241
|
-
const candidate = resolvedPath + ext;
|
|
242
|
-
if (parseResult.files.some(f => f.filePath === candidate)) {
|
|
243
|
-
return candidate;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
private findClassByName(name: string, _parseResult: ParseResult, classLookup: Map<string, { filePath: string; name: string; lineNumber: number }>): { filePath: string; name: string; lineNumber: number } | null {
|
|
251
|
-
for (const [_key, value] of classLookup) {
|
|
252
|
-
if (value.name === name) {
|
|
253
|
-
return value;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
}
|
package/src/graph/types.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
export interface GraphNode {
|
|
2
|
-
id: string;
|
|
3
|
-
kind: "file" | "function" | "class";
|
|
4
|
-
label: string;
|
|
5
|
-
filePath: string;
|
|
6
|
-
lineNumber?: number;
|
|
7
|
-
metadata?: Record<string, unknown>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface GraphEdge {
|
|
11
|
-
source: string;
|
|
12
|
-
target: string;
|
|
13
|
-
kind: "imports" | "calls" | "extends" | "implements" | "contains";
|
|
14
|
-
label: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface RankedFile {
|
|
18
|
-
filePath: string;
|
|
19
|
-
relativePath: string;
|
|
20
|
-
score: number;
|
|
21
|
-
metric: string;
|
|
22
|
-
functionCount: number;
|
|
23
|
-
classCount: number;
|
|
24
|
-
importCount: number;
|
|
25
|
-
exportCount: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface FunctionMatch {
|
|
29
|
-
name: string;
|
|
30
|
-
filePath: string;
|
|
31
|
-
relativePath: string;
|
|
32
|
-
lineNumber: number;
|
|
33
|
-
kind: "function" | "class";
|
|
34
|
-
parameters: string[];
|
|
35
|
-
returnType: string;
|
|
36
|
-
isExported: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface CallChainResult {
|
|
40
|
-
found: boolean;
|
|
41
|
-
paths: string[][];
|
|
42
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { McpServer } from "@modelcontextprotocol/server";
|
|
4
|
-
import { StdioServerTransport } from "@modelcontextprotocol/server";
|
|
5
|
-
import { registerTools } from "./mcp/tools.js";
|
|
6
|
-
import { registerResources } from "./mcp/resources.js";
|
|
7
|
-
|
|
8
|
-
async function main() {
|
|
9
|
-
const server = new McpServer(
|
|
10
|
-
{
|
|
11
|
-
name: "code-mapper",
|
|
12
|
-
version: "1.0.0",
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
instructions:
|
|
16
|
-
"CodeMapper analyzes TypeScript/JavaScript codebases using AST parsing. " +
|
|
17
|
-
"Always start by calling scan_codebase with the target directory. " +
|
|
18
|
-
"Then use find_function to locate symbols, analyze_dependencies to see the graph, " +
|
|
19
|
-
"rank_impact to find central files, or trace_call_chain to follow dependency paths. " +
|
|
20
|
-
"The codebase://summary and codebase://graph/{format} resources provide cached views.",
|
|
21
|
-
}
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
// Register all tools and resources
|
|
25
|
-
registerTools(server);
|
|
26
|
-
registerResources(server);
|
|
27
|
-
|
|
28
|
-
// Connect via stdio
|
|
29
|
-
const transport = new StdioServerTransport();
|
|
30
|
-
await server.connect(transport);
|
|
31
|
-
|
|
32
|
-
console.error("CodeMapper MCP server running on stdio");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
main().catch((error) => {
|
|
36
|
-
console.error("Fatal error:", error);
|
|
37
|
-
process.exit(1);
|
|
38
|
-
});
|
package/src/mcp/cache.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
|
-
import { GraphAnalyzer } from "../graph/GraphAnalyzer.js";
|
|
3
|
-
|
|
4
|
-
interface CacheEntry {
|
|
5
|
-
analyzer: GraphAnalyzer;
|
|
6
|
-
timestamp: number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const MAX_CACHE_SIZE = 10;
|
|
10
|
-
const CACHE_TTL_MS = 30 * 60 * 1000;
|
|
11
|
-
|
|
12
|
-
export const analyzerCache = new Map<string, CacheEntry>();
|
|
13
|
-
const pendingAnalyzers = new Map<string, Promise<GraphAnalyzer>>();
|
|
14
|
-
|
|
15
|
-
let _lastScannedDirectory: string | null = null;
|
|
16
|
-
|
|
17
|
-
export function setLastScannedDirectory(dir: string): void {
|
|
18
|
-
_lastScannedDirectory = dir;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function getLastScannedDirectory(): string | null {
|
|
22
|
-
return _lastScannedDirectory;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function normalizeCacheKey(directory: string): string {
|
|
26
|
-
return resolve(directory);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function evictIfNeeded(): void {
|
|
30
|
-
if (analyzerCache.size >= MAX_CACHE_SIZE) {
|
|
31
|
-
let oldestKey: string | null = null;
|
|
32
|
-
let oldestTime = Infinity;
|
|
33
|
-
for (const [key, entry] of analyzerCache) {
|
|
34
|
-
if (entry.timestamp < oldestTime) {
|
|
35
|
-
oldestTime = entry.timestamp;
|
|
36
|
-
oldestKey = key;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
if (oldestKey) {
|
|
40
|
-
analyzerCache.delete(oldestKey);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function isEntryExpired(entry: CacheEntry): boolean {
|
|
46
|
-
return Date.now() - entry.timestamp > CACHE_TTL_MS;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function getAnalyzerFromCache(directory: string): GraphAnalyzer | undefined {
|
|
50
|
-
const normalizedDir = normalizeCacheKey(directory);
|
|
51
|
-
const entry = analyzerCache.get(normalizedDir);
|
|
52
|
-
if (entry && !isEntryExpired(entry)) {
|
|
53
|
-
return entry.analyzer;
|
|
54
|
-
}
|
|
55
|
-
if (entry) {
|
|
56
|
-
analyzerCache.delete(normalizedDir);
|
|
57
|
-
}
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function setAnalyzerInCache(directory: string, analyzer: GraphAnalyzer): void {
|
|
62
|
-
evictIfNeeded();
|
|
63
|
-
const normalizedDir = normalizeCacheKey(directory);
|
|
64
|
-
analyzerCache.set(normalizedDir, {
|
|
65
|
-
analyzer,
|
|
66
|
-
timestamp: Date.now(),
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function getPendingAnalyzer(directory: string): Promise<GraphAnalyzer> | undefined {
|
|
71
|
-
const normalizedDir = normalizeCacheKey(directory);
|
|
72
|
-
return pendingAnalyzers.get(normalizedDir);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function setPendingAnalyzer(directory: string, promise: Promise<GraphAnalyzer>): void {
|
|
76
|
-
const normalizedDir = normalizeCacheKey(directory);
|
|
77
|
-
pendingAnalyzers.set(normalizedDir, promise);
|
|
78
|
-
promise.finally(() => {
|
|
79
|
-
pendingAnalyzers.delete(normalizedDir);
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function clearAnalyzerCache(directory?: string): void {
|
|
84
|
-
if (directory) {
|
|
85
|
-
analyzerCache.delete(normalizeCacheKey(directory));
|
|
86
|
-
} else {
|
|
87
|
-
analyzerCache.clear();
|
|
88
|
-
}
|
|
89
|
-
}
|
package/src/mcp/resources.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
|
|
2
|
-
import { GraphNode, GraphEdge, RankedFile } from "../graph/types.js";
|
|
3
|
-
import { analyzerCache, getLastScannedDirectory, getAnalyzerFromCache } from "./cache.js";
|
|
4
|
-
|
|
5
|
-
const RANK_CACHE_LIMIT = 100;
|
|
6
|
-
const rankCache = new Map<string, RankedFile[]>();
|
|
7
|
-
|
|
8
|
-
export function registerResources(server: McpServer): void {
|
|
9
|
-
// Resource 1: codebase://summary
|
|
10
|
-
server.registerResource(
|
|
11
|
-
"codebase-summary",
|
|
12
|
-
"codebase://summary",
|
|
13
|
-
{
|
|
14
|
-
title: "Codebase Summary",
|
|
15
|
-
description: "Returns a summary of the most recently scanned codebase. Scan a codebase first using the scan_codebase tool.",
|
|
16
|
-
mimeType: "application/json",
|
|
17
|
-
},
|
|
18
|
-
async () => {
|
|
19
|
-
if (analyzerCache.size === 0) {
|
|
20
|
-
return {
|
|
21
|
-
contents: [{
|
|
22
|
-
uri: "codebase://summary",
|
|
23
|
-
text: JSON.stringify({ message: "No codebase has been scanned yet. Use the scan_codebase tool first." }, null, 2),
|
|
24
|
-
}],
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const summaries: Record<string, unknown>[] = [];
|
|
29
|
-
for (const [directory, entry] of analyzerCache) {
|
|
30
|
-
const analyzer = entry.analyzer;
|
|
31
|
-
const nodes = analyzer.getNodes();
|
|
32
|
-
const edges = analyzer.getEdges();
|
|
33
|
-
let ranked: RankedFile[];
|
|
34
|
-
if (rankCache.has(directory)) {
|
|
35
|
-
ranked = rankCache.get(directory)!;
|
|
36
|
-
} else {
|
|
37
|
-
ranked = analyzer.rankImpact("inDegree");
|
|
38
|
-
rankCache.set(directory, ranked);
|
|
39
|
-
}
|
|
40
|
-
const cycles = analyzer.detectCycles();
|
|
41
|
-
|
|
42
|
-
const fileNodes = nodes.filter((n: GraphNode) => n.kind === "file");
|
|
43
|
-
const functionNodes = nodes.filter((n: GraphNode) => n.kind === "function");
|
|
44
|
-
const classNodes = nodes.filter((n: GraphNode) => n.kind === "class");
|
|
45
|
-
|
|
46
|
-
summaries.push({
|
|
47
|
-
directory,
|
|
48
|
-
totalFiles: fileNodes.length,
|
|
49
|
-
totalFunctions: functionNodes.length,
|
|
50
|
-
totalClasses: classNodes.length,
|
|
51
|
-
totalEdges: edges.length,
|
|
52
|
-
mostCentralFile: ranked.length > 0 ? ranked[0] : null,
|
|
53
|
-
cycleCount: cycles.length,
|
|
54
|
-
topDependencies: ranked.slice(0, 5).map((r: RankedFile) => ({
|
|
55
|
-
path: r.relativePath,
|
|
56
|
-
inDegree: r.score,
|
|
57
|
-
})),
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
contents: [{
|
|
63
|
-
uri: "codebase://summary",
|
|
64
|
-
text: JSON.stringify({ scannedCodebases: summaries }, null, 2),
|
|
65
|
-
}],
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
// Resource 2: codebase://graph/{format}
|
|
71
|
-
server.registerResource(
|
|
72
|
-
"codebase-graph",
|
|
73
|
-
new ResourceTemplate("codebase://graph/{format}", {
|
|
74
|
-
list: async () => ({
|
|
75
|
-
resources: [
|
|
76
|
-
{ uri: "codebase://graph/json", name: "Graph (JSON)" },
|
|
77
|
-
{ uri: "codebase://graph/mermaid", name: "Graph (Mermaid)" },
|
|
78
|
-
],
|
|
79
|
-
}),
|
|
80
|
-
}),
|
|
81
|
-
{
|
|
82
|
-
title: "Codebase Dependency Graph",
|
|
83
|
-
description: "Returns the dependency graph of the scanned codebase in JSON or Mermaid format.",
|
|
84
|
-
mimeType: "application/json",
|
|
85
|
-
},
|
|
86
|
-
async (uri, { format }) => {
|
|
87
|
-
if (analyzerCache.size === 0) {
|
|
88
|
-
return {
|
|
89
|
-
contents: [{
|
|
90
|
-
uri: uri.href,
|
|
91
|
-
text: JSON.stringify({ message: "No codebase has been scanned yet. Use the scan_codebase tool first." }, null, 2),
|
|
92
|
-
}],
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const targetKey = getLastScannedDirectory();
|
|
97
|
-
if (!targetKey) {
|
|
98
|
-
return {
|
|
99
|
-
contents: [{
|
|
100
|
-
uri: uri.href,
|
|
101
|
-
text: JSON.stringify({ message: "No codebase has been scanned yet. Use the scan_codebase tool first." }, null, 2),
|
|
102
|
-
}],
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
const entry = analyzerCache.get(targetKey);
|
|
106
|
-
if (!entry) {
|
|
107
|
-
return {
|
|
108
|
-
contents: [{
|
|
109
|
-
uri: uri.href,
|
|
110
|
-
text: JSON.stringify({ message: "Analyzer not found for the most recently scanned codebase." }, null, 2),
|
|
111
|
-
}],
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
const analyzer = entry.analyzer;
|
|
115
|
-
const nodes = analyzer.getNodes();
|
|
116
|
-
const edges = analyzer.getEdges();
|
|
117
|
-
|
|
118
|
-
let content: string;
|
|
119
|
-
if (format === "mermaid") {
|
|
120
|
-
content = analyzer.toMermaid();
|
|
121
|
-
} else {
|
|
122
|
-
content = JSON.stringify({
|
|
123
|
-
directory: targetKey,
|
|
124
|
-
nodes: nodes.map((n: GraphNode) => ({ id: n.id, kind: n.kind, label: n.label, filePath: n.filePath })),
|
|
125
|
-
edges: edges.map((e: GraphEdge) => ({ source: e.source, target: e.target, kind: e.kind, label: e.label })),
|
|
126
|
-
}, null, 2);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
contents: [{
|
|
131
|
-
uri: uri.href,
|
|
132
|
-
text: content,
|
|
133
|
-
}],
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
);
|
|
137
|
-
}
|
package/src/mcp/tools.test.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
validateDirectory,
|
|
5
|
-
getAnalyzer,
|
|
6
|
-
safeHandler,
|
|
7
|
-
clearAnalyzerCache,
|
|
8
|
-
} from "../mcp/tools.js";
|
|
9
|
-
|
|
10
|
-
describe("tools", () => {
|
|
11
|
-
describe("validateDirectory", () => {
|
|
12
|
-
it("should accept valid directory", () => {
|
|
13
|
-
const testDir = path.resolve("./fixtures/test-project");
|
|
14
|
-
const result = validateDirectory(testDir);
|
|
15
|
-
expect(result).toBe(testDir);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("should throw for non-existent directory", () => {
|
|
19
|
-
expect(() => validateDirectory("/non/existent/path")).toThrow();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("should throw for file instead of directory", () => {
|
|
23
|
-
const testFile = path.resolve("./fixtures/test-project/math.ts");
|
|
24
|
-
expect(() => validateDirectory(testFile)).toThrow();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("should block Windows system paths", () => {
|
|
28
|
-
expect(() => validateDirectory("C:\\Windows\\System32")).toThrow();
|
|
29
|
-
expect(() => validateDirectory("C:\\Program Files")).toThrow();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("should block Unix system paths", () => {
|
|
33
|
-
expect(() => validateDirectory("/etc/passwd")).toThrow();
|
|
34
|
-
expect(() => validateDirectory("/usr/bin")).toThrow();
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("safeHandler", () => {
|
|
39
|
-
it("should return result on success", async () => {
|
|
40
|
-
const handler = vi.fn().mockResolvedValue({
|
|
41
|
-
content: [{ type: "text", text: "success" }],
|
|
42
|
-
});
|
|
43
|
-
const result = await safeHandler(handler);
|
|
44
|
-
|
|
45
|
-
expect(handler).toHaveBeenCalled();
|
|
46
|
-
expect(result.content[0].text).toBe("success");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("should catch errors and return error content", async () => {
|
|
50
|
-
const handler = vi.fn().mockRejectedValue(new Error("test error"));
|
|
51
|
-
const result = await safeHandler(handler);
|
|
52
|
-
|
|
53
|
-
expect(result.isError).toBe(true);
|
|
54
|
-
expect(result.content[0].text).toContain("test error");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("should handle non-Error rejections", async () => {
|
|
58
|
-
const handler = vi.fn().mockRejectedValue("string error");
|
|
59
|
-
const result = await safeHandler(handler);
|
|
60
|
-
|
|
61
|
-
expect(result.isError).toBe(true);
|
|
62
|
-
expect(result.content[0].text).toContain("string error");
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("getAnalyzer", () => {
|
|
67
|
-
beforeEach(() => {
|
|
68
|
-
clearAnalyzerCache();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("should create analyzer for valid directory", async () => {
|
|
72
|
-
const testDir = path.resolve("./fixtures/test-project");
|
|
73
|
-
const analyzer = await getAnalyzer(testDir);
|
|
74
|
-
|
|
75
|
-
expect(analyzer).toBeDefined();
|
|
76
|
-
expect(analyzer.getParseResult().totalFiles).toBe(3);
|
|
77
|
-
}, 30000);
|
|
78
|
-
|
|
79
|
-
it("should cache analyzer for same directory", async () => {
|
|
80
|
-
const testDir = path.resolve("./fixtures/test-project");
|
|
81
|
-
const analyzer1 = await getAnalyzer(testDir);
|
|
82
|
-
const analyzer2 = await getAnalyzer(testDir);
|
|
83
|
-
|
|
84
|
-
expect(analyzer1).toBe(analyzer2);
|
|
85
|
-
}, 30000);
|
|
86
|
-
|
|
87
|
-
it("should throw for invalid directory", async () => {
|
|
88
|
-
const testDir = path.resolve("./fixtures/test-project");
|
|
89
|
-
await getAnalyzer(testDir);
|
|
90
|
-
expect(true).toBe(true);
|
|
91
|
-
}, 30000);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe("clearAnalyzerCache", () => {
|
|
95
|
-
it("should clear cache when called without argument", async () => {
|
|
96
|
-
const testDir = path.resolve("./fixtures/test-project");
|
|
97
|
-
await getAnalyzer(testDir);
|
|
98
|
-
|
|
99
|
-
clearAnalyzerCache();
|
|
100
|
-
const analyzer = await getAnalyzer(testDir);
|
|
101
|
-
expect(analyzer.getParseResult().totalFiles).toBe(3);
|
|
102
|
-
}, 30000);
|
|
103
|
-
});
|
|
104
|
-
});
|