milens 0.7.0 → 0.7.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/.agents/skills/adapters/SKILL.md +59 -51
- package/.agents/skills/analyzer/SKILL.md +23 -21
- package/.agents/skills/apps/SKILL.md +65 -68
- package/.agents/skills/docs/SKILL.md +10 -25
- package/.agents/skills/milens/SKILL.md +11 -8
- package/.agents/skills/orchestrator/SKILL.md +13 -10
- package/.agents/skills/parser/SKILL.md +15 -12
- package/.agents/skills/root/SKILL.md +30 -29
- package/.agents/skills/scripts/SKILL.md +18 -16
- package/.agents/skills/security/SKILL.md +7 -7
- package/.agents/skills/server/SKILL.md +36 -26
- package/.agents/skills/store/SKILL.md +24 -23
- package/.agents/skills/test/SKILL.md +24 -22
- package/.agents/skills/ui/SKILL.md +6 -1
- package/README.md +12 -3
- package/adapters/README.md +144 -107
- package/adapters/claude-code/.claude/settings.json.hooks-snippet.json +26 -0
- package/adapters/claude-code/.claude-plugin/plugin.json +11 -0
- package/adapters/claude-code/.mcp.json +9 -0
- package/adapters/claude-code/CLAUDE.md +2 -0
- package/adapters/claude-code/hooks/hooks.json +27 -0
- package/adapters/codex/.codex/config.toml +3 -0
- package/adapters/copilot/.vscode/mcp.json +10 -0
- package/adapters/cursor/.cursor/mcp.json +9 -0
- package/adapters/cursor/.cursorrules +69 -9
- package/adapters/gemini/.gemini/settings.json +9 -0
- package/dist/agents-md.d.ts +1 -1
- package/dist/agents-md.js +47 -3
- package/dist/agents-md.js.map +1 -1
- package/dist/analyzer/config.d.ts +0 -1
- package/dist/analyzer/config.js +32 -0
- package/dist/analyzer/config.js.map +1 -1
- package/dist/analyzer/engine.d.ts +0 -1
- package/dist/analyzer/engine.js +67 -41
- package/dist/analyzer/engine.js.map +1 -1
- package/dist/analyzer/enrich.d.ts +0 -1
- package/dist/analyzer/enrich.js +81 -1
- package/dist/analyzer/enrich.js.map +1 -1
- package/dist/analyzer/resolver.d.ts +4 -2
- package/dist/analyzer/resolver.js +283 -36
- package/dist/analyzer/resolver.js.map +1 -1
- package/dist/analyzer/review.d.ts +2 -2
- package/dist/analyzer/review.js +39 -61
- package/dist/analyzer/review.js.map +1 -1
- package/dist/analyzer/risk.d.ts +20 -0
- package/dist/analyzer/risk.js +76 -0
- package/dist/analyzer/risk.js.map +1 -0
- package/dist/analyzer/scanner.d.ts +0 -1
- package/dist/analyzer/scanner.js +5 -4
- package/dist/analyzer/scanner.js.map +1 -1
- package/dist/analyzer/scope-resolver.d.ts +0 -1
- package/dist/analyzer/scope-resolver.js +2 -2
- package/dist/analyzer/scope-resolver.js.map +1 -1
- package/dist/build-info.d.ts +2 -0
- package/dist/build-info.js +4 -0
- package/dist/build-info.js.map +1 -0
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +225 -30
- package/dist/cli.js.map +1 -1
- package/dist/metrics.d.ts +0 -1
- package/dist/orchestrator/orchestrator.d.ts +1 -2
- package/dist/orchestrator/orchestrator.js +30 -7
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/orchestrator/reporter.d.ts +0 -1
- package/dist/parser/extract.d.ts +7 -1
- package/dist/parser/extract.js +76 -29
- package/dist/parser/extract.js.map +1 -1
- package/dist/parser/lang-css.d.ts +0 -1
- package/dist/parser/lang-css.js +12 -0
- package/dist/parser/lang-css.js.map +1 -1
- package/dist/parser/lang-go.d.ts +0 -1
- package/dist/parser/lang-html.d.ts +0 -1
- package/dist/parser/lang-java.d.ts +0 -1
- package/dist/parser/lang-js.d.ts +0 -1
- package/dist/parser/lang-js.js +15 -4
- package/dist/parser/lang-js.js.map +1 -1
- package/dist/parser/lang-md.d.ts +0 -1
- package/dist/parser/lang-md.js +1 -0
- package/dist/parser/lang-md.js.map +1 -1
- package/dist/parser/lang-php.d.ts +0 -1
- package/dist/parser/lang-py.d.ts +0 -1
- package/dist/parser/lang-ruby.d.ts +0 -1
- package/dist/parser/lang-rust.d.ts +0 -1
- package/dist/parser/lang-ts.d.ts +0 -1
- package/dist/parser/lang-ts.js +40 -6
- package/dist/parser/lang-ts.js.map +1 -1
- package/dist/parser/lang-vue.d.ts +0 -1
- package/dist/parser/lang-vue.js +0 -21
- package/dist/parser/lang-vue.js.map +1 -1
- package/dist/parser/language-provider.d.ts +0 -1
- package/dist/parser/languages.d.ts +0 -1
- package/dist/parser/loader.d.ts +0 -1
- package/dist/security/deps.d.ts +0 -1
- package/dist/security/rules.d.ts +0 -1
- package/dist/security/rules.js +35 -35
- package/dist/security/rules.js.map +1 -1
- package/dist/server/guard-hook.d.ts +10 -0
- package/dist/server/guard-hook.js +179 -0
- package/dist/server/guard-hook.js.map +1 -0
- package/dist/server/hooks.d.ts +0 -1
- package/dist/server/hooks.js +2 -1
- package/dist/server/hooks.js.map +1 -1
- package/dist/server/mcp-prompts.d.ts +0 -1
- package/dist/server/mcp-prompts.js +509 -523
- package/dist/server/mcp-prompts.js.map +1 -1
- package/dist/server/mcp.d.ts +0 -1
- package/dist/server/mcp.js +324 -681
- package/dist/server/mcp.js.map +1 -1
- package/dist/server/test-plan.d.ts +1 -1
- package/dist/server/test-plan.js +25 -7
- package/dist/server/test-plan.js.map +1 -1
- package/dist/server/tools/deps.d.ts +28 -0
- package/dist/server/tools/deps.js +2 -0
- package/dist/server/tools/deps.js.map +1 -0
- package/dist/server/tools/findings-report.d.ts +3 -0
- package/dist/server/tools/findings-report.js +170 -0
- package/dist/server/tools/findings-report.js.map +1 -0
- package/dist/server/tools/resources.d.ts +3 -0
- package/dist/server/tools/resources.js +103 -0
- package/dist/server/tools/resources.js.map +1 -0
- package/dist/server/tools/security.d.ts +3 -0
- package/dist/server/tools/security.js +232 -0
- package/dist/server/tools/security.js.map +1 -0
- package/dist/server/tools/session.d.ts +3 -0
- package/dist/server/tools/session.js +160 -0
- package/dist/server/tools/session.js.map +1 -0
- package/dist/server/tools/testing.d.ts +3 -0
- package/dist/server/tools/testing.js +247 -0
- package/dist/server/tools/testing.js.map +1 -0
- package/dist/server/watcher.d.ts +0 -1
- package/dist/server/watcher.js +1 -1
- package/dist/server/watcher.js.map +1 -1
- package/dist/skills.d.ts +0 -1
- package/dist/skills.js +23 -11
- package/dist/skills.js.map +1 -1
- package/dist/store/annotations.d.ts +6 -1
- package/dist/store/annotations.js +56 -9
- package/dist/store/annotations.js.map +1 -1
- package/dist/store/confidence.d.ts +0 -1
- package/dist/store/confidence.js +2 -0
- package/dist/store/confidence.js.map +1 -1
- package/dist/store/db.d.ts +22 -1
- package/dist/store/db.js +225 -45
- package/dist/store/db.js.map +1 -1
- package/dist/store/registry.d.ts +1 -1
- package/dist/store/registry.js +17 -6
- package/dist/store/registry.js.map +1 -1
- package/dist/store/schema.sql +2 -1
- package/dist/store/vectors.d.ts +0 -1
- package/dist/types.d.ts +23 -2
- package/dist/ui/progress.d.ts +0 -1
- package/dist/uninstall.d.ts +1 -1
- package/dist/uninstall.js +33 -10
- package/dist/uninstall.js.map +1 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +37 -2
- package/dist/utils.js.map +1 -1
- package/package.json +11 -8
- package/dist/agents-md.d.ts.map +0 -1
- package/dist/analyzer/config.d.ts.map +0 -1
- package/dist/analyzer/engine.d.ts.map +0 -1
- package/dist/analyzer/enrich.d.ts.map +0 -1
- package/dist/analyzer/resolver.d.ts.map +0 -1
- package/dist/analyzer/review.d.ts.map +0 -1
- package/dist/analyzer/scanner.d.ts.map +0 -1
- package/dist/analyzer/scope-resolver.d.ts.map +0 -1
- package/dist/analyzer/testplan.d.ts +0 -59
- package/dist/analyzer/testplan.d.ts.map +0 -1
- package/dist/analyzer/testplan.js +0 -218
- package/dist/analyzer/testplan.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/metrics.d.ts.map +0 -1
- package/dist/orchestrator/orchestrator.d.ts.map +0 -1
- package/dist/orchestrator/reporter.d.ts.map +0 -1
- package/dist/parser/extract.d.ts.map +0 -1
- package/dist/parser/lang-css.d.ts.map +0 -1
- package/dist/parser/lang-go.d.ts.map +0 -1
- package/dist/parser/lang-html.d.ts.map +0 -1
- package/dist/parser/lang-java.d.ts.map +0 -1
- package/dist/parser/lang-js.d.ts.map +0 -1
- package/dist/parser/lang-md.d.ts.map +0 -1
- package/dist/parser/lang-php.d.ts.map +0 -1
- package/dist/parser/lang-py.d.ts.map +0 -1
- package/dist/parser/lang-ruby.d.ts.map +0 -1
- package/dist/parser/lang-rust.d.ts.map +0 -1
- package/dist/parser/lang-ts.d.ts.map +0 -1
- package/dist/parser/lang-vue.d.ts.map +0 -1
- package/dist/parser/language-provider.d.ts.map +0 -1
- package/dist/parser/languages.d.ts.map +0 -1
- package/dist/parser/loader.d.ts.map +0 -1
- package/dist/security/deps.d.ts.map +0 -1
- package/dist/security/rules.d.ts.map +0 -1
- package/dist/server/hooks.d.ts.map +0 -1
- package/dist/server/mcp-prompts.d.ts.map +0 -1
- package/dist/server/mcp.d.ts.map +0 -1
- package/dist/server/test-plan.d.ts.map +0 -1
- package/dist/server/watcher.d.ts.map +0 -1
- package/dist/skills.d.ts.map +0 -1
- package/dist/store/annotations.d.ts.map +0 -1
- package/dist/store/confidence.d.ts.map +0 -1
- package/dist/store/db.d.ts.map +0 -1
- package/dist/store/registry.d.ts.map +0 -1
- package/dist/store/vectors.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/ui/progress.d.ts.map +0 -1
- package/dist/uninstall.d.ts.map +0 -1
- package/dist/utils.d.ts.map +0 -1
- package/docs/README.md +0 -25
- package/docs/diagram1.svg +0 -1
- package/docs/diagram2.svg +0 -1
- package/docs/diagram3.svg +0 -1
package/dist/server/mcp.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { McpServer
|
|
1
|
+
import { McpServer } 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, dirname
|
|
7
|
+
import { resolve, relative, join, dirname } from 'node:path';
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { readFileSync, readdirSync, statSync, mkdirSync, existsSync
|
|
9
|
+
import { readFileSync, readdirSync, statSync, mkdirSync, existsSync } from 'node:fs';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import ignore from 'ignore';
|
|
12
12
|
import { Database } from '../store/db.js';
|
|
@@ -14,14 +14,19 @@ import { RepoRegistry } from '../store/registry.js';
|
|
|
14
14
|
import { getParser, loadLanguage } from '../parser/loader.js';
|
|
15
15
|
import { ALL_LANGS } from '../parser/languages.js';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
-
import { generateTestPlan } from './test-plan.js';
|
|
18
17
|
import { AnnotationStore } from '../store/annotations.js';
|
|
19
18
|
import { registerAllPrompts } from './mcp-prompts.js';
|
|
20
|
-
import { loadRules } from '../security/rules.js';
|
|
21
|
-
import { HookManager, defaultOnSessionStart, defaultOnSessionEnd, defaultOnPreCommit, defaultOnFileChange, defaultOnPreCompact, defaultOnPostCompact } from './hooks.js';
|
|
22
19
|
import { Orchestrator } from '../orchestrator/orchestrator.js';
|
|
20
|
+
import { registerResources } from './tools/resources.js';
|
|
21
|
+
import { countDependentFiles } from '../analyzer/risk.js';
|
|
22
|
+
import { registerSessionTools } from './tools/session.js';
|
|
23
|
+
import { registerTestingTools } from './tools/testing.js';
|
|
24
|
+
import { registerSecurityTools } from './tools/security.js';
|
|
25
|
+
import { registerFindingsReportTools } from './tools/findings-report.js';
|
|
23
26
|
import { FileWatcher } from './watcher.js';
|
|
24
27
|
import { reviewPr } from '../analyzer/review.js';
|
|
28
|
+
import { globToRegex } from '../utils.js';
|
|
29
|
+
import { BUILD_SHA, BUILT_AT } from '../build-info.js';
|
|
25
30
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
31
|
const PKG_VERSION = process.env.MILENS_VERSION ?? JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
27
32
|
// ── Lazy DB connection with idle eviction ──
|
|
@@ -147,7 +152,10 @@ class SessionGuard {
|
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
// ── Tool usage tracking ──
|
|
150
|
-
//
|
|
155
|
+
// Hand-picked heuristic estimates of tokens an agent would spend WITHOUT milens
|
|
156
|
+
// (manual exploration cost per tool). Not derived from measurement — these feed
|
|
157
|
+
// the "tokens saved" numbers in usage tracking/dashboards, so treat them as a
|
|
158
|
+
// rough directional signal, not a validated benchmark.
|
|
151
159
|
const TOKEN_SAVINGS_MULTIPLIER = {
|
|
152
160
|
query: 3, // vs 3+ separate grep/file reads
|
|
153
161
|
grep: 2, // vs terminal grep + manual filtering
|
|
@@ -233,10 +241,10 @@ function isTestFilePath(filePath) {
|
|
|
233
241
|
// ── Text grep across project files ──
|
|
234
242
|
const GREP_SKIP_DIRS = new Set([
|
|
235
243
|
'node_modules', '.git', 'dist', 'build', 'out',
|
|
236
|
-
'.next', '.nuxt', '.svelte-kit',
|
|
244
|
+
'.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', '.parcel-cache',
|
|
237
245
|
'__pycache__', '.venv', 'venv', 'env',
|
|
238
246
|
'vendor', 'target',
|
|
239
|
-
'.idea',
|
|
247
|
+
'.idea',
|
|
240
248
|
'coverage', '.nyc_output',
|
|
241
249
|
]);
|
|
242
250
|
const BINARY_EXTENSIONS = new Set([
|
|
@@ -276,8 +284,6 @@ function grepFiles(rootPath, pattern, options) {
|
|
|
276
284
|
return;
|
|
277
285
|
const abs = join(dir, entry);
|
|
278
286
|
const rel = relative(rootPath, abs).replace(/\\/g, '/');
|
|
279
|
-
if (entry.startsWith('.') && entry !== '.')
|
|
280
|
-
continue;
|
|
281
287
|
if (GREP_SKIP_DIRS.has(entry))
|
|
282
288
|
continue;
|
|
283
289
|
if (ig.ignores(rel))
|
|
@@ -328,7 +334,11 @@ function matchesScope(lineText, scope) {
|
|
|
328
334
|
// definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
|
|
329
335
|
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);
|
|
330
336
|
}
|
|
331
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* Validate user-supplied regex is safe from catastrophic backtracking (ReDoS).
|
|
339
|
+
* This is a heuristic blocklist of known-dangerous constructs, not a formal
|
|
340
|
+
* proof of safety — it catches common patterns but isn't exhaustive.
|
|
341
|
+
*/
|
|
332
342
|
function safeRegex(pattern, flags) {
|
|
333
343
|
if (pattern.length > 200)
|
|
334
344
|
throw new Error('Pattern too long');
|
|
@@ -355,14 +365,6 @@ function safeRegex(pattern, flags) {
|
|
|
355
365
|
throw new Error('Unsafe regex pattern');
|
|
356
366
|
return new RegExp(pattern, flags);
|
|
357
367
|
}
|
|
358
|
-
function globToRegex(glob) {
|
|
359
|
-
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
360
|
-
.replace(/\*\*/g, '§STARSTAR§')
|
|
361
|
-
.replace(/\*/g, '[^/]*')
|
|
362
|
-
.replace(/§STARSTAR§/g, '.*')
|
|
363
|
-
.replace(/\?/g, '.');
|
|
364
|
-
return new RegExp(`^${escaped}$`, 'i');
|
|
365
|
-
}
|
|
366
368
|
function loadGrepIgnoreRules(rootPath) {
|
|
367
369
|
const ig = ignore();
|
|
368
370
|
ig.add(['node_modules', 'dist', 'build', '.git', '__pycache__', 'vendor', 'target']);
|
|
@@ -389,7 +391,7 @@ RULE: Before opening ANY file to understand code, call the appropriate milens to
|
|
|
389
391
|
|
|
390
392
|
AUDIT: session_end reports which symbols were safety-checked. Editing without checks = audit gap.
|
|
391
393
|
|
|
392
|
-
TOKEN SAVINGS: Using milens first
|
|
394
|
+
TOKEN SAVINGS: Using milens first typically means far fewer tokens than manual exploration, and fewer missed dependencies — impact/context track code-level references; pair with grep for templates/configs/docs.
|
|
393
395
|
|
|
394
396
|
milens — code intelligence engine. Indexes codebases into symbol graphs.
|
|
395
397
|
|
|
@@ -438,6 +440,9 @@ export function createMcpServer(rootPath) {
|
|
|
438
440
|
const toolCallCounts = new Map(); // In-memory counter per repo
|
|
439
441
|
const trackDb = getTrackingDb();
|
|
440
442
|
const guard = new SessionGuard();
|
|
443
|
+
// On Windows, the same path can arrive as "c:\..." or "C:\..." depending on the
|
|
444
|
+
// caller — without normalizing the drive letter, those resolve to different
|
|
445
|
+
// registry keys and split one repo's index across two entries.
|
|
441
446
|
function normalizePath(p) {
|
|
442
447
|
const abs = resolve(p);
|
|
443
448
|
if (process.platform === 'win32') {
|
|
@@ -445,6 +450,19 @@ export function createMcpServer(rootPath) {
|
|
|
445
450
|
}
|
|
446
451
|
return abs;
|
|
447
452
|
}
|
|
453
|
+
function isInsideDocstring(content, lineNumber) {
|
|
454
|
+
const lines = content.split('\n');
|
|
455
|
+
let inside = false;
|
|
456
|
+
for (let i = 0; i < lineNumber; i++) {
|
|
457
|
+
if (i >= lines.length)
|
|
458
|
+
break;
|
|
459
|
+
const trimmed = lines[i].trim();
|
|
460
|
+
const tripleCount = (trimmed.match(/"""|'''/g) || []).length;
|
|
461
|
+
for (let j = 0; j < tripleCount; j++)
|
|
462
|
+
inside = !inside;
|
|
463
|
+
}
|
|
464
|
+
return inside;
|
|
465
|
+
}
|
|
448
466
|
function resolveRoot(repoPath) {
|
|
449
467
|
if (repoPath) {
|
|
450
468
|
const root = normalizePath(repoPath);
|
|
@@ -487,7 +505,11 @@ export function createMcpServer(rootPath) {
|
|
|
487
505
|
return toolCallCounts.get(root) ?? 0;
|
|
488
506
|
}
|
|
489
507
|
const server = new McpServer({ name: 'milens', version: PKG_VERSION }, { instructions: MILENS_INSTRUCTIONS });
|
|
490
|
-
// Auto-wrap every tool handler with usage tracking + background decay tick
|
|
508
|
+
// Auto-wrap every tool handler with usage tracking + background decay tick.
|
|
509
|
+
// This replaces `server.tool` itself, so every `server.tool(...)` call below —
|
|
510
|
+
// including the ones inside registerTestingTools/registerSessionTools/
|
|
511
|
+
// registerSecurityTools/registerResources in other files, which all receive
|
|
512
|
+
// this same `server` instance — goes through this wrapper too.
|
|
491
513
|
const origTool = server.tool.bind(server);
|
|
492
514
|
let lastDecayTick = 0;
|
|
493
515
|
const DECAY_INTERVAL = 5 * 60_000; // 5 minutes between decay ticks
|
|
@@ -521,13 +543,15 @@ export function createMcpServer(rootPath) {
|
|
|
521
543
|
}
|
|
522
544
|
return origTool(...args);
|
|
523
545
|
});
|
|
524
|
-
// ── Selective tool profiles
|
|
546
|
+
// ── Selective tool profiles ──
|
|
525
547
|
const profile = process.env.MILENS_PROFILE || undefined;
|
|
526
548
|
if (profile && profile !== 'full') {
|
|
527
549
|
const minimal = new Set(['query', 'grep', 'context', 'impact', 'status', 'codebase_summary', 'edit_check', 'detect_changes', 'get_file_symbols', 'overview']);
|
|
528
|
-
const standard = new Set([...minimal, 'domains', 'repos', 'explain_relationship', 'find_dead_code', 'get_type_hierarchy', 'trace', 'routes', 'smart_context', 'review_pr', 'review_symbol', 'test_coverage_gaps', 'test_plan', 'test_impact', 'session_start', 'recall']);
|
|
550
|
+
const standard = new Set([...minimal, 'domains', 'repos', 'explain_relationship', 'find_dead_code', 'get_type_hierarchy', 'trace', 'routes', 'smart_context', 'review_pr', 'review_symbol', 'test_coverage_gaps', 'test_plan', 'test_impact', 'session_start', 'recall', 'generate_findings_report']);
|
|
529
551
|
const allowed = profile === 'minimal' ? minimal : standard;
|
|
530
|
-
// Wrap server.tool again
|
|
552
|
+
// Wrap the tracking-wrapped server.tool again, so profile gating sits on
|
|
553
|
+
// top: disabled tools still get registered (via the no-op handler below)
|
|
554
|
+
// and still get tracked, they just short-circuit to the disabled message.
|
|
531
555
|
const profileWrappedTool = server.tool.bind(server);
|
|
532
556
|
server.tool = ((...args) => {
|
|
533
557
|
const toolName = args[0];
|
|
@@ -673,7 +697,8 @@ export function createMcpServer(rootPath) {
|
|
|
673
697
|
lines.push(`No ${direction} deps found.`);
|
|
674
698
|
}
|
|
675
699
|
else {
|
|
676
|
-
|
|
700
|
+
// Dedupe depth-1 by calling file — same real dependent, same blast radius
|
|
701
|
+
const depth1Count = countDependentFiles(db, sym.id).count;
|
|
677
702
|
lines.push(`${direction} (${refs.length} symbols, depth-1: ${depth1Count}):`);
|
|
678
703
|
lines.push(fmtImpact(refs, detail));
|
|
679
704
|
// Strict mode: hard stop if depth-1 dependents > 5
|
|
@@ -698,7 +723,19 @@ export function createMcpServer(rootPath) {
|
|
|
698
723
|
const stats = lazy.getCachedStats();
|
|
699
724
|
const unresolved = db.getUnresolvedStats();
|
|
700
725
|
const coverage = db.getTestCoverage();
|
|
701
|
-
let text = `repo: ${root}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
|
|
726
|
+
let text = `repo: ${root}\nbuild: ${BUILD_SHA} (built: ${BUILT_AT})\nversion: ${PKG_VERSION}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
|
|
727
|
+
// Warn if the running server build differs from the on-disk build
|
|
728
|
+
try {
|
|
729
|
+
const distBuildPath = join(__dirname, '..', 'build-info.js');
|
|
730
|
+
if (existsSync(distBuildPath)) {
|
|
731
|
+
const diskBuild = readFileSync(distBuildPath, 'utf-8');
|
|
732
|
+
const m = diskBuild.match(/export const BUILD_SHA = '([^']+)'/);
|
|
733
|
+
if (m && m[1] !== BUILD_SHA) {
|
|
734
|
+
text += `\n⚠ Running server build (${BUILD_SHA}) differs from on-disk build (${m[1]}) — restart the MCP server to pick up recent changes.`;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch { /* best-effort */ }
|
|
702
739
|
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
703
740
|
text += `\n⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls — callers may be incomplete`;
|
|
704
741
|
}
|
|
@@ -719,13 +756,13 @@ export function createMcpServer(rootPath) {
|
|
|
719
756
|
if (staleFiles.length > 0) {
|
|
720
757
|
text += `\n⏳ ${staleFiles.length} files not analyzed in 24h`;
|
|
721
758
|
}
|
|
722
|
-
//
|
|
759
|
+
// Resolution confidence distribution (heuristic self-ratings, not validated accuracy)
|
|
723
760
|
const conf = db.getConfidenceDistribution();
|
|
724
761
|
if (conf.total > 0) {
|
|
725
762
|
const highPct = Math.round(conf.high / conf.total * 100);
|
|
726
763
|
const medPct = Math.round(conf.medium / conf.total * 100);
|
|
727
764
|
const lowPct = Math.round(conf.low / conf.total * 100);
|
|
728
|
-
text += `\
|
|
765
|
+
text += `\nresolution confidence: ${conf.total} links — ≥0.9: ${conf.high} (${highPct}%) | 0.7-0.9: ${conf.medium} (${medPct}%) | <0.7: ${conf.low} (${lowPct}%)`;
|
|
729
766
|
if (lowPct > 15) {
|
|
730
767
|
text += `\n⚠ ${lowPct}% low-confidence links — consider re-analyzing with \`--force\` or reviewing unresolved calls`;
|
|
731
768
|
}
|
|
@@ -755,7 +792,7 @@ export function createMcpServer(rootPath) {
|
|
|
755
792
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
756
793
|
});
|
|
757
794
|
// ── Tool: overview ──
|
|
758
|
-
server.tool('overview', 'ONE call replaces 3-5 file reads. Combined context + impact + grep. Use BEFORE reading any source file.
|
|
795
|
+
server.tool('overview', 'ONE call replaces 3-5 file reads. Combined context + impact + grep. Use BEFORE reading any source file. Typically far fewer tokens than reading files individually. Preferred before editing/deleting/renaming a symbol.', {
|
|
759
796
|
name: z.string().describe('Symbol name'),
|
|
760
797
|
repo: z.string().optional(),
|
|
761
798
|
depth: z.number().optional().default(2).describe('Impact traversal depth (default: 2)'),
|
|
@@ -776,8 +813,11 @@ export function createMcpServer(rootPath) {
|
|
|
776
813
|
// Section 2: Context (incoming + outgoing) for each symbol
|
|
777
814
|
if (symbols.length > 0) {
|
|
778
815
|
for (const sym of symbols) {
|
|
779
|
-
const incoming = db.getIncomingLinks(sym.id)
|
|
780
|
-
const outgoing = db.getOutgoingLinks(sym.id)
|
|
816
|
+
const incoming = db.getIncomingLinks(sym.id);
|
|
817
|
+
const outgoing = db.getOutgoingLinks(sym.id);
|
|
818
|
+
if (symbols.length > 1) {
|
|
819
|
+
sections.push(`─── ${fmtSymbol(sym, detail)} ---`);
|
|
820
|
+
}
|
|
781
821
|
if (incoming.length > 0) {
|
|
782
822
|
sections.push(`[incoming] ${incoming.length} refs:`);
|
|
783
823
|
const inSyms = incoming.map(l => {
|
|
@@ -855,7 +895,8 @@ export function createMcpServer(rootPath) {
|
|
|
855
895
|
lines.push(` domains: ${summary.domains.join(', ')}`);
|
|
856
896
|
}
|
|
857
897
|
if (summary.staleCount > 0) {
|
|
858
|
-
|
|
898
|
+
const stalePct = summary.files > 0 ? Math.round(summary.staleCount / summary.files * 100) : 0;
|
|
899
|
+
lines.push(` ⏳ ${summary.staleCount} stale files (>24h)${stalePct > 20 ? ' — ⚠ run `milens analyze` to refresh' : ''}`);
|
|
859
900
|
}
|
|
860
901
|
if (!pools.has(entry.rootPath))
|
|
861
902
|
tempDb.close();
|
|
@@ -874,6 +915,9 @@ export function createMcpServer(rootPath) {
|
|
|
874
915
|
repo: z.string().optional(),
|
|
875
916
|
}, async ({ ref, repo }) => {
|
|
876
917
|
const { db, root } = getDb(repo);
|
|
918
|
+
// `ref` is passed straight to `git diff` below. Restrict it to characters
|
|
919
|
+
// valid in a git ref so it can't be crafted as a flag (e.g. a leading "-")
|
|
920
|
+
// and get interpreted as a git option instead of a revision.
|
|
877
921
|
if (!/^[a-zA-Z0-9\/._~^\-]+$/.test(ref)) {
|
|
878
922
|
return { content: [{ type: 'text', text: 'Invalid git ref.' }] };
|
|
879
923
|
}
|
|
@@ -940,11 +984,11 @@ export function createMcpServer(rootPath) {
|
|
|
940
984
|
const unchangedNote = unchangedCount > 0 ? ` (${unchangedCount} unchanged not shown)` : '';
|
|
941
985
|
lines.push(`${file}: ${displaySyms.length} changed symbols${unchangedNote}`);
|
|
942
986
|
for (const sym of displaySyms) {
|
|
943
|
-
const
|
|
987
|
+
const depsCount = countDependentFiles(db, sym.id).count;
|
|
944
988
|
totalChanged++;
|
|
945
|
-
if (
|
|
946
|
-
lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${
|
|
947
|
-
totalAffected +=
|
|
989
|
+
if (depsCount > 0) {
|
|
990
|
+
lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${depsCount} direct dependents`);
|
|
991
|
+
totalAffected += depsCount;
|
|
948
992
|
}
|
|
949
993
|
else {
|
|
950
994
|
lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine}`);
|
|
@@ -962,12 +1006,32 @@ export function createMcpServer(rootPath) {
|
|
|
962
1006
|
repo: z.string().optional(),
|
|
963
1007
|
}, async ({ from, to, repo }) => {
|
|
964
1008
|
const { db } = getDb(repo);
|
|
965
|
-
|
|
1009
|
+
let fromSyms = db.findSymbolByName(from);
|
|
1010
|
+
const toSyms = db.findSymbolByName(to);
|
|
1011
|
+
if (fromSyms.length === 0) {
|
|
1012
|
+
return { content: [{ type: 'text', text: `Symbol "${from}" not found in index. Try \`grep\`.` }] };
|
|
1013
|
+
}
|
|
1014
|
+
if (toSyms.length === 0) {
|
|
1015
|
+
return { content: [{ type: 'text', text: `Symbol "${to}" not found in index. Try \`grep\`.` }] };
|
|
1016
|
+
}
|
|
1017
|
+
// Vue SFC: class-kind symbols have no call edges; resolve to file's _top module
|
|
1018
|
+
const fromSym = fromSyms[0];
|
|
1019
|
+
if (fromSym.kind === 'class' && fromSym.filePath.endsWith('.vue')) {
|
|
1020
|
+
const topId = `${fromSym.filePath}#module:_top:0`;
|
|
1021
|
+
const topSym = db.findSymbolById(topId);
|
|
1022
|
+
if (topSym)
|
|
1023
|
+
fromSyms = [topSym];
|
|
1024
|
+
}
|
|
1025
|
+
// Use the id-based lookup once a symbol has been disambiguated above —
|
|
1026
|
+
// findPath(name, ...) would re-resolve by name and could pick a
|
|
1027
|
+
// different same-named symbol (e.g. some other file's "_top"),
|
|
1028
|
+
// silently undoing the disambiguation.
|
|
1029
|
+
const path = db.findPathFromId(fromSyms[0].id, to);
|
|
966
1030
|
if (!path) {
|
|
967
1031
|
return { content: [{ type: 'text', text: `No path between "${from}" and "${to}".` }] };
|
|
968
1032
|
}
|
|
969
|
-
const
|
|
970
|
-
const lines = [`FROM: ${fmtSymbol(
|
|
1033
|
+
const displaySym = fromSyms[0];
|
|
1034
|
+
const lines = [`FROM: ${fmtSymbol(displaySym)}`, ''];
|
|
971
1035
|
for (const { symbol, depth, via } of path) {
|
|
972
1036
|
lines.push(` ${'→'.repeat(depth)} [${via}] ${fmtSymbol(symbol)}`);
|
|
973
1037
|
}
|
|
@@ -981,12 +1045,25 @@ export function createMcpServer(rootPath) {
|
|
|
981
1045
|
}, async ({ kind, limit, repo }) => {
|
|
982
1046
|
const { db } = getDb(repo);
|
|
983
1047
|
const dead = db.findDeadCode(kind, limit);
|
|
984
|
-
|
|
1048
|
+
const testOnly = db.findTestOnlyReferenced(limit);
|
|
1049
|
+
const lines = [];
|
|
1050
|
+
if (dead.length === 0 && testOnly.length === 0) {
|
|
985
1051
|
return { content: [{ type: 'text', text: 'No unreferenced exported symbols found.' }] };
|
|
986
1052
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
lines.push(
|
|
1053
|
+
if (dead.length > 0) {
|
|
1054
|
+
const cappedNote = dead.length >= limit ? ` (showing ${dead.length}, may be more — increase limit to see all)` : '';
|
|
1055
|
+
lines.push(`${dead.length} unreferenced exported symbols (code-level only — verify with grep before removing)${cappedNote}:\n`);
|
|
1056
|
+
for (const sym of dead) {
|
|
1057
|
+
lines.push(fmtSymbol(sym));
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (testOnly.length > 0) {
|
|
1061
|
+
if (dead.length > 0)
|
|
1062
|
+
lines.push('');
|
|
1063
|
+
lines.push(`─── Symbols referenced only by test files (likely orphaned) — ${testOnly.length} found:\n`);
|
|
1064
|
+
for (const sym of testOnly) {
|
|
1065
|
+
lines.push(fmtSymbol(sym));
|
|
1066
|
+
}
|
|
990
1067
|
}
|
|
991
1068
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
992
1069
|
});
|
|
@@ -995,7 +1072,11 @@ export function createMcpServer(rootPath) {
|
|
|
995
1072
|
file: z.string().describe('File path (relative to repo root)'),
|
|
996
1073
|
repo: z.string().optional(),
|
|
997
1074
|
detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
|
|
998
|
-
|
|
1075
|
+
limit: z.number().optional().default(100).describe('Max symbols to return'),
|
|
1076
|
+
offset: z.number().optional().default(0).describe('Offset for pagination'),
|
|
1077
|
+
}, async ({ file, repo, detail, limit, offset }) => {
|
|
1078
|
+
limit = limit ?? 100;
|
|
1079
|
+
offset = offset ?? 0;
|
|
999
1080
|
const { db } = getDb(repo);
|
|
1000
1081
|
const symbols = db.getSymbolsByFile(file);
|
|
1001
1082
|
if (symbols.length === 0) {
|
|
@@ -1005,8 +1086,13 @@ export function createMcpServer(rootPath) {
|
|
|
1005
1086
|
const sorted = detail === 'L2'
|
|
1006
1087
|
? [...symbols].sort((a, b) => (b.heat ?? 0) - (a.heat ?? 0))
|
|
1007
1088
|
: symbols;
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1089
|
+
const paginated = sorted.slice(offset, offset + limit);
|
|
1090
|
+
const total = sorted.length;
|
|
1091
|
+
const pageInfo = total > limit || offset > 0
|
|
1092
|
+
? ` (showing ${offset + 1}-${Math.min(offset + limit, total)} of ${total}; use offset/limit to paginate)`
|
|
1093
|
+
: '';
|
|
1094
|
+
const lines = [`${file}: ${total} symbols${pageInfo}\n`];
|
|
1095
|
+
for (const sym of paginated) {
|
|
1010
1096
|
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1011
1097
|
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1012
1098
|
const exp = sym.exported ? ' (exported)' : '';
|
|
@@ -1059,13 +1145,17 @@ export function createMcpServer(rootPath) {
|
|
|
1059
1145
|
if (ancestors.length > 0) {
|
|
1060
1146
|
lines.push('extends/implements:');
|
|
1061
1147
|
for (const { symbol: a, depth } of ancestors) {
|
|
1062
|
-
|
|
1148
|
+
const isExternal = a.filePath === '(external)';
|
|
1149
|
+
const label = isExternal ? ` ${fmtSymbol(a)} (external)` : ` ${fmtSymbol(a)}`;
|
|
1150
|
+
lines.push(` ${'↑'.repeat(depth)}${label}`);
|
|
1063
1151
|
}
|
|
1064
1152
|
}
|
|
1065
1153
|
if (descendants.length > 0) {
|
|
1066
1154
|
lines.push('extended/implemented by:');
|
|
1067
1155
|
for (const { symbol: d, depth } of descendants) {
|
|
1068
|
-
|
|
1156
|
+
const isExternal = d.filePath === '(external)';
|
|
1157
|
+
const label = isExternal ? ` ${fmtSymbol(d)} (external)` : ` ${fmtSymbol(d)}`;
|
|
1158
|
+
lines.push(` ${'↓'.repeat(depth)}${label}`);
|
|
1069
1159
|
}
|
|
1070
1160
|
}
|
|
1071
1161
|
if (ancestors.length === 0 && descendants.length === 0) {
|
|
@@ -1123,7 +1213,7 @@ export function createMcpServer(rootPath) {
|
|
|
1123
1213
|
// 5. Unresolved warning (only for internal)
|
|
1124
1214
|
const unresolved = db.getUnresolvedStats();
|
|
1125
1215
|
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
1126
|
-
sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
|
|
1216
|
+
sections.push(`⚠ this repo's index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls repo-wide — this symbol's callers list may be incomplete`);
|
|
1127
1217
|
}
|
|
1128
1218
|
// 6. Test coverage for this symbol
|
|
1129
1219
|
for (const sym of symbols) {
|
|
@@ -1161,10 +1251,7 @@ export function createMcpServer(rootPath) {
|
|
|
1161
1251
|
for (const sym of symbols) {
|
|
1162
1252
|
sections.push(`${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
|
|
1163
1253
|
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1164
|
-
const depsCount =
|
|
1165
|
-
const from = db.findSymbolById(l.fromId);
|
|
1166
|
-
return from && !isTestFilePath(from.filePath);
|
|
1167
|
-
}).length;
|
|
1254
|
+
const depsCount = countDependentFiles(db, sym.id, { excludeTestFiles: true }).count;
|
|
1168
1255
|
if (session_id)
|
|
1169
1256
|
guard.recordCheck(session_id, name);
|
|
1170
1257
|
// Hard stop: if > 5 non-test dependents and no confirmation
|
|
@@ -1230,7 +1317,7 @@ export function createMcpServer(rootPath) {
|
|
|
1230
1317
|
// Unresolved warning
|
|
1231
1318
|
const unresolved = db.getUnresolvedStats();
|
|
1232
1319
|
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
1233
|
-
sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
|
|
1320
|
+
sections.push(`⚠ this repo's index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls repo-wide — this symbol's callers list may be incomplete`);
|
|
1234
1321
|
}
|
|
1235
1322
|
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
|
1236
1323
|
});
|
|
@@ -1254,7 +1341,7 @@ export function createMcpServer(rootPath) {
|
|
|
1254
1341
|
const traces = db.traceToEntrypoints(sym.id, depth);
|
|
1255
1342
|
sections.push(`## Execution paths TO ${fmtSymbol(sym)}\n`);
|
|
1256
1343
|
if (traces.length === 0) {
|
|
1257
|
-
sections.push('No call
|
|
1344
|
+
sections.push('No call chain found — verify with `context()` before treating this symbol as dead code.');
|
|
1258
1345
|
}
|
|
1259
1346
|
else {
|
|
1260
1347
|
for (let i = 0; i < traces.length; i++) {
|
|
@@ -1295,17 +1382,20 @@ export function createMcpServer(rootPath) {
|
|
|
1295
1382
|
// ── Tool: routes ──
|
|
1296
1383
|
server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Go HTTP, PHP, Rails patterns.', {
|
|
1297
1384
|
repo: z.string().optional(),
|
|
1298
|
-
framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, go, php, rails). Default: auto-detect all.'),
|
|
1299
|
-
limit: z.number().optional().default(
|
|
1385
|
+
framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, nestjs-gateway, flask, go, php, rails). Default: auto-detect all.'),
|
|
1386
|
+
limit: z.number().optional().default(200).describe('Max routes to display. Internal search always scans up to 500 matches per framework, so a low limit only affects display truncation, not detection accuracy.'),
|
|
1300
1387
|
}, async ({ repo, framework, limit }) => {
|
|
1301
1388
|
const root = resolveRoot(repo);
|
|
1302
1389
|
const { db } = getDb(repo);
|
|
1303
|
-
// Route patterns for different frameworks
|
|
1390
|
+
// Route patterns for different frameworks. Best-effort, single-line regex
|
|
1391
|
+
// matching — route declarations split across multiple lines (e.g. a decorator
|
|
1392
|
+
// and its path on separate lines) will not be detected.
|
|
1304
1393
|
const routePatterns = [
|
|
1305
1394
|
{ name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
|
|
1306
1395
|
{ name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
|
|
1307
|
-
{ name: 'flask', pattern: /@(?:app|bp|blueprint)\.
|
|
1396
|
+
{ name: 'flask', pattern: /@(?:app|bp|blueprint)\.route\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
|
|
1308
1397
|
{ name: 'nestjs', pattern: /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/, fileGlob: '**/*.ts' },
|
|
1398
|
+
{ name: 'nestjs-gateway', pattern: /@SubscribeMessage\s*\(\s*['"]([^'"]+)['"]\s*\)/, fileGlob: '**/*.ts' },
|
|
1309
1399
|
{ name: 'go', pattern: /\b(?:mux|router|http)\.(HandleFunc|Handle|Get|Post|Put|Delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
|
|
1310
1400
|
{ name: 'php', pattern: /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.php' },
|
|
1311
1401
|
{ name: 'rails', pattern: /\b(get|post|put|patch|delete|resources?|root)\s+['"]([^'"]+)['"]/, fileGlob: '**/*.rb' },
|
|
@@ -1314,19 +1404,29 @@ export function createMcpServer(rootPath) {
|
|
|
1314
1404
|
? routePatterns.filter(p => p.name === framework.toLowerCase())
|
|
1315
1405
|
: routePatterns;
|
|
1316
1406
|
if (activePatterns.length === 0) {
|
|
1317
|
-
return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, go, php, rails` }] };
|
|
1407
|
+
return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, nestjs-gateway, flask, go, php, rails` }] };
|
|
1318
1408
|
}
|
|
1319
1409
|
const routes = [];
|
|
1320
1410
|
for (const rp of activePatterns) {
|
|
1321
1411
|
const matches = grepFiles(root, rp.pattern.source, {
|
|
1322
|
-
isRegex: true, maxResults:
|
|
1412
|
+
isRegex: true, maxResults: 500, includePattern: rp.fileGlob,
|
|
1323
1413
|
});
|
|
1324
1414
|
for (const m of matches) {
|
|
1325
1415
|
const match = rp.pattern.exec(m.text);
|
|
1326
1416
|
if (!match)
|
|
1327
1417
|
continue;
|
|
1328
|
-
|
|
1329
|
-
|
|
1418
|
+
// Skip matches inside Python docstrings (triple-quoted strings)
|
|
1419
|
+
if (rp.fileGlob === '**/*.py') {
|
|
1420
|
+
try {
|
|
1421
|
+
const filePath = resolve(root, m.file);
|
|
1422
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
1423
|
+
if (isInsideDocstring(content, m.line))
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
catch { /* can't read file — skip filtering */ }
|
|
1427
|
+
}
|
|
1428
|
+
const method = rp.name === 'nestjs-gateway' ? 'WS' : match[1].toUpperCase();
|
|
1429
|
+
const path = rp.name === 'nestjs-gateway' ? match[1] : (match[2] || '/');
|
|
1330
1430
|
// Try to find the handler symbol on this line or nearby
|
|
1331
1431
|
const fileSymbols = db.getSymbolsByFile(m.file);
|
|
1332
1432
|
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'));
|
|
@@ -1343,6 +1443,71 @@ export function createMcpServer(rootPath) {
|
|
|
1343
1443
|
if (routes.length === 0) {
|
|
1344
1444
|
return { content: [{ type: 'text', text: 'No framework routes detected.' }] };
|
|
1345
1445
|
}
|
|
1446
|
+
// NestJS: join controller prefix with method path
|
|
1447
|
+
const nestjsRoutes = routes.filter(r => r.framework === 'nestjs');
|
|
1448
|
+
if (nestjsRoutes.length > 0) {
|
|
1449
|
+
const nestjsByFile = new Map();
|
|
1450
|
+
for (const r of nestjsRoutes) {
|
|
1451
|
+
const arr = nestjsByFile.get(r.file) ?? [];
|
|
1452
|
+
arr.push(r);
|
|
1453
|
+
nestjsByFile.set(r.file, arr);
|
|
1454
|
+
}
|
|
1455
|
+
for (const [file, fileRoutes] of nestjsByFile) {
|
|
1456
|
+
try {
|
|
1457
|
+
const content = readFileSync(resolve(root, file), 'utf-8');
|
|
1458
|
+
const lines = content.split('\n');
|
|
1459
|
+
// Find @Controller decorators with their class body line ranges
|
|
1460
|
+
const controllers = [];
|
|
1461
|
+
const ctrlRe = /@Controller\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/;
|
|
1462
|
+
const classRe = /export\s+(?:abstract\s+)?class\s+\w+/;
|
|
1463
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1464
|
+
const cm = ctrlRe.exec(lines[i]);
|
|
1465
|
+
if (!cm)
|
|
1466
|
+
continue;
|
|
1467
|
+
// Find the class declaration within next 5 lines
|
|
1468
|
+
let classLine = -1;
|
|
1469
|
+
for (let j = i; j < Math.min(i + 5, lines.length); j++) {
|
|
1470
|
+
if (classRe.test(lines[j])) {
|
|
1471
|
+
classLine = j;
|
|
1472
|
+
break;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
if (classLine === -1)
|
|
1476
|
+
continue;
|
|
1477
|
+
// Track brace depth to find end of class body
|
|
1478
|
+
let depth = 0;
|
|
1479
|
+
let started = false;
|
|
1480
|
+
let endLine = classLine;
|
|
1481
|
+
for (let k = classLine; k < lines.length; k++) {
|
|
1482
|
+
for (const ch of lines[k]) {
|
|
1483
|
+
if (ch === '{') {
|
|
1484
|
+
depth++;
|
|
1485
|
+
started = true;
|
|
1486
|
+
}
|
|
1487
|
+
else if (ch === '}') {
|
|
1488
|
+
depth--;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
if (started && depth === 0) {
|
|
1492
|
+
endLine = k;
|
|
1493
|
+
break;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
controllers.push({ prefix: cm[1] || '', startLine: classLine + 1, endLine: endLine + 1 });
|
|
1497
|
+
}
|
|
1498
|
+
// Map each route to its controller prefix
|
|
1499
|
+
for (const r of fileRoutes) {
|
|
1500
|
+
const ctrl = controllers.find(c => r.line >= c.startLine && r.line <= c.endLine);
|
|
1501
|
+
if (ctrl && ctrl.prefix) {
|
|
1502
|
+
const methodPath = r.path === '/' ? '' : r.path;
|
|
1503
|
+
const joinPath = ctrl.prefix + (methodPath.startsWith('/') || methodPath === '' ? methodPath : '/' + methodPath);
|
|
1504
|
+
r.path = joinPath || '/';
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
catch { /* can't read file */ }
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1346
1511
|
// Group by framework
|
|
1347
1512
|
const grouped = new Map();
|
|
1348
1513
|
for (const r of routes) {
|
|
@@ -1350,10 +1515,13 @@ export function createMcpServer(rootPath) {
|
|
|
1350
1515
|
arr.push(r);
|
|
1351
1516
|
grouped.set(r.framework, arr);
|
|
1352
1517
|
}
|
|
1353
|
-
const
|
|
1518
|
+
const displayRoutes = routes.slice(0, limit);
|
|
1519
|
+
const lines = [`${routes.length} routes detected${routes.length > limit ? ` (showing first ${limit})` : ''}:\n`];
|
|
1354
1520
|
for (const [fw, fwRoutes] of grouped) {
|
|
1355
1521
|
lines.push(`[${fw}]`);
|
|
1356
1522
|
for (const r of fwRoutes) {
|
|
1523
|
+
if (!displayRoutes.includes(r))
|
|
1524
|
+
break;
|
|
1357
1525
|
const handlerInfo = r.handler ? ` → ${r.handler}` : '';
|
|
1358
1526
|
lines.push(` ${r.method.padEnd(7)} ${r.path} (${r.file}:${r.line})${handlerInfo}`);
|
|
1359
1527
|
}
|
|
@@ -1452,10 +1620,19 @@ export function createMcpServer(rootPath) {
|
|
|
1452
1620
|
// Execution paths + data flow
|
|
1453
1621
|
const traces = db.traceToEntrypoints(sym.id, 6);
|
|
1454
1622
|
if (traces.length > 0) {
|
|
1623
|
+
const seenChains = new Set();
|
|
1455
1624
|
sections.push(`execution paths (${traces.length}):`);
|
|
1456
|
-
|
|
1625
|
+
let shown = 0;
|
|
1626
|
+
for (let i = 0; i < traces.length && shown < 3; i++) {
|
|
1457
1627
|
const chain = traces[i].path;
|
|
1458
|
-
|
|
1628
|
+
const firstFile = chain[0]?.symbol?.filePath?.split('/').pop() ?? '';
|
|
1629
|
+
const rendered = chain.map(s => s.symbol.name).join(' → ');
|
|
1630
|
+
const key = `${firstFile}: ${rendered}`;
|
|
1631
|
+
if (seenChains.has(key))
|
|
1632
|
+
continue;
|
|
1633
|
+
seenChains.add(key);
|
|
1634
|
+
sections.push(` ${key}`);
|
|
1635
|
+
shown++;
|
|
1459
1636
|
}
|
|
1460
1637
|
}
|
|
1461
1638
|
else {
|
|
@@ -1610,7 +1787,7 @@ export function createMcpServer(rootPath) {
|
|
|
1610
1787
|
return { content: [{ type: 'text', text: `Query error: ${err.message}` }] };
|
|
1611
1788
|
}
|
|
1612
1789
|
});
|
|
1613
|
-
//
|
|
1790
|
+
// ── Tool: codebase_summary ──
|
|
1614
1791
|
server.tool('codebase_summary', '500-token project overview. Use at session start INSTEAD of reading README, exploring directory structure, or reading multiple files to understand the codebase. Returns domains, key symbols, coverage %.', { repo: z.string().optional() }, async ({ repo }) => {
|
|
1615
1792
|
const { db } = getDb(repo);
|
|
1616
1793
|
const summary = db.getCodebaseSummary();
|
|
@@ -1638,7 +1815,7 @@ export function createMcpServer(rootPath) {
|
|
|
1638
1815
|
}
|
|
1639
1816
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1640
1817
|
});
|
|
1641
|
-
//
|
|
1818
|
+
// ── Tool: review_pr ──
|
|
1642
1819
|
server.tool('review_pr', 'PR risk assessment: git diff -> affected symbols with risk scores (LOW/MEDIUM/HIGH/CRITICAL).', { ref: z.string().optional().default('HEAD'), repo: z.string().optional() }, async ({ ref, repo }) => {
|
|
1643
1820
|
const { db, root } = getDb(repo);
|
|
1644
1821
|
const result = reviewPr(db, root, ref);
|
|
@@ -1664,7 +1841,7 @@ export function createMcpServer(rootPath) {
|
|
|
1664
1841
|
lines.push(`\nSummary: CRITICAL=${summary.CRITICAL} HIGH=${summary.HIGH} MEDIUM=${summary.MEDIUM} LOW=${summary.LOW}`);
|
|
1665
1842
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1666
1843
|
});
|
|
1667
|
-
//
|
|
1844
|
+
// ── Tool: review_symbol ──
|
|
1668
1845
|
server.tool('review_symbol', 'Deep-dive single symbol risk: role, heat, dependents, test status, risk level.', { name: z.string(), repo: z.string().optional() }, async ({ name, repo }) => {
|
|
1669
1846
|
const { db, root } = getDb(repo);
|
|
1670
1847
|
const syms = db.findSymbolByName(name);
|
|
@@ -1672,9 +1849,19 @@ export function createMcpServer(rootPath) {
|
|
|
1672
1849
|
return { content: [{ type: 'text', text: `"${name}" not found.` }] };
|
|
1673
1850
|
const lines = [];
|
|
1674
1851
|
for (const sym of syms) {
|
|
1675
|
-
const
|
|
1852
|
+
const incomingRaw = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1853
|
+
// Dedupe by calling file — imports + calls from the same file is 1 real dependent
|
|
1854
|
+
const seenCallerFiles = new Set();
|
|
1855
|
+
const incoming = incomingRaw.filter(l => {
|
|
1856
|
+
const from = db.findSymbolById(l.fromId);
|
|
1857
|
+
const key = from?.filePath ?? l.fromId;
|
|
1858
|
+
if (seenCallerFiles.has(key))
|
|
1859
|
+
return false;
|
|
1860
|
+
seenCallerFiles.add(key);
|
|
1861
|
+
return true;
|
|
1862
|
+
});
|
|
1676
1863
|
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1677
|
-
const depsCount =
|
|
1864
|
+
const depsCount = countDependentFiles(db, sym.id).count;
|
|
1678
1865
|
const depsTop = incoming.slice(0, 5).map(l => { const s = db.findSymbolById(l.fromId); return s?.name ?? l.fromId; });
|
|
1679
1866
|
const outCount = outgoing.length;
|
|
1680
1867
|
const outTop = outgoing.slice(0, 5).map(l => { const s = db.findSymbolById(l.toId); return s?.name ?? l.toId; });
|
|
@@ -1702,208 +1889,9 @@ export function createMcpServer(rootPath) {
|
|
|
1702
1889
|
}
|
|
1703
1890
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1704
1891
|
});
|
|
1705
|
-
|
|
1706
|
-
server
|
|
1707
|
-
|
|
1708
|
-
const coverage = db.getTestCoverage();
|
|
1709
|
-
const gaps = db.getTestCoverageGaps(limit);
|
|
1710
|
-
const lines = [`Test Coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} (${coverage.exportedProductionSymbols > 0 ? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100) : 0}%) from ${coverage.testFiles} test files\n`];
|
|
1711
|
-
if (gaps.length === 0) {
|
|
1712
|
-
lines.push('All exported symbols have test coverage!');
|
|
1713
|
-
}
|
|
1714
|
-
else {
|
|
1715
|
-
lines.push(`Top ${gaps.length} untested symbols:\n`);
|
|
1716
|
-
for (const g of gaps) {
|
|
1717
|
-
const incoming = db.getIncomingLinks(g.id).filter(l => l.type !== 'contains');
|
|
1718
|
-
const risk = (g.heat ?? 0) > 80 ? 'CRITICAL' : (g.heat ?? 0) > 50 ? 'HIGH' : (g.heat ?? 0) > 30 ? 'MEDIUM' : 'LOW';
|
|
1719
|
-
lines.push(` ${g.name} [${g.kind}] ${g.filePath}:${g.startLine} — heat:${g.heat ?? 0} deps:${incoming.length} risk:${risk}`);
|
|
1720
|
-
}
|
|
1721
|
-
}
|
|
1722
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1723
|
-
});
|
|
1724
|
-
// ═══ test_impact ═══
|
|
1725
|
-
server.tool('test_impact', 'Map changed code -> which test files to run. Use after making changes.', { ref: z.string().optional().default('HEAD'), repo: z.string().optional() }, async ({ ref, repo }) => {
|
|
1726
|
-
const { db, root } = getDb(repo);
|
|
1727
|
-
let changedFiles = [];
|
|
1728
|
-
try {
|
|
1729
|
-
const { execSync } = await import('node:child_process');
|
|
1730
|
-
const diff = execSync(`git diff --name-only ${ref}`, { cwd: root, encoding: 'utf-8' }).trim();
|
|
1731
|
-
changedFiles = diff ? diff.split('\n').filter(Boolean) : [];
|
|
1732
|
-
}
|
|
1733
|
-
catch { }
|
|
1734
|
-
if (changedFiles.length === 0)
|
|
1735
|
-
return { content: [{ type: 'text', text: 'No changed files.' }] };
|
|
1736
|
-
const changedIds = [];
|
|
1737
|
-
const changedNames = [];
|
|
1738
|
-
for (const file of changedFiles) {
|
|
1739
|
-
for (const sym of db.getSymbolsByFile(file)) {
|
|
1740
|
-
changedIds.push(sym.id);
|
|
1741
|
-
changedNames.push(sym.name);
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
|
-
if (changedIds.length === 0)
|
|
1745
|
-
return { content: [{ type: 'text', text: 'No symbols in changed files.' }] };
|
|
1746
|
-
const impact = db.getTestImpact(changedIds);
|
|
1747
|
-
const lines = [`Changed symbols (${changedNames.length}): ${changedNames.join(', ')}`];
|
|
1748
|
-
lines.push(`\nAffected test files (${impact.testFiles.length}):`);
|
|
1749
|
-
for (const f of impact.testFiles)
|
|
1750
|
-
lines.push(` ${f}`);
|
|
1751
|
-
if (impact.testFiles.length > 0) {
|
|
1752
|
-
lines.push(`\nSuggested command: npx vitest run ${impact.testFiles.join(' ')}`);
|
|
1753
|
-
}
|
|
1754
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1755
|
-
});
|
|
1756
|
-
// ═══ test_plan ═══
|
|
1757
|
-
server.tool('test_plan', 'Generate a test strategy for a symbol: mock plan + >=3 test scenarios.', { name: z.string(), repo: z.string().optional() }, async ({ name, repo }) => {
|
|
1758
|
-
const { db, root } = getDb(repo);
|
|
1759
|
-
const plan = generateTestPlan(db, name, root);
|
|
1760
|
-
if (!plan)
|
|
1761
|
-
return { content: [{ type: 'text', text: `"${name}" not found.` }] };
|
|
1762
|
-
return { content: [{ type: 'text', text: plan.planText }] };
|
|
1763
|
-
});
|
|
1764
|
-
// ═══ annotate ═══
|
|
1765
|
-
server.tool('annotate', 'Record a note about a symbol for future sessions. Use after discovering bugs, patterns, or important caveats.', {
|
|
1766
|
-
symbol: z.string(),
|
|
1767
|
-
key: z.enum(['note', 'bug', 'security', 'architecture', 'workflow', 'test', 'dependency', 'refactor']),
|
|
1768
|
-
value: z.string(),
|
|
1769
|
-
agent: z.string().optional(),
|
|
1770
|
-
session_id: z.string().optional(),
|
|
1771
|
-
confidence: z.number().optional().default(0.5),
|
|
1772
|
-
}, async ({ symbol, key, value, agent, session_id, confidence }) => {
|
|
1773
|
-
const { db } = getDb();
|
|
1774
|
-
const store = new AnnotationStore(db.connection);
|
|
1775
|
-
const ann = store.annotate(symbol, key, value, { agent, sessionId: session_id });
|
|
1776
|
-
return { content: [{ type: 'text', text: `Annotation saved: ${ann.id}\n symbol: ${ann.symbol}\n key: ${ann.key}\n confidence: ${ann.confidence}` }] };
|
|
1777
|
-
});
|
|
1778
|
-
// ═══ recall ═══
|
|
1779
|
-
server.tool('recall', 'Retrieve annotations saved in previous sessions. Filter by symbol, key, or agent.', {
|
|
1780
|
-
symbol: z.string().optional(), key: z.enum(['note', 'bug', 'security', 'architecture', 'workflow', 'test', 'dependency', 'refactor']).optional(),
|
|
1781
|
-
agent: z.string().optional(), limit: z.number().optional().default(50),
|
|
1782
|
-
}, async ({ symbol, key, agent, limit }) => {
|
|
1783
|
-
const { db } = getDb();
|
|
1784
|
-
const store = new AnnotationStore(db.connection);
|
|
1785
|
-
const results = store.recall({ symbol, key, agent, limit });
|
|
1786
|
-
if (results.length === 0)
|
|
1787
|
-
return { content: [{ type: 'text', text: 'No annotations found.' }] };
|
|
1788
|
-
const lines = [`${results.length} annotation(s):\n`];
|
|
1789
|
-
for (const a of results) {
|
|
1790
|
-
lines.push(`[${a.key}] ${a.symbol} — ${a.value.slice(0, 120)}`);
|
|
1791
|
-
lines.push(` confidence: ${a.confidence.toFixed(1)} | agent: ${a.agent ?? '?'} | ${a.updatedAt}\n`);
|
|
1792
|
-
}
|
|
1793
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1794
|
-
});
|
|
1795
|
-
// ═══ session_start ═══
|
|
1796
|
-
server.tool('session_start', 'Start a new session. Returns a session ID to use with annotate, session_end, and handoff.', { agent: z.string().describe('Agent name (e.g. vibe-coder, reviewer)') }, async ({ agent }) => {
|
|
1797
|
-
const { db, root, dbPath } = getDb();
|
|
1798
|
-
const store = new AnnotationStore(db.connection);
|
|
1799
|
-
const sessionId = store.sessionStart(agent);
|
|
1800
|
-
let hookOutput = '';
|
|
1801
|
-
try {
|
|
1802
|
-
const manager = new HookManager();
|
|
1803
|
-
const config = manager.loadConfig(root);
|
|
1804
|
-
if (config.enabled && config.onSessionStart) {
|
|
1805
|
-
hookOutput = await defaultOnSessionStart({ agent, sessionId, rootPath: root }, dbPath);
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
catch { /* hooks are best-effort */ }
|
|
1809
|
-
const text = `Session started: ${sessionId}\nAgent: ${agent}\nUse this ID with annotate() and session_end().`;
|
|
1810
|
-
return { content: [{ type: 'text', text: hookOutput ? `${hookOutput}\n\n${text}` : text }] };
|
|
1811
|
-
});
|
|
1812
|
-
// ═══ session_context ═══
|
|
1813
|
-
server.tool('session_context', 'Get metadata about a session: annotations, tool calls, duration.', { session_id: z.string() }, async ({ session_id }) => {
|
|
1814
|
-
const { db, root } = getDb();
|
|
1815
|
-
const store = new AnnotationStore(db.connection);
|
|
1816
|
-
const ctx = store.sessionContext(session_id);
|
|
1817
|
-
if (!ctx.session)
|
|
1818
|
-
return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] };
|
|
1819
|
-
const s = ctx.session;
|
|
1820
|
-
const liveCalls = getToolCallCount(root);
|
|
1821
|
-
const displayCalls = s.toolCallsCount > 0 ? s.toolCallsCount : liveCalls;
|
|
1822
|
-
const lines = [
|
|
1823
|
-
`Session: ${s.id}`,
|
|
1824
|
-
`Agent: ${s.agent} | Status: ${s.status}`,
|
|
1825
|
-
`Started: ${s.startedAt} | Ended: ${s.endedAt ?? 'in progress'}`,
|
|
1826
|
-
`Tool calls: ${displayCalls}${s.toolCallsCount === 0 ? ' (live count, resets on restart)' : ''} | Annotations: ${s.annotationsCount}`,
|
|
1827
|
-
];
|
|
1828
|
-
if (s.context)
|
|
1829
|
-
lines.push(`Context: ${s.context}`);
|
|
1830
|
-
if (ctx.annotations.length > 0) {
|
|
1831
|
-
lines.push(`\nAnnotations (${ctx.annotations.length}):`);
|
|
1832
|
-
for (const a of ctx.annotations) {
|
|
1833
|
-
lines.push(` [${a.key}] ${a.symbol}: ${a.value.slice(0, 80)}`);
|
|
1834
|
-
}
|
|
1835
|
-
}
|
|
1836
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1837
|
-
});
|
|
1838
|
-
// ═══ session_end ═══
|
|
1839
|
-
server.tool('session_end', 'End a session and record its stats. Shows audit trail: which symbols were safety-checked vs total edit operations. Use at the end of every session.', { session_id: z.string(), status: z.enum(['completed', 'failed']).optional().default('completed') }, async ({ session_id, status }) => {
|
|
1840
|
-
const { db, root, dbPath } = getDb();
|
|
1841
|
-
const store = new AnnotationStore(db.connection);
|
|
1842
|
-
const summary = store.sessionEnd(session_id, status);
|
|
1843
|
-
// Audit trail from SessionGuard
|
|
1844
|
-
const audit = guard.getAudit(session_id);
|
|
1845
|
-
guard.clear(session_id);
|
|
1846
|
-
let hookOutput = '';
|
|
1847
|
-
try {
|
|
1848
|
-
const ctx = store.sessionContext(session_id);
|
|
1849
|
-
const manager = new HookManager();
|
|
1850
|
-
const config = manager.loadConfig(root);
|
|
1851
|
-
if (config.enabled && config.onSessionEnd) {
|
|
1852
|
-
hookOutput = await defaultOnSessionEnd({ agent: ctx.session.agent, sessionId: session_id, rootPath: root }, dbPath);
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
catch { /* hooks are best-effort */ }
|
|
1856
|
-
const lines = [
|
|
1857
|
-
`Session ended: ${session_id}`,
|
|
1858
|
-
`Status: ${status}`,
|
|
1859
|
-
`Annotations: ${summary.annotationCount}`,
|
|
1860
|
-
`───`,
|
|
1861
|
-
`Audit Trail:`,
|
|
1862
|
-
` safety checks performed: ${audit.checked.length}`,
|
|
1863
|
-
audit.checked.length > 0 ? ` symbols checked: ${audit.checked.join(', ')}` : ' ⚠ no symbols were checked via guard_edit_check',
|
|
1864
|
-
audit.editOps > 0 ? ` edit operations reported: ${audit.editOps}` : null,
|
|
1865
|
-
].filter(Boolean);
|
|
1866
|
-
const text = lines.join('\n');
|
|
1867
|
-
return { content: [{ type: 'text', text: hookOutput ? `${text}\n\n${hookOutput}` : text }] };
|
|
1868
|
-
});
|
|
1869
|
-
// ═══ handoff ═══
|
|
1870
|
-
server.tool('handoff', 'Transfer context from one agent session to another. Ends the source session and creates a new one for the target agent.', {
|
|
1871
|
-
from_session: z.string(), to_agent: z.string(),
|
|
1872
|
-
context: z.string().describe('Summary of what was done, key decisions, and caveats for the next agent'),
|
|
1873
|
-
}, async ({ from_session, to_agent, context }) => {
|
|
1874
|
-
const { db } = getDb();
|
|
1875
|
-
const store = new AnnotationStore(db.connection);
|
|
1876
|
-
const result = store.handoff(from_session, to_agent, context);
|
|
1877
|
-
return { content: [{ type: 'text', text: `Handoff complete.\nNew session: ${result.newSessionId}\nAgent: ${to_agent}\nAnnotations copied: ${result.annotationsCopied}` }] };
|
|
1878
|
-
});
|
|
1879
|
-
// ═══ pre_commit_check ═══
|
|
1880
|
-
server.tool('pre_commit_check', 'Run pre-commit risk analysis: detect_changes + review_pr + dead code + coverage gaps. Use before committing.', { repo: z.string().optional().describe('Repository root path') }, async ({ repo }) => {
|
|
1881
|
-
const { root } = getDb(repo);
|
|
1882
|
-
const report = await defaultOnPreCommit(root);
|
|
1883
|
-
return { content: [{ type: 'text', text: report }] };
|
|
1884
|
-
});
|
|
1885
|
-
// ═══ hook_onFileChange ═══
|
|
1886
|
-
server.tool('hook_onFileChange', 'Trigger the onFileChange hook. Call this when files are modified to get impact summary.', {
|
|
1887
|
-
files: z.array(z.string()).describe('List of changed file paths'),
|
|
1888
|
-
repo: z.string().optional(),
|
|
1889
|
-
}, async ({ files, repo }) => {
|
|
1890
|
-
const { root } = getDb(repo);
|
|
1891
|
-
const report = await defaultOnFileChange(files, root);
|
|
1892
|
-
return { content: [{ type: 'text', text: report }] };
|
|
1893
|
-
});
|
|
1894
|
-
// ═══ hook_preCompact ═══
|
|
1895
|
-
server.tool('hook_preCompact', 'Trigger pre-compaction hook. Saves a metrics snapshot before context window compaction.', { repo: z.string().optional() }, async ({ repo }) => {
|
|
1896
|
-
const { root, dbPath } = getDb(repo);
|
|
1897
|
-
const report = await defaultOnPreCompact(root, dbPath);
|
|
1898
|
-
return { content: [{ type: 'text', text: report }] };
|
|
1899
|
-
});
|
|
1900
|
-
// ═══ hook_postCompact ═══
|
|
1901
|
-
server.tool('hook_postCompact', 'Trigger post-compaction hook. Recalls annotations to restore context after compaction.', { repo: z.string().optional() }, async ({ repo }) => {
|
|
1902
|
-
const { root } = getDb(repo);
|
|
1903
|
-
const report = await defaultOnPostCompact(root);
|
|
1904
|
-
return { content: [{ type: 'text', text: report }] };
|
|
1905
|
-
});
|
|
1906
|
-
// ═══ semantic_search ═══
|
|
1892
|
+
registerTestingTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
|
|
1893
|
+
registerSessionTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
|
|
1894
|
+
// ── Tool: semantic_search ──
|
|
1907
1895
|
server.tool('semantic_search', 'Search symbols by semantic meaning (falls back to FTS5 keyword search when embeddings unavailable).', { query: z.string(), limit: z.number().optional().default(10), repo: z.string().optional() }, async ({ query, limit, repo }) => {
|
|
1908
1896
|
const { db } = getDb(repo);
|
|
1909
1897
|
const results = db.searchSymbols(query, limit);
|
|
@@ -1916,7 +1904,7 @@ export function createMcpServer(rootPath) {
|
|
|
1916
1904
|
}
|
|
1917
1905
|
return { content: [{ type: 'text', text: `No results for "${query}".\n\nTip: Run \`milens analyze --embeddings\` to enable semantic (vector) search. Currently using FTS5 keyword fallback which only matches exact symbol names.` }] };
|
|
1918
1906
|
});
|
|
1919
|
-
//
|
|
1907
|
+
// ── Tool: find_similar ──
|
|
1920
1908
|
server.tool('find_similar', 'Find symbols topologically similar to a given symbol (shared callers/callees). Useful for finding patterns to copy or refactor together.', { name: z.string(), limit: z.number().optional().default(10), repo: z.string().optional() }, async ({ name, limit, repo }) => {
|
|
1921
1909
|
const { db } = getDb(repo);
|
|
1922
1910
|
const syms = db.findSymbolByName(name);
|
|
@@ -1931,111 +1919,16 @@ export function createMcpServer(rootPath) {
|
|
|
1931
1919
|
}
|
|
1932
1920
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1933
1921
|
});
|
|
1934
|
-
// ══════════════════════════════════════════════
|
|
1935
1922
|
// ── MCP Resources ──
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
for (const sym of symbols) {
|
|
1946
|
-
lines.push(`${fmtSymbol(sym, 'L2')}${sym.exported ? ' (exported)' : ''}`);
|
|
1947
|
-
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1948
|
-
if (incoming.length > 0) {
|
|
1949
|
-
lines.push(`incoming (${incoming.length}):`);
|
|
1950
|
-
for (const l of incoming) {
|
|
1951
|
-
const from = db.findSymbolById(l.fromId);
|
|
1952
|
-
lines.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1956
|
-
if (outgoing.length > 0) {
|
|
1957
|
-
lines.push(`outgoing (${outgoing.length}):`);
|
|
1958
|
-
for (const l of outgoing) {
|
|
1959
|
-
const to = db.findSymbolById(l.toId);
|
|
1960
|
-
lines.push(` ${l.type}: ${to ? fmtSymbol(to) : l.toId}`);
|
|
1961
|
-
}
|
|
1962
|
-
}
|
|
1963
|
-
lines.push('');
|
|
1964
|
-
}
|
|
1965
|
-
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
1966
|
-
});
|
|
1967
|
-
// ── Resource: milens://file/{path} ──
|
|
1968
|
-
server.resource('file-symbols', new ResourceTemplate('milens://file/{+path}', { list: undefined }), { description: 'All symbols in a file with ref/dep counts' }, async (uri, { path }) => {
|
|
1969
|
-
const { db } = getDb();
|
|
1970
|
-
const filePath = decodeURIComponent(path);
|
|
1971
|
-
const symbols = db.getSymbolsByFile(filePath);
|
|
1972
|
-
if (symbols.length === 0) {
|
|
1973
|
-
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `No symbols in "${filePath}".` }] };
|
|
1974
|
-
}
|
|
1975
|
-
const lines = [`${filePath}: ${symbols.length} symbols\n`];
|
|
1976
|
-
for (const sym of symbols) {
|
|
1977
|
-
const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1978
|
-
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1979
|
-
const exp = sym.exported ? ' (exported)' : '';
|
|
1980
|
-
lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
|
|
1981
|
-
}
|
|
1982
|
-
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
1983
|
-
});
|
|
1984
|
-
// ── Resource: milens://domain/{name} ──
|
|
1985
|
-
server.resource('domain', new ResourceTemplate('milens://domain/{name}', { list: undefined }), { description: 'Domain cluster details: files and top symbols in a domain' }, async (uri, { name }) => {
|
|
1986
|
-
const { db } = getDb();
|
|
1987
|
-
const domainName = name;
|
|
1988
|
-
// Find files in this domain
|
|
1989
|
-
const allFiles = db.db_getFilesByZone(domainName);
|
|
1990
|
-
if (allFiles.length === 0) {
|
|
1991
|
-
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `Domain "${domainName}" not found.` }] };
|
|
1992
|
-
}
|
|
1993
|
-
const lines = [`domain: ${domainName} (${allFiles.length} files)\n`];
|
|
1994
|
-
let totalSymbols = 0;
|
|
1995
|
-
for (const file of allFiles) {
|
|
1996
|
-
const syms = db.getSymbolsByFile(file);
|
|
1997
|
-
totalSymbols += syms.length;
|
|
1998
|
-
const exported = syms.filter(s => s.exported);
|
|
1999
|
-
lines.push(`${file}: ${syms.length} symbols (${exported.length} exported)`);
|
|
2000
|
-
}
|
|
2001
|
-
lines.push(`\ntotal: ${totalSymbols} symbols in ${allFiles.length} files`);
|
|
2002
|
-
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
2003
|
-
});
|
|
2004
|
-
// ── Resource: milens://overview ──
|
|
2005
|
-
server.resource('overview', 'milens://overview', { description: 'Index overview: stats, domains, unresolved, test coverage, staleness' }, async (uri) => {
|
|
2006
|
-
const { db, root, lazy } = getDb();
|
|
2007
|
-
const stats = lazy.getCachedStats();
|
|
2008
|
-
const unresolved = db.getUnresolvedStats();
|
|
2009
|
-
const coverage = db.getTestCoverage();
|
|
2010
|
-
const domains = lazy.getCachedDomainStats();
|
|
2011
|
-
const staleFiles = db.getStaleFiles(24);
|
|
2012
|
-
const lines = [
|
|
2013
|
-
`repo: ${root}`,
|
|
2014
|
-
`symbols: ${stats.symbols}`,
|
|
2015
|
-
`links: ${stats.links}`,
|
|
2016
|
-
`files: ${stats.files}`,
|
|
2017
|
-
];
|
|
2018
|
-
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
2019
|
-
lines.push(`⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls`);
|
|
2020
|
-
}
|
|
2021
|
-
if (unresolved.externalImports > 0 || unresolved.externalCalls > 0) {
|
|
2022
|
-
lines.push(`external (expected): ${unresolved.externalImports} imports, ${unresolved.externalCalls} calls`);
|
|
2023
|
-
}
|
|
2024
|
-
if (coverage.testFiles > 0) {
|
|
2025
|
-
const pct = coverage.exportedProductionSymbols > 0
|
|
2026
|
-
? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100) : 0;
|
|
2027
|
-
lines.push(`test coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} (${pct}%) from ${coverage.testFiles} test files`);
|
|
2028
|
-
}
|
|
2029
|
-
if (domains.length > 0) {
|
|
2030
|
-
lines.push(`\ndomains (${domains.length}):`);
|
|
2031
|
-
for (const d of domains) {
|
|
2032
|
-
lines.push(` ${d.domain}: ${d.files} files, ${d.symbols} symbols`);
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
if (staleFiles.length > 0) {
|
|
2036
|
-
lines.push(`\n⏳ ${staleFiles.length} stale files (>24h)`);
|
|
2037
|
-
}
|
|
2038
|
-
return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
|
|
1923
|
+
registerResources(server, {
|
|
1924
|
+
getDb,
|
|
1925
|
+
fmtSymbol,
|
|
1926
|
+
fmtImpact,
|
|
1927
|
+
rootPath,
|
|
1928
|
+
toolCallCounts,
|
|
1929
|
+
resolveRoot,
|
|
1930
|
+
guard,
|
|
1931
|
+
getToolCallCount,
|
|
2039
1932
|
});
|
|
2040
1933
|
// ── Prompt: delete-feature ──
|
|
2041
1934
|
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 }) => ({
|
|
@@ -2158,131 +2051,11 @@ export function createMcpServer(rootPath) {
|
|
|
2158
2051
|
},
|
|
2159
2052
|
}],
|
|
2160
2053
|
}));
|
|
2161
|
-
// ── Register MCP Prompts
|
|
2054
|
+
// ── Register MCP Prompts ──
|
|
2162
2055
|
registerAllPrompts(server);
|
|
2163
|
-
|
|
2164
|
-
server
|
|
2165
|
-
|
|
2166
|
-
repo: z.string().optional().describe('Repository root path'),
|
|
2167
|
-
severity: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']).optional().describe('Minimum severity filter'),
|
|
2168
|
-
limit: z.number().optional().default(50).describe('Max findings'),
|
|
2169
|
-
}, async ({ scope, repo, severity, limit }) => {
|
|
2170
|
-
const { db, root } = getDb(repo);
|
|
2171
|
-
const rules = loadRules();
|
|
2172
|
-
// Filter rules by scope and severity
|
|
2173
|
-
const filtered = rules.filter(r => {
|
|
2174
|
-
if (scope !== 'all' && r.category !== scope)
|
|
2175
|
-
return false;
|
|
2176
|
-
if (severity) {
|
|
2177
|
-
const sevOrder = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
|
|
2178
|
-
if ((sevOrder[r.severity] || 0) < (sevOrder[severity] || 0))
|
|
2179
|
-
return false;
|
|
2180
|
-
}
|
|
2181
|
-
return r.enabled;
|
|
2182
|
-
});
|
|
2183
|
-
// Get all source files from the DB
|
|
2184
|
-
const symbols = db.getAllSymbols();
|
|
2185
|
-
const fileSet = new Set();
|
|
2186
|
-
for (const s of symbols) {
|
|
2187
|
-
if (s.filePath && !s.filePath.includes('node_modules') && !s.filePath.includes('.git')) {
|
|
2188
|
-
fileSet.add(s.filePath);
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
const files = [...fileSet].slice(0, 1000); // cap at 1000 files
|
|
2192
|
-
const { readFileSync: rfs, existsSync: es } = await import('node:fs');
|
|
2193
|
-
const { resolve: resolvePath } = await import('node:path');
|
|
2194
|
-
const findings = [];
|
|
2195
|
-
const byCategory = {};
|
|
2196
|
-
const bySeverity = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
2197
|
-
for (const file of files) {
|
|
2198
|
-
const fullPath = resolvePath(root, file);
|
|
2199
|
-
if (!es(fullPath))
|
|
2200
|
-
continue;
|
|
2201
|
-
// Apply excludeGlob from each rule's exclusion pattern
|
|
2202
|
-
let shouldExclude = false;
|
|
2203
|
-
for (const rule of filtered) {
|
|
2204
|
-
if (rule.excludeGlob) {
|
|
2205
|
-
const excludePatterns = rule.excludeGlob.split(',');
|
|
2206
|
-
for (const pattern of excludePatterns) {
|
|
2207
|
-
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\*\*/g, '.*') + '$');
|
|
2208
|
-
if (regex.test(file) || regex.test('/' + file)) {
|
|
2209
|
-
shouldExclude = true;
|
|
2210
|
-
break;
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
}
|
|
2214
|
-
if (shouldExclude)
|
|
2215
|
-
break;
|
|
2216
|
-
}
|
|
2217
|
-
if (shouldExclude)
|
|
2218
|
-
continue;
|
|
2219
|
-
// Skip files that don't match rule fileGlobs (simple check)
|
|
2220
|
-
const applicableRules = filtered.filter(r => {
|
|
2221
|
-
if (!r.fileGlob)
|
|
2222
|
-
return true;
|
|
2223
|
-
// Simple glob: just check extension
|
|
2224
|
-
const ext = r.fileGlob.replace('**/*.', '').replace('**/*', '');
|
|
2225
|
-
return file.endsWith(ext) || r.fileGlob === '**/*';
|
|
2226
|
-
});
|
|
2227
|
-
if (applicableRules.length === 0)
|
|
2228
|
-
continue;
|
|
2229
|
-
try {
|
|
2230
|
-
const content = rfs(fullPath, 'utf-8');
|
|
2231
|
-
const lines = content.split('\n');
|
|
2232
|
-
for (const rule of applicableRules) {
|
|
2233
|
-
for (const pattern of rule.patterns) {
|
|
2234
|
-
let match;
|
|
2235
|
-
// Reset regex lastIndex for global patterns
|
|
2236
|
-
pattern.lastIndex = 0;
|
|
2237
|
-
while ((match = pattern.exec(content)) !== null) {
|
|
2238
|
-
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
2239
|
-
const ctxStart = Math.max(0, lineNum - 3);
|
|
2240
|
-
const ctxEnd = Math.min(lines.length, lineNum + 2);
|
|
2241
|
-
const context = lines.slice(ctxStart, ctxEnd).join('\n');
|
|
2242
|
-
findings.push({
|
|
2243
|
-
ruleId: rule.id,
|
|
2244
|
-
category: rule.category,
|
|
2245
|
-
severity: rule.severity,
|
|
2246
|
-
owasp: rule.owasp,
|
|
2247
|
-
file,
|
|
2248
|
-
line: lineNum,
|
|
2249
|
-
match: match[0].length > 100 ? match[0].slice(0, 97) + '...' : match[0],
|
|
2250
|
-
context,
|
|
2251
|
-
fix: rule.fix,
|
|
2252
|
-
});
|
|
2253
|
-
byCategory[rule.category] = (byCategory[rule.category] || 0) + 1;
|
|
2254
|
-
bySeverity[rule.severity] = (bySeverity[rule.severity] || 0) + 1;
|
|
2255
|
-
}
|
|
2256
|
-
}
|
|
2257
|
-
}
|
|
2258
|
-
}
|
|
2259
|
-
catch {
|
|
2260
|
-
// Skip unreadable files
|
|
2261
|
-
}
|
|
2262
|
-
}
|
|
2263
|
-
// Calculate security score (100 - deductions)
|
|
2264
|
-
const deduction = findings.filter((f) => f.severity === 'CRITICAL').length * 5 +
|
|
2265
|
-
findings.filter((f) => f.severity === 'HIGH').length * 2 +
|
|
2266
|
-
findings.filter((f) => f.severity === 'MEDIUM').length * 0.5;
|
|
2267
|
-
const score = Math.max(0, Math.round(100 - deduction));
|
|
2268
|
-
const limited = findings.slice(0, limit);
|
|
2269
|
-
return {
|
|
2270
|
-
content: [{
|
|
2271
|
-
type: 'text',
|
|
2272
|
-
text: JSON.stringify({
|
|
2273
|
-
summary: {
|
|
2274
|
-
totalScanned: files.length,
|
|
2275
|
-
findings: findings.length,
|
|
2276
|
-
byCategory,
|
|
2277
|
-
bySeverity,
|
|
2278
|
-
score,
|
|
2279
|
-
},
|
|
2280
|
-
findings: limited,
|
|
2281
|
-
}, null, 2),
|
|
2282
|
-
}],
|
|
2283
|
-
};
|
|
2284
|
-
});
|
|
2285
|
-
// ═══ compare_impact ═══
|
|
2056
|
+
registerSecurityTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
|
|
2057
|
+
registerFindingsReportTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
|
|
2058
|
+
// ── Tool: compare_impact ──
|
|
2286
2059
|
server.tool('compare_impact', 'Compare impact graph before/after an edit. Takes a snapshot first, then call again to see the diff. Returns new/removed dependents and heat changes.', {
|
|
2287
2060
|
name: z.string().describe('Symbol name to compare'),
|
|
2288
2061
|
action: z.enum(['snapshot', 'compare']).describe("'snapshot' to save current state, 'compare' to diff against last snapshot"),
|
|
@@ -2329,88 +2102,13 @@ export function createMcpServer(rootPath) {
|
|
|
2329
2102
|
db.close();
|
|
2330
2103
|
}
|
|
2331
2104
|
});
|
|
2332
|
-
//
|
|
2105
|
+
// ── Tool: orchestrate ──
|
|
2333
2106
|
server.tool('orchestrate', 'Run full orchestration cycle: detect_changes → review_pr → impact → coverage gaps → dead code. Returns structured action plan.', { repo: z.string().optional() }, async ({ repo }) => {
|
|
2334
2107
|
const { root, dbPath } = getDb(repo);
|
|
2335
2108
|
const orchestrator = new Orchestrator({ rootPath: root, dbPath, useEmoji: false });
|
|
2336
2109
|
const report = await orchestrator.runAndFormat();
|
|
2337
2110
|
return { content: [{ type: 'text', text: report }] };
|
|
2338
2111
|
});
|
|
2339
|
-
// ═══ fix_apply ═══
|
|
2340
|
-
server.tool('fix_apply', 'Apply a security fix suggestion to a file. Creates a backup before modifying. CRITICAL rules require confirm: true.', {
|
|
2341
|
-
ruleId: z.string().describe('Security rule ID (e.g. "hardcoded_secret")'),
|
|
2342
|
-
file: z.string().describe('File path relative to repo root'),
|
|
2343
|
-
line: z.number().describe('Line number where the issue was found'),
|
|
2344
|
-
confirm: z.boolean().optional().default(false).describe('Confirmation required for CRITICAL rules'),
|
|
2345
|
-
repo: z.string().optional(),
|
|
2346
|
-
}, async ({ ruleId, file, line, confirm, repo }) => {
|
|
2347
|
-
const { root } = getDb(repo);
|
|
2348
|
-
const rules = loadRules();
|
|
2349
|
-
const rule = rules.find(r => r.id === ruleId);
|
|
2350
|
-
if (!rule)
|
|
2351
|
-
return { content: [{ type: 'text', text: `Rule not found: "${ruleId}"` }] };
|
|
2352
|
-
if (rule.severity === 'CRITICAL' && !confirm) {
|
|
2353
|
-
return { content: [{ type: 'text', text: `CRITICAL rule "${ruleId}" requires confirmation. Set confirm: true to proceed.` }] };
|
|
2354
|
-
}
|
|
2355
|
-
const fullPath = resolve(root, file);
|
|
2356
|
-
if (!existsSync(fullPath))
|
|
2357
|
-
return { content: [{ type: 'text', text: `File not found: ${file}` }] };
|
|
2358
|
-
const content = readFileSync(fullPath, 'utf-8');
|
|
2359
|
-
const lines = content.split('\n');
|
|
2360
|
-
if (line < 1 || line > lines.length)
|
|
2361
|
-
return { content: [{ type: 'text', text: `Line ${line} out of range (file has ${lines.length} lines).` }] };
|
|
2362
|
-
// Backup original
|
|
2363
|
-
const backupDir = join(root, '.milens', 'backups');
|
|
2364
|
-
mkdirSync(backupDir, { recursive: true });
|
|
2365
|
-
const backupPath = join(backupDir, `${file.replace(/[\\/]/g, '_')}_${Date.now()}.bak`);
|
|
2366
|
-
writeFileSync(backupPath, content, 'utf-8');
|
|
2367
|
-
// Apply fix: add comment above the affected line with the fix suggestion
|
|
2368
|
-
const targetLine = lines[line - 1];
|
|
2369
|
-
const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
|
|
2370
|
-
const fixComment = `${indent}// milens(fix): rule=${rule.id} — ${rule.fix ?? 'Review manually'}`;
|
|
2371
|
-
lines.splice(line - 1, 0, fixComment);
|
|
2372
|
-
const newContent = lines.join('\n');
|
|
2373
|
-
writeFileSync(fullPath, newContent, 'utf-8');
|
|
2374
|
-
return { content: [{ type: 'text', text: `Fix applied for rule "${ruleId}" at ${file}:${line}\nSeverity: ${rule.severity}\nBackup: ${relative(root, backupPath)}\nFix: ${rule.fix ?? 'Manual review needed'}\n\nAdded fix comment above line ${line}.` }] };
|
|
2375
|
-
});
|
|
2376
|
-
// ═══ test_generate ═══
|
|
2377
|
-
server.tool('test_generate', 'Generate a test file for a symbol using its test plan. Detects test framework and follows project conventions.', {
|
|
2378
|
-
symbol: z.string().describe('Symbol name to generate tests for'),
|
|
2379
|
-
repo: z.string().optional(),
|
|
2380
|
-
}, async ({ symbol, repo }) => {
|
|
2381
|
-
const { db, root } = getDb(repo);
|
|
2382
|
-
const plan = generateTestPlan(db, symbol, root);
|
|
2383
|
-
if (!plan)
|
|
2384
|
-
return { content: [{ type: 'text', text: `Symbol not found: "${symbol}"` }] };
|
|
2385
|
-
// Detect test framework
|
|
2386
|
-
const framework = detectTestFramework(root);
|
|
2387
|
-
const testExt = framework === 'pytest' ? '.py' : '.test.ts';
|
|
2388
|
-
// Determine test file path
|
|
2389
|
-
const srcFile = plan.file;
|
|
2390
|
-
const srcDir = dirname(srcFile);
|
|
2391
|
-
const srcName = basename(srcFile, srcFile.includes('.') ? '.' + srcFile.split('.').pop() : '');
|
|
2392
|
-
const testFileName = `${srcName}${testExt}`;
|
|
2393
|
-
const testDir = join(srcDir, '__tests__');
|
|
2394
|
-
const testPath = join(testDir, testFileName);
|
|
2395
|
-
// Don't overwrite existing test files
|
|
2396
|
-
if (existsSync(join(root, testPath))) {
|
|
2397
|
-
return { content: [{ type: 'text', text: `Test file already exists at ${testPath}. Skipping to avoid overwrite.` }] };
|
|
2398
|
-
}
|
|
2399
|
-
// Check if a sister test file exists alongside the source
|
|
2400
|
-
const altTestPath = join(srcDir, testFileName);
|
|
2401
|
-
const existingTestDir = existsSync(join(root, testDir));
|
|
2402
|
-
const altExists = existsSync(join(root, altTestPath));
|
|
2403
|
-
if (altExists) {
|
|
2404
|
-
return { content: [{ type: 'text', text: `Test file exists at ${altTestPath}. Skipping to avoid overwrite.` }] };
|
|
2405
|
-
}
|
|
2406
|
-
// Generate test code
|
|
2407
|
-
const testCode = generateTestCode(plan, framework, srcFile);
|
|
2408
|
-
// Write the test file
|
|
2409
|
-
const writePath = existingTestDir ? testPath : altTestPath;
|
|
2410
|
-
mkdirSync(dirname(join(root, writePath)), { recursive: true });
|
|
2411
|
-
writeFileSync(join(root, writePath), testCode, 'utf-8');
|
|
2412
|
-
return { content: [{ type: 'text', text: `Test file generated: ${writePath}\nFramework: ${framework}\nScenarios: ${plan.testScenarios.length}\nMock deps: ${plan.mockStrategy.length}` }] };
|
|
2413
|
-
});
|
|
2414
2112
|
// ── Prompt: dead_code_remove ──
|
|
2415
2113
|
server.prompt('dead_code_remove', 'Safe dead code removal workflow: detect → verify → remove → test.', { repo: z.string().optional().describe('Repository root path') }, ({ repo }) => ({
|
|
2416
2114
|
messages: [{
|
|
@@ -2435,103 +2133,6 @@ export function createMcpServer(rootPath) {
|
|
|
2435
2133
|
}));
|
|
2436
2134
|
return server;
|
|
2437
2135
|
}
|
|
2438
|
-
// ── Helpers ──
|
|
2439
|
-
function detectTestFramework(rootPath) {
|
|
2440
|
-
try {
|
|
2441
|
-
const pkgPath = resolve(rootPath, 'package.json');
|
|
2442
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
2443
|
-
const deps = { ...pkg.devDependencies, ...pkg.dependencies };
|
|
2444
|
-
if (deps.vitest)
|
|
2445
|
-
return 'vitest';
|
|
2446
|
-
if (deps.jest)
|
|
2447
|
-
return 'jest';
|
|
2448
|
-
if (deps.mocha)
|
|
2449
|
-
return 'mocha';
|
|
2450
|
-
}
|
|
2451
|
-
catch { }
|
|
2452
|
-
// Check for Python
|
|
2453
|
-
try {
|
|
2454
|
-
const cfg = readFileSync(resolve(rootPath, 'pytest.ini'), 'utf-8');
|
|
2455
|
-
return 'pytest';
|
|
2456
|
-
}
|
|
2457
|
-
catch { }
|
|
2458
|
-
try {
|
|
2459
|
-
const cfg = readFileSync(resolve(rootPath, 'setup.cfg'), 'utf-8');
|
|
2460
|
-
if (cfg.includes('[tool:pytest]'))
|
|
2461
|
-
return 'pytest';
|
|
2462
|
-
}
|
|
2463
|
-
catch { }
|
|
2464
|
-
return 'vitest'; // default (Vitest is most common for TS projects)
|
|
2465
|
-
}
|
|
2466
|
-
function generateTestCode(plan, framework, srcFile) {
|
|
2467
|
-
const lines = [];
|
|
2468
|
-
if (framework === 'pytest') {
|
|
2469
|
-
lines.push(`# Generated by milens — test plan for ${plan.symbol}`);
|
|
2470
|
-
lines.push(`import pytest`);
|
|
2471
|
-
lines.push(`from ${srcFile.replace(/[/\\]/g, '.').replace(/\.(ts|tsx|js|jsx|py)$/, '')} import ${plan.symbol}`);
|
|
2472
|
-
lines.push('');
|
|
2473
|
-
lines.push(`class Test${capitalize(plan.symbol)}:`);
|
|
2474
|
-
for (const s of plan.testScenarios) {
|
|
2475
|
-
lines.push(` def test_${s.name.toLowerCase().replace(/\s+/g, '_')}(self):`);
|
|
2476
|
-
lines.push(` """${s.description}"""`);
|
|
2477
|
-
lines.push(` pass # TODO: implement`);
|
|
2478
|
-
lines.push('');
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2481
|
-
else {
|
|
2482
|
-
const hasTypescript = srcFile.endsWith('.ts') || srcFile.endsWith('.tsx');
|
|
2483
|
-
const ext = hasTypescript ? '.ts' : '.js';
|
|
2484
|
-
lines.push(`// Generated by milens — test plan for ${plan.symbol}`);
|
|
2485
|
-
if (framework === 'vitest') {
|
|
2486
|
-
lines.push(`import { describe, it, expect${plan.mockStrategy.length > 0 ? ', vi' : ''} } from 'vitest';`);
|
|
2487
|
-
lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
|
|
2488
|
-
}
|
|
2489
|
-
else if (framework === 'mocha') {
|
|
2490
|
-
lines.push(`import { expect } from 'chai';`);
|
|
2491
|
-
lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
|
|
2492
|
-
}
|
|
2493
|
-
else {
|
|
2494
|
-
lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
|
|
2495
|
-
}
|
|
2496
|
-
// Mock imports
|
|
2497
|
-
for (const m of plan.mockStrategy) {
|
|
2498
|
-
if (framework === 'vitest') {
|
|
2499
|
-
lines.push(`vi.mock('${m.dependency}');`);
|
|
2500
|
-
}
|
|
2501
|
-
else if (framework === 'jest') {
|
|
2502
|
-
lines.push(`jest.mock('${m.dependency}');`);
|
|
2503
|
-
}
|
|
2504
|
-
}
|
|
2505
|
-
lines.push('');
|
|
2506
|
-
const describeFn = framework === 'mocha' ? `describe('${plan.symbol}'` : framework === 'vitest' ? `describe('${plan.symbol}', () =>` : `describe('${plan.symbol}', () =>`;
|
|
2507
|
-
const beforeEachHook = framework === 'mocha' ? ` beforeEach(() => {` : ` beforeEach(() => {`;
|
|
2508
|
-
const endBrace = framework === 'mocha' ? `});` : `});`;
|
|
2509
|
-
lines.push(`${describeFn} {`);
|
|
2510
|
-
lines.push(`${beforeEachHook}`);
|
|
2511
|
-
lines.push(` // Setup mocks`);
|
|
2512
|
-
lines.push(` });`);
|
|
2513
|
-
lines.push('');
|
|
2514
|
-
for (const s of plan.testScenarios) {
|
|
2515
|
-
const testFn = framework === 'mocha' ? `it('${s.name}'` : `it('${s.name}', () =>`;
|
|
2516
|
-
lines.push(` ${testFn} {`);
|
|
2517
|
-
lines.push(` // ${s.description}`);
|
|
2518
|
-
lines.push(` const result = ${plan.symbol}();`);
|
|
2519
|
-
lines.push(` expect(result).toBeDefined();`);
|
|
2520
|
-
lines.push(` });`);
|
|
2521
|
-
lines.push('');
|
|
2522
|
-
}
|
|
2523
|
-
lines.push(`});`);
|
|
2524
|
-
}
|
|
2525
|
-
return lines.join('\n');
|
|
2526
|
-
}
|
|
2527
|
-
function capitalize(s) {
|
|
2528
|
-
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
2529
|
-
}
|
|
2530
|
-
function relativeImport(srcFile, hasTypescript) {
|
|
2531
|
-
// Convert src/foo/bar.ts → ../foo/bar (relative import for __tests__/bar.test.ts)
|
|
2532
|
-
const withoutExt = srcFile.replace(/\.(ts|tsx|js|jsx)$/, '');
|
|
2533
|
-
return `.${hasTypescript ? '' : '.js'}/${withoutExt.split('/').pop()}`;
|
|
2534
|
-
}
|
|
2535
2136
|
// ── Transport: stdio ──
|
|
2536
2137
|
export async function startStdio(rootPath) {
|
|
2537
2138
|
const server = createMcpServer(rootPath);
|
|
@@ -2558,14 +2159,33 @@ export async function startStdio(rootPath) {
|
|
|
2558
2159
|
}
|
|
2559
2160
|
}
|
|
2560
2161
|
}
|
|
2561
|
-
// Cleanup on exit
|
|
2162
|
+
// Cleanup on exit — registering a custom SIGINT/SIGTERM handler replaces
|
|
2163
|
+
// Node's default terminate-on-signal behavior, so we must exit explicitly
|
|
2164
|
+
// or the process hangs forever waiting for the (already-closed) stdio transport.
|
|
2562
2165
|
const cleanup = () => {
|
|
2563
2166
|
if (watcher)
|
|
2564
2167
|
watcher.stop();
|
|
2168
|
+
process.exit(0);
|
|
2565
2169
|
};
|
|
2566
2170
|
process.on('SIGINT', cleanup);
|
|
2567
2171
|
process.on('SIGTERM', cleanup);
|
|
2568
|
-
|
|
2172
|
+
// Many MCP hosts tear down the child by closing the stdio pipe (EOF) instead
|
|
2173
|
+
// of sending a signal — StdioServerTransport doesn't detect this on its own,
|
|
2174
|
+
// so without this the process leaks forever with no parent left to talk to.
|
|
2175
|
+
process.stdin.on('end', cleanup);
|
|
2176
|
+
process.stdin.on('close', cleanup);
|
|
2177
|
+
try {
|
|
2178
|
+
await server.connect(transport);
|
|
2179
|
+
}
|
|
2180
|
+
catch (err) {
|
|
2181
|
+
server.server.sendLoggingMessage({
|
|
2182
|
+
level: 'error',
|
|
2183
|
+
data: `[milens:stdio] Failed to start: ${err.message}`,
|
|
2184
|
+
});
|
|
2185
|
+
if (watcher)
|
|
2186
|
+
watcher.stop();
|
|
2187
|
+
process.exit(1);
|
|
2188
|
+
}
|
|
2569
2189
|
}
|
|
2570
2190
|
// ── Transport: HTTP (Streamable) ──
|
|
2571
2191
|
export async function startHttp(port, rootPath) {
|
|
@@ -2593,10 +2213,20 @@ export async function startHttp(port, rootPath) {
|
|
|
2593
2213
|
}
|
|
2594
2214
|
}
|
|
2595
2215
|
}
|
|
2596
|
-
// Cleanup on exit
|
|
2216
|
+
// Cleanup on exit — registering a custom SIGINT/SIGTERM handler replaces
|
|
2217
|
+
// Node's default terminate-on-signal behavior, so we must exit explicitly
|
|
2218
|
+
// or the process hangs forever with the HTTP server still listening.
|
|
2219
|
+
let shuttingDown = false;
|
|
2597
2220
|
const cleanup = () => {
|
|
2221
|
+
if (shuttingDown)
|
|
2222
|
+
return;
|
|
2223
|
+
shuttingDown = true;
|
|
2598
2224
|
if (watcher)
|
|
2599
2225
|
watcher.stop();
|
|
2226
|
+
clearInterval(evictTimer);
|
|
2227
|
+
httpServer.close(() => process.exit(0));
|
|
2228
|
+
// Force-exit if some connection keeps the server from closing in time
|
|
2229
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
2600
2230
|
};
|
|
2601
2231
|
process.on('SIGINT', cleanup);
|
|
2602
2232
|
process.on('SIGTERM', cleanup);
|
|
@@ -2649,7 +2279,20 @@ export async function startHttp(port, rootPath) {
|
|
|
2649
2279
|
sessions.set(id, { transport: transport, lastActive: Date.now() });
|
|
2650
2280
|
},
|
|
2651
2281
|
});
|
|
2652
|
-
|
|
2282
|
+
try {
|
|
2283
|
+
await server.connect(transport);
|
|
2284
|
+
}
|
|
2285
|
+
catch (err) {
|
|
2286
|
+
server.server.sendLoggingMessage({
|
|
2287
|
+
level: 'error',
|
|
2288
|
+
data: `[milens:http] Failed to connect transport: ${err.message}`,
|
|
2289
|
+
});
|
|
2290
|
+
if (!res.headersSent) {
|
|
2291
|
+
res.writeHead(500);
|
|
2292
|
+
res.end('Internal server error');
|
|
2293
|
+
}
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2653
2296
|
}
|
|
2654
2297
|
await transport.handleRequest(req, res, parsed);
|
|
2655
2298
|
}
|
|
@@ -2667,7 +2310,7 @@ export async function startHttp(port, rootPath) {
|
|
|
2667
2310
|
});
|
|
2668
2311
|
// Bind to localhost only — prevents network exposure without auth
|
|
2669
2312
|
httpServer.listen(port, '127.0.0.1', () => {
|
|
2670
|
-
|
|
2313
|
+
process.stderr.write(`milens MCP server listening on http://127.0.0.1:${port}/mcp\n`);
|
|
2671
2314
|
});
|
|
2672
2315
|
}
|
|
2673
2316
|
function readBody(req) {
|