grepmax 0.23.0 → 0.24.0

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.
@@ -10,7 +10,7 @@
10
10
  "name": "grepmax",
11
11
  "source": "./plugins/grepmax",
12
12
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
13
- "version": "0.23.0",
13
+ "version": "0.24.0",
14
14
  "author": {
15
15
  "name": "Robert Owens",
16
16
  "email": "robowens@me.com"
package/README.md CHANGED
@@ -73,6 +73,7 @@ gmax test handleAuth # Find tests via reverse call graph
73
73
  gmax impact handleAuth # Dependents + affected tests
74
74
  gmax similar handleAuth # Find similar code patterns
75
75
  gmax audit --top 10 # God nodes, hub files, file cycles, dead candidates
76
+ gmax surprises --experimental --agent # Experimental: similar but graph-disconnected file pairs
76
77
  gmax dead handleAuth # Unused-symbol check via call graph (DEAD / PUBLIC EXPORT / LIVE)
77
78
  gmax context "auth system" --budget 4000 # Token-budgeted topic summary
78
79
  gmax context src/lib/auth.ts --budget 4000 # Deterministic file/path context
@@ -134,6 +135,7 @@ Plugins auto-update when you run `npm install -g grepmax@latest` — no need to
134
135
  | `peek_symbol` | Compact overview: signature + callers + callees. |
135
136
  | `dead` | Unused-symbol check via call graph. Returns `DEAD`, `PUBLIC EXPORT`, or `LIVE` with caller count. |
136
137
  | `audit` | Graph summary: god nodes, hub files, symbol-derived file dependency cycles, and dead-code candidates. |
138
+ | `surprising_connections` | Experimental orientation signal: embedding-similar file pairs with no direct indexed static file edge. Requires `experimental:true`. |
137
139
  | `get_neighbors` | Graph primitive: symbols reachable from a node along call edges within N hops. |
138
140
  | `find_paths` | Graph primitive: shortest call-graph path between two symbols. |
139
141
  | `subgraph_for_files` | Graph primitive: local dependency subgraph for a set of files. |
@@ -177,6 +179,18 @@ gmax "query" [options]
177
179
  | `--seed-symbol <name>` | Bias results toward an identifier you're working with (repeatable). | — |
178
180
  | `--skeleton` | Show file skeletons for top matches. | `false` |
179
181
  | `--context-for-llm` | Full function bodies + imports per result. | `false` |
182
+
183
+ ## Experimental Orientation
184
+
185
+ `gmax surprises --experimental` finds file pairs that are semantically similar but not already connected by the indexed static graph. Use it for architecture orientation, duplicate-logic sweeps, and cross-package drift checks, not as proof that two files are unrelated.
186
+
187
+ ```bash
188
+ gmax surprises --experimental --agent
189
+ gmax surprises --experimental --in packages/app/src --dir-depth 4 --agent
190
+ gmax surprises --experimental --exclude generated --top 10
191
+ ```
192
+
193
+ The CLI and MCP output include score, max similarity, pair count, representative symbols, directory buckets, top similarities, applied penalties, and `gmax skeleton` follow-up hints. On large monorepos, prefer `--in`/`--exclude`; the default scan is capped at 50,000 rows and user-provided `--max-rows` is capped at 100,000. If a narrow `--in` scope returns no findings, increase `--dir-depth` so subdirectories are compared inside the scope.
180
194
  | `--budget <tokens>` | Cap output tokens (for `--context-for-llm`). | `8000` |
181
195
  | `--explain` | Show scoring breakdown per result. | `false` |
182
196
  | `--scores` | Show relevance scores. | `false` |
@@ -46,6 +46,7 @@ exports.impact = void 0;
46
46
  const path = __importStar(require("node:path"));
47
47
  const commander_1 = require("commander");
48
48
  const impact_1 = require("../lib/graph/impact");
49
+ const impact_rollup_1 = require("../lib/graph/impact-rollup");
49
50
  const test_hits_1 = require("../lib/graph/test-hits");
50
51
  const vector_db_1 = require("../lib/store/vector-db");
51
52
  const agent_errors_1 = require("../lib/utils/agent-errors");
@@ -61,12 +62,16 @@ exports.impact = new commander_1.Command("impact")
61
62
  .option("--in <subpath>", "Restrict to a sub-path of the project (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
62
63
  .option("--exclude <subpath>", "Exclude a sub-path of the project (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
63
64
  .option("--no-tests", "Skip affected-test analysis; show production blast radius only")
65
+ .option("--rollup", "Show export/package rollup (default for file targets in human output)", false)
66
+ .option("--flat", "Use the legacy flat dependent/test list", false)
67
+ .option("--top <n>", "Max rows per rollup section", "10")
64
68
  .option("--agent", "Compact output for AI agents", false)
65
69
  .action((target, opts) => __awaiter(void 0, void 0, void 0, function* () {
66
70
  var _a;
67
71
  const depth = Math.min(Math.max(Number.parseInt(opts.depth || "1", 10), 1), 3);
68
72
  // commander maps --no-tests → opts.tests === false (defaults true).
69
73
  const includeTests = opts.tests !== false;
74
+ const top = Math.min(Math.max(Number.parseInt(opts.top || "10", 10) || 10, 1), 100);
70
75
  let vectorDb = null;
71
76
  try {
72
77
  const root = (0, project_registry_1.resolveRootOrExit)(opts.root);
@@ -105,15 +110,42 @@ exports.impact = new commander_1.Command("impact")
105
110
  const queryRoot = opts.in && opts.in.length > 0
106
111
  ? scope.pathPrefix.replace(/\/$/, "")
107
112
  : projectRoot;
113
+ const useRollup = !opts.flat && ((resolvedAsFile && !opts.agent) || opts.rollup === true);
114
+ const rollupLimit = Math.min(Math.max(top * 10, 100), 500);
108
115
  // Run dependents and tests in parallel. --no-tests skips the test
109
116
  // traversal entirely so the affected-tests section is omitted (not just
110
117
  // empty) below.
111
118
  const [dependents, tests] = yield Promise.all([
112
- (0, impact_1.findDependents)(symbols, vectorDb, queryRoot, excludePaths, undefined, scope.excludePrefixes, symbolFamilies),
119
+ useRollup
120
+ ? (0, impact_1.findDependentsDetailed)(symbols, vectorDb, queryRoot, excludePaths, rollupLimit, scope.excludePrefixes, symbolFamilies)
121
+ : (0, impact_1.findDependents)(symbols, vectorDb, queryRoot, excludePaths, undefined, scope.excludePrefixes, symbolFamilies),
113
122
  includeTests
114
123
  ? (0, impact_1.findTests)(symbols, vectorDb, queryRoot, depth, scope.excludePrefixes, symbolFamilies)
115
124
  : Promise.resolve([]),
116
125
  ]);
126
+ if (useRollup) {
127
+ const detailedDependents = dependents;
128
+ const rollup = (0, impact_rollup_1.buildImpactRollup)({
129
+ targetSymbols: symbols,
130
+ dependents: detailedDependents,
131
+ tests,
132
+ projectRoot,
133
+ top,
134
+ });
135
+ const formatted = opts.agent
136
+ ? (0, impact_rollup_1.formatImpactRollupAgent)(rollup, {
137
+ target,
138
+ projectRoot,
139
+ includeTests,
140
+ })
141
+ : (0, impact_rollup_1.formatImpactRollupHuman)(rollup, {
142
+ target,
143
+ projectRoot,
144
+ includeTests,
145
+ });
146
+ console.log(formatted);
147
+ return;
148
+ }
117
149
  // Separate test files from non-test dependents
118
150
  const nonTestDeps = dependents.filter((d) => !(0, impact_1.isTestPath)(d.file));
119
151
  // One line per test file; caller symbols inside it become `via` detail.
@@ -53,11 +53,14 @@ exports.mcp = void 0;
53
53
  exports.toStringArray = toStringArray;
54
54
  exports.ok = ok;
55
55
  exports.err = err;
56
+ exports.isExplicitCrossProjectSearch = isExplicitCrossProjectSearch;
56
57
  exports.searchResultPath = searchResultPath;
57
58
  exports.searchResultStartLine = searchResultStartLine;
58
59
  exports.searchResultEndLine = searchResultEndLine;
59
60
  exports.filterMcpSearchResults = filterMcpSearchResults;
60
61
  exports.formatMcpPointerSearchResults = formatMcpPointerSearchResults;
62
+ exports.formatMcpSurprisingConnections = formatMcpSurprisingConnections;
63
+ exports.mcpLogQuery = mcpLogQuery;
61
64
  const fs = __importStar(require("node:fs"));
62
65
  const path = __importStar(require("node:path"));
63
66
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
@@ -65,6 +68,7 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
65
68
  const commander_1 = require("commander");
66
69
  const zod_1 = require("zod");
67
70
  const config_1 = require("../config");
71
+ const surprising_connections_1 = require("../lib/analysis/surprising-connections");
68
72
  const languages_1 = require("../lib/core/languages");
69
73
  const graph_builder_1 = require("../lib/graph/graph-builder");
70
74
  const index_config_1 = require("../lib/index/index-config");
@@ -109,6 +113,13 @@ function ok(text) {
109
113
  function err(text) {
110
114
  return { content: [{ type: "text", text }], isError: true };
111
115
  }
116
+ function isExplicitCrossProjectSearch(args, isSearchAll = false) {
117
+ return (isSearchAll ||
118
+ args.scope === "all" ||
119
+ (typeof args.projects === "string" && args.projects.trim() !== "") ||
120
+ (typeof args.exclude_projects === "string" &&
121
+ args.exclude_projects.trim() !== ""));
122
+ }
112
123
  function chunkAbsPath(chunk) {
113
124
  const metadata = chunk.metadata;
114
125
  return String(chunk.path || (metadata === null || metadata === void 0 ? void 0 : metadata.path) || "");
@@ -171,6 +182,59 @@ function formatMcpPointerSearchResults(data, displayRoot, options = {}) {
171
182
  query: options.query,
172
183
  });
173
184
  }
185
+ function formatMcpSurprisingConnections(result, top = 10) {
186
+ const { summary, findings } = result;
187
+ const lines = [
188
+ [
189
+ "summary",
190
+ `sampled=${summary.sampledAnchors}`,
191
+ `code=${summary.codeRows}`,
192
+ `pairs=${summary.acceptedPairs}`,
193
+ `file_pairs=${summary.acceptedFilePairs}`,
194
+ `score_p90=${summary.actionabilityScore.p90}`,
195
+ ].join("\t"),
196
+ ];
197
+ for (const finding of findings.slice(0, top)) {
198
+ const pair = finding.representative;
199
+ lines.push([
200
+ "surprise",
201
+ finding.score.toFixed(3),
202
+ finding.maxSimilarity.toFixed(3),
203
+ String(finding.pairCount),
204
+ finding.fileA,
205
+ finding.fileB,
206
+ (0, surprising_connections_1.lineLabel)(pair.source),
207
+ (0, surprising_connections_1.lineLabel)(pair.target),
208
+ finding.reasons.join(","),
209
+ `buckets=${(0, surprising_connections_1.findingBucketLabel)(finding, summary.options.dirDepth)}`,
210
+ `top_sims=${finding.topSimilarities.join(",")}`,
211
+ `penalties=${(0, surprising_connections_1.formatPenaltySummary)(pair.scoreParts)}`,
212
+ `next=${(0, surprising_connections_1.skeletonHint)(finding)}`,
213
+ ].join("\t"));
214
+ }
215
+ if (findings.length === 0)
216
+ lines.push("none");
217
+ return lines.join("\n");
218
+ }
219
+ function mcpLogQuery(name, args) {
220
+ var _a, _b;
221
+ const direct = (_b = (_a = args.query) !== null && _a !== void 0 ? _a : args.symbol) !== null && _b !== void 0 ? _b : args.target;
222
+ if (direct !== undefined && direct !== null && String(direct) !== "") {
223
+ return String(direct);
224
+ }
225
+ if (name === "surprising_connections") {
226
+ const parts = [
227
+ "surprising_connections",
228
+ typeof args.root === "string" && args.root ? `root=${args.root}` : "",
229
+ typeof args.in === "string" && args.in ? `in=${args.in}` : "",
230
+ typeof args.exclude === "string" && args.exclude
231
+ ? `exclude=${args.exclude}`
232
+ : "",
233
+ ].filter(Boolean);
234
+ return parts.join(" ");
235
+ }
236
+ return "";
237
+ }
174
238
  // ---------------------------------------------------------------------------
175
239
  // Command
176
240
  // ---------------------------------------------------------------------------
@@ -264,38 +328,56 @@ exports.mcp = new commander_1.Command("mcp")
264
328
  }
265
329
  });
266
330
  }
331
+ // Resolve the registered project this server scopes to. The server pins to
332
+ // its launch cwd (findProjectRoot, .git-only), but a registered project may
333
+ // be an umbrella with no .git of its own — so a session launched in a
334
+ // subdirectory resolves to a path that isn't the registered root. Prefer an
335
+ // exact registry match; otherwise walk the registry for an ancestor that
336
+ // covers the cwd, and scope to it instead of silently widening to the whole
337
+ // index. `root` is the registered project root to scope to.
338
+ function resolveRegisteredProject() {
339
+ const exact = (0, project_registry_1.getProject)(projectRoot);
340
+ if (exact)
341
+ return { proj: exact, root: projectRoot };
342
+ const parent = (0, project_registry_1.getParentProject)(projectRoot);
343
+ if (parent)
344
+ return { proj: parent, root: parent.root };
345
+ return { proj: undefined, root: projectRoot };
346
+ }
267
347
  // --- Tool handlers ---
268
348
  function handleSemanticSearch(args_1) {
269
349
  return __awaiter(this, arguments, void 0, function* (args, isSearchAll = false) {
270
350
  const query = String(args.query || "");
271
351
  if (!query)
272
352
  return err("Missing required parameter: query");
273
- let searchAll = isSearchAll || args.scope === "all";
353
+ const searchAll = isExplicitCrossProjectSearch(args, isSearchAll);
274
354
  const limit = Math.min(Math.max(Number(args.limit) || 3, 1), 50);
275
355
  ensureWatcher();
276
356
  // Project resolution. The server is pinned to whatever cwd it launched in
277
- // (resolved once at startup). When that cwd ISN'T an indexed project and
278
- // the caller hasn't pinned an explicit --root, fall back to cross-project
279
- // search instead of dead-ending this is what makes the server usable
280
- // from a session that isn't sitting inside a registered repo.
281
- let scopeNote = "";
282
- const proj = (0, project_registry_1.getProject)(projectRoot);
283
- if (!proj && typeof args.root !== "string") {
284
- const indexed = (0, project_registry_1.listProjects)().filter((p) => { var _a; return p.status !== "pending" && ((_a = p.chunkCount) !== null && _a !== void 0 ? _a : 0) > 0; });
285
- if (indexed.length === 0) {
286
- return err("No indexed projects yet. cd into a repo and run `gmax add` to index it first.");
287
- }
288
- searchAll = true;
289
- scopeNote =
290
- `Note: ${path.basename(projectRoot)}/ isn't an indexed gmax project ` +
291
- `searching all ${indexed.length} indexed project(s). ` +
292
- `Call list_projects to see them, then pass projects:"name" to narrow.`;
293
- }
294
- else if (!proj) {
295
- return err("Project not added to gmax yet. Run `gmax add` to index it first.");
296
- }
297
- else if (proj.status === "pending" || proj.chunkCount === 0) {
298
- return err("Project not indexed yet. Run `gmax add` to index it first.");
357
+ // (resolved once at startup). Resolve that cwd to its registered project
358
+ // exact match, or a registered ancestor when the cwd is a subdirectory of
359
+ // an umbrella project. Searches scope to `resolvedRoot`.
360
+ const { proj, root: resolvedRoot } = resolveRegisteredProject();
361
+ // Cross-project search (the whole index) is opt-in: it happens only when
362
+ // the caller passes scope:"all" or projects:"…". Otherwise we require a
363
+ // resolved project and error loudly when there isn't one — never silently
364
+ // widen a scoped query to every indexed project.
365
+ if (!searchAll) {
366
+ if (!proj) {
367
+ if (typeof args.root === "string") {
368
+ return err("Project not added to gmax yet. Run `gmax add` to index it first.");
369
+ }
370
+ const indexed = (0, project_registry_1.listProjects)().filter((p) => { var _a; return p.status !== "pending" && ((_a = p.chunkCount) !== null && _a !== void 0 ? _a : 0) > 0; });
371
+ if (indexed.length === 0) {
372
+ return err("No indexed projects yet. cd into a repo and run `gmax add` to index it first.");
373
+ }
374
+ return err(`${path.basename(projectRoot)}/ isn't an indexed gmax project. ` +
375
+ `Pass scope:"all" to search all ${indexed.length} indexed project(s), ` +
376
+ `projects:"name" to target specific ones, or cd into an indexed project.`);
377
+ }
378
+ if (proj.status === "pending" || proj.chunkCount === 0) {
379
+ return err("Project not indexed yet. Run `gmax add` to index it first.");
380
+ }
299
381
  }
300
382
  try {
301
383
  const searcher = getSearcher();
@@ -305,7 +387,7 @@ exports.mcp = new commander_1.Command("mcp")
305
387
  if (!searchAll) {
306
388
  const searchRoot = typeof args.root === "string"
307
389
  ? path.resolve(args.root)
308
- : path.resolve(projectRoot);
390
+ : path.resolve(resolvedRoot);
309
391
  if (typeof args.root === "string" && !fs.existsSync(searchRoot)) {
310
392
  return err(`Directory not found: ${args.root}`);
311
393
  }
@@ -372,11 +454,10 @@ exports.mcp = new commander_1.Command("mcp")
372
454
  ? { files: seedFiles, symbols: seedSymbols }
373
455
  : undefined;
374
456
  const result = yield searcher.search(query, limit, { rerank: process.env.GMAX_RERANK === "1", seeds }, Object.keys(filters).length > 0 ? filters : undefined, pathPrefix);
375
- // Prepend the scope-fallback note (if any) + searcher warnings to
376
- // whatever body we return, so the agent learns it searched all projects.
457
+ // Prepend any searcher warnings to whatever body we return.
377
458
  const prefixNotes = (body) => {
378
459
  var _a;
379
- const notes = [scopeNote, ...((_a = result.warnings) !== null && _a !== void 0 ? _a : [])].filter(Boolean);
460
+ const notes = ((_a = result.warnings) !== null && _a !== void 0 ? _a : []).filter(Boolean);
380
461
  return notes.length ? `${notes.join("\n")}\n\n${body}` : body;
381
462
  };
382
463
  if (!result.data || result.data.length === 0) {
@@ -676,7 +757,7 @@ exports.mcp = new commander_1.Command("mcp")
676
757
  const symbol = String(args.symbol || "");
677
758
  if (!symbol)
678
759
  return err("Missing required parameter: symbol");
679
- const proj = (0, project_registry_1.getProject)(projectRoot);
760
+ const { proj } = resolveRegisteredProject();
680
761
  if (!proj) {
681
762
  return err("Project not added to gmax yet. Run `gmax add` to index it first.");
682
763
  }
@@ -1106,6 +1187,43 @@ exports.mcp = new commander_1.Command("mcp")
1106
1187
  }
1107
1188
  });
1108
1189
  }
1190
+ function handleSurprisingConnections(args) {
1191
+ return __awaiter(this, void 0, void 0, function* () {
1192
+ ensureWatcher();
1193
+ if (args.experimental !== true) {
1194
+ return err("surprising_connections is experimental; pass experimental:true.");
1195
+ }
1196
+ const root = typeof args.root === "string" && args.root
1197
+ ? path.resolve(args.root)
1198
+ : projectRoot;
1199
+ const top = Math.min(Math.max(Number(args.top) || 10, 1), 100);
1200
+ const sample = Math.min(Math.max(Number(args.sample) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.sample, 1), 10000);
1201
+ const neighbors = Math.min(Math.max(Number(args.neighbors) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.neighbors, 1), 200);
1202
+ const dirDepth = Math.min(Math.max(Number(args.dir_depth) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.dirDepth, 1), 20);
1203
+ const minSimilarity = Math.max(Number(args.min_similarity) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.minSimilarity, 0);
1204
+ const maxRows = Math.min(Math.max(Number(args.max_rows) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.maxRows, 1), surprising_connections_1.MAX_SURPRISE_ROWS);
1205
+ try {
1206
+ const db = getVectorDb();
1207
+ const table = yield db.ensureTable();
1208
+ const result = yield (0, surprising_connections_1.analyzeSurprisingConnections)(table, root, {
1209
+ sample,
1210
+ neighbors,
1211
+ dirDepth,
1212
+ minSimilarity,
1213
+ maxRows,
1214
+ includeTests: Boolean(args.include_tests),
1215
+ includeEval: Boolean(args.include_eval),
1216
+ in: typeof args.in === "string" ? args.in : undefined,
1217
+ exclude: typeof args.exclude === "string" ? args.exclude : undefined,
1218
+ });
1219
+ return ok(formatMcpSurprisingConnections(result, top));
1220
+ }
1221
+ catch (e) {
1222
+ const msg = e instanceof Error ? e.message : String(e);
1223
+ return err(`Surprising connections failed: ${msg}`);
1224
+ }
1225
+ });
1226
+ }
1109
1227
  function handleGetNeighbors(args) {
1110
1228
  return __awaiter(this, void 0, void 0, function* () {
1111
1229
  ensureWatcher();
@@ -1218,7 +1336,7 @@ exports.mcp = new commander_1.Command("mcp")
1218
1336
  const pattern = typeof args.pattern === "string" ? args.pattern : undefined;
1219
1337
  const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
1220
1338
  const pathPrefix = typeof args.path === "string" ? args.path : undefined;
1221
- const proj = (0, project_registry_1.getProject)(projectRoot);
1339
+ const { proj } = resolveRegisteredProject();
1222
1340
  if (!proj) {
1223
1341
  return err("Project not added to gmax yet. Run `gmax add` to index it first.");
1224
1342
  }
@@ -1759,8 +1877,11 @@ exports.mcp = new commander_1.Command("mcp")
1759
1877
  if (!target)
1760
1878
  return err("Missing required parameter: target");
1761
1879
  const depth = Math.min(Math.max(Number(args.depth) || 1, 1), 3);
1880
+ const rollup = args.rollup === true;
1881
+ const top = Math.min(Math.max(Number(args.top) || 10, 1), 100);
1762
1882
  try {
1763
- const { resolveTargetSymbols, findTests, findDependents, isTestPath } = yield Promise.resolve().then(() => __importStar(require("../lib/graph/impact")));
1883
+ const { resolveTargetSymbols, findTests, findDependents, findDependentsDetailed, isTestPath, } = yield Promise.resolve().then(() => __importStar(require("../lib/graph/impact")));
1884
+ const { buildImpactRollup, formatImpactRollupAgent } = yield Promise.resolve().then(() => __importStar(require("../lib/graph/impact-rollup")));
1764
1885
  const db = getVectorDb();
1765
1886
  const { symbols, resolvedAsFile, symbolFamilies } = yield resolveTargetSymbols(target, db, projectRoot);
1766
1887
  if (symbols.length === 0)
@@ -1769,10 +1890,28 @@ exports.mcp = new commander_1.Command("mcp")
1769
1890
  ? path.resolve(projectRoot, target)
1770
1891
  : undefined;
1771
1892
  const excludePaths = targetPath ? new Set([targetPath]) : undefined;
1893
+ const rollupLimit = Math.min(Math.max(top * 10, 100), 500);
1772
1894
  const [dependents, tests] = yield Promise.all([
1773
- findDependents(symbols, db, projectRoot, excludePaths, undefined, undefined, symbolFamilies),
1895
+ rollup
1896
+ ? findDependentsDetailed(symbols, db, projectRoot, excludePaths, rollupLimit, undefined, symbolFamilies)
1897
+ : findDependents(symbols, db, projectRoot, excludePaths, undefined, undefined, symbolFamilies),
1774
1898
  findTests(symbols, db, projectRoot, depth, undefined, symbolFamilies),
1775
1899
  ]);
1900
+ if (rollup) {
1901
+ const detailedDependents = dependents;
1902
+ const impactRollup = buildImpactRollup({
1903
+ targetSymbols: symbols,
1904
+ dependents: detailedDependents,
1905
+ tests,
1906
+ projectRoot,
1907
+ top,
1908
+ });
1909
+ return ok(formatImpactRollupAgent(impactRollup, {
1910
+ target,
1911
+ projectRoot,
1912
+ includeTests: true,
1913
+ }));
1914
+ }
1776
1915
  const nonTestDeps = dependents.filter((d) => !isTestPath(d.file));
1777
1916
  const rel = (p) => p.startsWith(`${projectRoot}/`) ? p.slice(projectRoot.length + 1) : p;
1778
1917
  const sections = [`Impact analysis for ${target}:\n`];
@@ -2045,15 +2184,16 @@ exports.mcp = new commander_1.Command("mcp")
2045
2184
  "If you are NOT inside a specific repo, or want a different one: call " +
2046
2185
  '`list_projects` to see what\'s indexed, then pass `projects:"name"` (or ' +
2047
2186
  '`scope:"all"`) to `semantic_search`. When the working directory isn\'t an ' +
2048
- "indexed project, `semantic_search` automatically searches all of them and " +
2049
- "says so. Prefer `semantic_search` (detail:'pointer') for discovery, then " +
2187
+ "indexed project, `semantic_search` errors instead of searching everything " +
2188
+ "implicitly. Prefer `semantic_search` (detail:'pointer') for discovery, then " +
2050
2189
  "`extract_symbol`/`peek_symbol`/`code_skeleton` to read, and " +
2051
- "`trace_calls`/`impact_analysis`/`find_tests` for call-graph questions.",
2190
+ "`trace_calls`/`impact_analysis`/`find_tests` for call-graph questions. " +
2191
+ "Use `audit` and experimental `surprising_connections` for project orientation.",
2052
2192
  });
2053
2193
  // Best-effort query logging, applied uniformly to every tool exactly as the
2054
2194
  // old single CallToolRequestSchema dispatch did before each return.
2055
2195
  const logToolCall = (name, toolArgs, startMs, result) => __awaiter(void 0, void 0, void 0, function* () {
2056
- var _a, _b, _c, _d, _e, _f;
2196
+ var _a, _b, _c;
2057
2197
  try {
2058
2198
  const { logQuery } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/query-log")));
2059
2199
  const text = (_c = (_b = (_a = result.content) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.text) !== null && _c !== void 0 ? _c : "";
@@ -2062,14 +2202,14 @@ exports.mcp = new commander_1.Command("mcp")
2062
2202
  ts: new Date().toISOString(),
2063
2203
  source: "mcp",
2064
2204
  tool: name,
2065
- query: String((_f = (_e = (_d = toolArgs.query) !== null && _d !== void 0 ? _d : toolArgs.symbol) !== null && _e !== void 0 ? _e : toolArgs.target) !== null && _f !== void 0 ? _f : ""),
2205
+ query: mcpLogQuery(name, toolArgs),
2066
2206
  project: projectRoot,
2067
2207
  results: resultLines,
2068
2208
  ms: Date.now() - startMs,
2069
2209
  error: result.isError ? text.slice(0, 200) : undefined,
2070
2210
  });
2071
2211
  }
2072
- catch (_g) { }
2212
+ catch (_d) { }
2073
2213
  });
2074
2214
  // Register a tool, wrapping its handler with timing + query logging. Zod raw
2075
2215
  // shapes give us free input validation (the SDK rejects calls that violate
@@ -2229,6 +2369,55 @@ exports.mcp = new commander_1.Command("mcp")
2229
2369
  .optional(),
2230
2370
  },
2231
2371
  }, handleAudit);
2372
+ tool("surprising_connections", {
2373
+ description: "Experimental orientation signal: embedding-similar cross-directory file pairs that are not directly connected by the static symbol graph. Useful for spotting duplicate or parallel logic. Requires experimental:true.",
2374
+ inputSchema: {
2375
+ experimental: zod_1.z
2376
+ .boolean()
2377
+ .describe("Must be true to acknowledge experimental signal quality"),
2378
+ root: zod_1.z
2379
+ .string()
2380
+ .describe("Project root (default: current)")
2381
+ .optional(),
2382
+ sample: zod_1.z
2383
+ .number()
2384
+ .describe("Indexed code chunks to sample (default 160, max 10000)")
2385
+ .optional(),
2386
+ neighbors: zod_1.z
2387
+ .number()
2388
+ .describe("Nearest neighbors per sampled chunk (default 20, max 200)")
2389
+ .optional(),
2390
+ top: zod_1.z
2391
+ .number()
2392
+ .describe("Grouped findings to return (default 10, max 100)")
2393
+ .optional(),
2394
+ dir_depth: zod_1.z
2395
+ .number()
2396
+ .describe("Directory bucket depth considered unsurprising")
2397
+ .optional(),
2398
+ min_similarity: zod_1.z
2399
+ .number()
2400
+ .describe("Minimum similarity 0-1 (default 0)")
2401
+ .optional(),
2402
+ max_rows: zod_1.z
2403
+ .number()
2404
+ .describe(`Maximum indexed rows to scan (default 50000, max ${surprising_connections_1.MAX_SURPRISE_ROWS})`)
2405
+ .optional(),
2406
+ in: zod_1.z
2407
+ .string()
2408
+ .describe("Restrict to a sub-path of the project")
2409
+ .optional(),
2410
+ exclude: zod_1.z
2411
+ .string()
2412
+ .describe("Exclude a sub-path of the project")
2413
+ .optional(),
2414
+ include_tests: zod_1.z.boolean().describe("Include test files").optional(),
2415
+ include_eval: zod_1.z
2416
+ .boolean()
2417
+ .describe("Include eval/experiment/script files")
2418
+ .optional(),
2419
+ },
2420
+ }, handleSurprisingConnections);
2232
2421
  tool("get_neighbors", {
2233
2422
  description: "Graph primitive: symbols reachable from a node along call edges within N hops. direction 'callees' = what it calls (outbound), 'callers' = what calls it (inbound). Each result carries its hop distance and definition location. Static call graph — same caveats as `dead`/`audit`.",
2234
2423
  inputSchema: {
@@ -2371,6 +2560,14 @@ exports.mcp = new commander_1.Command("mcp")
2371
2560
  .number()
2372
2561
  .describe("Caller traversal depth 1-3 (default 1)")
2373
2562
  .optional(),
2563
+ rollup: zod_1.z
2564
+ .boolean()
2565
+ .describe("Return export/package rollup TSV rows")
2566
+ .optional(),
2567
+ top: zod_1.z
2568
+ .number()
2569
+ .describe("Max rows per rollup section (default 10)")
2570
+ .optional(),
2374
2571
  },
2375
2572
  }, handleImpactAnalysis);
2376
2573
  tool("find_similar", {