milens 0.4.1 → 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 +474 -31
- 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 +860 -98
- package/dist/server/mcp.js.map +1 -1
- package/dist/skills.js +100 -100
- 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,4 +1,4 @@
|
|
|
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';
|
|
@@ -6,7 +6,8 @@ import { createServer } from 'node:http';
|
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
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';
|
|
@@ -45,15 +46,72 @@ class LazyDb {
|
|
|
45
46
|
this.instance = null;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
|
-
// ──
|
|
49
|
-
|
|
50
|
-
|
|
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);
|
|
51
73
|
}
|
|
52
|
-
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') {
|
|
53
111
|
const grouped = new Map();
|
|
54
112
|
for (const { symbol, depth, via } of items) {
|
|
55
113
|
const arr = grouped.get(depth) ?? [];
|
|
56
|
-
arr.push(`${fmtSymbol(symbol)} (${via})`);
|
|
114
|
+
arr.push(`${fmtSymbol(symbol, detail)} (${via})`);
|
|
57
115
|
grouped.set(depth, arr);
|
|
58
116
|
}
|
|
59
117
|
const lines = [];
|
|
@@ -64,6 +122,14 @@ function fmtImpact(items) {
|
|
|
64
122
|
}
|
|
65
123
|
return lines.join('\n');
|
|
66
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
|
+
}
|
|
67
133
|
// ── Text grep across project files ──
|
|
68
134
|
const GREP_SKIP_DIRS = new Set([
|
|
69
135
|
'node_modules', '.git', 'dist', 'build', 'out',
|
|
@@ -153,6 +219,15 @@ function grepFiles(rootPath, pattern, options) {
|
|
|
153
219
|
function escapeRegExp(s) {
|
|
154
220
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
155
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
|
+
}
|
|
156
231
|
/** Validate user-supplied regex is safe from catastrophic backtracking (ReDoS). */
|
|
157
232
|
function safeRegex(pattern, flags) {
|
|
158
233
|
if (pattern.length > 200)
|
|
@@ -199,58 +274,70 @@ function loadGrepIgnoreRules(rootPath) {
|
|
|
199
274
|
return ig;
|
|
200
275
|
}
|
|
201
276
|
// ── Server instructions (sent to client via MCP protocol on initialize) ──
|
|
202
|
-
const MILENS_INSTRUCTIONS = `milens
|
|
203
|
-
|
|
204
|
-
##
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
\`
|
|
208
|
-
\`
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
-
|
|
224
|
-
-
|
|
225
|
-
-
|
|
226
|
-
-
|
|
227
|
-
-
|
|
228
|
-
-
|
|
229
|
-
-
|
|
230
|
-
-
|
|
231
|
-
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
-
|
|
235
|
-
-
|
|
236
|
-
-
|
|
237
|
-
|
|
238
|
-
### Impact depth guide
|
|
239
|
-
- depth 1: WILL BREAK — direct callers/importers → must update
|
|
240
|
-
- depth 2: LIKELY AFFECTED — indirect dependents → should test
|
|
241
|
-
- depth 3: MAY NEED TESTING — transitive → test if critical path
|
|
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
|
|
242
313
|
`;
|
|
243
314
|
// ── Server setup ──
|
|
244
315
|
export function createMcpServer(rootPath) {
|
|
245
316
|
const registry = new RepoRegistry();
|
|
246
317
|
const pools = new Map();
|
|
318
|
+
const trackDb = getTrackingDb();
|
|
247
319
|
function resolveRoot(repoPath) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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.`);
|
|
254
341
|
}
|
|
255
342
|
function getDb(repoPath) {
|
|
256
343
|
const root = resolveRoot(repoPath);
|
|
@@ -262,9 +349,25 @@ export function createMcpServer(rootPath) {
|
|
|
262
349
|
return { db: pools.get(root).get(), root };
|
|
263
350
|
}
|
|
264
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
|
+
});
|
|
265
369
|
// ── Tool: query ──
|
|
266
|
-
server.tool('query', 'Search indexed symbol definitions
|
|
267
|
-
'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`.', {
|
|
268
371
|
query: z.string().describe('Symbol name, kind, or keyword to search'),
|
|
269
372
|
repo: z.string().optional().describe('Repository root path (optional if only one indexed)'),
|
|
270
373
|
limit: z.number().optional().default(15).describe('Max results'),
|
|
@@ -272,75 +375,87 @@ export function createMcpServer(rootPath) {
|
|
|
272
375
|
const { db } = getDb(repo);
|
|
273
376
|
const results = db.searchSymbols(query, limit);
|
|
274
377
|
if (results.length === 0) {
|
|
275
|
-
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.` }] };
|
|
276
379
|
}
|
|
277
380
|
const text = results.map(s => fmtSymbol(s)).join('\n');
|
|
278
381
|
return { content: [{ type: 'text', text }] };
|
|
279
382
|
});
|
|
280
383
|
// ── Tool: grep ──
|
|
281
|
-
server.tool('grep', 'Text search
|
|
282
|
-
'Unlike `query` which only searches indexed symbol definitions, grep finds every text occurrence. ' +
|
|
283
|
-
'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.', {
|
|
284
385
|
pattern: z.string().describe('Text or regex pattern to search for'),
|
|
285
386
|
repo: z.string().optional().describe('Repository root path (optional)'),
|
|
286
387
|
isRegex: z.boolean().optional().default(false).describe('Treat pattern as regex'),
|
|
287
388
|
caseSensitive: z.boolean().optional().default(false),
|
|
288
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'),
|
|
289
392
|
limit: z.number().optional().default(50).describe('Max results'),
|
|
290
|
-
}, async ({ pattern, repo, isRegex, caseSensitive, include, limit }) => {
|
|
393
|
+
}, async ({ pattern, repo, isRegex, caseSensitive, include, scope, limit }) => {
|
|
291
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;
|
|
292
398
|
const matches = grepFiles(root, pattern, {
|
|
293
|
-
isRegex, caseSensitive, maxResults: limit, includePattern:
|
|
399
|
+
isRegex, caseSensitive, maxResults: limit, includePattern: effectiveInclude,
|
|
294
400
|
});
|
|
295
|
-
|
|
296
|
-
|
|
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})` : ''}` }] };
|
|
297
407
|
}
|
|
298
408
|
// Group by file for compact output
|
|
299
409
|
const grouped = new Map();
|
|
300
|
-
for (const m of
|
|
410
|
+
for (const m of filtered) {
|
|
301
411
|
const arr = grouped.get(m.file) ?? [];
|
|
302
412
|
arr.push({ line: m.line, text: m.text });
|
|
303
413
|
grouped.set(m.file, arr);
|
|
304
414
|
}
|
|
305
|
-
const lines = [`${
|
|
415
|
+
const lines = [`${filtered.length} matches in ${grouped.size} files${scope !== 'all' ? ` (scope: ${scope})` : ''}:\n`];
|
|
306
416
|
for (const [file, hits] of grouped) {
|
|
307
417
|
lines.push(file);
|
|
308
418
|
for (const h of hits) {
|
|
309
419
|
lines.push(` L${h.line}: ${h.text}`);
|
|
310
420
|
}
|
|
311
421
|
}
|
|
312
|
-
if (
|
|
422
|
+
if (filtered.length >= limit) {
|
|
313
423
|
lines.push(`\n(truncated at ${limit} results — increase limit or narrow pattern)`);
|
|
314
424
|
}
|
|
315
425
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
316
426
|
});
|
|
317
427
|
// ── Tool: context ──
|
|
318
|
-
server.tool('context', '
|
|
428
|
+
server.tool('context', 'Symbol 360°: incoming refs + outgoing deps. Use `overview` for combined context+impact+grep.', {
|
|
319
429
|
name: z.string().describe('Symbol name to inspect'),
|
|
320
430
|
repo: z.string().optional(),
|
|
321
|
-
|
|
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 }) => {
|
|
322
433
|
const { db } = getDb(repo);
|
|
323
434
|
const symbols = db.findSymbolByName(name);
|
|
324
435
|
if (symbols.length === 0) {
|
|
325
|
-
return { content: [{ type: 'text', text: `
|
|
436
|
+
return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
|
|
326
437
|
}
|
|
327
438
|
const lines = [];
|
|
328
439
|
for (const sym of symbols) {
|
|
329
|
-
lines.push(`## ${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
|
|
440
|
+
lines.push(`## ${fmtSymbol(sym, detail)}${sym.exported ? ' (exported)' : ''}`);
|
|
330
441
|
const incoming = db.getIncomingLinks(sym.id);
|
|
331
442
|
if (incoming.length > 0) {
|
|
332
443
|
lines.push('incoming:');
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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}`);
|
|
336
449
|
}
|
|
337
450
|
}
|
|
338
451
|
const outgoing = db.getOutgoingLinks(sym.id);
|
|
339
452
|
if (outgoing.length > 0) {
|
|
340
453
|
lines.push('outgoing:');
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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}`);
|
|
344
459
|
}
|
|
345
460
|
}
|
|
346
461
|
lines.push('');
|
|
@@ -348,47 +463,204 @@ export function createMcpServer(rootPath) {
|
|
|
348
463
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
349
464
|
});
|
|
350
465
|
// ── Tool: impact ──
|
|
351
|
-
server.tool('impact', 'Blast radius
|
|
352
|
-
'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.', {
|
|
353
467
|
target: z.string().describe('Symbol name to analyze'),
|
|
354
468
|
direction: z.enum(['upstream', 'downstream']).default('upstream'),
|
|
355
469
|
depth: z.number().optional().default(3),
|
|
356
470
|
repo: z.string().optional(),
|
|
357
|
-
|
|
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 }) => {
|
|
358
473
|
const { db } = getDb(repo);
|
|
359
474
|
const symbols = db.findSymbolByName(target);
|
|
360
475
|
if (symbols.length === 0) {
|
|
361
|
-
return { content: [{ type: 'text', text: `
|
|
476
|
+
return { content: [{ type: 'text', text: `"${target}" not found. Try \`grep\`.` }] };
|
|
362
477
|
}
|
|
363
478
|
const lines = [];
|
|
364
479
|
for (const sym of symbols) {
|
|
365
|
-
lines.push(`TARGET: ${fmtSymbol(sym)}`);
|
|
480
|
+
lines.push(`TARGET: ${fmtSymbol(sym, detail)}`);
|
|
366
481
|
const refs = direction === 'upstream'
|
|
367
482
|
? db.findUpstream(sym.id, depth)
|
|
368
483
|
: db.findDownstream(sym.id, depth);
|
|
369
484
|
if (refs.length === 0) {
|
|
370
|
-
lines.push(`No ${direction}
|
|
485
|
+
lines.push(`No ${direction} deps found.`);
|
|
371
486
|
}
|
|
372
487
|
else {
|
|
373
488
|
lines.push(`${direction} (${refs.length} symbols):`);
|
|
374
|
-
lines.push(fmtImpact(refs));
|
|
375
|
-
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));
|
|
376
490
|
}
|
|
377
491
|
lines.push('');
|
|
378
492
|
}
|
|
379
493
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
380
494
|
});
|
|
381
495
|
// ── Tool: status ──
|
|
382
|
-
server.tool('status', '
|
|
496
|
+
server.tool('status', 'Index stats for a repository.', {
|
|
383
497
|
repo: z.string().optional(),
|
|
384
498
|
}, async ({ repo }) => {
|
|
385
499
|
const { db, root } = getDb(repo);
|
|
386
500
|
const stats = db.getStats();
|
|
387
|
-
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
|
+
}
|
|
388
524
|
return { content: [{ type: 'text', text }] };
|
|
389
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
|
+
});
|
|
390
662
|
// ── Tool: detect_changes ──
|
|
391
|
-
server.tool('detect_changes', '
|
|
663
|
+
server.tool('detect_changes', 'Git diff → affected symbols + direct dependents.', {
|
|
392
664
|
ref: z.string().optional().default('HEAD').describe('Git ref to diff against (default: HEAD)'),
|
|
393
665
|
repo: z.string().optional(),
|
|
394
666
|
}, async ({ ref, repo }) => {
|
|
@@ -430,7 +702,7 @@ export function createMcpServer(rootPath) {
|
|
|
430
702
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
431
703
|
});
|
|
432
704
|
// ── Tool: explain_relationship ──
|
|
433
|
-
server.tool('explain_relationship', '
|
|
705
|
+
server.tool('explain_relationship', 'Shortest dependency path between two symbols.', {
|
|
434
706
|
from: z.string().describe('Source symbol name'),
|
|
435
707
|
to: z.string().describe('Target symbol name'),
|
|
436
708
|
repo: z.string().optional(),
|
|
@@ -438,7 +710,7 @@ export function createMcpServer(rootPath) {
|
|
|
438
710
|
const { db } = getDb(repo);
|
|
439
711
|
const path = db.findPath(from, to);
|
|
440
712
|
if (!path) {
|
|
441
|
-
return { content: [{ type: 'text', text: `No
|
|
713
|
+
return { content: [{ type: 'text', text: `No path between "${from}" and "${to}".` }] };
|
|
442
714
|
}
|
|
443
715
|
const fromSym = db.findSymbolByName(from)[0];
|
|
444
716
|
const lines = [`FROM: ${fmtSymbol(fromSym)}`, ''];
|
|
@@ -448,7 +720,7 @@ export function createMcpServer(rootPath) {
|
|
|
448
720
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
449
721
|
});
|
|
450
722
|
// ── Tool: find_dead_code ──
|
|
451
|
-
server.tool('find_dead_code', '
|
|
723
|
+
server.tool('find_dead_code', 'Exported symbols with zero incoming references (potentially unused).', {
|
|
452
724
|
kind: z.string().optional().describe('Filter by symbol kind (function, class, method, etc.)'),
|
|
453
725
|
limit: z.number().optional().default(30),
|
|
454
726
|
repo: z.string().optional(),
|
|
@@ -465,33 +737,52 @@ export function createMcpServer(rootPath) {
|
|
|
465
737
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
466
738
|
});
|
|
467
739
|
// ── Tool: get_file_symbols ──
|
|
468
|
-
server.tool('get_file_symbols', '
|
|
740
|
+
server.tool('get_file_symbols', 'All symbols in a file with ref/dep counts.', {
|
|
469
741
|
file: z.string().describe('File path (relative to repo root)'),
|
|
470
742
|
repo: z.string().optional(),
|
|
471
|
-
|
|
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 }) => {
|
|
472
745
|
const { db } = getDb(repo);
|
|
473
746
|
const symbols = db.getSymbolsByFile(file);
|
|
474
747
|
if (symbols.length === 0) {
|
|
475
748
|
return { content: [{ type: 'text', text: `No symbols found in "${file}". Is the path relative to repo root?` }] };
|
|
476
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;
|
|
477
754
|
const lines = [`${file}: ${symbols.length} symbols\n`];
|
|
478
|
-
for (const sym of
|
|
755
|
+
for (const sym of sorted) {
|
|
479
756
|
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
480
757
|
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
481
758
|
const exp = sym.exported ? ' (exported)' : '';
|
|
482
|
-
|
|
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
|
+
}
|
|
483
774
|
}
|
|
484
775
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
485
776
|
});
|
|
486
777
|
// ── Tool: get_type_hierarchy ──
|
|
487
|
-
server.tool('get_type_hierarchy', '
|
|
778
|
+
server.tool('get_type_hierarchy', 'Inheritance/implementation tree for a class, interface, or trait.', {
|
|
488
779
|
name: z.string().describe('Symbol name to show hierarchy for'),
|
|
489
780
|
repo: z.string().optional(),
|
|
490
781
|
}, async ({ name, repo }) => {
|
|
491
782
|
const { db } = getDb(repo);
|
|
492
783
|
const symbols = db.findSymbolByName(name);
|
|
493
784
|
if (symbols.length === 0) {
|
|
494
|
-
return { content: [{ type: 'text', text: `
|
|
785
|
+
return { content: [{ type: 'text', text: `"${name}" not found. Try \`grep\`.` }] };
|
|
495
786
|
}
|
|
496
787
|
const lines = [];
|
|
497
788
|
for (const sym of symbols) {
|
|
@@ -504,18 +795,489 @@ export function createMcpServer(rootPath) {
|
|
|
504
795
|
}
|
|
505
796
|
}
|
|
506
797
|
if (descendants.length > 0) {
|
|
507
|
-
lines.push('implemented
|
|
798
|
+
lines.push('extended/implemented by:');
|
|
508
799
|
for (const { symbol: d, depth } of descendants) {
|
|
509
800
|
lines.push(` ${'↓'.repeat(depth)} ${fmtSymbol(d)}`);
|
|
510
801
|
}
|
|
511
802
|
}
|
|
512
803
|
if (ancestors.length === 0 && descendants.length === 0) {
|
|
513
|
-
lines.push('No inheritance relationships
|
|
804
|
+
lines.push('No inheritance relationships.');
|
|
514
805
|
}
|
|
515
806
|
lines.push('');
|
|
516
807
|
}
|
|
517
808
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
518
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}`);
|
|
1002
|
+
}
|
|
1003
|
+
lines.push('');
|
|
1004
|
+
}
|
|
1005
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
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
|
+
});
|
|
519
1281
|
// ── Prompt: delete-feature ──
|
|
520
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 }) => ({
|
|
521
1283
|
messages: [{
|