grepmax 0.16.3 → 0.16.4

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.
@@ -110,6 +110,7 @@ exports.extract = new commander_1.Command("extract")
110
110
  .option("--exclude <subpath>", "Exclude a sub-path of the project (repeatable)", (value, prev) => (prev ? [...prev, value] : [value]))
111
111
  .option("--agent", "Compact output for AI agents", false)
112
112
  .option("--imports", "Prepend file imports", false)
113
+ .option("--no-tests", "Suppress the tests footer")
113
114
  .action((symbol, opts) => __awaiter(void 0, void 0, void 0, function* () {
114
115
  var _a;
115
116
  let vectorDb = null;
@@ -176,6 +177,16 @@ exports.extract = new commander_1.Command("extract")
176
177
  }
177
178
  console.log(`${relPath}:${startLine + 1}-${endLine + 1}`);
178
179
  console.log(body.join("\n"));
180
+ if (opts.tests !== false) {
181
+ const { fetchTestsForFooter, renderTestsFooterAgent } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/tests-footer")));
182
+ const tests = yield fetchTestsForFooter(symbol, vectorDb, scope.pathPrefix, scope.excludePrefixes);
183
+ if (tests && tests.length > 0) {
184
+ console.log("--- tests:");
185
+ for (const line of renderTestsFooterAgent(tests, projectRoot)) {
186
+ console.log(line);
187
+ }
188
+ }
189
+ }
179
190
  }
180
191
  else {
181
192
  // Rich output with line numbers
@@ -207,6 +218,15 @@ exports.extract = new commander_1.Command("extract")
207
218
  .join(", ");
208
219
  console.log(`\n${style.dim(`Also defined in: ${otherLocs}`)}`);
209
220
  }
221
+ if (!opts.agent && opts.tests !== false) {
222
+ const { fetchTestsForFooter, renderTestsFooterHuman } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/tests-footer")));
223
+ const tests = yield fetchTestsForFooter(symbol, vectorDb, scope.pathPrefix, scope.excludePrefixes);
224
+ if (tests && tests.length > 0) {
225
+ for (const line of renderTestsFooterHuman(tests, projectRoot)) {
226
+ console.log(line);
227
+ }
228
+ }
229
+ }
210
230
  }
