grepmax 0.22.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.22.0",
13
+ "version": "0.24.0",
14
14
  "author": {
15
15
  "name": "Robert Owens",
16
16
  "email": "robowens@me.com"
package/README.md CHANGED
@@ -72,6 +72,8 @@ gmax log src/lib/auth.ts # Git commit history for a path or symb
72
72
  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
+ gmax audit --top 10 # God nodes, hub files, file cycles, dead candidates
76
+ gmax surprises --experimental --agent # Experimental: similar but graph-disconnected file pairs
75
77
  gmax dead handleAuth # Unused-symbol check via call graph (DEAD / PUBLIC EXPORT / LIVE)
76
78
  gmax context "auth system" --budget 4000 # Token-budgeted topic summary
77
79
  gmax context src/lib/auth.ts --budget 4000 # Deterministic file/path context
@@ -132,7 +134,8 @@ Plugins auto-update when you run `npm install -g grepmax@latest` — no need to
132
134
  | `extract_symbol` | Complete function/class body by symbol name. |
133
135
  | `peek_symbol` | Compact overview: signature + callers + callees. |
134
136
  | `dead` | Unused-symbol check via call graph. Returns `DEAD`, `PUBLIC EXPORT`, or `LIVE` with caller count. |
135
- | `audit` | Graph summary: god nodes, hub files, and dead-code candidates in one call. |
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`. |
136
139
  | `get_neighbors` | Graph primitive: symbols reachable from a node along call edges within N hops. |
137
140
  | `find_paths` | Graph primitive: shortest call-graph path between two symbols. |
138
141
  | `subgraph_for_files` | Graph primitive: local dependency subgraph for a set of files. |
@@ -176,6 +179,18 @@ gmax "query" [options]
176
179
  | `--seed-symbol <name>` | Bias results toward an identifier you're working with (repeatable). | — |
177
180
  | `--skeleton` | Show file skeletons for top matches. | `false` |
178
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.
179
194
  | `--budget <tokens>` | Cap output tokens (for `--context-for-llm`). | `8000` |
180
195
  | `--explain` | Show scoring breakdown per result. | `false` |
181
196
  | `--scores` | Show relevance scores. | `false` |
@@ -64,6 +64,73 @@ const MIN_GOD_NAME_LEN = 3;
64
64
  function rel(p, prefix) {
65
65
  return p.startsWith(prefix) ? p.slice(prefix.length) : p;
66
66
  }
67
+ function findFileCycles(deps, prefix, top) {
68
+ const indexByFile = new Map();
69
+ const lowlink = new Map();
70
+ const stack = [];
71
+ const onStack = new Set();
72
+ const components = [];
73
+ let index = 0;
74
+ const strongConnect = (file) => {
75
+ var _a, _b, _c, _d, _e;
76
+ indexByFile.set(file, index);
77
+ lowlink.set(file, index);
78
+ index++;
79
+ stack.push(file);
80
+ onStack.add(file);
81
+ for (const dep of (_a = deps.get(file)) !== null && _a !== void 0 ? _a : []) {
82
+ if (!indexByFile.has(dep)) {
83
+ strongConnect(dep);
84
+ lowlink.set(file, Math.min((_b = lowlink.get(file)) !== null && _b !== void 0 ? _b : 0, (_c = lowlink.get(dep)) !== null && _c !== void 0 ? _c : 0));
85
+ }
86
+ else if (onStack.has(dep)) {
87
+ lowlink.set(file, Math.min((_d = lowlink.get(file)) !== null && _d !== void 0 ? _d : 0, (_e = indexByFile.get(dep)) !== null && _e !== void 0 ? _e : 0));
88
+ }
89
+ }
90
+ if (lowlink.get(file) !== indexByFile.get(file))
91
+ return;
92
+ const component = [];
93
+ while (stack.length > 0) {
94
+ const dep = stack.pop();
95
+ onStack.delete(dep);
96
+ component.push(dep);
97
+ if (dep === file)
98
+ break;
99
+ }
100
+ if (component.length > 1)
101
+ components.push(component);
102
+ };
103
+ const files = new Set();
104
+ for (const [file, targets] of deps) {
105
+ files.add(file);
106
+ for (const target of targets)
107
+ files.add(target);
108
+ }
109
+ for (const file of [...files].sort()) {
110
+ if (!indexByFile.has(file))
111
+ strongConnect(file);
112
+ }
113
+ return components
114
+ .map((component) => {
115
+ var _a;
116
+ const set = new Set(component);
117
+ let edgeCount = 0;
118
+ for (const file of component) {
119
+ for (const dep of (_a = deps.get(file)) !== null && _a !== void 0 ? _a : []) {
120
+ if (set.has(dep))
121
+ edgeCount++;
122
+ }
123
+ }
124
+ return {
125
+ files: component.map((f) => rel(f, prefix)).sort(),
126
+ edgeCount,
127
+ };
128
+ })
129
+ .sort((a, b) => b.files.length - a.files.length ||
130
+ b.edgeCount - a.edgeCount ||
131
+ a.files.join("\0").localeCompare(b.files.join("\0")))
132
+ .slice(0, top);
133
+ }
67
134
  /**
68
135
  * Pure aggregation over chunk rows — no DB, no I/O. Builds the symbol→def map,
69
136
  * cross-file inbound edges, and per-file fan-in/out, then derives god nodes
@@ -170,6 +237,23 @@ function computeAudit(rows, prefix, top) {
170
237
  });
171
238
  }
172
239
  hubFiles.sort((a, b) => b.dependents - a.dependents || b.defines - a.defines);
240
+ const fileDeps = new Map();
241
+ for (const [file, refs] of fileOutRefs) {
242
+ for (const s of refs) {
243
+ if ((0, callsites_1.isBuiltinCallee)(s))
244
+ continue;
245
+ const defFiles = defFileCounts.get(s);
246
+ if (!defFiles || defFiles.size !== 1)
247
+ continue;
248
+ const [depFile] = defFiles;
249
+ if (!depFile || depFile === file)
250
+ continue;
251
+ if (!fileDeps.has(file))
252
+ fileDeps.set(file, new Set());
253
+ fileDeps.get(file).add(depFile);
254
+ }
255
+ }
256
+ const fileCycles = findFileCycles(fileDeps, prefix, top);
173
257
  // Dead candidates — non-exported in-project symbols with zero inbound
174
258
  // references anywhere (including their own file).
175
259
  const deadAll = [];
@@ -186,10 +270,17 @@ function computeAudit(rows, prefix, top) {
186
270
  scannedFiles: files.size,
187
271
  godNodes: godNodes.slice(0, top),
188
272
  hubFiles: hubFiles.filter((h) => h.dependents > 0).slice(0, top),
273
+ fileCycles,
189
274
  deadCandidates: deadAll.slice(0, top),
190
275
  deadTotal: deadAll.length,
191
276
  };
192
277
  }
278
+ function formatCycle(files) {
279
+ const shown = files.length > 6
280
+ ? [...files.slice(0, 6), `... ${files.length - 6} more`]
281
+ : files;
282
+ return shown.join(", ");
283
+ }
193
284
  function formatHuman(r) {
194
285
  const out = [];
195
286
  out.push(`${style.bold("Audit")} ${style.dim(`— ${r.scannedChunks} chunks across ${r.scannedFiles} files`)}`);
@@ -217,6 +308,17 @@ function formatHuman(r) {
217
308
  }
218
309
  }
219
310
  out.push("");
311
+ out.push(style.bold("File dependency cycles") +
312
+ style.dim(" (symbol-derived, bounded SCCs)"));
313
+ if (r.fileCycles.length === 0) {
314
+ out.push(style.dim(" none"));
315
+ }
316
+ else {
317
+ for (const c of r.fileCycles) {
318
+ out.push(` ${formatCycle(c.files)} ${style.dim(`${c.files.length} files, ${c.edgeCount} internal edges`)}`);
319
+ }
320
+ }
321
+ out.push("");
220
322
  out.push(style.bold("Dead-code candidates") +
221
323
  style.dim(` (${r.deadTotal} non-exported symbols with zero inbound refs)`));
222
324
  if (r.deadCandidates.length === 0) {
@@ -246,6 +348,9 @@ function formatAgent(r) {
246
348
  for (const h of r.hubFiles) {
247
349
  lines.push(`hub\t${h.file}\t${h.dependents}\t${h.defines}\t${h.fanOut}`);
248
350
  }
351
+ for (const c of r.fileCycles) {
352
+ lines.push(`cycle\t${c.files.join(",")}\t${c.files.length}\t${c.edgeCount}`);
353
+ }
249
354
  for (const d of r.deadCandidates) {
250
355
  lines.push(`dead\t${d.symbol}\t${d.file}:${d.line + 1}`);
251
356
  }
@@ -253,11 +358,12 @@ function formatAgent(r) {
253
358
  return lines.join("\n");
254
359
  }
255
360
  exports.audit = new commander_1.Command("audit")
256
- .description("Graph-summary of the indexed project god nodes (most depended-upon " +
257
- "symbols), hub files (most depended-upon files), and dead-code candidates " +
258
- "(non-exported symbols with zero inbound references). One pass over the " +
259
- "static call graph; dynamic dispatch / reflection / eval are invisible, so " +
260
- "dead candidates are hypotheses, not proof.")
361
+ .description("Graph-summary of the indexed project: god nodes (most depended-upon " +
362
+ "symbols), hub files (most depended-upon files), symbol-derived file " +
363
+ "dependency cycles, and dead-code candidates (non-exported symbols with " +
364
+ "zero inbound references). One pass over the static reference graph; " +
365
+ "dynamic dispatch / reflection / eval are invisible, so dead candidates " +
366
+ "are hypotheses, not proof.")
261
367
  .option("--root <dir>", "Project root directory")
262
368
  .option("--in <subpath>", "Restrict to a sub-path of the project (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
263
369
  .option("--exclude <subpath>", "Exclude a sub-path of the project (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
@@ -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.