milens 0.7.1 → 0.7.3

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.
Files changed (119) hide show
  1. package/.agents/skills/adapters/SKILL.md +59 -54
  2. package/.agents/skills/analyzer/SKILL.md +83 -78
  3. package/.agents/skills/apps/SKILL.md +65 -60
  4. package/.agents/skills/docs/SKILL.md +63 -58
  5. package/.agents/skills/milens/SKILL.md +322 -319
  6. package/.agents/skills/milens-security-review/SKILL.md +224 -224
  7. package/.agents/skills/orchestrator/SKILL.md +64 -59
  8. package/.agents/skills/parser/SKILL.md +86 -81
  9. package/.agents/skills/root/SKILL.md +89 -84
  10. package/.agents/skills/scripts/SKILL.md +56 -51
  11. package/.agents/skills/security/SKILL.md +67 -62
  12. package/.agents/skills/server/SKILL.md +84 -77
  13. package/.agents/skills/store/SKILL.md +77 -72
  14. package/.agents/skills/test/SKILL.md +155 -148
  15. package/.agents/skills/ui/SKILL.md +56 -51
  16. package/README.md +577 -577
  17. package/adapters/README.md +144 -144
  18. package/adapters/claude-code/.claude/mcp.json +9 -9
  19. package/adapters/claude-code/.claude/settings.json.hooks-snippet.json +26 -26
  20. package/adapters/claude-code/.claude-plugin/plugin.json +11 -11
  21. package/adapters/claude-code/.mcp.json +9 -9
  22. package/adapters/claude-code/CLAUDE.md +81 -79
  23. package/adapters/claude-code/hooks/hooks.json +27 -0
  24. package/adapters/codex/.codex/config.toml +3 -3
  25. package/adapters/copilot/.vscode/mcp.json +10 -10
  26. package/adapters/cursor/.cursor/mcp.json +9 -9
  27. package/adapters/cursor/.cursorrules +69 -69
  28. package/adapters/gemini/.gemini/context.md +81 -81
  29. package/adapters/gemini/.gemini/settings.json +9 -9
  30. package/adapters/opencode/.opencode/config.json +8 -8
  31. package/adapters/zed/.zed/settings.json +8 -8
  32. package/dist/agents-md.d.ts +1 -0
  33. package/dist/agents-md.js +45 -1
  34. package/dist/agents-md.js.map +1 -1
  35. package/dist/analyzer/config.js +32 -0
  36. package/dist/analyzer/config.js.map +1 -1
  37. package/dist/analyzer/engine.js +26 -2
  38. package/dist/analyzer/engine.js.map +1 -1
  39. package/dist/analyzer/enrich.js +68 -1
  40. package/dist/analyzer/enrich.js.map +1 -1
  41. package/dist/analyzer/resolver.d.ts +4 -1
  42. package/dist/analyzer/resolver.js +241 -38
  43. package/dist/analyzer/resolver.js.map +1 -1
  44. package/dist/analyzer/review.js +32 -2
  45. package/dist/analyzer/review.js.map +1 -1
  46. package/dist/analyzer/scanner.js +3 -0
  47. package/dist/analyzer/scanner.js.map +1 -1
  48. package/dist/analyzer/scope-resolver.js +1 -1
  49. package/dist/analyzer/scope-resolver.js.map +1 -1
  50. package/dist/build-info.d.ts +2 -2
  51. package/dist/build-info.js +2 -2
  52. package/dist/cli.js +566 -497
  53. package/dist/cli.js.map +1 -1
  54. package/dist/orchestrator/orchestrator.d.ts +1 -1
  55. package/dist/orchestrator/orchestrator.js +14 -6
  56. package/dist/orchestrator/orchestrator.js.map +1 -1
  57. package/dist/parser/extract.d.ts +7 -0
  58. package/dist/parser/extract.js +23 -1
  59. package/dist/parser/extract.js.map +1 -1
  60. package/dist/parser/lang-css.js +18 -6
  61. package/dist/parser/lang-css.js.map +1 -1
  62. package/dist/parser/lang-go.js +58 -58
  63. package/dist/parser/lang-html.js +3 -3
  64. package/dist/parser/lang-java.js +37 -37
  65. package/dist/parser/lang-js.js +120 -109
  66. package/dist/parser/lang-js.js.map +1 -1
  67. package/dist/parser/lang-md.js +1 -0
  68. package/dist/parser/lang-md.js.map +1 -1
  69. package/dist/parser/lang-php.js +45 -45
  70. package/dist/parser/lang-py.js +43 -43
  71. package/dist/parser/lang-ruby.js +28 -28
  72. package/dist/parser/lang-rust.js +48 -48
  73. package/dist/parser/lang-ts.js +230 -196
  74. package/dist/parser/lang-ts.js.map +1 -1
  75. package/dist/parser/lang-vue.js +0 -21
  76. package/dist/parser/lang-vue.js.map +1 -1
  77. package/dist/security/rules.js +22 -23
  78. package/dist/security/rules.js.map +1 -1
  79. package/dist/server/guard-hook.d.ts +10 -0
  80. package/dist/server/guard-hook.js +179 -0
  81. package/dist/server/guard-hook.js.map +1 -0
  82. package/dist/server/hooks.js +2 -1
  83. package/dist/server/hooks.js.map +1 -1
  84. package/dist/server/mcp-prompts.js +502 -502
  85. package/dist/server/mcp.js +228 -86
  86. package/dist/server/mcp.js.map +1 -1
  87. package/dist/server/test-plan.d.ts +1 -0
  88. package/dist/server/test-plan.js +25 -7
  89. package/dist/server/test-plan.js.map +1 -1
  90. package/dist/server/tools/findings-report.d.ts +3 -0
  91. package/dist/server/tools/findings-report.js +170 -0
  92. package/dist/server/tools/findings-report.js.map +1 -0
  93. package/dist/server/tools/security.js +61 -5
  94. package/dist/server/tools/security.js.map +1 -1
  95. package/dist/server/tools/session.js +37 -17
  96. package/dist/server/tools/session.js.map +1 -1
  97. package/dist/server/tools/testing.js +58 -11
  98. package/dist/server/tools/testing.js.map +1 -1
  99. package/dist/skills.js +307 -301
  100. package/dist/skills.js.map +1 -1
  101. package/dist/store/annotations.d.ts +5 -0
  102. package/dist/store/annotations.js +45 -9
  103. package/dist/store/annotations.js.map +1 -1
  104. package/dist/store/db.d.ts +13 -0
  105. package/dist/store/db.js +318 -205
  106. package/dist/store/db.js.map +1 -1
  107. package/dist/store/registry.d.ts +1 -0
  108. package/dist/store/registry.js +17 -6
  109. package/dist/store/registry.js.map +1 -1
  110. package/dist/store/schema.sql +144 -143
  111. package/dist/store/vectors.js +2 -2
  112. package/dist/types.d.ts +16 -1
  113. package/dist/uninstall.d.ts +1 -0
  114. package/dist/uninstall.js +33 -10
  115. package/dist/uninstall.js.map +1 -1
  116. package/dist/utils.d.ts +1 -1
  117. package/dist/utils.js +0 -0
  118. package/dist/utils.js.map +1 -1
  119. package/package.json +85 -85