211
231
  catch (error) {
212
232
  const message = error instanceof Error ? error.message : "Unknown error";
@@ -98,6 +98,7 @@ exports.peek = new commander_1.Command("peek")
98
98
  .option("--in <subpath>", "Restrict to a sub-path of the project (repeatable)", (value, prev) => (prev ? [...prev, value] : [value]))
99
99
  .option("--exclude <subpath>", "Exclude a sub-path of the project (repeatable)", (value, prev) => (prev ? [...prev, value] : [value]))
100
100
  .option("--agent", "Compact output for AI agents", false)
101
+ .option("--no-tests", "Suppress the tests footer")
101
102
  .action((symbol, opts) => __awaiter(void 0, void 0, void 0, function* () {
102
103
  var _a;
103
104
  let vectorDb = null;
@@ -225,6 +226,15 @@ exports.peek = new commander_1.Command("peek")
225
226
  if (calleeList.length > MAX_CALLEES) {
226
227
  console.log(`-> ... ${calleeList.length - MAX_CALLEES} more`);
227
228
  }
229
+ if (opts.tests !== false) {
230
+ const { fetchTestsForFooter, renderTestsFooterAgent } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/tests-footer")));
231
+ const tests = yield fetchTestsForFooter(symbol, vectorDb, scope.pathPrefix, scope.excludePrefixes);
232
+ if (tests && tests.length > 0) {
233
+ for (const line of renderTestsFooterAgent(tests, projectRoot)) {
234
+ console.log(line);
235
+ }
236
+ }
237
+ }
228
238
  }
229
239
  else {
230
240
  // Rich output
@@ -276,6 +286,15 @@ exports.peek = new commander_1.Command("peek")
276
286
  else {
277
287
  console.log(style.dim("No known callees."));
278
288
  }
289
+ if (opts.tests !== false) {
290
+ const { fetchTestsForFooter, renderTestsFooterHuman } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/tests-footer")));
291
+ const tests = yield fetchTestsForFooter(symbol, vectorDb, scope.pathPrefix, scope.excludePrefixes);
292
+ if (tests && tests.length > 0) {
293
+ for (const line of renderTestsFooterHuman(tests, projectRoot)) {
294
+ console.log(line);
295
+ }
296
+ }
297
+ }
279
298
  }
280
299
  }
281
300
  catch (error) {
@@ -138,6 +138,52 @@ exports.related = new commander_1.Command("related")
138
138
  const topRevs = Array.from(revCounts.entries())
139
139
  .sort((a, b) => b[1] - a[1])
140
140
  .slice(0, limit);
141
+ // Mention-based fallback: when symbol intersection turns up nothing
142
+ // in either direction, look for files in scope whose content mentions
143
+ // this file's basename. Catches leaf modules with no shared symbols
144
+ // and side-effect-only imports.
145
+ const GENERIC_BASENAMES = new Set([
146
+ "index",
147
+ "main",
148
+ "mod",
149
+ "init",
150
+ "lib",
151
+ "types",
152
+ "util",
153
+ "utils",
154
+ "common",
155
+ "shared",
156
+ ]);
157
+ let mentions = [];
158
+ let basename = "";
159
+ let basenameRejected = false;
160
+ if (topDeps.length === 0 && topRevs.length === 0) {
161
+ const ext = path.extname(absPath);
162
+ basename = path.basename(absPath, ext);
163
+ if (basename.length < 4 || GENERIC_BASENAMES.has(basename.toLowerCase())) {
164
+ basenameRejected = true;
165
+ }
166
+ else {
167
+ const rows = yield table
168
+ .query()
169
+ .select(["path"])
170
+ .where(`content LIKE '%${(0, filter_builder_1.escapeSqlString)(basename)}%' AND ${pathScope}`)
171
+ .limit(limit * 4)
172
+ .toArray();
173
+ const seen = new Set();
174
+ for (const row of rows) {
175
+ const p = String(row.path || "");
176
+ if (!p || p === absPath)
177
+ continue;
178
+ if (seen.has(p))
179
+ continue;
180
+ seen.add(p);
181
+ mentions.push(p);
182
+ if (mentions.length >= limit)
183
+ break;
184
+ }
185
+ }
186
+ }
141
187
  if (opts.agent) {
142
188
  const rel = (p) => p.startsWith(`${projectRoot}/`)
143
189
  ? p.slice(projectRoot.length + 1)
@@ -149,7 +195,18 @@ exports.related = new commander_1.Command("related")
149
195
  console.log(`rev: ${rel(p)}\t${count}`);
150
196
  }
151
197
  if (!topDeps.length && !topRevs.length) {
152
- console.log("(none)");
198
+ if (basenameRejected) {
199
+ console.log(`(no semantic neighbors; basename '${basename}' too generic to fall back)`);
200
+ }
201
+ else if (mentions.length > 0) {
202
+ console.log(`(no semantic neighbors; showing ${mentions.length} files mentioning '${basename}')`);
203
+ for (const p of mentions) {
204
+ console.log(`imp: ${rel(p)}\t1`);
205
+ }
206
+ }
207
+ else {
208
+ console.log("(none)");
209
+ }
153
210
  }
154
211
  }
155
212
  else {
@@ -179,6 +236,21 @@ exports.related = new commander_1.Command("related")
179
236
  else {
180
237
  console.log("Dependents: none found");
181
238
  }
239
+ if (topDeps.length === 0 && topRevs.length === 0) {
240
+ console.log("");
241
+ if (basenameRejected) {
242
+ console.log(`(basename '${basename}' too generic to fall back to mentions)`);
243
+ }
244
+ else if (mentions.length > 0) {
245
+ console.log(`Mentions of "${basename}" in other files:`);
246
+ for (const p of mentions) {
247
+ const rel = p.startsWith(`${projectRoot}/`)
248
+ ? p.slice(projectRoot.length + 1)
249
+ : p;
250
+ console.log(` ${rel}`);
251
+ }
252
+ }
253
+ }
182
254
  }
183
255
  }
184
256
  catch (error) {
@@ -712,53 +712,26 @@ Examples:
712
712
  process.exitCode = 1;
713
713
  }
714
714
  else {
715
- // In agent mode, print imports header per file
716
- const seenImportFiles = new Set();
715
+ // Pre-pass: group by absolute path in first-occurrence order so we
716
+ // can collapse same-file repeats under a one-line header. Score
717
+ // order is preserved globally for first-of-file; siblings cluster
718
+ // under their first hit's rank.
719
+ const groups = new Map();
717
720
  for (const r of filteredData) {
718
721
  const absP = (_j = (_g = r.path) !== null && _g !== void 0 ? _g : (_h = r.metadata) === null || _h === void 0 ? void 0 : _h.path) !== null && _j !== void 0 ? _j : "";
719
- const relPath = absP.startsWith(effectiveRoot)
720
- ? absP.slice(effectiveRoot.length + 1)
721
- : absP;
722
- const startLine = Math.max(1, ((_o = (_l = (_k = r.startLine) !== null && _k !== void 0 ? _k : r.start_line) !== null && _l !== void 0 ? _l : (_m = r.generated_metadata) === null || _m === void 0 ? void 0 : _m.start_line) !== null && _o !== void 0 ? _o : 0) + 1);
723
- const defs = Array.isArray(r.defined_symbols)
724
- ? r.defined_symbols
725
- : [];
726
- const symbol = defs[0] || "";
727
- const role = ((_p = r.role) !== null && _p !== void 0 ? _p : "")
728
- .slice(0, 4)
729
- .toUpperCase();
730
- let hint = "";
731
- if (r.summary) {
732
- hint = ` — ${r.summary}`;
722
+ const arr = groups.get(absP);
723
+ if (arr) {
724
+ arr.push(r);
733
725
  }
734
726
  else {
735
- // Extract first meaningful signature line from content
736
- const raw = (_r = (_q = r.content) !== null && _q !== void 0 ? _q : r.text) !== null && _r !== void 0 ? _r : "";
737
- const lines = raw.split("\n");
738
- for (const line of lines) {
739
- const trimmed = line.trim();
740
- // Skip empty, comments, imports, braces, and mid-line fragments
741
- if (!trimmed || trimmed.length < 5)
742
- continue;
743
- if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*"))
744
- continue;
745
- if (trimmed.startsWith("import ") || trimmed.startsWith("#") || trimmed.startsWith("File:"))
746
- continue;
747
- if (trimmed === "{" || trimmed === "}")
748
- continue;
749
- // Skip lines that look like continuations (start with punctuation, closing braces, or spread)
750
- if (/^[.),;:}\]|&(+`'"!~]/.test(trimmed))
751
- continue;
752
- if (trimmed.startsWith("} ") || trimmed.startsWith("- ") || trimmed.startsWith("..."))
753
- continue;
754
- // Skip lines that look like mid-expression fragments (no keyword/declaration prefix)
755
- if (/^[a-z]/.test(trimmed) && !/^(export|function|class|interface|type|const|let|var|async|return|if|for|while|switch|enum|struct|pub |fn |def |impl |mod |use )/.test(trimmed))
756
- continue;
757
- hint = ` — ${trimmed.length > 120 ? trimmed.slice(0, 117) + "..." : trimmed}`;
758
- break;
759
- }
727
+ groups.set(absP, [r]);
760
728
  }
761
- // Print file imports once per file when --imports is used
729
+ }
730
+ const seenImportFiles = new Set();
731
+ for (const [absP, members] of groups) {
732
+ const relPath = absP.startsWith(effectiveRoot)
733
+ ? absP.slice(effectiveRoot.length + 1)
734
+ : absP;
762
735
  if (options.imports && absP && !seenImportFiles.has(absP)) {
763
736
  seenImportFiles.add(absP);
764
737
  const imports = getImportsForFile(absP);
@@ -766,14 +739,62 @@ Examples:
766
739
  console.log(`[imports ${relPath}] ${imports.split("\n").join(" | ")}`);
767
740
  }
768
741
  }
769
- const sym = symbol ? ` ${symbol}` : "";
770
- const rl = role ? ` [${role}]` : "";
771
- const score = r.score;
772
- const scoreCol = typeof score === "number" ? `\ts=${score.toFixed(3)}` : "";
773
- const explainSuffix = options.explain && r.scoreBreakdown
774
- ? `\texplain:rerank=${r.scoreBreakdown.rerank.toFixed(3)},fused=${r.scoreBreakdown.fused.toFixed(3)},boost=${r.scoreBreakdown.boost.toFixed(2)}x,score=${r.scoreBreakdown.normalized.toFixed(3)}`
775
- : "";
776
- console.log(`${relPath}:${startLine}${scoreCol}${sym}${rl}${hint}${explainSuffix}`);
742
+ const grouped = members.length > 1;
743
+ if (grouped) {
744
+ console.log(`${relPath} (${members.length} hits):`);
745
+ }
746
+ for (const r of members) {
747
+ const startLine = Math.max(1, ((_o = (_l = (_k = r.startLine) !== null && _k !== void 0 ? _k : r.start_line) !== null && _l !== void 0 ? _l : (_m = r.generated_metadata) === null || _m === void 0 ? void 0 : _m.start_line) !== null && _o !== void 0 ? _o : 0) + 1);
748
+ const defs = Array.isArray(r.defined_symbols)
749
+ ? r.defined_symbols
750
+ : [];
751
+ const symbol = defs[0] || "";
752
+ const role = ((_p = r.role) !== null && _p !== void 0 ? _p : "")
753
+ .slice(0, 4)
754
+ .toUpperCase();
755
+ let hint = "";
756
+ if (r.summary) {
757
+ hint = ` — ${r.summary}`;
758
+ }
759
+ else {
760
+ // Extract first meaningful signature line from content
761
+ const raw = (_r = (_q = r.content) !== null && _q !== void 0 ? _q : r.text) !== null && _r !== void 0 ? _r : "";
762
+ const lines = raw.split("\n");
763
+ for (const line of lines) {
764
+ const trimmed = line.trim();
765
+ // Skip empty, comments, imports, braces, and mid-line fragments
766
+ if (!trimmed || trimmed.length < 5)
767
+ continue;
768
+ if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*"))
769
+ continue;
770
+ if (trimmed.startsWith("import ") || trimmed.startsWith("#") || trimmed.startsWith("File:"))
771
+ continue;
772
+ if (trimmed === "{" || trimmed === "}")
773
+ continue;
774
+ // Skip lines that look like continuations (start with punctuation, closing braces, or spread)
775
+ if (/^[.),;:}\]|&(+`'"!~]/.test(trimmed))
776
+ continue;
777
+ if (trimmed.startsWith("} ") || trimmed.startsWith("- ") || trimmed.startsWith("..."))
778
+ continue;
779
+ // Skip lines that look like mid-expression fragments (no keyword/declaration prefix)
780
+ if (/^[a-z]/.test(trimmed) && !/^(export|function|class|interface|type|const|let|var|async|return|if|for|while|switch|enum|struct|pub |fn |def |impl |mod |use )/.test(trimmed))
781
+ continue;
782
+ hint = ` — ${trimmed.length > 120 ? trimmed.slice(0, 117) + "..." : trimmed}`;
783
+ break;
784
+ }
785
+ }
786
+ const sym = symbol ? ` ${symbol}` : "";
787
+ const rl = role ? ` [${role}]` : "";
788
+ const score = r.score;
789
+ const scoreCol = typeof score === "number" ? `\ts=${score.toFixed(3)}` : "";
790
+ const explainSuffix = options.explain && r.scoreBreakdown
791
+ ? `\texplain:rerank=${r.scoreBreakdown.rerank.toFixed(3)},fused=${r.scoreBreakdown.fused.toFixed(3)},boost=${r.scoreBreakdown.boost.toFixed(2)}x,score=${r.scoreBreakdown.normalized.toFixed(3)}`
792
+ : "";
793
+ const locator = grouped
794
+ ? ` :${startLine}`
795
+ : `${relPath}:${startLine}`;
796
+ console.log(`${locator}${scoreCol}${sym}${rl}${hint}${explainSuffix}`);
797
+ }
777
798
  }
778
799
  }
779
800
  // Agent trace (compact)
@@ -93,14 +93,22 @@ exports.testFind = new commander_1.Command("test")
93
93
  const rel = (p) => p.startsWith(`${projectRoot}/`) ? p.slice(projectRoot.length + 1) : p;
94
94
  if (opts.agent) {
95
95
  for (const t of tests) {
96
- const hopLabel = t.hops === 0 ? "direct" : `${t.hops}-hop`;
96
+ const hopLabel = t.hops === -1
97
+ ? "via-import"
98
+ : t.hops === 0
99
+ ? "direct"
100
+ : `${t.hops}-hop`;
97
101
  console.log(`${rel(t.file)}:${t.line + 1}\t${t.symbol}\t${hopLabel}`);
98
102
  }
99
103
  }
100
104
  else {
101
105
  console.log(`Tests for ${target}:\n`);
102
106
  for (const t of tests) {
103
- const hopLabel = t.hops === 0 ? "calls directly" : `${t.hops} hop${t.hops > 1 ? "s" : ""} away`;
107
+ const hopLabel = t.hops === -1
108
+ ? "via import"
109
+ : t.hops === 0
110
+ ? "calls directly"
111
+ : `${t.hops} hop${t.hops > 1 ? "s" : ""} away`;
104
112
  console.log(` ${rel(t.file)}:${t.line + 1} ${t.symbol} (${hopLabel})`);
105
113
  }
106
114
  }
@@ -43,6 +43,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
43
43
  };
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.trace = void 0;
46
+ const fs = __importStar(require("node:fs"));
46
47
  const commander_1 = require("commander");
