milens 0.4.0 → 0.4.2
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/LICENSE +75 -75
- package/README.md +479 -453
- package/dist/analyzer/config.d.ts +8 -0
- package/dist/analyzer/config.d.ts.map +1 -0
- package/dist/analyzer/config.js +132 -0
- package/dist/analyzer/config.js.map +1 -0
- package/dist/analyzer/engine.d.ts.map +1 -1
- package/dist/analyzer/engine.js +77 -4
- package/dist/analyzer/engine.js.map +1 -1
- package/dist/analyzer/enrich.d.ts +18 -0
- package/dist/analyzer/enrich.d.ts.map +1 -0
- package/dist/analyzer/enrich.js +139 -0
- package/dist/analyzer/enrich.js.map +1 -0
- package/dist/analyzer/resolver.d.ts +10 -1
- package/dist/analyzer/resolver.d.ts.map +1 -1
- package/dist/analyzer/resolver.js +309 -18
- package/dist/analyzer/resolver.js.map +1 -1
- package/dist/cli.js +478 -32
- package/dist/cli.js.map +1 -1
- package/dist/parser/extract.d.ts +2 -0
- package/dist/parser/extract.d.ts.map +1 -1
- package/dist/parser/extract.js +27 -3
- package/dist/parser/extract.js.map +1 -1
- package/dist/parser/lang-go.js +22 -22
- package/dist/parser/lang-go.js.map +1 -1
- package/dist/parser/lang-java.d.ts.map +1 -1
- package/dist/parser/lang-java.js +29 -25
- package/dist/parser/lang-java.js.map +1 -1
- package/dist/parser/lang-js.d.ts.map +1 -1
- package/dist/parser/lang-js.js +60 -43
- package/dist/parser/lang-js.js.map +1 -1
- package/dist/parser/lang-php.d.ts.map +1 -1
- package/dist/parser/lang-php.js +39 -33
- package/dist/parser/lang-php.js.map +1 -1
- package/dist/parser/lang-py.js +31 -31
- package/dist/parser/lang-ruby.d.ts +4 -0
- package/dist/parser/lang-ruby.d.ts.map +1 -0
- package/dist/parser/lang-ruby.js +50 -0
- package/dist/parser/lang-ruby.js.map +1 -0
- package/dist/parser/lang-rust.js +24 -24
- package/dist/parser/lang-ts.d.ts.map +1 -1
- package/dist/parser/lang-ts.js +73 -57
- package/dist/parser/lang-ts.js.map +1 -1
- package/dist/parser/languages.d.ts.map +1 -1
- package/dist/parser/languages.js +2 -1
- package/dist/parser/languages.js.map +1 -1
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +883 -95
- package/dist/server/mcp.js.map +1 -1
- package/dist/skills.js +100 -88
- package/dist/skills.js.map +1 -1
- package/dist/store/db.d.ts +62 -0
- package/dist/store/db.d.ts.map +1 -1
- package/dist/store/db.js +244 -59
- package/dist/store/db.js.map +1 -1
- package/dist/store/schema.sql +83 -60
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +60 -60
package/dist/server/mcp.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
1
|
+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { resolve, relative, join } from 'node:path';
|
|
7
|
+
import { resolve, relative, join, dirname } from 'node:path';
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
10
11
|
import ignore from 'ignore';
|
|
11
12
|
import { Database } from '../store/db.js';
|
|
12
13
|
import { RepoRegistry } from '../store/registry.js';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
13
17
|
// ── Lazy DB connection with idle eviction ──
|
|
14
18
|
class LazyDb {
|
|
15
19
|
dbPath;
|
|
@@ -42,15 +46,72 @@ class LazyDb {
|
|
|
42
46
|
this.instance = null;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
|
-
// ──
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
// ── Tool usage tracking ──
|
|
50
|
+
// Estimated tokens an agent would spend WITHOUT milens (manual exploration cost per tool)
|
|
51
|
+
const TOKEN_SAVINGS_MULTIPLIER = {
|
|
52
|
+
query: 3, // vs 3+ separate grep/file reads
|
|
53
|
+
grep: 2, // vs terminal grep + manual filtering
|
|
54
|
+
context: 5, // vs incoming + outgoing + file reads
|
|
55
|
+
impact: 6, // vs recursive manual upstream/downstream exploration
|
|
56
|
+
edit_check: 8, // vs context + impact + grep + coverage check
|
|
57
|
+
smart_context: 6, // vs multiple tool calls based on intent
|
|
58
|
+
overview: 8, // vs context + impact + grep combined
|
|
59
|
+
trace: 5, // vs manually tracing call chains
|
|
60
|
+
routes: 3, // vs searching for route patterns
|
|
61
|
+
domains: 3, // vs exploring file structure
|
|
62
|
+
status: 2, // vs checking multiple stats
|
|
63
|
+
detect_changes: 4, // vs git diff + manual symbol mapping
|
|
64
|
+
explain_relationship: 4, // vs manual path finding
|
|
65
|
+
find_dead_code: 3, // vs manual export usage search
|
|
66
|
+
get_file_symbols: 2, // vs reading entire file
|
|
67
|
+
get_type_hierarchy: 4, // vs manual inheritance traversal
|
|
68
|
+
repos: 1, // simple listing
|
|
69
|
+
};
|
|
70
|
+
function estimateTokens(text) {
|
|
71
|
+
// Rough token estimate: ~4 chars per token for English/code
|
|
72
|
+
return Math.ceil(text.length / 4);
|
|
48
73
|
}
|
|
49
|
-
function
|
|
74
|
+
function getTrackingDb() {
|
|
75
|
+
try {
|
|
76
|
+
const dir = join(homedir(), '.milens');
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
return new Database(join(dir, 'tracking.db'));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null; // tracking is best-effort, never block
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function trackToolCall(trackDb, tool, startMs, responseText, repo) {
|
|
85
|
+
if (!trackDb)
|
|
86
|
+
return;
|
|
87
|
+
try {
|
|
88
|
+
const durationMs = Date.now() - startMs;
|
|
89
|
+
const tokensOut = estimateTokens(responseText);
|
|
90
|
+
const multiplier = TOKEN_SAVINGS_MULTIPLIER[tool] ?? 2;
|
|
91
|
+
const tokensSaved = tokensOut * (multiplier - 1); // net savings = what agent would spend minus what milens returned
|
|
92
|
+
trackDb.logToolUsage(tool, durationMs, tokensOut, tokensSaved, repo);
|
|
93
|
+
}
|
|
94
|
+
catch { /* best-effort */ }
|
|
95
|
+
}
|
|
96
|
+
function fmtSymbol(s, detail = 'L1') {
|
|
97
|
+
const base = `${s.name} [${s.kind}] ${s.filePath}:${s.startLine}`;
|
|
98
|
+
if (detail === 'L0')
|
|
99
|
+
return `${s.name} [${s.kind}]`;
|
|
100
|
+
if (detail === 'L2') {
|
|
101
|
+
const meta = [];
|
|
102
|
+
if (s.role)
|
|
103
|
+
meta.push(s.role);
|
|
104
|
+
if (s.heat != null && s.heat > 0)
|
|
105
|
+
meta.push(`heat:${s.heat}`);
|
|
106
|
+
return meta.length > 0 ? `${base} {${meta.join(',')}}` : base;
|
|
107
|
+
}
|
|
108
|
+
return base;
|
|
109
|
+
}
|
|
110
|
+
function fmtImpact(items, detail = 'L1') {
|
|
50
111
|
const grouped = new Map();
|
|
51
112
|
for (const { symbol, depth, via } of items) {
|
|
52
113
|
const arr = grouped.get(depth) ?? [];
|
|
53
|
-
arr.push(`${fmtSymbol(symbol)} (${via})`);
|
|
114
|
+
arr.push(`${fmtSymbol(symbol, detail)} (${via})`);
|
|
54
115
|
grouped.set(depth, arr);
|
|
55
116
|
}
|
|
56
117
|
const lines = [];
|
|
@@ -61,6 +122,14 @@ function fmtImpact(items) {
|
|
|
61
122
|
}
|
|
62
123
|
return lines.join('\n');
|
|
63
124
|
}
|
|
125
|
+
/** Check if a file path looks like a test/spec file */
|
|
126
|
+
function isTestFilePath(filePath) {
|
|
127
|
+
return /\.(test|spec)\.[jt]sx?$/.test(filePath) ||
|
|
128
|
+
/^tests?[/\\]/.test(filePath) ||
|
|
129
|
+
/__tests__[/\\]/.test(filePath) ||
|
|
130
|
+
/_test\.(go|py|rb|rs|java|php)$/.test(filePath) ||
|
|
131
|
+
/^test_.*\.py$/.test(filePath.split('/').pop() ?? '');
|
|
132
|
+
}
|
|
64
133
|
// ── Text grep across project files ──
|
|
65
134
|
const GREP_SKIP_DIRS = new Set([
|
|
66
135
|
'node_modules', '.git', 'dist', 'build', 'out',
|
|
@@ -150,6 +219,15 @@ function grepFiles(rootPath, pattern, options) {
|
|
|
150
219
|
function escapeRegExp(s) {
|
|
151
220
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
152
221
|
}
|
|
222
|
+
/** Line-level scope matching for scoped grep */
|
|
223
|
+
function matchesScope(lineText, scope) {
|
|
224
|
+
const trimmed = lineText.trimStart();
|
|
225
|
+
if (scope === 'imports') {
|
|
226
|
+
return /^(import\s|from\s|require\(|use\s|include\s|require_relative|require\s)/.test(trimmed);
|
|
227
|
+
}
|
|
228
|
+
// definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
|
|
229
|
+
return /^(export\s+)?(async\s+)?(function|class|interface|type|enum|struct|trait|const|let|var|def|fn|pub\s+fn|pub\s+struct|pub\s+enum|module)\s/.test(trimmed);
|
|
230
|
+
}
|
|
153
231
|
/** Validate user-supplied regex is safe from catastrophic backtracking (ReDoS). */
|
|
154
232
|
function safeRegex(pattern, flags) {
|
|
155
233
|
if (pattern.length > 200)
|
|
@@ -157,6 +235,24 @@ function safeRegex(pattern, flags) {
|
|
|
157
235
|
// Reject nested quantifiers like (a+)+, (a*)*, (a{1,})+
|
|
158
236
|
if (/([+*}])\)?[+*{]/.test(pattern))
|
|
159
237
|
throw new Error('Unsafe regex pattern');
|
|
238
|
+
// Reject overlapping alternation inside quantified groups: (a|a)*, (ab|a)+
|
|
239
|
+
if (/\((?:[^)]*\|[^)]*)\)[+*{]/.test(pattern))
|
|
240
|
+
throw new Error('Unsafe regex pattern');
|
|
241
|
+
// Reject backreferences inside quantified groups (exponential matching)
|
|
242
|
+
if (/\((?:[^)]*\\[1-9][^)]*)\)[+*{]/.test(pattern))
|
|
243
|
+
throw new Error('Unsafe regex pattern');
|
|
244
|
+
// Reject deeply nested groups (>3 levels)
|
|
245
|
+
let depth = 0, maxDepth = 0;
|
|
246
|
+
for (const ch of pattern) {
|
|
247
|
+
if (ch === '(') {
|
|
248
|
+
depth++;
|
|
249
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
250
|
+
}
|
|
251
|
+
else if (ch === ')')
|
|
252
|
+
depth--;
|
|
253
|
+
}
|
|
254
|
+
if (maxDepth > 3)
|
|
255
|
+
throw new Error('Unsafe regex pattern');
|
|
160
256
|
return new RegExp(pattern, flags);
|
|
161
257
|
}
|
|
162
258
|
function globToRegex(glob) {
|
|
@@ -178,53 +274,70 @@ function loadGrepIgnoreRules(rootPath) {
|
|
|
178
274
|
return ig;
|
|
179
275
|
}
|
|
180
276
|
// ── Server instructions (sent to client via MCP protocol on initialize) ──
|
|
181
|
-
const MILENS_INSTRUCTIONS = `milens
|
|
182
|
-
|
|
183
|
-
##
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
\`
|
|
187
|
-
\`
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
-
|
|
203
|
-
-
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
207
|
-
-
|
|
208
|
-
-
|
|
209
|
-
-
|
|
210
|
-
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
-
|
|
214
|
-
-
|
|
215
|
-
-
|
|
277
|
+
const MILENS_INSTRUCTIONS = `milens — code intelligence engine. Indexes codebases into symbol graphs.
|
|
278
|
+
|
|
279
|
+
## Tool selection
|
|
280
|
+
- \`query\` — find symbol definitions (code identifiers only)
|
|
281
|
+
- \`grep\` — text search ALL files. Use \`scope\` param: all (default), code (source only), imports, definitions
|
|
282
|
+
- \`context\` — 360° view: incoming + outgoing for a symbol
|
|
283
|
+
- \`impact\` — blast radius: what breaks if symbol changes
|
|
284
|
+
- \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
|
|
285
|
+
- \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
|
|
286
|
+
- \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
|
|
287
|
+
- \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Go, PHP, Rails)
|
|
288
|
+
- \`smart_context\` — intent-aware context: understand/edit/debug/test (returns only what matters for intent)
|
|
289
|
+
- \`domains\` — show domain clusters: groups of files forming logical modules based on dependency graph
|
|
290
|
+
- \`repos\` — list all indexed repositories with summary stats (multi-repo support)
|
|
291
|
+
- \`detect_changes\` — git diff → affected symbols
|
|
292
|
+
- \`explain_relationship\` — shortest path between two symbols
|
|
293
|
+
- \`find_dead_code\` — unused exports
|
|
294
|
+
- \`get_file_symbols\` — all symbols in a file
|
|
295
|
+
- \`get_type_hierarchy\` — inheritance tree
|
|
296
|
+
|
|
297
|
+
## Rules
|
|
298
|
+
- Before editing a symbol: run \`edit_check\` or \`smart_context\` with intent=edit
|
|
299
|
+
- For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
|
|
300
|
+
- For writing tests: run \`smart_context\` with intent=test — shows deps to mock + callers to cover
|
|
301
|
+
- \`impact\` only tracks code deps — always pair with \`grep\` for templates/configs
|
|
302
|
+
- Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
|
|
303
|
+
- impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
|
|
304
|
+
- ⚠ markers indicate unresolved INTERNAL references — external package imports/calls are tracked separately
|
|
305
|
+
- ✓ test coverage shown on edit_check — symbols with no test coverage get a warning
|
|
306
|
+
- ⏳ staleness: files not re-analyzed in 24h are flagged — consider re-running \`milens analyze\`
|
|
307
|
+
|
|
308
|
+
## Resources (MCP Resources protocol)
|
|
309
|
+
- \`milens://overview\` — index overview (stats, domains, coverage, staleness)
|
|
310
|
+
- \`milens://symbol/{name}\` — symbol context by name
|
|
311
|
+
- \`milens://file/{path}\` — all symbols in a file
|
|
312
|
+
- \`milens://domain/{name}\` — domain cluster details
|
|
216
313
|
`;
|
|
217
314
|
// ── Server setup ──
|
|
218
315
|
export function createMcpServer(rootPath) {
|
|
219
316
|
const registry = new RepoRegistry();
|
|
220
317
|
const pools = new Map();
|
|
318
|
+
const trackDb = getTrackingDb();
|
|
221
319
|
function resolveRoot(repoPath) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
320
|
+
if (repoPath) {
|
|
321
|
+
const root = resolve(repoPath);
|
|
322
|
+
const entry = registry.findByRoot(root);
|
|
323
|
+
if (!entry)
|
|
324
|
+
throw new Error(`No index for ${root}. Run \`milens analyze\` first.`);
|
|
325
|
+
return root;
|
|
326
|
+
}
|
|
327
|
+
if (rootPath) {
|
|
328
|
+
const root = resolve(rootPath);
|
|
329
|
+
const entry = registry.findByRoot(root);
|
|
330
|
+
if (!entry)
|
|
331
|
+
throw new Error(`No index for ${root}. Run \`milens analyze\` first.`);
|
|
332
|
+
return root;
|
|
333
|
+
}
|
|
334
|
+
// Auto-resolve: if exactly 1 repo is indexed, use it
|
|
335
|
+
const all = registry.listAll();
|
|
336
|
+
if (all.length === 1)
|
|
337
|
+
return all[0].rootPath;
|
|
338
|
+
if (all.length === 0)
|
|
339
|
+
throw new Error('No indexed repositories. Run `milens analyze` first.');
|
|
340
|
+
throw new Error(`Multiple repos indexed (${all.length}). Specify \`repo\` parameter.`);
|
|
228
341
|
}
|
|
229
342
|
function getDb(repoPath) {
|
|
230
343
|
const root = resolveRoot(repoPath);
|
|
@@ -235,10 +348,26 @@ export function createMcpServer(rootPath) {
|
|
|
235
348
|
pools.set(root, new LazyDb(dbPath));
|
|
236
349
|
return { db: pools.get(root).get(), root };
|
|
237
350
|
}
|
|
238
|
-
const server = new McpServer({ name: 'milens', version:
|
|
351
|
+
const server = new McpServer({ name: 'milens', version: PKG_VERSION }, { instructions: MILENS_INSTRUCTIONS });
|
|
352
|
+
// Auto-wrap every tool handler with usage tracking
|
|
353
|
+
const origTool = server.tool.bind(server);
|
|
354
|
+
server.tool = ((...args) => {
|
|
355
|
+
const toolName = args[0];
|
|
356
|
+
const handler = args[args.length - 1];
|
|
357
|
+
if (typeof handler === 'function') {
|
|
358
|
+
args[args.length - 1] = async (...handlerArgs) => {
|
|
359
|
+
const start = Date.now();
|
|
360
|
+
const result = await handler(...handlerArgs);
|
|
361
|
+
const responseText = result?.content?.map((c) => c.text).join('\n') ?? '';
|
|
362
|
+
const repo = handlerArgs[0]?.repo;
|
|
363
|
+
trackToolCall(trackDb, toolName, start, responseText, repo);
|
|
364
|
+
return result;
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return origTool(...args);
|
|
368
|
+
});
|
|
239
369
|
// ── Tool: query ──
|
|
240
|
-
server.tool('query', 'Search indexed symbol definitions
|
|
241
|
-
'Only finds symbols in indexed code files. For text references in templates, SCSS, configs, or docs, use `grep` instead.', {
|
|
370
|
+
server.tool('query', 'Search indexed symbol definitions by name/kind. For text in templates/configs/docs, use `grep`.', {
|
|
242
371
|
query: z.string().describe('Symbol name, kind, or keyword to search'),
|
|
243
372
|
repo: z.string().optional().describe('Repository root path (optional if only one indexed)'),
|
|
244
373
|
limit: z.number().optional().default(15).describe('Max results'),
|
|
@@ -246,75 +375,87 @@ export function createMcpServer(rootPath) {
|
|
|
246
375
|
const { db } = getDb(repo);
|
|
247
376
|
const results = db.searchSymbols(query, limit);
|
|
248
377
|
if (results.length === 0) {
|
|
249
|
-
return { content: [{ type: 'text', text: `No symbols matching "${query}".
|
|
378
|
+
return { content: [{ type: 'text', text: `No symbols matching "${query}". Try \`grep\` for non-code references.` }] };
|
|
250
379
|
}
|
|
251
380
|
const text = results.map(s => fmtSymbol(s)).join('\n');
|
|
252
381
|
return { content: [{ type: 'text', text }] };
|
|
253
382
|
});
|
|
254
383
|
// ── Tool: grep ──
|
|
255
|
-
server.tool('grep', 'Text search
|
|
256
|
-
'Unlike `query` which only searches indexed symbol definitions, grep finds every text occurrence. ' +
|
|
257
|
-
'Essential for: deleting features, renaming across templates/SCSS/configs, finding route/config references.', {
|
|
384
|
+
server.tool('grep', 'Text search ALL project files (templates, styles, configs, docs). Finds every text occurrence, not just symbols.', {
|
|
258
385
|
pattern: z.string().describe('Text or regex pattern to search for'),
|
|
259
386
|
repo: z.string().optional().describe('Repository root path (optional)'),
|
|
260
387
|
isRegex: z.boolean().optional().default(false).describe('Treat pattern as regex'),
|
|
261
388
|
caseSensitive: z.boolean().optional().default(false),
|
|
262
389
|
include: z.string().optional().describe('Glob filter for file paths (e.g. "**/*.vue", "*.scss")'),
|
|
390
|
+
scope: z.enum(['all', 'code', 'imports', 'definitions']).optional().default('all')
|
|
391
|
+
.describe('Scope: all=everything, code=source files only (no configs/docs), imports=import/require lines only, definitions=function/class/interface declarations only'),
|
|
263
392
|
limit: z.number().optional().default(50).describe('Max results'),
|
|
264
|
-
}, async ({ pattern, repo, isRegex, caseSensitive, include, limit }) => {
|
|
393
|
+
}, async ({ pattern, repo, isRegex, caseSensitive, include, scope, limit }) => {
|
|
265
394
|
const root = resolveRoot(repo);
|
|
395
|
+
const effectiveInclude = scope === 'code' && !include
|
|
396
|
+
? '**/*.{ts,tsx,js,jsx,mjs,cjs,vue,py,go,rs,java,php,rb}'
|
|
397
|
+
: include;
|
|
266
398
|
const matches = grepFiles(root, pattern, {
|
|
267
|
-
isRegex, caseSensitive, maxResults: limit, includePattern:
|
|
399
|
+
isRegex, caseSensitive, maxResults: limit, includePattern: effectiveInclude,
|
|
268
400
|
});
|
|
269
|
-
|
|
270
|
-
|
|
401
|
+
// Apply scope-specific line filtering
|
|
402
|
+
const filtered = scope === 'all' || scope === 'code'
|
|
403
|
+
? matches
|
|
404
|
+
: matches.filter(m => matchesScope(m.text, scope));
|
|
405
|
+
if (filtered.length === 0) {
|
|
406
|
+
return { content: [{ type: 'text', text: `No matches for "${pattern}"${scope !== 'all' ? ` (scope: ${scope})` : ''}` }] };
|
|
271
407
|
}
|
|
272
408
|
// Group by file for compact output
|
|
273
409
|
const grouped = new Map();
|
|
274
|
-
for (const m of
|
|
410
|
+
for (const m of filtered) {
|
|
275
411
|
const arr = grouped.get(m.file) ?? [];
|
|
276
412
|
arr.push({ line: m.line, text: m.text });
|
|
277
413
|
grouped.set(m.file, arr);
|
|
278
414
|
}
|
|
279
|
-
const lines = [`${
|
|
415
|
+
const lines = [`${filtered.length} matches in ${grouped.size} files${scope !== 'all' ? ` (scope: ${scope})` : ''}:\n`];
|
|
280
416
|
for (const [file, hits] of grouped) {
|
|
281
417
|
lines.push(file);
|
|
282
418
|
for (const h of hits) {
|
|
283
419
|
lines.push(` L${h.line}: ${h.text}`);
|
|
284
420
|
}
|
|
285
421
|
}
|
|
286
|
-
if (
|
|
422
|
+
if (filtered.length >= limit) {
|
|
287
423
|
lines.push(`\n(truncated at ${limit} results — increase limit or narrow pattern)`);
|
|
288
424
|
}
|
|
289
425
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
290
426
|
});
|
|
291
427
|
// ── Tool: context ──
|
|
292
|
-
server.tool('context', '
|
|
428
|
+
server.tool('context', 'Symbol 360°: incoming refs + outgoing deps. Use `overview` for combined context+impact+grep.', {
|
|
293
429
|
name: z.string().describe('Symbol name to inspect'),
|
|
294
430
|
repo: z.string().optional(),
|
|
295
|
-
|
|
431
|
+
detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
|
|
432
|
+
}, async ({ name, repo, detail }) => {
|
|
296
433
|
const { db } = getDb(repo);
|
|
297
434
|
const symbols = db.findSymbolByName(name);
|
|
298
435
|
if (symbols.length === 0) {
|
|
299
|
-
return { content: [{ type: 'text', text: `
|
|
436
|
+
return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
|
|
300
437
|
}
|
|
301
438
|
const lines = [];
|
|
302
439
|
for (const sym of symbols) {
|
|
303
|
-
lines.push(`## ${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
|
|
440
|
+
lines.push(`## ${fmtSymbol(sym, detail)}${sym.exported ? ' (exported)' : ''}`);
|
|
304
441
|
const incoming = db.getIncomingLinks(sym.id);
|
|
305
442
|
if (incoming.length > 0) {
|
|
306
443
|
lines.push('incoming:');
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
444
|
+
const inSyms = incoming.map(l => ({ link: l, sym: db.findSymbolById(l.fromId) }));
|
|
445
|
+
if (detail === 'L2')
|
|
446
|
+
inSyms.sort((a, b) => (b.sym?.heat ?? 0) - (a.sym?.heat ?? 0));
|
|
447
|
+
for (const { link: l, sym: from } of inSyms) {
|
|
448
|
+
lines.push(` ${l.type}: ${from ? fmtSymbol(from, detail) : l.fromId}`);
|
|
310
449
|
}
|
|
311
450
|
}
|
|
312
451
|
const outgoing = db.getOutgoingLinks(sym.id);
|
|
313
452
|
if (outgoing.length > 0) {
|
|
314
453
|
lines.push('outgoing:');
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
454
|
+
const outSyms = outgoing.map(l => ({ link: l, sym: db.findSymbolById(l.toId) }));
|
|
455
|
+
if (detail === 'L2')
|
|
456
|
+
outSyms.sort((a, b) => (b.sym?.heat ?? 0) - (a.sym?.heat ?? 0));
|
|
457
|
+
for (const { link: l, sym: to } of outSyms) {
|
|
458
|
+
lines.push(` ${l.type}: ${to ? fmtSymbol(to, detail) : l.toId}`);
|
|
318
459
|
}
|
|
319
460
|
}
|
|
320
461
|
lines.push('');
|
|
@@ -322,47 +463,204 @@ export function createMcpServer(rootPath) {
|
|
|
322
463
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
323
464
|
});
|
|
324
465
|
// ── Tool: impact ──
|
|
325
|
-
server.tool('impact', 'Blast radius
|
|
326
|
-
'Note: only tracks code dependencies (calls, imports, extends). For template/SCSS/config references, also use `grep`.', {
|
|
466
|
+
server.tool('impact', 'Blast radius: what symbols break if target changes. Code deps only — pair with `grep` for templates/configs.', {
|
|
327
467
|
target: z.string().describe('Symbol name to analyze'),
|
|
328
468
|
direction: z.enum(['upstream', 'downstream']).default('upstream'),
|
|
329
469
|
depth: z.number().optional().default(3),
|
|
330
470
|
repo: z.string().optional(),
|
|
331
|
-
|
|
471
|
+
detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
|
|
472
|
+
}, async ({ target, direction, depth, repo, detail }) => {
|
|
332
473
|
const { db } = getDb(repo);
|
|
333
474
|
const symbols = db.findSymbolByName(target);
|
|
334
475
|
if (symbols.length === 0) {
|
|
335
|
-
return { content: [{ type: 'text', text: `
|
|
476
|
+
return { content: [{ type: 'text', text: `"${target}" not found. Try \`grep\`.` }] };
|
|
336
477
|
}
|
|
337
478
|
const lines = [];
|
|
338
479
|
for (const sym of symbols) {
|
|
339
|
-
lines.push(`TARGET: ${fmtSymbol(sym)}`);
|
|
480
|
+
lines.push(`TARGET: ${fmtSymbol(sym, detail)}`);
|
|
340
481
|
const refs = direction === 'upstream'
|
|
341
482
|
? db.findUpstream(sym.id, depth)
|
|
342
483
|
: db.findDownstream(sym.id, depth);
|
|
343
484
|
if (refs.length === 0) {
|
|
344
|
-
lines.push(`No ${direction}
|
|
485
|
+
lines.push(`No ${direction} deps found.`);
|
|
345
486
|
}
|
|
346
487
|
else {
|
|
347
488
|
lines.push(`${direction} (${refs.length} symbols):`);
|
|
348
|
-
lines.push(fmtImpact(refs));
|
|
349
|
-
lines.push(`\nNOTE: impact only tracks code-level dependencies. Also run \`grep\` for "${target}" to find template/style/config/doc references.`);
|
|
489
|
+
lines.push(fmtImpact(refs, detail));
|
|
350
490
|
}
|
|
351
491
|
lines.push('');
|
|
352
492
|
}
|
|
353
493
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
354
494
|
});
|
|
355
495
|
// ── Tool: status ──
|
|
356
|
-
server.tool('status', '
|
|
496
|
+
server.tool('status', 'Index stats for a repository.', {
|
|
357
497
|
repo: z.string().optional(),
|
|
358
498
|
}, async ({ repo }) => {
|
|
359
499
|
const { db, root } = getDb(repo);
|
|
360
500
|
const stats = db.getStats();
|
|
361
|
-
const
|
|
501
|
+
const unresolved = db.getUnresolvedStats();
|
|
502
|
+
const coverage = db.getTestCoverage();
|
|
503
|
+
let text = `repo: ${root}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
|
|
504
|
+
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
505
|
+
text += `\n⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls — callers may be incomplete`;
|
|
506
|
+
}
|
|
507
|
+
if (unresolved.externalImports > 0 || unresolved.externalCalls > 0) {
|
|
508
|
+
text += `\nexternal (expected): ${unresolved.externalImports} imports, ${unresolved.externalCalls} calls`;
|
|
509
|
+
}
|
|
510
|
+
if (coverage.testFiles > 0) {
|
|
511
|
+
const pct = coverage.exportedProductionSymbols > 0
|
|
512
|
+
? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100)
|
|
513
|
+
: 0;
|
|
514
|
+
text += `\ntest coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} exported symbols (${pct}%) from ${coverage.testFiles} test files`;
|
|
515
|
+
}
|
|
516
|
+
const domains = db.getDomainStats();
|
|
517
|
+
if (domains.length > 0) {
|
|
518
|
+
text += `\ndomains: ${domains.map(d => `${d.domain}(${d.files}f/${d.symbols}s)`).join(', ')}`;
|
|
519
|
+
}
|
|
520
|
+
const staleFiles = db.getStaleFiles(24);
|
|
521
|
+
if (staleFiles.length > 0) {
|
|
522
|
+
text += `\n⏳ ${staleFiles.length} files not analyzed in 24h`;
|
|
523
|
+
}
|
|
362
524
|
return { content: [{ type: 'text', text }] };
|
|
363
525
|
});
|
|
526
|
+
// ── Tool: domains ──
|
|
527
|
+
server.tool('domains', 'Show domain clusters — groups of files forming logical modules based on dependency graph. Helps understand codebase structure at a glance.', {
|
|
528
|
+
repo: z.string().optional(),
|
|
529
|
+
}, async ({ repo }) => {
|
|
530
|
+
const { db } = getDb(repo);
|
|
531
|
+
const domains = db.getDomainStats();
|
|
532
|
+
if (domains.length === 0) {
|
|
533
|
+
return { content: [{ type: 'text', text: 'No domains detected. Run `milens analyze` first.' }] };
|
|
534
|
+
}
|
|
535
|
+
const totalFiles = domains.reduce((s, d) => s + d.files, 0);
|
|
536
|
+
const totalSymbols = domains.reduce((s, d) => s + d.symbols, 0);
|
|
537
|
+
const lines = [`${domains.length} domains (${totalFiles} files, ${totalSymbols} symbols):\n`];
|
|
538
|
+
for (const d of domains) {
|
|
539
|
+
const pct = totalSymbols > 0 ? Math.round(d.symbols / totalSymbols * 100) : 0;
|
|
540
|
+
lines.push(` ${d.domain}: ${d.files} files, ${d.symbols} symbols (${pct}%)`);
|
|
541
|
+
}
|
|
542
|
+
const staleFiles = db.getStaleFiles(24);
|
|
543
|
+
if (staleFiles.length > 0) {
|
|
544
|
+
lines.push(`\n⏳ ${staleFiles.length} files stale (>24h) — re-run \`milens analyze\` for fresh clusters`);
|
|
545
|
+
}
|
|
546
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
547
|
+
});
|
|
548
|
+
// ── Tool: overview ──
|
|
549
|
+
server.tool('overview', 'Combined context + impact + grep in ONE call. Preferred before editing/deleting/renaming a symbol. Saves 2-3 round trips.', {
|
|
550
|
+
name: z.string().describe('Symbol name'),
|
|
551
|
+
repo: z.string().optional(),
|
|
552
|
+
depth: z.number().optional().default(2).describe('Impact traversal depth (default: 2)'),
|
|
553
|
+
detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail level'),
|
|
554
|
+
}, async ({ name, repo, depth, detail }) => {
|
|
555
|
+
const { db, root } = getDb(repo);
|
|
556
|
+
const symbols = db.findSymbolByName(name);
|
|
557
|
+
const sections = [];
|
|
558
|
+
// Section 1: Symbol definitions
|
|
559
|
+
if (symbols.length === 0) {
|
|
560
|
+
sections.push(`[symbol] "${name}" not found in index.`);
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
for (const sym of symbols) {
|
|
564
|
+
sections.push(`[symbol] ${fmtSymbol(sym, detail)}${sym.exported ? ' (exported)' : ''}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Section 2: Context (incoming + outgoing) for each symbol
|
|
568
|
+
if (symbols.length > 0) {
|
|
569
|
+
for (const sym of symbols) {
|
|
570
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
571
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
572
|
+
if (incoming.length > 0) {
|
|
573
|
+
sections.push(`[incoming] ${incoming.length} refs:`);
|
|
574
|
+
const inSyms = incoming.map(l => {
|
|
575
|
+
const s = db.findSymbolById(l.fromId);
|
|
576
|
+
return s ? ` ${l.type}: ${fmtSymbol(s, detail)}` : ` ${l.type}: ${l.fromId}`;
|
|
577
|
+
});
|
|
578
|
+
sections.push(...inSyms);
|
|
579
|
+
}
|
|
580
|
+
if (outgoing.length > 0) {
|
|
581
|
+
sections.push(`[outgoing] ${outgoing.length} deps:`);
|
|
582
|
+
const outSyms = outgoing.map(l => {
|
|
583
|
+
const s = db.findSymbolById(l.toId);
|
|
584
|
+
return s ? ` ${l.type}: ${fmtSymbol(s, detail)}` : ` ${l.type}: ${l.toId}`;
|
|
585
|
+
});
|
|
586
|
+
sections.push(...outSyms);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Section 3: Impact (upstream)
|
|
590
|
+
for (const sym of symbols) {
|
|
591
|
+
const upstream = db.findUpstream(sym.id, depth);
|
|
592
|
+
if (upstream.length > 0) {
|
|
593
|
+
sections.push(`[impact] ${upstream.length} upstream deps:`);
|
|
594
|
+
sections.push(fmtImpact(upstream, detail));
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
sections.push(`[impact] No upstream deps.`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Section 4: Grep (text references across all files)
|
|
602
|
+
const grepMatches = grepFiles(root, name, { maxResults: 20 });
|
|
603
|
+
if (grepMatches.length > 0) {
|
|
604
|
+
const grouped = new Map();
|
|
605
|
+
for (const m of grepMatches) {
|
|
606
|
+
const arr = grouped.get(m.file) ?? [];
|
|
607
|
+
arr.push({ line: m.line, text: m.text });
|
|
608
|
+
grouped.set(m.file, arr);
|
|
609
|
+
}
|
|
610
|
+
sections.push(`[grep] ${grepMatches.length} text matches in ${grouped.size} files:`);
|
|
611
|
+
for (const [file, hits] of grouped) {
|
|
612
|
+
sections.push(` ${file}`);
|
|
613
|
+
for (const h of hits)
|
|
614
|
+
sections.push(` L${h.line}: ${h.text}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
sections.push(`[grep] No text matches.`);
|
|
619
|
+
}
|
|
620
|
+
// Section 5: Unresolved warnings (only for internal)
|
|
621
|
+
const unresolved = db.getUnresolvedStats();
|
|
622
|
+
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
623
|
+
sections.push(`[⚠ unresolved internal] ${unresolved.imports} imports, ${unresolved.calls} calls — some references may be missing`);
|
|
624
|
+
}
|
|
625
|
+
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
626
|
+
});
|
|
627
|
+
// ── Tool: repos ──
|
|
628
|
+
server.tool('repos', 'List all indexed repositories with summary stats. Useful for multi-repo workspaces.', {}, async () => {
|
|
629
|
+
const entries = registry.listAll();
|
|
630
|
+
if (entries.length === 0) {
|
|
631
|
+
return { content: [{ type: 'text', text: 'No indexed repositories. Run `milens analyze` first.' }] };
|
|
632
|
+
}
|
|
633
|
+
const lines = [`${entries.length} indexed repositories:\n`];
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
lines.push(`${entry.rootPath}`);
|
|
636
|
+
lines.push(` indexed: ${entry.analyzedAt}`);
|
|
637
|
+
try {
|
|
638
|
+
const dbPath = registry.findDbPath(entry.rootPath);
|
|
639
|
+
if (dbPath) {
|
|
640
|
+
const tempDb = pools.has(entry.rootPath)
|
|
641
|
+
? pools.get(entry.rootPath).get()
|
|
642
|
+
: new Database(dbPath);
|
|
643
|
+
const summary = tempDb.getRepoSummary();
|
|
644
|
+
lines.push(` ${summary.symbols} symbols, ${summary.links} links, ${summary.files} files`);
|
|
645
|
+
if (summary.domains.length > 0) {
|
|
646
|
+
lines.push(` domains: ${summary.domains.join(', ')}`);
|
|
647
|
+
}
|
|
648
|
+
if (summary.staleCount > 0) {
|
|
649
|
+
lines.push(` ⏳ ${summary.staleCount} stale files`);
|
|
650
|
+
}
|
|
651
|
+
if (!pools.has(entry.rootPath))
|
|
652
|
+
tempDb.close();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
lines.push(` (unable to read index)`);
|
|
657
|
+
}
|
|
658
|
+
lines.push('');
|
|
659
|
+
}
|
|
660
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
661
|
+
});
|
|
364
662
|
// ── Tool: detect_changes ──
|
|
365
|
-
server.tool('detect_changes', '
|
|
663
|
+
server.tool('detect_changes', 'Git diff → affected symbols + direct dependents.', {
|
|
366
664
|
ref: z.string().optional().default('HEAD').describe('Git ref to diff against (default: HEAD)'),
|
|
367
665
|
repo: z.string().optional(),
|
|
368
666
|
}, async ({ ref, repo }) => {
|
|
@@ -404,7 +702,7 @@ export function createMcpServer(rootPath) {
|
|
|
404
702
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
405
703
|
});
|
|
406
704
|
// ── Tool: explain_relationship ──
|
|
407
|
-
server.tool('explain_relationship', '
|
|
705
|
+
server.tool('explain_relationship', 'Shortest dependency path between two symbols.', {
|
|
408
706
|
from: z.string().describe('Source symbol name'),
|
|
409
707
|
to: z.string().describe('Target symbol name'),
|
|
410
708
|
repo: z.string().optional(),
|
|
@@ -412,7 +710,7 @@ export function createMcpServer(rootPath) {
|
|
|
412
710
|
const { db } = getDb(repo);
|
|
413
711
|
const path = db.findPath(from, to);
|
|
414
712
|
if (!path) {
|
|
415
|
-
return { content: [{ type: 'text', text: `No
|
|
713
|
+
return { content: [{ type: 'text', text: `No path between "${from}" and "${to}".` }] };
|
|
416
714
|
}
|
|
417
715
|
const fromSym = db.findSymbolByName(from)[0];
|
|
418
716
|
const lines = [`FROM: ${fmtSymbol(fromSym)}`, ''];
|
|
@@ -422,7 +720,7 @@ export function createMcpServer(rootPath) {
|
|
|
422
720
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
423
721
|
});
|
|
424
722
|
// ── Tool: find_dead_code ──
|
|
425
|
-
server.tool('find_dead_code', '
|
|
723
|
+
server.tool('find_dead_code', 'Exported symbols with zero incoming references (potentially unused).', {
|
|
426
724
|
kind: z.string().optional().describe('Filter by symbol kind (function, class, method, etc.)'),
|
|
427
725
|
limit: z.number().optional().default(30),
|
|
428
726
|
repo: z.string().optional(),
|
|
@@ -439,33 +737,52 @@ export function createMcpServer(rootPath) {
|
|
|
439
737
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
440
738
|
});
|
|
441
739
|
// ── Tool: get_file_symbols ──
|
|
442
|
-
server.tool('get_file_symbols', '
|
|
740
|
+
server.tool('get_file_symbols', 'All symbols in a file with ref/dep counts.', {
|
|
443
741
|
file: z.string().describe('File path (relative to repo root)'),
|
|
444
742
|
repo: z.string().optional(),
|
|
445
|
-
|
|
743
|
+
detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
|
|
744
|
+
}, async ({ file, repo, detail }) => {
|
|
446
745
|
const { db } = getDb(repo);
|
|
447
746
|
const symbols = db.getSymbolsByFile(file);
|
|
448
747
|
if (symbols.length === 0) {
|
|
449
748
|
return { content: [{ type: 'text', text: `No symbols found in "${file}". Is the path relative to repo root?` }] };
|
|
450
749
|
}
|
|
750
|
+
// Sort by heat descending in L2 mode for relevance-first output
|
|
751
|
+
const sorted = detail === 'L2'
|
|
752
|
+
? [...symbols].sort((a, b) => (b.heat ?? 0) - (a.heat ?? 0))
|
|
753
|
+
: symbols;
|
|
451
754
|
const lines = [`${file}: ${symbols.length} symbols\n`];
|
|
452
|
-
for (const sym of
|
|
755
|
+
for (const sym of sorted) {
|
|
453
756
|
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
454
757
|
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
455
758
|
const exp = sym.exported ? ' (exported)' : '';
|
|
456
|
-
|
|
759
|
+
if (detail === 'L0') {
|
|
760
|
+
lines.push(`${sym.name} [${sym.kind}]${exp}`);
|
|
761
|
+
}
|
|
762
|
+
else if (detail === 'L2') {
|
|
763
|
+
const meta = [];
|
|
764
|
+
if (sym.role)
|
|
765
|
+
meta.push(sym.role);
|
|
766
|
+
if (sym.heat != null && sym.heat > 0)
|
|
767
|
+
meta.push(`heat:${sym.heat}`);
|
|
768
|
+
const metaStr = meta.length > 0 ? ` {${meta.join(',')}}` : '';
|
|
769
|
+
lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp}${metaStr} ← ${incoming.length} refs, → ${outgoing.length} deps`);
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
lines.push(`${sym.name} [${sym.kind}] L${sym.startLine}-${sym.endLine}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
|
|
773
|
+
}
|
|
457
774
|
}
|
|
458
775
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
459
776
|
});
|
|
460
777
|
// ── Tool: get_type_hierarchy ──
|
|
461
|
-
server.tool('get_type_hierarchy', '
|
|
778
|
+
server.tool('get_type_hierarchy', 'Inheritance/implementation tree for a class, interface, or trait.', {
|
|
462
779
|
name: z.string().describe('Symbol name to show hierarchy for'),
|
|
463
780
|
repo: z.string().optional(),
|
|
464
781
|
}, async ({ name, repo }) => {
|
|
465
782
|
const { db } = getDb(repo);
|
|
466
783
|
const symbols = db.findSymbolByName(name);
|
|
467
784
|
if (symbols.length === 0) {
|
|
468
|
-
return { content: [{ type: 'text', text: `
|
|
785
|
+
return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
|
|
469
786
|
}
|
|
470
787
|
const lines = [];
|
|
471
788
|
for (const sym of symbols) {
|
|
@@ -478,18 +795,489 @@ export function createMcpServer(rootPath) {
|
|
|
478
795
|
}
|
|
479
796
|
}
|
|
480
797
|
if (descendants.length > 0) {
|
|
481
|
-
lines.push('implemented
|
|
798
|
+
lines.push('extended/implemented by:');
|
|
482
799
|
for (const { symbol: d, depth } of descendants) {
|
|
483
800
|
lines.push(` ${'↓'.repeat(depth)} ${fmtSymbol(d)}`);
|
|
484
801
|
}
|
|
485
802
|
}
|
|
486
803
|
if (ancestors.length === 0 && descendants.length === 0) {
|
|
487
|
-
lines.push('No inheritance relationships
|
|
804
|
+
lines.push('No inheritance relationships.');
|
|
805
|
+
}
|
|
806
|
+
lines.push('');
|
|
807
|
+
}
|
|
808
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
809
|
+
});
|
|
810
|
+
// ── Tool: edit_check ──
|
|
811
|
+
server.tool('edit_check', 'Pre-edit safety check: callers, export status, re-export chains, ⚠ warnings. Focused for editing intent — no downstream deps, no outgoing calls. Use BEFORE modifying a symbol.', {
|
|
812
|
+
name: z.string().describe('Symbol name to check before editing'),
|
|
813
|
+
repo: z.string().optional(),
|
|
814
|
+
}, async ({ name, repo }) => {
|
|
815
|
+
const { db, root } = getDb(repo);
|
|
816
|
+
const symbols = db.findSymbolByName(name);
|
|
817
|
+
const sections = [];
|
|
818
|
+
if (symbols.length === 0) {
|
|
819
|
+
sections.push(`"${name}" not found in index. Try \`grep\`.`);
|
|
820
|
+
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
821
|
+
}
|
|
822
|
+
for (const sym of symbols) {
|
|
823
|
+
// 1. Symbol info
|
|
824
|
+
sections.push(`${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
|
|
825
|
+
// 2. Who calls/uses this? (direct upstream only — what WILL break)
|
|
826
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
827
|
+
if (incoming.length > 0) {
|
|
828
|
+
sections.push(`callers (${incoming.length}):`);
|
|
829
|
+
for (const l of incoming) {
|
|
830
|
+
const from = db.findSymbolById(l.fromId);
|
|
831
|
+
sections.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
sections.push(`callers: none`);
|
|
836
|
+
}
|
|
837
|
+
// 3. Export chain — is this re-exported from barrel files?
|
|
838
|
+
const grepMatches = grepFiles(root, name, { maxResults: 10, includePattern: '**/index.{ts,js,mjs}' });
|
|
839
|
+
const reExportMatches = grepMatches.filter(m => /export\s*\{[^}]*/.test(m.text) && m.text.includes('from'));
|
|
840
|
+
if (reExportMatches.length > 0) {
|
|
841
|
+
sections.push(`re-exported via:`);
|
|
842
|
+
for (const m of reExportMatches) {
|
|
843
|
+
sections.push(` ${m.file}:${m.line}`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// 4. Heritage — is this a parent class?
|
|
847
|
+
const descendants = db.getTypeHierarchy(sym.id).descendants;
|
|
848
|
+
if (descendants.length > 0) {
|
|
849
|
+
sections.push(`⚠ inherited by ${descendants.length} types:`);
|
|
850
|
+
for (const { symbol: d } of descendants) {
|
|
851
|
+
sections.push(` ${fmtSymbol(d)}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// 5. Unresolved warning (only for internal)
|
|
856
|
+
const unresolved = db.getUnresolvedStats();
|
|
857
|
+
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
858
|
+
sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
|
|
859
|
+
}
|
|
860
|
+
// 6. Test coverage for this symbol
|
|
861
|
+
for (const sym of symbols) {
|
|
862
|
+
const incoming = db.getIncomingLinks(sym.id);
|
|
863
|
+
const testRefs = incoming.filter(l => {
|
|
864
|
+
const from = db.findSymbolById(l.fromId);
|
|
865
|
+
return from && isTestFilePath(from.filePath);
|
|
866
|
+
});
|
|
867
|
+
if (testRefs.length > 0) {
|
|
868
|
+
const testFiles = [...new Set(testRefs.map(l => {
|
|
869
|
+
const from = db.findSymbolById(l.fromId);
|
|
870
|
+
return from?.filePath;
|
|
871
|
+
}).filter(Boolean))];
|
|
872
|
+
sections.push(`✓ tested from: ${testFiles.join(', ')}`);
|
|
873
|
+
}
|
|
874
|
+
else if (sym.exported) {
|
|
875
|
+
sections.push(`⚠ no test coverage for this exported symbol`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
879
|
+
});
|
|
880
|
+
// ── Tool: trace ──
|
|
881
|
+
server.tool('trace', 'Trace execution flow: find call chains from entrypoints to a target symbol, or from a symbol downstream. Shows HOW code gets executed.', {
|
|
882
|
+
name: z.string().describe('Symbol name to trace'),
|
|
883
|
+
direction: z.enum(['to', 'from']).optional().default('to')
|
|
884
|
+
.describe('to=trace paths TO this symbol from entrypoints, from=trace paths FROM this symbol downstream'),
|
|
885
|
+
repo: z.string().optional(),
|
|
886
|
+
depth: z.number().optional().default(8).describe('Max chain depth'),
|
|
887
|
+
}, async ({ name, direction, repo, depth }) => {
|
|
888
|
+
const { db } = getDb(repo);
|
|
889
|
+
const symbols = db.findSymbolByName(name);
|
|
890
|
+
if (symbols.length === 0) {
|
|
891
|
+
return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
|
|
892
|
+
}
|
|
893
|
+
const sections = [];
|
|
894
|
+
for (const sym of symbols) {
|
|
895
|
+
if (direction === 'to') {
|
|
896
|
+
// Trace upstream to entrypoints
|
|
897
|
+
const traces = db.traceToEntrypoints(sym.id, depth);
|
|
898
|
+
sections.push(`## Execution paths TO ${fmtSymbol(sym)}\n`);
|
|
899
|
+
if (traces.length === 0) {
|
|
900
|
+
sections.push('No call chains found (symbol may be an entrypoint itself or unreachable).');
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
for (let i = 0; i < traces.length; i++) {
|
|
904
|
+
const chain = traces[i].path;
|
|
905
|
+
sections.push(`path ${i + 1} (${chain.length} steps):`);
|
|
906
|
+
sections.push(chain.map((step, idx) => {
|
|
907
|
+
const arrow = idx === chain.length - 1 ? '→ (target)' : `→ [${chain[idx + 1]?.via ?? ''}]`;
|
|
908
|
+
return ` ${' '.repeat(idx)}${fmtSymbol(step.symbol)} ${arrow}`;
|
|
909
|
+
}).join('\n'));
|
|
910
|
+
sections.push('');
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
// Trace downstream — show call/dependency tree from this symbol
|
|
916
|
+
const downstream = db.findDownstream(sym.id, depth);
|
|
917
|
+
sections.push(`## Execution paths FROM ${fmtSymbol(sym)}\n`);
|
|
918
|
+
if (downstream.length === 0) {
|
|
919
|
+
sections.push('No downstream dependencies (leaf symbol).');
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
// Group by depth and show as tree
|
|
923
|
+
const byDepth = new Map();
|
|
924
|
+
for (const { symbol, depth: d, via } of downstream) {
|
|
925
|
+
const arr = byDepth.get(d) ?? [];
|
|
926
|
+
arr.push(`${' '.repeat(d)}[${via}] ${fmtSymbol(symbol)}`);
|
|
927
|
+
byDepth.set(d, arr);
|
|
928
|
+
}
|
|
929
|
+
for (const [, items] of [...byDepth].sort((a, b) => a[0] - b[0])) {
|
|
930
|
+
sections.push(...items);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
sections.push('');
|
|
935
|
+
}
|
|
936
|
+
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
937
|
+
});
|
|
938
|
+
// ── Tool: routes ──
|
|
939
|
+
server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Go HTTP, PHP, Rails patterns.', {
|
|
940
|
+
repo: z.string().optional(),
|
|
941
|
+
framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, go, php, rails). Default: auto-detect all.'),
|
|
942
|
+
limit: z.number().optional().default(50),
|
|
943
|
+
}, async ({ repo, framework, limit }) => {
|
|
944
|
+
const root = resolveRoot(repo);
|
|
945
|
+
const { db } = getDb(repo);
|
|
946
|
+
// Route patterns for different frameworks
|
|
947
|
+
const routePatterns = [
|
|
948
|
+
{ name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
|
|
949
|
+
{ name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
|
|
950
|
+
{ name: 'flask', pattern: /@(?:app|bp|blueprint)\.(route|get|post|put|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
|
|
951
|
+
{ name: 'nestjs', pattern: /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/, fileGlob: '**/*.ts' },
|
|
952
|
+
{ name: 'go', pattern: /\b(?:mux|router|http)\.(HandleFunc|Handle|Get|Post|Put|Delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
|
|
953
|
+
{ name: 'php', pattern: /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.php' },
|
|
954
|
+
{ name: 'rails', pattern: /\b(get|post|put|patch|delete|resources?|root)\s+['"]([^'"]+)['"]/, fileGlob: '**/*.rb' },
|
|
955
|
+
];
|
|
956
|
+
const activePatterns = framework
|
|
957
|
+
? routePatterns.filter(p => p.name === framework.toLowerCase())
|
|
958
|
+
: routePatterns;
|
|
959
|
+
if (activePatterns.length === 0) {
|
|
960
|
+
return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, go, php, rails` }] };
|
|
961
|
+
}
|
|
962
|
+
const routes = [];
|
|
963
|
+
for (const rp of activePatterns) {
|
|
964
|
+
const matches = grepFiles(root, rp.pattern.source, {
|
|
965
|
+
isRegex: true, maxResults: limit, includePattern: rp.fileGlob,
|
|
966
|
+
});
|
|
967
|
+
for (const m of matches) {
|
|
968
|
+
const match = rp.pattern.exec(m.text);
|
|
969
|
+
if (!match)
|
|
970
|
+
continue;
|
|
971
|
+
const method = match[1].toUpperCase();
|
|
972
|
+
const path = match[2] || '/';
|
|
973
|
+
// Try to find the handler symbol on this line or nearby
|
|
974
|
+
const fileSymbols = db.getSymbolsByFile(m.file);
|
|
975
|
+
const handler = fileSymbols.find(s => s.startLine <= m.line && s.endLine >= m.line && s.kind === 'method') ?? fileSymbols.find(s => s.startLine <= m.line && s.endLine >= m.line) ?? fileSymbols.find(s => Math.abs(s.startLine - m.line) <= 3 && (s.kind === 'function' || s.kind === 'method'));
|
|
976
|
+
routes.push({
|
|
977
|
+
framework: rp.name,
|
|
978
|
+
method,
|
|
979
|
+
path,
|
|
980
|
+
file: m.file,
|
|
981
|
+
line: m.line,
|
|
982
|
+
handler: handler ? `${handler.name} [${handler.kind}]` : undefined,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (routes.length === 0) {
|
|
987
|
+
return { content: [{ type: 'text', text: 'No framework routes detected.' }] };
|
|
988
|
+
}
|
|
989
|
+
// Group by framework
|
|
990
|
+
const grouped = new Map();
|
|
991
|
+
for (const r of routes) {
|
|
992
|
+
const arr = grouped.get(r.framework) ?? [];
|
|
993
|
+
arr.push(r);
|
|
994
|
+
grouped.set(r.framework, arr);
|
|
995
|
+
}
|
|
996
|
+
const lines = [`${routes.length} routes detected:\n`];
|
|
997
|
+
for (const [fw, fwRoutes] of grouped) {
|
|
998
|
+
lines.push(`[${fw}]`);
|
|
999
|
+
for (const r of fwRoutes) {
|
|
1000
|
+
const handlerInfo = r.handler ? ` → ${r.handler}` : '';
|
|
1001
|
+
lines.push(` ${r.method.padEnd(7)} ${r.path} (${r.file}:${r.line})${handlerInfo}`);
|
|
488
1002
|
}
|
|
489
1003
|
lines.push('');
|
|
490
1004
|
}
|
|
491
1005
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
492
1006
|
});
|
|
1007
|
+
// ── Tool: smart_context ──
|
|
1008
|
+
server.tool('smart_context', 'Intent-aware context: returns different information based on what you want to do. Saves tokens by showing only what matters for your intent.', {
|
|
1009
|
+
name: z.string().describe('Symbol name'),
|
|
1010
|
+
intent: z.enum(['understand', 'edit', 'debug', 'test'])
|
|
1011
|
+
.describe('understand=360° view, edit=callers+blast radius, debug=execution paths+data flow, test=coverage+dependencies'),
|
|
1012
|
+
repo: z.string().optional(),
|
|
1013
|
+
}, async ({ name, intent, repo }) => {
|
|
1014
|
+
const { db, root } = getDb(repo);
|
|
1015
|
+
const symbols = db.findSymbolByName(name);
|
|
1016
|
+
if (symbols.length === 0) {
|
|
1017
|
+
return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
|
|
1018
|
+
}
|
|
1019
|
+
const sections = [];
|
|
1020
|
+
for (const sym of symbols) {
|
|
1021
|
+
sections.push(`${fmtSymbol(sym, 'L2')}${sym.exported ? ' (exported)' : ''}\n`);
|
|
1022
|
+
if (intent === 'understand') {
|
|
1023
|
+
// Full 360° — context + downstream + file structure
|
|
1024
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1025
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1026
|
+
if (incoming.length > 0) {
|
|
1027
|
+
sections.push(`incoming (${incoming.length}):`);
|
|
1028
|
+
for (const l of incoming) {
|
|
1029
|
+
const from = db.findSymbolById(l.fromId);
|
|
1030
|
+
sections.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (outgoing.length > 0) {
|
|
1034
|
+
sections.push(`outgoing (${outgoing.length}):`);
|
|
1035
|
+
for (const l of outgoing) {
|
|
1036
|
+
const to = db.findSymbolById(l.toId);
|
|
1037
|
+
sections.push(` ${l.type}: ${to ? fmtSymbol(to) : l.toId}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
// Heritage
|
|
1041
|
+
const { ancestors, descendants } = db.getTypeHierarchy(sym.id);
|
|
1042
|
+
if (ancestors.length > 0) {
|
|
1043
|
+
sections.push(`extends: ${ancestors.map(a => fmtSymbol(a.symbol)).join(', ')}`);
|
|
1044
|
+
}
|
|
1045
|
+
if (descendants.length > 0) {
|
|
1046
|
+
sections.push(`extended by: ${descendants.map(d => fmtSymbol(d.symbol)).join(', ')}`);
|
|
1047
|
+
}
|
|
1048
|
+
// Siblings — other symbols in same file
|
|
1049
|
+
const siblings = db.getSymbolsByFile(sym.filePath)
|
|
1050
|
+
.filter(s => s.id !== sym.id && !s.parentId)
|
|
1051
|
+
.slice(0, 10);
|
|
1052
|
+
if (siblings.length > 0) {
|
|
1053
|
+
sections.push(`file peers: ${siblings.map(s => `${s.name} [${s.kind}]`).join(', ')}`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
else if (intent === 'edit') {
|
|
1057
|
+
// Focused: who calls this + blast radius + test coverage
|
|
1058
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1059
|
+
const upstream = db.findUpstream(sym.id, 2);
|
|
1060
|
+
if (incoming.length > 0) {
|
|
1061
|
+
sections.push(`direct callers (${incoming.length}):`);
|
|
1062
|
+
for (const l of incoming) {
|
|
1063
|
+
const from = db.findSymbolById(l.fromId);
|
|
1064
|
+
sections.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
sections.push(`direct callers: none`);
|
|
1069
|
+
}
|
|
1070
|
+
if (upstream.length > incoming.length) {
|
|
1071
|
+
const depth2 = upstream.filter(u => u.depth === 2);
|
|
1072
|
+
if (depth2.length > 0) {
|
|
1073
|
+
sections.push(`indirect dependents (depth 2): ${depth2.length} symbols`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// Re-export detection
|
|
1077
|
+
const reExportMatches = grepFiles(root, name, { maxResults: 5, includePattern: '**/index.{ts,js,mjs}' })
|
|
1078
|
+
.filter(m => /export\s*\{/.test(m.text) && m.text.includes('from'));
|
|
1079
|
+
if (reExportMatches.length > 0) {
|
|
1080
|
+
sections.push(`re-exported via: ${reExportMatches.map(m => `${m.file}:${m.line}`).join(', ')}`);
|
|
1081
|
+
}
|
|
1082
|
+
// Test coverage
|
|
1083
|
+
const testRefs = incoming.filter(l => {
|
|
1084
|
+
const from = db.findSymbolById(l.fromId);
|
|
1085
|
+
return from && isTestFilePath(from.filePath);
|
|
1086
|
+
});
|
|
1087
|
+
if (testRefs.length > 0) {
|
|
1088
|
+
sections.push(`✓ has test coverage`);
|
|
1089
|
+
}
|
|
1090
|
+
else if (sym.exported) {
|
|
1091
|
+
sections.push(`⚠ no test coverage`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
else if (intent === 'debug') {
|
|
1095
|
+
// Execution paths + data flow
|
|
1096
|
+
const traces = db.traceToEntrypoints(sym.id, 6);
|
|
1097
|
+
if (traces.length > 0) {
|
|
1098
|
+
sections.push(`execution paths (${traces.length}):`);
|
|
1099
|
+
for (let i = 0; i < Math.min(traces.length, 3); i++) {
|
|
1100
|
+
const chain = traces[i].path;
|
|
1101
|
+
sections.push(` ${chain.map(s => s.symbol.name).join(' → ')}`);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
sections.push(`no call chains found (may be entrypoint or unreachable)`);
|
|
1106
|
+
}
|
|
1107
|
+
// What does this call? (downstream immediate)
|
|
1108
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type === 'calls');
|
|
1109
|
+
if (outgoing.length > 0) {
|
|
1110
|
+
sections.push(`calls (${outgoing.length}):`);
|
|
1111
|
+
for (const l of outgoing) {
|
|
1112
|
+
const to = db.findSymbolById(l.toId);
|
|
1113
|
+
sections.push(` ${to ? fmtSymbol(to) : l.toId}`);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// Data types used
|
|
1117
|
+
const dataTypes = db.getOutgoingLinks(sym.id)
|
|
1118
|
+
.filter(l => l.type === 'imports')
|
|
1119
|
+
.map(l => db.findSymbolById(l.toId))
|
|
1120
|
+
.filter(s => s && (s.kind === 'interface' || s.kind === 'type' || s.kind === 'class'))
|
|
1121
|
+
.slice(0, 10);
|
|
1122
|
+
if (dataTypes.length > 0) {
|
|
1123
|
+
sections.push(`data types: ${dataTypes.map(s => s.name).join(', ')}`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
else if (intent === 'test') {
|
|
1127
|
+
// Test coverage + what to mock
|
|
1128
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1129
|
+
const testRefs = incoming.filter(l => {
|
|
1130
|
+
const from = db.findSymbolById(l.fromId);
|
|
1131
|
+
return from && isTestFilePath(from.filePath);
|
|
1132
|
+
});
|
|
1133
|
+
if (testRefs.length > 0) {
|
|
1134
|
+
const testFiles = [...new Set(testRefs.map(l => {
|
|
1135
|
+
const from = db.findSymbolById(l.fromId);
|
|
1136
|
+
return from?.filePath;
|
|
1137
|
+
}).filter(Boolean))];
|
|
1138
|
+
sections.push(`✓ tested from: ${testFiles.join(', ')}`);
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
sections.push(`⚠ no existing tests`);
|
|
1142
|
+
}
|
|
1143
|
+
// Dependencies to mock
|
|
1144
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1145
|
+
const externalDeps = outgoing.filter(l => {
|
|
1146
|
+
const to = db.findSymbolById(l.toId);
|
|
1147
|
+
return to && to.filePath !== sym.filePath;
|
|
1148
|
+
});
|
|
1149
|
+
if (externalDeps.length > 0) {
|
|
1150
|
+
sections.push(`dependencies to mock (${externalDeps.length}):`);
|
|
1151
|
+
for (const l of externalDeps) {
|
|
1152
|
+
const to = db.findSymbolById(l.toId);
|
|
1153
|
+
if (to)
|
|
1154
|
+
sections.push(` ${l.type}: ${fmtSymbol(to)}`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
// Inputs — what calls this? (test should cover these call patterns)
|
|
1158
|
+
const nonTestCallers = incoming.filter(l => {
|
|
1159
|
+
const from = db.findSymbolById(l.fromId);
|
|
1160
|
+
return from && !isTestFilePath(from.filePath);
|
|
1161
|
+
});
|
|
1162
|
+
if (nonTestCallers.length > 0) {
|
|
1163
|
+
sections.push(`callers to cover (${nonTestCallers.length}):`);
|
|
1164
|
+
for (const l of nonTestCallers.slice(0, 5)) {
|
|
1165
|
+
const from = db.findSymbolById(l.fromId);
|
|
1166
|
+
if (from)
|
|
1167
|
+
sections.push(` ${fmtSymbol(from)}`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
sections.push('');
|
|
1172
|
+
}
|
|
1173
|
+
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
1174
|
+
});
|
|
1175
|
+
// ══════════════════════════════════════════════
|
|
1176
|
+
// ── MCP Resources ──
|
|
1177
|
+
// ══════════════════════════════════════════════
|
|
1178
|
+
// ── Resource: milens://symbol/{name} ──
|
|
1179
|
+
server.resource('symbol', new ResourceTemplate('milens://symbol/{name}', { list: undefined }), { description: 'Symbol context: definition, incoming refs, outgoing deps, role/heat metadata' }, async (uri, { name }) => {
|
|
1180
|
+
const { db } = getDb();
|
|
1181
|
+
const symbols = db.findSymbolByName(name);
|
|
1182
|
+
if (symbols.length === 0) {
|
|
1183
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `"${name}" not found.` }] };
|
|
1184
|
+
}
|
|
1185
|
+
const lines = [];
|
|
1186
|
+
for (const sym of symbols) {
|
|
1187
|
+
lines.push(`${fmtSymbol(sym, 'L2')}${sym.exported ? ' (exported)' : ''}`);
|
|
1188
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1189
|
+
if (incoming.length > 0) {
|
|
1190
|
+
lines.push(`incoming (${incoming.length}):`);
|
|
1191
|
+
for (const l of incoming) {
|
|
1192
|
+
const from = db.findSymbolById(l.fromId);
|
|
1193
|
+
lines.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1197
|
+
if (outgoing.length > 0) {
|
|
1198
|
+
lines.push(`outgoing (${outgoing.length}):`);
|
|
1199
|
+
for (const l of outgoing) {
|
|
1200
|
+
const to = db.findSymbolById(l.toId);
|
|
1201
|
+
lines.push(` ${l.type}: ${to ? fmtSymbol(to) : l.toId}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
lines.push('');
|
|
1205
|
+
}
|
|
1206
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
1207
|
+
});
|
|
1208
|
+
// ── Resource: milens://file/{path} ──
|
|
1209
|
+
server.resource('file-symbols', new ResourceTemplate('milens://file/{+path}', { list: undefined }), { description: 'All symbols in a file with ref/dep counts' }, async (uri, { path }) => {
|
|
1210
|
+
const { db } = getDb();
|
|
1211
|
+
const filePath = decodeURIComponent(path);
|
|
1212
|
+
const symbols = db.getSymbolsByFile(filePath);
|
|
1213
|
+
if (symbols.length === 0) {
|
|
1214
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `No symbols in "${filePath}".` }] };
|
|
1215
|
+
}
|
|
1216
|
+
const lines = [`${filePath}: ${symbols.length} symbols\n`];
|
|
1217
|
+
for (const sym of symbols) {
|
|
1218
|
+
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1219
|
+
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1220
|
+
const exp = sym.exported ? ' (exported)' : '';
|
|
1221
|
+
lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
|
|
1222
|
+
}
|
|
1223
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
1224
|
+
});
|
|
1225
|
+
// ── Resource: milens://domain/{name} ──
|
|
1226
|
+
server.resource('domain', new ResourceTemplate('milens://domain/{name}', { list: undefined }), { description: 'Domain cluster details: files and top symbols in a domain' }, async (uri, { name }) => {
|
|
1227
|
+
const { db } = getDb();
|
|
1228
|
+
const domainName = name;
|
|
1229
|
+
// Find files in this domain
|
|
1230
|
+
const allFiles = db.db_getFilesByZone(domainName);
|
|
1231
|
+
if (allFiles.length === 0) {
|
|
1232
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `Domain "${domainName}" not found.` }] };
|
|
1233
|
+
}
|
|
1234
|
+
const lines = [`domain: ${domainName} (${allFiles.length} files)\n`];
|
|
1235
|
+
let totalSymbols = 0;
|
|
1236
|
+
for (const file of allFiles) {
|
|
1237
|
+
const syms = db.getSymbolsByFile(file);
|
|
1238
|
+
totalSymbols += syms.length;
|
|
1239
|
+
const exported = syms.filter(s => s.exported);
|
|
1240
|
+
lines.push(`${file}: ${syms.length} symbols (${exported.length} exported)`);
|
|
1241
|
+
}
|
|
1242
|
+
lines.push(`\ntotal: ${totalSymbols} symbols in ${allFiles.length} files`);
|
|
1243
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
1244
|
+
});
|
|
1245
|
+
// ── Resource: milens://overview ──
|
|
1246
|
+
server.resource('overview', 'milens://overview', { description: 'Index overview: stats, domains, unresolved, test coverage, staleness' }, async (uri) => {
|
|
1247
|
+
const { db, root } = getDb();
|
|
1248
|
+
const stats = db.getStats();
|
|
1249
|
+
const unresolved = db.getUnresolvedStats();
|
|
1250
|
+
const coverage = db.getTestCoverage();
|
|
1251
|
+
const domains = db.getDomainStats();
|
|
1252
|
+
const staleFiles = db.getStaleFiles(24);
|
|
1253
|
+
const lines = [
|
|
1254
|
+
`repo: ${root}`,
|
|
1255
|
+
`symbols: ${stats.symbols}`,
|
|
1256
|
+
`links: ${stats.links}`,
|
|
1257
|
+
`files: ${stats.files}`,
|
|
1258
|
+
];
|
|
1259
|
+
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
1260
|
+
lines.push(`⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls`);
|
|
1261
|
+
}
|
|
1262
|
+
if (unresolved.externalImports > 0 || unresolved.externalCalls > 0) {
|
|
1263
|
+
lines.push(`external (expected): ${unresolved.externalImports} imports, ${unresolved.externalCalls} calls`);
|
|
1264
|
+
}
|
|
1265
|
+
if (coverage.testFiles > 0) {
|
|
1266
|
+
const pct = coverage.exportedProductionSymbols > 0
|
|
1267
|
+
? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100) : 0;
|
|
1268
|
+
lines.push(`test coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} (${pct}%) from ${coverage.testFiles} test files`);
|
|
1269
|
+
}
|
|
1270
|
+
if (domains.length > 0) {
|
|
1271
|
+
lines.push(`\ndomains (${domains.length}):`);
|
|
1272
|
+
for (const d of domains) {
|
|
1273
|
+
lines.push(` ${d.domain}: ${d.files} files, ${d.symbols} symbols`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
if (staleFiles.length > 0) {
|
|
1277
|
+
lines.push(`\n⏳ ${staleFiles.length} stale files (>24h)`);
|
|
1278
|
+
}
|
|
1279
|
+
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
1280
|
+
});
|
|
493
1281
|
// ── Prompt: delete-feature ──
|
|
494
1282
|
server.prompt('delete-feature', 'Step-by-step workflow for safely deleting a feature from the codebase', { name: z.string().describe('Feature or symbol name to delete') }, ({ name }) => ({
|
|
495
1283
|
messages: [{
|