@@ -6,7 +6,7 @@ import { createServer } from 'node:http';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { resolve, relative, join, dirname } from 'node:path';
8
8
  import { execFileSync } from 'node:child_process';
9
- import { readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
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';
@@ -22,6 +22,7 @@ import { countDependentFiles } from '../analyzer/risk.js';
22
22
  import { registerSessionTools } from './tools/session.js';
23
23
  import { registerTestingTools } from './tools/testing.js';
24
24
  import { registerSecurityTools } from './tools/security.js';
25
+ import { registerFindingsReportTools } from './tools/findings-report.js';
25
26
  import { FileWatcher } from './watcher.js';
26
27
  import { reviewPr } from '../analyzer/review.js';
27
28
  import { globToRegex } from '../utils.js';
@@ -240,10 +241,10 @@ function isTestFilePath(filePath) {
240
241
  // ── Text grep across project files ──
241
242
  const GREP_SKIP_DIRS = new Set([
242
243
  'node_modules', '.git', 'dist', 'build', 'out',
243
- '.next', '.nuxt', '.svelte-kit',
244
+ '.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', '.parcel-cache',
244
245
  '__pycache__', '.venv', 'venv', 'env',
245
246
  'vendor', 'target',
246
- '.idea', '.vscode',
247
+ '.idea',
247
248
  'coverage', '.nyc_output',
248
249
  ]);
249
250
  const BINARY_EXTENSIONS = new Set([
@@ -283,8 +284,6 @@ function grepFiles(rootPath, pattern, options) {
283
284
  return;
284
285
  const abs = join(dir, entry);
285
286
  const rel = relative(rootPath, abs).replace(/\\/g, '/');
286
- if (entry.startsWith('.') && entry !== '.')
287
- continue;
288
287
  if (GREP_SKIP_DIRS.has(entry))
289
288
  continue;
290
289
  if (ig.ignores(rel))
@@ -377,62 +376,62 @@ function loadGrepIgnoreRules(rootPath) {
377
376
  return ig;
378
377
  }
379
378
  // ── Server instructions (sent to client via MCP protocol on initialize) ──
380
- const MILENS_INSTRUCTIONS = `⚠️ CRITICAL: This project is indexed by milens (knowledge graph). Use milens MCP tools BEFORE reading files directly.
381
-
382
- WHY: The knowledge graph already knows every symbol, dependency, and reference. Reading files manually = slower, more expensive, and misses hidden connections.
383
-
384
- RULE: Before opening ANY file to understand code, call the appropriate milens tool:
385
- - overview({name: "X"}) — understand a symbol (context + impact + grep). Replaces 3-5 file reads.
386
- - impact({target: "X", mode: "strict"}) — check blast radius BEFORE editing. strict mode BLOCKS if >5 deps.
387
- - guard_edit_check({name: "X", session_id}) — HARD safety gate. Call BEFORE every edit. Returns BLOCKED if high risk.
388
- - grep({pattern: "X"}) — find ALL text references (code, templates, docs, configs, styles).
389
- - codebase_summary() — 500-token project overview. Use INSTEAD of reading README or exploring directories.
390
- - detect_changes() — verify changes before committing. Shows changed symbols + risk scores.
391
- - query({query: "X"}) — find symbol definitions by name. FTS5 instant search.
392
-
393
- AUDIT: session_end reports which symbols were safety-checked. Editing without checks = audit gap.
394
-
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.
396
-
397
- milens — code intelligence engine. Indexes codebases into symbol graphs.
398
-
399
- ## Tool selection
400
- - \`query\` — find symbol definitions (code identifiers only)
401
- - \`grep\` — text search ALL files. Use \`scope\` param: all (default), code (source only), imports, definitions
402
- - \`context\` — 360° view: incoming + outgoing for a symbol
403
- - \`impact\` — blast radius: what breaks if symbol changes
404
- - \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
405
- - \`guard_edit_check\` — HARD pre-edit gate: blocks if dependents > 5, tracks checks for session audit
406
- - \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
407
- - \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
408
- - \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Go, PHP, Rails)
409
- - \`smart_context\` — intent-aware context: understand/edit/debug/test (returns only what matters for intent)
410
- - \`domains\` — show domain clusters: groups of files forming logical modules based on dependency graph
411
- - \`repos\` — list all indexed repositories with summary stats (multi-repo support)
412
- - \`detect_changes\` — git diff → affected symbols
413
- - \`explain_relationship\` — shortest path between two symbols
414
- - \`find_dead_code\` — unused exports
415
- - \`get_file_symbols\` — all symbols in a file
416
- - \`get_type_hierarchy\` — inheritance tree
417
-
418
- ## Rules
419
- - Before editing a symbol: run \`guard_edit_check\` or \`edit_check\` or \`smart_context\` with intent=edit
420
- - \`guard_edit_check({name, session_id})\` — HARD gate: records check for audit, blocks if dependents > 5
421
- - \`impact({mode: "strict"})\` — strict mode returns BLOCKED when depth-1 deps > 5
422
- - For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
423
- - For writing tests: run \`smart_context\` with intent=test — shows deps to mock + callers to cover
424
- - \`impact\` only tracks code deps — always pair with \`grep\` for templates/configs
425
- - Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
426
- - impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
427
- - ⚠ markers indicate unresolved INTERNAL references — external package imports/calls are tracked separately
428
- - ✓ test coverage shown on edit_check — symbols with no test coverage get a warning
429
- - ⏳ staleness: files not re-analyzed in 24h are flagged — consider re-running \`milens analyze\`
430
-
431
- ## Resources (MCP Resources protocol)
432
- - \`milens://overview\` — index overview (stats, domains, coverage, staleness)
433
- - \`milens://symbol/{name}\` — symbol context by name
434
- - \`milens://file/{path}\` — all symbols in a file
435
- - \`milens://domain/{name}\` — domain cluster details
379
+ const MILENS_INSTRUCTIONS = `⚠️ CRITICAL: This project is indexed by milens (knowledge graph). Use milens MCP tools BEFORE reading files directly.
380
+
381
+ WHY: The knowledge graph already knows every symbol, dependency, and reference. Reading files manually = slower, more expensive, and misses hidden connections.
382
+
383
+ RULE: Before opening ANY file to understand code, call the appropriate milens tool:
384
+ - overview({name: "X"}) — understand a symbol (context + impact + grep). Replaces 3-5 file reads.
385
+ - impact({target: "X", mode: "strict"}) — check blast radius BEFORE editing. strict mode BLOCKS if >5 deps.
386
+ - guard_edit_check({name: "X", session_id}) — HARD safety gate. Call BEFORE every edit. Returns BLOCKED if high risk.
387
+ - grep({pattern: "X"}) — find ALL text references (code, templates, docs, configs, styles).
388
+ - codebase_summary() — 500-token project overview. Use INSTEAD of reading README or exploring directories.
389
+ - detect_changes() — verify changes before committing. Shows changed symbols + risk scores.
390
+ - query({query: "X"}) — find symbol definitions by name. FTS5 instant search.
391
+
392
+ AUDIT: session_end reports which symbols were safety-checked. Editing without checks = audit gap.
393
+
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.
395
+
396
+ milens — code intelligence engine. Indexes codebases into symbol graphs.
397
+
398
+ ## Tool selection
399
+ - \`query\` — find symbol definitions (code identifiers only)
400
+ - \`grep\` — text search ALL files. Use \`scope\` param: all (default), code (source only), imports, definitions
401
+ - \`context\` — 360° view: incoming + outgoing for a symbol
402
+ - \`impact\` — blast radius: what breaks if symbol changes
403
+ - \`overview\` — combined context + impact + grep in one call (preferred for editing workflows)
404
+ - \`guard_edit_check\` — HARD pre-edit gate: blocks if dependents > 5, tracks checks for session audit
405
+ - \`edit_check\` — pre-edit safety: callers + export status + re-export chains + test coverage + ⚠ warnings (fastest for edits)
406
+ - \`trace\` — execution flow: call chains from entrypoints to a symbol (or downstream from it)
407
+ - \`routes\` — detect framework routes/endpoints (Express, FastAPI, NestJS, Flask, Go, PHP, Rails)
408
+ - \`smart_context\` — intent-aware context: understand/edit/debug/test (returns only what matters for intent)
409
+ - \`domains\` — show domain clusters: groups of files forming logical modules based on dependency graph
410
+ - \`repos\` — list all indexed repositories with summary stats (multi-repo support)
411
+ - \`detect_changes\` — git diff → affected symbols
412
+ - \`explain_relationship\` — shortest path between two symbols
413
+ - \`find_dead_code\` — unused exports
414
+ - \`get_file_symbols\` — all symbols in a file
415
+ - \`get_type_hierarchy\` — inheritance tree
416
+
417
+ ## Rules
418
+ - Before editing a symbol: run \`guard_edit_check\` or \`edit_check\` or \`smart_context\` with intent=edit
419
+ - \`guard_edit_check({name, session_id})\` — HARD gate: records check for audit, blocks if dependents > 5
420
+ - \`impact({mode: "strict"})\` — strict mode returns BLOCKED when depth-1 deps > 5
421
+ - For debugging: run \`smart_context\` with intent=debug or \`trace\` to=symbol
422
+ - For writing tests: run \`smart_context\` with intent=test — shows deps to mock + callers to cover
423
+ - \`impact\` only tracks code deps — always pair with \`grep\` for templates/configs
424
+ - Use \`query\` for camelCase/PascalCase identifiers, \`grep\` for display text or multi-word strings
425
+ - impact depth: 1=WILL BREAK, 2=LIKELY AFFECTED, 3=MAY NEED TESTING
426
+ - ⚠ markers indicate unresolved INTERNAL references — external package imports/calls are tracked separately
427
+ - ✓ test coverage shown on edit_check — symbols with no test coverage get a warning
428
+ - ⏳ staleness: files not re-analyzed in 24h are flagged — consider re-running \`milens analyze\`
429
+
430
+ ## Resources (MCP Resources protocol)
431
+ - \`milens://overview\` — index overview (stats, domains, coverage, staleness)
432
+ - \`milens://symbol/{name}\` — symbol context by name
433
+ - \`milens://file/{path}\` — all symbols in a file
434
+ - \`milens://domain/{name}\` — domain cluster details
436
435
  `;
437
436
  // ── Server setup ──
438
437
  export function createMcpServer(rootPath) {
@@ -451,6 +450,19 @@ export function createMcpServer(rootPath) {
451
450
  }
452
451
  return abs;
453
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
+ }
454
466
  function resolveRoot(repoPath) {
455
467
  if (repoPath) {
456
468
  const root = normalizePath(repoPath);
@@ -535,7 +547,7 @@ export function createMcpServer(rootPath) {
535
547
  const profile = process.env.MILENS_PROFILE || undefined;
536
548
  if (profile && profile !== 'full') {
537
549
  const minimal = new Set(['query', 'grep', 'context', 'impact', 'status', 'codebase_summary', 'edit_check', 'detect_changes', 'get_file_symbols', 'overview']);
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']);
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']);
539
551
  const allowed = profile === 'minimal' ? minimal : standard;
540
552
  // Wrap the tracking-wrapped server.tool again, so profile gating sits on
541
553
  // top: disabled tools still get registered (via the no-op handler below)
@@ -712,6 +724,18 @@ export function createMcpServer(rootPath) {
712
724
  const unresolved = db.getUnresolvedStats();
713
725
  const coverage = db.getTestCoverage();
714
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 */ }
715
739
  if (unresolved.imports > 0 || unresolved.calls > 0) {
716
740
  text += `\n⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls — callers may be incomplete`;
717
741
  }
@@ -789,8 +813,11 @@ export function createMcpServer(rootPath) {
789
813
  // Section 2: Context (incoming + outgoing) for each symbol
790
814
  if (symbols.length > 0) {
791
815
  for (const sym of symbols) {
792
- const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
793
- const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
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
+ }
794
821
  if (incoming.length > 0) {
795
822
  sections.push(`[incoming] ${incoming.length} refs:`);
796
823
  const inSyms = incoming.map(l => {
@@ -979,7 +1006,7 @@ export function createMcpServer(rootPath) {
979
1006
  repo: z.string().optional(),
980
1007
  }, async ({ from, to, repo }) => {
981
1008
  const { db } = getDb(repo);
982
- const fromSyms = db.findSymbolByName(from);
1009
+ let fromSyms = db.findSymbolByName(from);
983
1010
  const toSyms = db.findSymbolByName(to);
984
1011
  if (fromSyms.length === 0) {
985
1012
  return { content: [{ type: 'text', text: `Symbol "${from}" not found in index. Try \`grep\`.` }] };
@@ -987,12 +1014,24 @@ export function createMcpServer(rootPath) {
987
1014
  if (toSyms.length === 0) {
988
1015
  return { content: [{ type: 'text', text: `Symbol "${to}" not found in index. Try \`grep\`.` }] };
989
1016
  }
990
- const path = db.findPath(from, to);
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);
991
1030
  if (!path) {
992
1031
  return { content: [{ type: 'text', text: `No path between "${from}" and "${to}".` }] };
993
1032
  }
994
- const fromSym = fromSyms[0];
995
- const lines = [`FROM: ${fmtSymbol(fromSym)}`, ''];
1033
+ const displaySym = fromSyms[0];
1034
+ const lines = [`FROM: ${fmtSymbol(displaySym)}`, ''];
996
1035
  for (const { symbol, depth, via } of path) {
997
1036
  lines.push(` ${'→'.repeat(depth)} [${via}] ${fmtSymbol(symbol)}`);
998
1037
  }
@@ -1012,7 +1051,8 @@ export function createMcpServer(rootPath) {
1012
1051
  return { content: [{ type: 'text', text: 'No unreferenced exported symbols found.' }] };
1013
1052
  }
1014
1053
  if (dead.length > 0) {
1015
- lines.push(`${dead.length} unreferenced exported symbols (code-level only verify with grep before removing):\n`);
1054
+ const cappedNote = dead.length >= limit ? ` (showing ${dead.length}, may be moreincrease limit to see all)` : '';
1055
+ lines.push(`${dead.length} unreferenced exported symbols (code-level only — verify with grep before removing)${cappedNote}:\n`);
1016
1056
  for (const sym of dead) {
1017
1057
  lines.push(fmtSymbol(sym));
1018
1058
  }
@@ -1032,7 +1072,11 @@ export function createMcpServer(rootPath) {
1032
1072
  file: z.string().describe('File path (relative to repo root)'),
1033
1073
  repo: z.string().optional(),
1034
1074
  detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
1035
- }, async ({ file, repo, detail }) => {
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;
1036
1080
  const { db } = getDb(repo);
1037
1081
  const symbols = db.getSymbolsByFile(file);
1038
1082
  if (symbols.length === 0) {
@@ -1042,8 +1086,13 @@ export function createMcpServer(rootPath) {
1042
1086
  const sorted = detail === 'L2'
1043
1087
  ? [...symbols].sort((a, b) => (b.heat ?? 0) - (a.heat ?? 0))
1044
1088
  : symbols;
1045
- const lines = [`${file}: ${symbols.length} symbols\n`];
1046
- for (const sym of sorted) {
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) {
1047
1096
  const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1048
1097
  const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1049
1098
  const exp = sym.exported ? ' (exported)' : '';
@@ -1096,13 +1145,17 @@ export function createMcpServer(rootPath) {
1096
1145
  if (ancestors.length > 0) {
1097
1146
  lines.push('extends/implements:');
1098
1147
  for (const { symbol: a, depth } of ancestors) {
1099
- lines.push(` ${'↑'.repeat(depth)} ${fmtSymbol(a)}`);
1148
+ const isExternal = a.filePath === '(external)';
1149
+ const label = isExternal ? ` ${fmtSymbol(a)} (external)` : ` ${fmtSymbol(a)}`;
1150
+ lines.push(` ${'↑'.repeat(depth)}${label}`);
1100
1151
  }
1101
1152
  }
1102
1153
  if (descendants.length > 0) {
1103
1154
  lines.push('extended/implemented by:');
1104
1155
  for (const { symbol: d, depth } of descendants) {
1105
- lines.push(` ${'↓'.repeat(depth)} ${fmtSymbol(d)}`);
1156
+ const isExternal = d.filePath === '(external)';
1157
+ const label = isExternal ? ` ${fmtSymbol(d)} (external)` : ` ${fmtSymbol(d)}`;
1158
+ lines.push(` ${'↓'.repeat(depth)}${label}`);
1106
1159
  }
1107
1160
  }
1108
1161
  if (ancestors.length === 0 && descendants.length === 0) {
@@ -1160,7 +1213,7 @@ export function createMcpServer(rootPath) {
1160
1213
  // 5. Unresolved warning (only for internal)
1161
1214
  const unresolved = db.getUnresolvedStats();
1162
1215
  if (unresolved.imports > 0 || unresolved.calls > 0) {
1163
- 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`);
1164
1217
  }
1165
1218
  // 6. Test coverage for this symbol
1166
1219
  for (const sym of symbols) {
@@ -1264,7 +1317,7 @@ export function createMcpServer(rootPath) {
1264
1317
  // Unresolved warning
1265
1318
  const unresolved = db.getUnresolvedStats();
1266
1319
  if (unresolved.imports > 0 || unresolved.calls > 0) {
1267
- 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`);
1268
1321
  }
1269
1322
  return { content: [{ type: 'text', text: sections.join('\n') }] };
1270
1323
  });
@@ -1329,8 +1382,8 @@ export function createMcpServer(rootPath) {
1329
1382
  // ── Tool: routes ──
1330
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.', {
1331
1384
  repo: z.string().optional(),
1332
- framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, go, php, rails). Default: auto-detect all.'),
1333
- limit: z.number().optional().default(50),
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.'),
1334
1387
  }, async ({ repo, framework, limit }) => {
1335
1388
  const root = resolveRoot(repo);
1336
1389
  const { db } = getDb(repo);
@@ -1340,8 +1393,9 @@ export function createMcpServer(rootPath) {
1340
1393
  const routePatterns = [
1341
1394
  { name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
1342
1395
  { name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1343
- { name: 'flask', pattern: /@(?:app|bp|blueprint)\.(route|get|post|put|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1396
+ { name: 'flask', pattern: /@(?:app|bp|blueprint)\.route\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1344
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' },
1345
1399
  { name: 'go', pattern: /\b(?:mux|router|http)\.(HandleFunc|Handle|Get|Post|Put|Delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
1346
1400
  { name: 'php', pattern: /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.php' },
1347
1401
  { name: 'rails', pattern: /\b(get|post|put|patch|delete|resources?|root)\s+['"]([^'"]+)['"]/, fileGlob: '**/*.rb' },
@@ -1350,19 +1404,29 @@ export function createMcpServer(rootPath) {
1350
1404
  ? routePatterns.filter(p => p.name === framework.toLowerCase())
1351
1405
  : routePatterns;
1352
1406
  if (activePatterns.length === 0) {
1353
- 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` }] };
1354
1408
  }
1355
1409
  const routes = [];
1356
1410
  for (const rp of activePatterns) {
1357
1411
  const matches = grepFiles(root, rp.pattern.source, {
1358
- isRegex: true, maxResults: limit, includePattern: rp.fileGlob,
1412
+ isRegex: true, maxResults: 500, includePattern: rp.fileGlob,
1359
1413
  });
1360
1414
  for (const m of matches) {
1361
1415
  const match = rp.pattern.exec(m.text);
1362
1416
  if (!match)
1363
1417
  continue;
1364
- const method = match[1].toUpperCase();
1365
- const path = match[2] || '/';
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] || '/');
1366
1430
  // Try to find the handler symbol on this line or nearby
1367
1431
  const fileSymbols = db.getSymbolsByFile(m.file);
1368
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'));
@@ -1379,6 +1443,71 @@ export function createMcpServer(rootPath) {
1379
1443
  if (routes.length === 0) {
1380
1444
  return { content: [{ type: 'text', text: 'No framework routes detected.' }] };
1381
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
+ }
1382
1511
  // Group by framework
1383
1512
  const grouped = new Map();
1384
1513
  for (const r of routes) {
@@ -1386,10 +1515,13 @@ export function createMcpServer(rootPath) {
1386
1515
  arr.push(r);
1387
1516
  grouped.set(r.framework, arr);
1388
1517
  }
1389
- const lines = [`${routes.length} routes detected:\n`];
1518
+ const displayRoutes = routes.slice(0, limit);
1519
+ const lines = [`${routes.length} routes detected${routes.length > limit ? ` (showing first ${limit})` : ''}:\n`];
1390
1520
  for (const [fw, fwRoutes] of grouped) {
1391
1521
  lines.push(`[${fw}]`);
1392
1522
  for (const r of fwRoutes) {
1523
+ if (!displayRoutes.includes(r))
1524
+ break;
1393
1525
  const handlerInfo = r.handler ? ` → ${r.handler}` : '';
1394
1526
  lines.push(` ${r.method.padEnd(7)} ${r.path} (${r.file}:${r.line})${handlerInfo}`);
1395
1527
  }
@@ -1488,10 +1620,19 @@ export function createMcpServer(rootPath) {
1488
1620
  // Execution paths + data flow
1489
1621
  const traces = db.traceToEntrypoints(sym.id, 6);
1490
1622
  if (traces.length > 0) {
1623
+ const seenChains = new Set();
1491
1624
  sections.push(`execution paths (${traces.length}):`);
1492
- for (let i = 0; i < Math.min(traces.length, 3); i++) {
1625
+ let shown = 0;
1626
+ for (let i = 0; i < traces.length && shown < 3; i++) {
1493
1627
  const chain = traces[i].path;
1494
- sections.push(` ${chain.map(s => s.symbol.name).join(' ')}`);
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++;
1495
1636
  }
1496
1637
  }
1497
1638
  else {
@@ -1913,6 +2054,7 @@ export function createMcpServer(rootPath) {
1913
2054
  // ── Register MCP Prompts ──
1914
2055
  registerAllPrompts(server);
1915
2056
  registerSecurityTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
2057
+ registerFindingsReportTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
1916
2058
  // ── Tool: compare_impact ──
1917
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.', {
1918
2060
  name: z.string().describe('Symbol name to compare'),