47
48
  const graph_builder_1 = require("../lib/graph/graph-builder");
48
49
  const formatter_1 = require("../lib/output/formatter");
@@ -50,6 +51,9 @@ const vector_db_1 = require("../lib/store/vector-db");
50
51
  const exit_1 = require("../lib/utils/exit");
51
52
  const project_registry_1 = require("../lib/utils/project-registry");
52
53
  const project_root_1 = require("../lib/utils/project-root");
54
+ const useColors = process.stdout.isTTY && !process.env.NO_COLOR;
55
+ const dim = (s) => (useColors ? `\x1b[2m${s}\x1b[22m` : s);
56
+ const bold = (s) => (useColors ? `\x1b[1m${s}\x1b[22m` : s);
53
57
  function formatTraceAgent(graph, projectRoot) {
54
58
  if (!graph.center)
55
59
  return "(not found)";
@@ -73,6 +77,117 @@ function formatTraceAgent(graph, projectRoot) {
73
77
  }
74
78
  return lines.join("\n");
75
79
  }
80
+ function findCallSiteSnippet(fileCache, callerFile, callerLine, targetSymbol) {
81
+ if (!callerFile)
82
+ return null;
83
+ let lines = fileCache.get(callerFile);
84
+ if (!lines) {
85
+ try {
86
+ lines = fs.readFileSync(callerFile, "utf-8").split("\n");
87
+ }
88
+ catch (_a) {
89
+ return null;
90
+ }
91
+ fileCache.set(callerFile, lines);
92
+ }
93
+ // Search a bounded window starting at the caller's definition line.
94
+ const start = Math.max(0, callerLine);
95
+ const end = Math.min(lines.length, callerLine + 200);
96
+ const wordRe = new RegExp(`\\b${targetSymbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
97
+ for (let i = start; i < end; i++) {
98
+ if (wordRe.test(lines[i])) {
99
+ return { snippet: lines[i].trim(), snippetLine: i };
100
+ }
101
+ }
102
+ // Window didn't contain the symbol — chunker rolled the reference up to a
103
+ // parent scope. Skip the snippet rather than showing a misleading default;
104
+ // the caller's file:line is still emitted.
105
+ return null;
106
+ }
107
+ function buildInboundTree(callerTree, targetSymbol, fileCache, withSnippets, limit) {
108
+ var _a, _b, _c;
109
+ const out = [];
110
+ // Dedupe by call-site location: when getCallers returns multiple chunks of
111
+ // the same file (e.g. several methods of a class) that all reference the
112
+ // target on the same line, collapse them into one row.
113
+ const seen = new Set();
114
+ for (const t of callerTree) {
115
+ const snippet = withSnippets
116
+ ? findCallSiteSnippet(fileCache, t.node.file, t.node.line, targetSymbol)
117
+ : null;
118
+ const dedupeKey = `${t.node.file}:${(_a = snippet === null || snippet === void 0 ? void 0 : snippet.snippetLine) !== null && _a !== void 0 ? _a : t.node.line}`;
119
+ if (seen.has(dedupeKey))
120
+ continue;
121
+ seen.add(dedupeKey);
122
+ out.push({
123
+ symbol: t.node.symbol,
124
+ file: t.node.file,
125
+ line: t.node.line,
126
+ snippet: (_b = snippet === null || snippet === void 0 ? void 0 : snippet.snippet) !== null && _b !== void 0 ? _b : null,
127
+ snippetLine: (_c = snippet === null || snippet === void 0 ? void 0 : snippet.snippetLine) !== null && _c !== void 0 ? _c : null,
128
+ callers: buildInboundTree(t.callers, t.node.symbol, fileCache, withSnippets, limit),
129
+ });
130
+ if (out.length >= limit)
131
+ break;
132
+ }
133
+ return out;
134
+ }
135
+ function formatInboundAgent(center, tree, projectRoot, withSnippets) {
136
+ const rel = (p) => p.startsWith(projectRoot) ? p.slice(projectRoot.length + 1) : p;
137
+ const lines = [];
138
+ lines.push(`${center.symbol}\t${rel(center.file)}:${center.line + 1}\t${center.role}`);
139
+ const walk = (nodes, depth) => {
140
+ var _a, _b;
141
+ for (const n of nodes) {
142
+ const prefix = " ".repeat(depth);
143
+ const loc = n.file ? `${rel(n.file)}:${((_a = n.snippetLine) !== null && _a !== void 0 ? _a : n.line) + 1}` : "(not indexed)";
144
+ const cols = withSnippets
145
+ ? `${loc}\t${n.symbol}\t${(_b = n.snippet) !== null && _b !== void 0 ? _b : ""}`
146
+ : `${loc}\t${n.symbol}`;
147
+ lines.push(`${prefix}${cols}`);
148
+ walk(n.callers, depth + 1);
149
+ }
150
+ };
151
+ walk(tree, 0);
152
+ return lines.join("\n");
153
+ }
154
+ function formatInboundHuman(center, tree, projectRoot, withSnippets) {
155
+ const rel = (p) => p.startsWith(projectRoot) ? p.slice(projectRoot.length + 1) : p;
156
+ const flatCount = (() => {
157
+ let n = 0;
158
+ const walk = (nodes) => {
159
+ for (const node of nodes) {
160
+ n++;
161
+ walk(node.callers);
162
+ }
163
+ };
164
+ walk(tree);
165
+ return n;
166
+ })();
167
+ const lines = [];
168
+ lines.push(`${bold(`inbound callers of ${center.symbol}`)} ${dim(`(${flatCount})`)}`);
169
+ lines.push(` ${dim(`${rel(center.file)}:${center.line + 1} [${center.role}]`)}`);
170
+ if (tree.length === 0) {
171
+ lines.push(dim(" (none in scope)"));
172
+ return lines.join("\n");
173
+ }
174
+ const walk = (nodes, depth) => {
175
+ var _a;
176
+ for (const n of nodes) {
177
+ const indent = " ".repeat(depth + 1);
178
+ const loc = n.file
179
+ ? `${rel(n.file)}:${((_a = n.snippetLine) !== null && _a !== void 0 ? _a : n.line) + 1}`
180
+ : "(not indexed)";
181
+ lines.push(`${indent}${n.symbol} ${dim(loc)}`);
182
+ if (withSnippets && n.snippet) {
183
+ lines.push(`${indent} ${dim(n.snippet)}`);
184
+ }
185
+ walk(n.callers, depth + 1);
186
+ }
187
+ };
188
+ walk(tree, 0);
189
+ return lines.join("\n");
190
+ }
76
191
  exports.trace = new commander_1.Command("trace")
77
192
  .description("Trace the call graph for a symbol")
78
193
  .argument("<symbol>", "The symbol to trace")
@@ -81,9 +196,13 @@ exports.trace = new commander_1.Command("trace")
81
196
  .option("--in <subpath>", "Restrict to a sub-path of the project (repeatable)", (value, prev) => (prev ? [...prev, value] : [value]))
82
197
  .option("--exclude <subpath>", "Exclude a sub-path of the project (repeatable)", (value, prev) => (prev ? [...prev, value] : [value]))
83
198
  .option("--agent", "Compact output for AI agents", false)
199
+ .option("--inbound", "Show only callers, with call-site snippets", false)
200
+ .option("--no-snippets", "Suppress call-site snippets in --inbound output")
201
+ .option("--limit <n>", "Max callers shown per node in --inbound (default 10)", "10")
84
202
  .action((symbol, opts) => __awaiter(void 0, void 0, void 0, function* () {
85
203
  var _a;
86
204
  const depth = Math.min(Math.max(Number.parseInt(opts.depth || "1", 10), 1), 3);
205
+ const inboundLimit = Math.min(Math.max(Number.parseInt(opts.limit || "10", 10), 1), 30);
87
206
  const root = (0, project_registry_1.resolveRootOrExit)(opts.root);
88
207
  if (root === null)
89
208
  return;
@@ -100,14 +219,32 @@ exports.trace = new commander_1.Command("trace")
100
219
  });
101
220
  const graphBuilder = new graph_builder_1.GraphBuilder(vectorDb, scope.pathPrefix, scope.excludePrefixes);
102
221
  const graph = yield graphBuilder.buildGraphMultiHop(symbol, depth);
103
- if (opts.agent) {
222
+ if (opts.inbound) {
223
+ if (!graph.center) {
224
+ console.log(opts.agent ? "(not found)" : `Symbol not found: ${symbol}`);
225
+ process.exitCode = 1;
226
+ }
227
+ else {
228
+ const fileCache = new Map();
229
+ const withSnippets = opts.snippets !== false;
230
+ const tree = buildInboundTree(graph.callerTree, symbol, fileCache, withSnippets, inboundLimit);
231
+ if (opts.agent) {
232
+ console.log(formatInboundAgent(graph.center, tree, projectRoot, withSnippets));
233
+ }
234
+ else {
235
+ console.log(formatInboundHuman(graph.center, tree, projectRoot, withSnippets));
236
+ }
237
+ }
238
+ }
239
+ else if (opts.agent) {
104
240
  console.log(formatTraceAgent(graph, projectRoot));
241
+ if (!graph.center)
242
+ process.exitCode = 1;
105
243
  }
106
244
  else {
107
245
  console.log((0, formatter_1.formatTrace)(graph, { symbol }));
108
- }
109
- if (!graph.center) {
110
- process.exitCode = 1;
246
+ if (!graph.center)
247
+ process.exitCode = 1;
111
248
  }
112
249
  }
113
250
  catch (error) {
@@ -95,6 +95,11 @@ function expandFileSymbols(symbols, vectorDb, projectRoot, excludePrefixes) {
95
95
  }
96
96
  /**
97
97
  * Find test files that exercise a set of symbols, using reverse call graph traversal.
98
+ * When the call-graph walk returns nothing, falls back to test files that
99
+ * reference the symbol via referenced_symbols or textual content match —
100
+ * catching the common case where a test imports a symbol but doesn't call it
101
+ * through the graph the chunker captures (UI components, mocked modules, etc).
102
+ * Fallback hits are tagged with hops = -1.
98
103
  */
99
104
  function findTests(symbols_1, vectorDb_1, projectRoot_1) {
100
105
  return __awaiter(this, arguments, void 0, function* (symbols, vectorDb, projectRoot, depth = 1, excludePrefixes) {
@@ -105,9 +110,55 @@ function findTests(symbols_1, vectorDb_1, projectRoot_1) {
105
110
  for (const symbol of expanded) {
106
111
  yield walkCallers(symbol, graphBuilder, testHits, 0, depth, new Set());
107
112
  }
113
+ if (testHits.size === 0) {
114
+ const importFiles = yield findImportFallbackTests(expanded, vectorDb, projectRoot, excludePrefixes);
115
+ for (const file of importFiles) {
116
+ testHits.set(`${file}:(referenced)`, {
117
+ file,
118
+ symbol: "(referenced)",
119
+ line: 0,
120
+ hops: -1,
121
+ });
122
+ }
123
+ }
108
124
  return [...testHits.values()].sort((a, b) => a.hops - b.hops || a.file.localeCompare(b.file));
109
125
  });
110
126
  }
127
+ function findImportFallbackTests(symbols, vectorDb, projectRoot, excludePrefixes) {
128
+ return __awaiter(this, void 0, void 0, function* () {
129
+ const files = new Set();
130
+ // Signal 1: referenced_symbols match (precise; works when the chunker
131
+ // captured call references in test bodies).
132
+ const dependents = yield findDependents(symbols, vectorDb, projectRoot, undefined, 50, excludePrefixes);
133
+ for (const d of dependents) {
134
+ if (isTestPath(d.file))
135
+ files.add(d.file);
136
+ }
137
+ // Signal 2: content LIKE match (textual; survives chunker quirks where a
138
+ // test body's referenced_symbols ends up empty).
139
+ const table = yield vectorDb.ensureTable();
140
+ const prefix = projectRoot.endsWith("/") ? projectRoot : `${projectRoot}/`;
141
+ let pathScope = `path LIKE '${(0, filter_builder_1.escapeSqlString)(prefix)}%'`;
142
+ for (const ex of excludePrefixes !== null && excludePrefixes !== void 0 ? excludePrefixes : []) {
143
+ const exNorm = ex.endsWith("/") ? ex : `${ex}/`;
144
+ pathScope += ` AND path NOT LIKE '${(0, filter_builder_1.escapeSqlString)(exNorm)}%'`;
145
+ }
146
+ for (const sym of symbols) {
147
+ const rows = yield table
148
+ .query()
149
+ .select(["path"])
150
+ .where(`content LIKE '%${(0, filter_builder_1.escapeSqlString)(sym)}%' AND ${pathScope}`)
151
+ .limit(100)
152
+ .toArray();
153
+ for (const row of rows) {
154
+ const p = String(row.path || "");
155
+ if (isTestPath(p))
156
+ files.add(p);
157
+ }
158
+ }
159
+ return files;
160
+ });
161
+ }
111
162
  function walkCallers(symbol, graphBuilder, testHits, currentHop, maxDepth, visited) {
112
163
  return __awaiter(this, void 0, void 0, function* () {
113
164
  if (visited.has(symbol))
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.fetchTestsForFooter = fetchTestsForFooter;
13
+ exports.renderTestsFooterAgent = renderTestsFooterAgent;
14
+ exports.renderTestsFooterHuman = renderTestsFooterHuman;
15
+ const impact_1 = require("../graph/impact");
16
+ const FOOTER_TIMEOUT_MS = 1500;
17
+ const MAX_SHOWN = 5;
18
+ const useColors = process.stdout.isTTY && !process.env.NO_COLOR;
19
+ const dim = (s) => (useColors ? `\x1b[2m${s}\x1b[22m` : s);
20
+ function withTimeout(p, ms) {
21
+ return __awaiter(this, void 0, void 0, function* () {
22
+ let timer;
23
+ const timeout = new Promise((resolve) => {
24
+ timer = setTimeout(() => resolve(null), ms);
25
+ });
26
+ try {
27
+ return yield Promise.race([p, timeout]);
28
+ }
29
+ finally {
30
+ if (timer)
31
+ clearTimeout(timer);
32
+ }
33
+ });
34
+ }
35
+ function fetchTestsForFooter(symbol, vectorDb, pathPrefix, excludePrefixes) {
36
+ return __awaiter(this, void 0, void 0, function* () {
37
+ return withTimeout((0, impact_1.findTests)([symbol], vectorDb, pathPrefix, 1, excludePrefixes), FOOTER_TIMEOUT_MS);
38
+ });
39
+ }
40
+ function relPath(p, projectRoot) {
41
+ return p.startsWith(`${projectRoot}/`) ? p.slice(projectRoot.length + 1) : p;
42
+ }
43
+ function hopLabelAgent(hops) {
44
+ if (hops === -1)
45
+ return "via-import";
46
+ if (hops === 0)
47
+ return "direct";
48
+ return `${hops}-hop`;
49
+ }
50
+ function hopLabelHuman(hops) {
51
+ if (hops === -1)
52
+ return "via import";
53
+ if (hops === 0)
54
+ return "direct";
55
+ return `${hops} hop${hops > 1 ? "s" : ""}`;
56
+ }
57
+ function renderTestsFooterAgent(tests, projectRoot) {
58
+ const lines = [];
59
+ for (const t of tests.slice(0, MAX_SHOWN)) {
60
+ lines.push(`t: ${relPath(t.file, projectRoot)}:${t.line + 1}\t${t.symbol}\t${hopLabelAgent(t.hops)}`);
61
+ }
62
+ if (tests.length > MAX_SHOWN) {
63
+ lines.push(`t: ... ${tests.length - MAX_SHOWN} more`);
64
+ }
65
+ return lines;
66
+ }
67
+ function renderTestsFooterHuman(tests, projectRoot) {
68
+ const lines = [];
69
+ lines.push("");
70
+ lines.push(`tests (${tests.length}):`);
71
+ for (const t of tests.slice(0, MAX_SHOWN)) {
72
+ lines.push(` ${t.symbol.padEnd(25)} ${dim(`${relPath(t.file, projectRoot)}:${t.line + 1}`)} ${dim(`(${hopLabelHuman(t.hops)})`)}`);
73
+ }
74
+ if (tests.length > MAX_SHOWN) {
75
+ lines.push(dim(` ... and ${tests.length - MAX_SHOWN} more`));
76
+ }
77
+ return lines;
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
4
4
  "author": "Robert Owens <78518764+reowens@users.noreply.github.com>",
5
5
  "homepage": "https://github.com/reowens/grepmax",
6
6
  "bugs": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.16.3",
3
+ "version": "0.16.4",
4
4
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
5
5
  "author": {
6
6
  "name": "Robert Owens",
@@ -53,7 +53,7 @@ gmax "auth" --root ~/other/project --agent # search a different project
53
53
  gmax "auth" --imports --agent # show file imports per file
54
54
  ```
55
55
 
56
- Output: `file:line symbol [ROLE] — signature_hint` (one line per result)
56
+ Output: `file:line symbol [ROLE] — signature_hint` (one line per result). When multiple hits land in the same file, they collapse under a `path/to/file.ts (N hits):` header with indented children — saves the repeated path prefix.
57
57
 
58
58
  All search flags: `--agent --plain -m <n> --per-file <n> --min-score <n> --root <dir> --file <name> --exclude <prefix> --lang <ext> --role <role> --symbol --imports --name <regex> -C <n> --compact --content --scores --skeleton --explain --context-for-llm --budget <tokens>`
59
59
 
@@ -87,6 +87,9 @@ gmax trace handleAuth # 1-hop: callers + callees
87
87
  gmax trace handleAuth -d 2 # 2-hop: callers-of-callers
88
88
  gmax trace handleAuth --root ~/project # trace in a different project
89
89
  gmax trace handleAuth --agent # compact: symbol\tpath:line, <- callers, -> callees
90
+ gmax trace handleAuth --inbound --agent # callers-only with call-site snippets (path:line\tsymbol\tsnippet)
91
+ gmax trace handleAuth --inbound --no-snippets # drop the snippet column
92
+ gmax trace handleAuth --inbound --limit 20 # show up to 20 callers (default 10, max 30)
90
93
  ```
91
94
 
92
95
  ### Extract — `gmax extract <symbol>`
@@ -95,7 +98,9 @@ gmax extract handleAuth # full function body with line number
95
98
  gmax extract handleAuth --agent # compact: path:start-end then raw code
96
99
  gmax extract handleAuth --imports # prepend file imports
97
100
  gmax extract handleAuth --root ~/project # extract from different project
101
+ gmax extract handleAuth --no-tests # suppress tests footer (default-on)
98
102
  ```
103
+ Output ends with a `--- tests:` footer listing tests that exercise the symbol (default-on; opt-out with `--no-tests`).
99
104
 
100
105
  ### Peek — `gmax peek <symbol>`
101
106
  ```
@@ -103,7 +108,9 @@ gmax peek handleAuth # signature + callers + callees
103
108
  gmax peek handleAuth --agent # compact TSV output
104
109
  gmax peek handleAuth -d 2 # 2-hop callers
105
110
  gmax peek handleAuth --root ~/project # peek in different project
111
+ gmax peek handleAuth --no-tests # suppress tests footer (default-on)
106
112
  ```
113
+ Agent output ends with `t: <test-file>:line\t<test-symbol>\t<hop-label>` rows where hop-label is `direct`, `N-hop`, or `via-import`.
107
114
 
108
115
  ### Skeleton — `gmax skeleton <target>`
109
116
  ```
@@ -124,6 +131,7 @@ gmax project --root ~/other/project # different project
124
131
  gmax related src/lib/index/syncer.ts # dependencies + dependents
125
132
  gmax related src/lib/index/syncer.ts --root ~/project
126
133
  ```
134
+ When both directions are empty, falls back to files mentioning the input file's basename. Generic basenames (`index`, `main`, `lib`, etc.) skip the fallback.
127
135
 
128
136
  ### Commit history — `gmax log <path-or-symbol>`
129
137
  ```
@@ -148,11 +156,12 @@ gmax symbols --agent # compact: symbol\tpath:line\tcount
148
156
 
149
157
  ### Test — `gmax test <symbol|file>`
150
158
  ```
151
- gmax test handleAuth # tests calling handleAuth
159
+ gmax test handleAuth # tests calling handleAuth (call-graph + import-fallback)
152
160
  gmax test src/lib/auth.ts # tests for symbols in this file
153
161
  gmax test handleAuth -d 2 # 2-hop: tests calling callers too
154
162
  gmax test handleAuth --agent # compact output
155
163
  ```
164
+ Hop labels: `direct` (calls the symbol), `N-hop` (calls a transitive caller), `via-import` (test references the symbol via import or mock when no call-graph match exists).
156
165
 
157
166
  ### Impact — `gmax impact <symbol|file>`
158
167
  ```