milens 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/adapters/SKILL.md +54 -51
- package/.agents/skills/analyzer/SKILL.md +16 -19
- package/.agents/skills/apps/SKILL.md +60 -68
- package/.agents/skills/docs/SKILL.md +5 -25
- package/.agents/skills/milens/SKILL.md +6 -6
- package/.agents/skills/orchestrator/SKILL.md +8 -10
- package/.agents/skills/parser/SKILL.md +9 -11
- package/.agents/skills/root/SKILL.md +25 -29
- package/.agents/skills/scripts/SKILL.md +13 -16
- package/.agents/skills/security/SKILL.md +4 -9
- package/.agents/skills/server/SKILL.md +28 -25
- package/.agents/skills/store/SKILL.md +17 -21
- package/.agents/skills/test/SKILL.md +14 -19
- package/.agents/skills/ui/SKILL.md +1 -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/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 +0 -1
- package/dist/agents-md.js +2 -2
- package/dist/agents-md.js.map +1 -1
- package/dist/analyzer/config.d.ts +0 -1
- package/dist/analyzer/engine.d.ts +0 -1
- package/dist/analyzer/engine.js +42 -40
- package/dist/analyzer/engine.js.map +1 -1
- package/dist/analyzer/enrich.d.ts +0 -1
- package/dist/analyzer/enrich.js +13 -0
- package/dist/analyzer/enrich.js.map +1 -1
- package/dist/analyzer/resolver.d.ts +0 -1
- package/dist/analyzer/resolver.js +48 -4
- package/dist/analyzer/resolver.js.map +1 -1
- package/dist/analyzer/review.d.ts +2 -2
- package/dist/analyzer/review.js +7 -59
- 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 +2 -4
- package/dist/analyzer/scanner.js.map +1 -1
- package/dist/analyzer/scope-resolver.d.ts +0 -1
- package/dist/analyzer/scope-resolver.js +1 -1
- 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 +142 -16
- package/dist/cli.js.map +1 -1
- package/dist/metrics.d.ts +0 -1
- package/dist/orchestrator/orchestrator.d.ts +0 -1
- package/dist/orchestrator/orchestrator.js +16 -1
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/orchestrator/reporter.d.ts +0 -1
- package/dist/parser/extract.d.ts +0 -1
- package/dist/parser/extract.js +53 -28
- package/dist/parser/extract.js.map +1 -1
- package/dist/parser/lang-css.d.ts +0 -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-md.d.ts +0 -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-vue.d.ts +0 -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 +13 -12
- package/dist/security/rules.js.map +1 -1
- package/dist/server/hooks.d.ts +0 -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 +156 -655
- package/dist/server/mcp.js.map +1 -1
- package/dist/server/test-plan.d.ts +0 -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/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 +176 -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 +140 -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 +200 -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 +8 -2
- package/dist/skills.js.map +1 -1
- package/dist/store/annotations.d.ts +1 -1
- package/dist/store/annotations.js +14 -3
- 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 +9 -1
- package/dist/store/db.js +87 -20
- package/dist/store/db.js.map +1 -1
- package/dist/store/registry.d.ts +0 -1
- package/dist/store/vectors.d.ts +0 -1
- package/dist/types.d.ts +7 -1
- package/dist/ui/progress.d.ts +0 -1
- package/dist/uninstall.d.ts +0 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +0 -0
- 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
|
|
9
|
+
import { readFileSync, readdirSync, statSync, mkdirSync } 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,18 @@ 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';
|
|
23
25
|
import { FileWatcher } from './watcher.js';
|
|
24
26
|
import { reviewPr } from '../analyzer/review.js';
|
|
27
|
+
import { globToRegex } from '../utils.js';
|
|
28
|
+
import { BUILD_SHA, BUILT_AT } from '../build-info.js';
|
|
25
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
30
|
const PKG_VERSION = process.env.MILENS_VERSION ?? JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
27
31
|
// ── Lazy DB connection with idle eviction ──
|
|
@@ -147,7 +151,10 @@ class SessionGuard {
|
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
153
|
// ── Tool usage tracking ──
|
|
150
|
-
//
|
|
154
|
+
// Hand-picked heuristic estimates of tokens an agent would spend WITHOUT milens
|
|
155
|
+
// (manual exploration cost per tool). Not derived from measurement — these feed
|
|
156
|
+
// the "tokens saved" numbers in usage tracking/dashboards, so treat them as a
|
|
157
|
+
// rough directional signal, not a validated benchmark.
|
|
151
158
|
const TOKEN_SAVINGS_MULTIPLIER = {
|
|
152
159
|
query: 3, // vs 3+ separate grep/file reads
|
|
153
160
|
grep: 2, // vs terminal grep + manual filtering
|
|
@@ -328,7 +335,11 @@ function matchesScope(lineText, scope) {
|
|
|
328
335
|
// definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
|
|
329
336
|
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
337
|
}
|
|
331
|
-
/**
|
|
338
|
+
/**
|
|
339
|
+
* Validate user-supplied regex is safe from catastrophic backtracking (ReDoS).
|
|
340
|
+
* This is a heuristic blocklist of known-dangerous constructs, not a formal
|
|
341
|
+
* proof of safety — it catches common patterns but isn't exhaustive.
|
|
342
|
+
*/
|
|
332
343
|
function safeRegex(pattern, flags) {
|
|
333
344
|
if (pattern.length > 200)
|
|
334
345
|
throw new Error('Pattern too long');
|
|
@@ -355,14 +366,6 @@ function safeRegex(pattern, flags) {
|
|
|
355
366
|
throw new Error('Unsafe regex pattern');
|
|
356
367
|
return new RegExp(pattern, flags);
|
|
357
368
|
}
|
|
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
369
|
function loadGrepIgnoreRules(rootPath) {
|
|
367
370
|
const ig = ignore();
|
|
368
371
|
ig.add(['node_modules', 'dist', 'build', '.git', '__pycache__', 'vendor', 'target']);
|
|
@@ -389,7 +392,7 @@ RULE: Before opening ANY file to understand code, call the appropriate milens to
|
|
|
389
392
|
|
|
390
393
|
AUDIT: session_end reports which symbols were safety-checked. Editing without checks = audit gap.
|
|
391
394
|
|
|
392
|
-
TOKEN SAVINGS: Using milens first
|
|
395
|
+
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
396
|
|
|
394
397
|
milens — code intelligence engine. Indexes codebases into symbol graphs.
|
|
395
398
|
|
|
@@ -438,6 +441,9 @@ export function createMcpServer(rootPath) {
|
|
|
438
441
|
const toolCallCounts = new Map(); // In-memory counter per repo
|
|
439
442
|
const trackDb = getTrackingDb();
|
|
440
443
|
const guard = new SessionGuard();
|
|
444
|
+
// On Windows, the same path can arrive as "c:\..." or "C:\..." depending on the
|
|
445
|
+
// caller — without normalizing the drive letter, those resolve to different
|
|
446
|
+
// registry keys and split one repo's index across two entries.
|
|
441
447
|
function normalizePath(p) {
|
|
442
448
|
const abs = resolve(p);
|
|
443
449
|
if (process.platform === 'win32') {
|
|
@@ -487,7 +493,11 @@ export function createMcpServer(rootPath) {
|
|
|
487
493
|
return toolCallCounts.get(root) ?? 0;
|
|
488
494
|
}
|
|
489
495
|
const server = new McpServer({ name: 'milens', version: PKG_VERSION }, { instructions: MILENS_INSTRUCTIONS });
|
|
490
|
-
// Auto-wrap every tool handler with usage tracking + background decay tick
|
|
496
|
+
// Auto-wrap every tool handler with usage tracking + background decay tick.
|
|
497
|
+
// This replaces `server.tool` itself, so every `server.tool(...)` call below —
|
|
498
|
+
// including the ones inside registerTestingTools/registerSessionTools/
|
|
499
|
+
// registerSecurityTools/registerResources in other files, which all receive
|
|
500
|
+
// this same `server` instance — goes through this wrapper too.
|
|
491
501
|
const origTool = server.tool.bind(server);
|
|
492
502
|
let lastDecayTick = 0;
|
|
493
503
|
const DECAY_INTERVAL = 5 * 60_000; // 5 minutes between decay ticks
|
|
@@ -521,13 +531,15 @@ export function createMcpServer(rootPath) {
|
|
|
521
531
|
}
|
|
522
532
|
return origTool(...args);
|
|
523
533
|
});
|
|
524
|
-
// ── Selective tool profiles
|
|
534
|
+
// ── Selective tool profiles ──
|
|
525
535
|
const profile = process.env.MILENS_PROFILE || undefined;
|
|
526
536
|
if (profile && profile !== 'full') {
|
|
527
537
|
const minimal = new Set(['query', 'grep', 'context', 'impact', 'status', 'codebase_summary', 'edit_check', 'detect_changes', 'get_file_symbols', 'overview']);
|
|
528
538
|
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']);
|
|
529
539
|
const allowed = profile === 'minimal' ? minimal : standard;
|
|
530
|
-
// Wrap server.tool again
|
|
540
|
+
// Wrap the tracking-wrapped server.tool again, so profile gating sits on
|
|
541
|
+
// top: disabled tools still get registered (via the no-op handler below)
|
|
542
|
+
// and still get tracked, they just short-circuit to the disabled message.
|
|
531
543
|
const profileWrappedTool = server.tool.bind(server);
|
|
532
544
|
server.tool = ((...args) => {
|
|
533
545
|
const toolName = args[0];
|
|
@@ -673,7 +685,8 @@ export function createMcpServer(rootPath) {
|
|
|
673
685
|
lines.push(`No ${direction} deps found.`);
|
|
674
686
|
}
|
|
675
687
|
else {
|
|
676
|
-
|
|
688
|
+
// Dedupe depth-1 by calling file — same real dependent, same blast radius
|
|
689
|
+
const depth1Count = countDependentFiles(db, sym.id).count;
|
|
677
690
|
lines.push(`${direction} (${refs.length} symbols, depth-1: ${depth1Count}):`);
|
|
678
691
|
lines.push(fmtImpact(refs, detail));
|
|
679
692
|
// Strict mode: hard stop if depth-1 dependents > 5
|
|
@@ -698,7 +711,7 @@ export function createMcpServer(rootPath) {
|
|
|
698
711
|
const stats = lazy.getCachedStats();
|
|
699
712
|
const unresolved = db.getUnresolvedStats();
|
|
700
713
|
const coverage = db.getTestCoverage();
|
|
701
|
-
let text = `repo: ${root}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
|
|
714
|
+
let text = `repo: ${root}\nbuild: ${BUILD_SHA} (built: ${BUILT_AT})\nversion: ${PKG_VERSION}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
|
|
702
715
|
if (unresolved.imports > 0 || unresolved.calls > 0) {
|
|
703
716
|
text += `\n⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls — callers may be incomplete`;
|
|
704
717
|
}
|
|
@@ -719,13 +732,13 @@ export function createMcpServer(rootPath) {
|
|
|
719
732
|
if (staleFiles.length > 0) {
|
|
720
733
|
text += `\n⏳ ${staleFiles.length} files not analyzed in 24h`;
|
|
721
734
|
}
|
|
722
|
-
//
|
|
735
|
+
// Resolution confidence distribution (heuristic self-ratings, not validated accuracy)
|
|
723
736
|
const conf = db.getConfidenceDistribution();
|
|
724
737
|
if (conf.total > 0) {
|
|
725
738
|
const highPct = Math.round(conf.high / conf.total * 100);
|
|
726
739
|
const medPct = Math.round(conf.medium / conf.total * 100);
|
|
727
740
|
const lowPct = Math.round(conf.low / conf.total * 100);
|
|
728
|
-
text += `\
|
|
741
|
+
text += `\nresolution confidence: ${conf.total} links — ≥0.9: ${conf.high} (${highPct}%) | 0.7-0.9: ${conf.medium} (${medPct}%) | <0.7: ${conf.low} (${lowPct}%)`;
|
|
729
742
|
if (lowPct > 15) {
|
|
730
743
|
text += `\n⚠ ${lowPct}% low-confidence links — consider re-analyzing with \`--force\` or reviewing unresolved calls`;
|
|
731
744
|
}
|
|
@@ -755,7 +768,7 @@ export function createMcpServer(rootPath) {
|
|
|
755
768
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
756
769
|
});
|
|
757
770
|
// ── Tool: overview ──
|
|
758
|
-
server.tool('overview', 'ONE call replaces 3-5 file reads. Combined context + impact + grep. Use BEFORE reading any source file.
|
|
771
|
+
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
772
|
name: z.string().describe('Symbol name'),
|
|
760
773
|
repo: z.string().optional(),
|
|
761
774
|
depth: z.number().optional().default(2).describe('Impact traversal depth (default: 2)'),
|
|
@@ -855,7 +868,8 @@ export function createMcpServer(rootPath) {
|
|
|
855
868
|
lines.push(` domains: ${summary.domains.join(', ')}`);
|
|
856
869
|
}
|
|
857
870
|
if (summary.staleCount > 0) {
|
|
858
|
-
|
|
871
|
+
const stalePct = summary.files > 0 ? Math.round(summary.staleCount / summary.files * 100) : 0;
|
|
872
|
+
lines.push(` ⏳ ${summary.staleCount} stale files (>24h)${stalePct > 20 ? ' — ⚠ run `milens analyze` to refresh' : ''}`);
|
|
859
873
|
}
|
|
860
874
|
if (!pools.has(entry.rootPath))
|
|
861
875
|
tempDb.close();
|
|
@@ -874,6 +888,9 @@ export function createMcpServer(rootPath) {
|
|
|
874
888
|
repo: z.string().optional(),
|
|
875
889
|
}, async ({ ref, repo }) => {
|
|
876
890
|
const { db, root } = getDb(repo);
|
|
891
|
+
// `ref` is passed straight to `git diff` below. Restrict it to characters
|
|
892
|
+
// valid in a git ref so it can't be crafted as a flag (e.g. a leading "-")
|
|
893
|
+
// and get interpreted as a git option instead of a revision.
|
|
877
894
|
if (!/^[a-zA-Z0-9\/._~^\-]+$/.test(ref)) {
|
|
878
895
|
return { content: [{ type: 'text', text: 'Invalid git ref.' }] };
|
|
879
896
|
}
|
|
@@ -940,11 +957,11 @@ export function createMcpServer(rootPath) {
|
|
|
940
957
|
const unchangedNote = unchangedCount > 0 ? ` (${unchangedCount} unchanged not shown)` : '';
|
|
941
958
|
lines.push(`${file}: ${displaySyms.length} changed symbols${unchangedNote}`);
|
|
942
959
|
for (const sym of displaySyms) {
|
|
943
|
-
const
|
|
960
|
+
const depsCount = countDependentFiles(db, sym.id).count;
|
|
944
961
|
totalChanged++;
|
|
945
|
-
if (
|
|
946
|
-
lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${
|
|
947
|
-
totalAffected +=
|
|
962
|
+
if (depsCount > 0) {
|
|
963
|
+
lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${depsCount} direct dependents`);
|
|
964
|
+
totalAffected += depsCount;
|
|
948
965
|
}
|
|
949
966
|
else {
|
|
950
967
|
lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine}`);
|
|
@@ -962,11 +979,19 @@ export function createMcpServer(rootPath) {
|
|
|
962
979
|
repo: z.string().optional(),
|
|
963
980
|
}, async ({ from, to, repo }) => {
|
|
964
981
|
const { db } = getDb(repo);
|
|
982
|
+
const fromSyms = db.findSymbolByName(from);
|
|
983
|
+
const toSyms = db.findSymbolByName(to);
|
|
984
|
+
if (fromSyms.length === 0) {
|
|
985
|
+
return { content: [{ type: 'text', text: `Symbol "${from}" not found in index. Try \`grep\`.` }] };
|
|
986
|
+
}
|
|
987
|
+
if (toSyms.length === 0) {
|
|
988
|
+
return { content: [{ type: 'text', text: `Symbol "${to}" not found in index. Try \`grep\`.` }] };
|
|
989
|
+
}
|
|
965
990
|
const path = db.findPath(from, to);
|
|
966
991
|
if (!path) {
|
|
967
992
|
return { content: [{ type: 'text', text: `No path between "${from}" and "${to}".` }] };
|
|
968
993
|
}
|
|
969
|
-
const fromSym =
|
|
994
|
+
const fromSym = fromSyms[0];
|
|
970
995
|
const lines = [`FROM: ${fmtSymbol(fromSym)}`, ''];
|
|
971
996
|
for (const { symbol, depth, via } of path) {
|
|
972
997
|
lines.push(` ${'→'.repeat(depth)} [${via}] ${fmtSymbol(symbol)}`);
|
|
@@ -981,12 +1006,24 @@ export function createMcpServer(rootPath) {
|
|
|
981
1006
|
}, async ({ kind, limit, repo }) => {
|
|
982
1007
|
const { db } = getDb(repo);
|
|
983
1008
|
const dead = db.findDeadCode(kind, limit);
|
|
984
|
-
|
|
1009
|
+
const testOnly = db.findTestOnlyReferenced(limit);
|
|
1010
|
+
const lines = [];
|
|
1011
|
+
if (dead.length === 0 && testOnly.length === 0) {
|
|
985
1012
|
return { content: [{ type: 'text', text: 'No unreferenced exported symbols found.' }] };
|
|
986
1013
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1014
|
+
if (dead.length > 0) {
|
|
1015
|
+
lines.push(`${dead.length} unreferenced exported symbols (code-level only — verify with grep before removing):\n`);
|
|
1016
|
+
for (const sym of dead) {
|
|
1017
|
+
lines.push(fmtSymbol(sym));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (testOnly.length > 0) {
|
|
1021
|
+
if (dead.length > 0)
|
|
1022
|
+
lines.push('');
|
|
1023
|
+
lines.push(`─── Symbols referenced only by test files (likely orphaned) — ${testOnly.length} found:\n`);
|
|
1024
|
+
for (const sym of testOnly) {
|
|
1025
|
+
lines.push(fmtSymbol(sym));
|
|
1026
|
+
}
|
|
990
1027
|
}
|
|
991
1028
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
992
1029
|
});
|
|
@@ -1161,10 +1198,7 @@ export function createMcpServer(rootPath) {
|
|
|
1161
1198
|
for (const sym of symbols) {
|
|
1162
1199
|
sections.push(`${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
|
|
1163
1200
|
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;
|
|
1201
|
+
const depsCount = countDependentFiles(db, sym.id, { excludeTestFiles: true }).count;
|
|
1168
1202
|
if (session_id)
|
|
1169
1203
|
guard.recordCheck(session_id, name);
|
|
1170
1204
|
// Hard stop: if > 5 non-test dependents and no confirmation
|
|
@@ -1254,7 +1288,7 @@ export function createMcpServer(rootPath) {
|
|
|
1254
1288
|
const traces = db.traceToEntrypoints(sym.id, depth);
|
|
1255
1289
|
sections.push(`## Execution paths TO ${fmtSymbol(sym)}\n`);
|
|
1256
1290
|
if (traces.length === 0) {
|
|
1257
|
-
sections.push('No call
|
|
1291
|
+
sections.push('No call chain found — verify with `context()` before treating this symbol as dead code.');
|
|
1258
1292
|
}
|
|
1259
1293
|
else {
|
|
1260
1294
|
for (let i = 0; i < traces.length; i++) {
|
|
@@ -1300,7 +1334,9 @@ export function createMcpServer(rootPath) {
|
|
|
1300
1334
|
}, async ({ repo, framework, limit }) => {
|
|
1301
1335
|
const root = resolveRoot(repo);
|
|
1302
1336
|
const { db } = getDb(repo);
|
|
1303
|
-
// Route patterns for different frameworks
|
|
1337
|
+
// Route patterns for different frameworks. Best-effort, single-line regex
|
|
1338
|
+
// matching — route declarations split across multiple lines (e.g. a decorator
|
|
1339
|
+
// and its path on separate lines) will not be detected.
|
|
1304
1340
|
const routePatterns = [
|
|
1305
1341
|
{ name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
|
|
1306
1342
|
{ name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
|
|
@@ -1610,7 +1646,7 @@ export function createMcpServer(rootPath) {
|
|
|
1610
1646
|
return { content: [{ type: 'text', text: `Query error: ${err.message}` }] };
|
|
1611
1647
|
}
|
|
1612
1648
|
});
|
|
1613
|
-
//
|
|
1649
|
+
// ── Tool: codebase_summary ──
|
|
1614
1650
|
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
1651
|
const { db } = getDb(repo);
|
|
1616
1652
|
const summary = db.getCodebaseSummary();
|
|
@@ -1638,7 +1674,7 @@ export function createMcpServer(rootPath) {
|
|
|
1638
1674
|
}
|
|
1639
1675
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1640
1676
|
});
|
|
1641
|
-
//
|
|
1677
|
+
// ── Tool: review_pr ──
|
|
1642
1678
|
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
1679
|
const { db, root } = getDb(repo);
|
|
1644
1680
|
const result = reviewPr(db, root, ref);
|
|
@@ -1664,7 +1700,7 @@ export function createMcpServer(rootPath) {
|
|
|
1664
1700
|
lines.push(`\nSummary: CRITICAL=${summary.CRITICAL} HIGH=${summary.HIGH} MEDIUM=${summary.MEDIUM} LOW=${summary.LOW}`);
|
|
1665
1701
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1666
1702
|
});
|
|
1667
|
-
//
|
|
1703
|
+
// ── Tool: review_symbol ──
|
|
1668
1704
|
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
1705
|
const { db, root } = getDb(repo);
|
|
1670
1706
|
const syms = db.findSymbolByName(name);
|
|
@@ -1672,9 +1708,19 @@ export function createMcpServer(rootPath) {
|
|
|
1672
1708
|
return { content: [{ type: 'text', text: `"${name}" not found.` }] };
|
|
1673
1709
|
const lines = [];
|
|
1674
1710
|
for (const sym of syms) {
|
|
1675
|
-
const
|
|
1711
|
+
const incomingRaw = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1712
|
+
// Dedupe by calling file — imports + calls from the same file is 1 real dependent
|
|
1713
|
+
const seenCallerFiles = new Set();
|
|
1714
|
+
const incoming = incomingRaw.filter(l => {
|
|
1715
|
+
const from = db.findSymbolById(l.fromId);
|
|
1716
|
+
const key = from?.filePath ?? l.fromId;
|
|
1717
|
+
if (seenCallerFiles.has(key))
|
|
1718
|
+
return false;
|
|
1719
|
+
seenCallerFiles.add(key);
|
|
1720
|
+
return true;
|
|
1721
|
+
});
|
|
1676
1722
|
const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
|
|
1677
|
-
const depsCount =
|
|
1723
|
+
const depsCount = countDependentFiles(db, sym.id).count;
|
|
1678
1724
|
const depsTop = incoming.slice(0, 5).map(l => { const s = db.findSymbolById(l.fromId); return s?.name ?? l.fromId; });
|
|
1679
1725
|
const outCount = outgoing.length;
|
|
1680
1726
|
const outTop = outgoing.slice(0, 5).map(l => { const s = db.findSymbolById(l.toId); return s?.name ?? l.toId; });
|
|
@@ -1702,208 +1748,9 @@ export function createMcpServer(rootPath) {
|
|
|
1702
1748
|
}
|
|
1703
1749
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1704
1750
|
});
|
|
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 ═══
|
|
1751
|
+
registerTestingTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
|
|
1752
|
+
registerSessionTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
|
|
1753
|
+
// ── Tool: semantic_search ──
|
|
1907
1754
|
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
1755
|
const { db } = getDb(repo);
|
|
1909
1756
|
const results = db.searchSymbols(query, limit);
|
|
@@ -1916,7 +1763,7 @@ export function createMcpServer(rootPath) {
|
|
|
1916
1763
|
}
|
|
1917
1764
|
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
1765
|
});
|
|
1919
|
-
//
|
|
1766
|
+
// ── Tool: find_similar ──
|
|
1920
1767
|
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
1768
|
const { db } = getDb(repo);
|
|
1922
1769
|
const syms = db.findSymbolByName(name);
|
|
@@ -1931,111 +1778,16 @@ export function createMcpServer(rootPath) {
|
|
|
1931
1778
|
}
|
|
1932
1779
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1933
1780
|
});
|
|
1934
|
-
// ══════════════════════════════════════════════
|
|
1935
1781
|
// ── 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') }] };
|
|
1782
|
+
registerResources(server, {
|
|
1783
|
+
getDb,
|
|
1784
|
+
fmtSymbol,
|
|
1785
|
+
fmtImpact,
|
|
1786
|
+
rootPath,
|
|
1787
|
+
toolCallCounts,
|
|
1788
|
+
resolveRoot,
|
|
1789
|
+
guard,
|
|
1790
|
+
getToolCallCount,
|
|
2039
1791
|
});
|
|
2040
1792
|
// ── Prompt: delete-feature ──
|
|
2041
1793
|
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 +1910,10 @@ export function createMcpServer(rootPath) {
|
|
|
2158
1910
|
},
|
|
2159
1911
|
}],
|
|
2160
1912
|
}));
|
|
2161
|
-
// ── Register MCP Prompts
|
|
1913
|
+
// ── Register MCP Prompts ──
|
|
2162
1914
|
registerAllPrompts(server);
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
scope: z.enum(['all', 'secrets', 'injection', 'rce', 'xss', 'deserialization', 'ssrf', 'xxe', 'path-traversal', 'file-upload', 'unicode', 'dangerous', 'config', 'data-leak', 'crypto', 'auth', 'jwt', 'cors-headers', 'dependency', 'cloud', 'docker', 'kubernetes', 'iac', 'business-logic', 'api-security', 'misc', 'file-access']).optional().default('all').describe('Scan scope'),
|
|
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 ═══
|
|
1915
|
+
registerSecurityTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
|
|
1916
|
+
// ── Tool: compare_impact ──
|
|
2286
1917
|
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
1918
|
name: z.string().describe('Symbol name to compare'),
|
|
2288
1919
|
action: z.enum(['snapshot', 'compare']).describe("'snapshot' to save current state, 'compare' to diff against last snapshot"),
|
|
@@ -2329,88 +1960,13 @@ export function createMcpServer(rootPath) {
|
|
|
2329
1960
|
db.close();
|
|
2330
1961
|
}
|
|
2331
1962
|
});
|
|
2332
|
-
//
|
|
1963
|
+
// ── Tool: orchestrate ──
|
|
2333
1964
|
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
1965
|
const { root, dbPath } = getDb(repo);
|
|
2335
1966
|
const orchestrator = new Orchestrator({ rootPath: root, dbPath, useEmoji: false });
|
|
2336
1967
|
const report = await orchestrator.runAndFormat();
|
|
2337
1968
|
return { content: [{ type: 'text', text: report }] };
|
|
2338
1969
|
});
|
|
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
1970
|
// ── Prompt: dead_code_remove ──
|
|
2415
1971
|
server.prompt('dead_code_remove', 'Safe dead code removal workflow: detect → verify → remove → test.', { repo: z.string().optional().describe('Repository root path') }, ({ repo }) => ({
|
|
2416
1972
|
messages: [{
|
|
@@ -2435,103 +1991,6 @@ export function createMcpServer(rootPath) {
|
|
|
2435
1991
|
}));
|
|
2436
1992
|
return server;
|
|
2437
1993
|
}
|
|
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
1994
|
// ── Transport: stdio ──
|
|
2536
1995
|
export async function startStdio(rootPath) {
|
|
2537
1996
|
const server = createMcpServer(rootPath);
|
|
@@ -2558,14 +2017,33 @@ export async function startStdio(rootPath) {
|
|
|
2558
2017
|
}
|
|
2559
2018
|
}
|
|
2560
2019
|
}
|
|
2561
|
-
// Cleanup on exit
|
|
2020
|
+
// Cleanup on exit — registering a custom SIGINT/SIGTERM handler replaces
|
|
2021
|
+
// Node's default terminate-on-signal behavior, so we must exit explicitly
|
|
2022
|
+
// or the process hangs forever waiting for the (already-closed) stdio transport.
|
|
2562
2023
|
const cleanup = () => {
|
|
2563
2024
|
if (watcher)
|
|
2564
2025
|
watcher.stop();
|
|
2026
|
+
process.exit(0);
|
|
2565
2027
|
};
|
|
2566
2028
|
process.on('SIGINT', cleanup);
|
|
2567
2029
|
process.on('SIGTERM', cleanup);
|
|
2568
|
-
|
|
2030
|
+
// Many MCP hosts tear down the child by closing the stdio pipe (EOF) instead
|
|
2031
|
+
// of sending a signal — StdioServerTransport doesn't detect this on its own,
|
|
2032
|
+
// so without this the process leaks forever with no parent left to talk to.
|
|
2033
|
+
process.stdin.on('end', cleanup);
|
|
2034
|
+
process.stdin.on('close', cleanup);
|
|
2035
|
+
try {
|
|
2036
|
+
await server.connect(transport);
|
|
2037
|
+
}
|
|
2038
|
+
catch (err) {
|
|
2039
|
+
server.server.sendLoggingMessage({
|
|
2040
|
+
level: 'error',
|
|
2041
|
+
data: `[milens:stdio] Failed to start: ${err.message}`,
|
|
2042
|
+
});
|
|
2043
|
+
if (watcher)
|
|
2044
|
+
watcher.stop();
|
|
2045
|
+
process.exit(1);
|
|
2046
|
+
}
|
|
2569
2047
|
}
|
|
2570
2048
|
// ── Transport: HTTP (Streamable) ──
|
|
2571
2049
|
export async function startHttp(port, rootPath) {
|
|
@@ -2593,10 +2071,20 @@ export async function startHttp(port, rootPath) {
|
|
|
2593
2071
|
}
|
|
2594
2072
|
}
|
|
2595
2073
|
}
|
|
2596
|
-
// Cleanup on exit
|
|
2074
|
+
// Cleanup on exit — registering a custom SIGINT/SIGTERM handler replaces
|
|
2075
|
+
// Node's default terminate-on-signal behavior, so we must exit explicitly
|
|
2076
|
+
// or the process hangs forever with the HTTP server still listening.
|
|
2077
|
+
let shuttingDown = false;
|
|
2597
2078
|
const cleanup = () => {
|
|
2079
|
+
if (shuttingDown)
|
|
2080
|
+
return;
|
|
2081
|
+
shuttingDown = true;
|
|
2598
2082
|
if (watcher)
|
|
2599
2083
|
watcher.stop();
|
|
2084
|
+
clearInterval(evictTimer);
|
|
2085
|
+
httpServer.close(() => process.exit(0));
|
|
2086
|
+
// Force-exit if some connection keeps the server from closing in time
|
|
2087
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
2600
2088
|
};
|
|
2601
2089
|
process.on('SIGINT', cleanup);
|
|
2602
2090
|
process.on('SIGTERM', cleanup);
|
|
@@ -2649,7 +2137,20 @@ export async function startHttp(port, rootPath) {
|
|
|
2649
2137
|
sessions.set(id, { transport: transport, lastActive: Date.now() });
|
|
2650
2138
|
},
|
|
2651
2139
|
});
|
|
2652
|
-
|
|
2140
|
+
try {
|
|
2141
|
+
await server.connect(transport);
|
|
2142
|
+
}
|
|
2143
|
+
catch (err) {
|
|
2144
|
+
server.server.sendLoggingMessage({
|
|
2145
|
+
level: 'error',
|
|
2146
|
+
data: `[milens:http] Failed to connect transport: ${err.message}`,
|
|
2147
|
+
});
|
|
2148
|
+
if (!res.headersSent) {
|
|
2149
|
+
res.writeHead(500);
|
|
2150
|
+
res.end('Internal server error');
|
|
2151
|
+
}
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2653
2154
|
}
|
|
2654
2155
|
await transport.handleRequest(req, res, parsed);
|
|
2655
2156
|
}
|
|
@@ -2667,7 +2168,7 @@ export async function startHttp(port, rootPath) {
|
|
|
2667
2168
|
});
|
|
2668
2169
|
// Bind to localhost only — prevents network exposure without auth
|
|
2669
2170
|
httpServer.listen(port, '127.0.0.1', () => {
|
|
2670
|
-
|
|
2171
|
+
process.stderr.write(`milens MCP server listening on http://127.0.0.1:${port}/mcp\n`);
|
|
2671
2172
|
});
|
|
2672
2173
|
}
|
|
2673
2174
|
function readBody(req) {
|