grepmax 0.16.2 → 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.
@@ -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) {
package/dist/index.js CHANGED
@@ -52,6 +52,7 @@ const index_1 = require("./commands/index");
52
52
  const investigate_1 = require("./commands/investigate");
53
53
  const list_1 = require("./commands/list");
54
54
  const llm_1 = require("./commands/llm");
55
+ const log_1 = require("./commands/log");
55
56
  const mcp_1 = require("./commands/mcp");
56
57
  const peek_1 = require("./commands/peek");
57
58
  const project_1 = require("./commands/project");
@@ -115,6 +116,7 @@ commander_1.program.addCommand(extract_1.extract);
115
116
  commander_1.program.addCommand(peek_1.peek);
116
117
  commander_1.program.addCommand(project_1.project);
117
118
  commander_1.program.addCommand(related_1.related);
119
+ commander_1.program.addCommand(log_1.log);
118
120
  commander_1.program.addCommand(recent_1.recent);
119
121
  commander_1.program.addCommand(diff_1.diff);
120
122
  commander_1.program.addCommand(test_find_1.testFind);
@@ -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))
@@ -37,6 +37,7 @@ exports.isWorktree = isWorktree;
37
37
  exports.getGitCommonDir = getGitCommonDir;
38
38
  exports.getMainRepoRoot = getMainRepoRoot;
39
39
  exports.getChangedFiles = getChangedFiles;
40
+ exports.getCommitHistory = getCommitHistory;
40
41
  exports.getUntrackedFiles = getUntrackedFiles;
41
42
  const node_child_process_1 = require("node:child_process");
42
43
  const fs = __importStar(require("node:fs"));
@@ -115,6 +116,95 @@ function getChangedFiles(ref, cwd) {
115
116
  return [];
116
117
  }
117
118
  }
119
+ /**
120
+ * Get commit history for one or more paths. When paths.length > 1 (symbol
121
+ * fan-out), git natively dedupes commits across paths. --follow only works
122
+ * with a single path; auto-disabled otherwise.
123
+ */
124
+ function getCommitHistory(opts) {
125
+ var _a;
126
+ if (opts.paths.length === 0)
127
+ return [];
128
+ const args = [
129
+ "log",
130
+ "--pretty=format:%x1e%H%x1f%aN%x1f%aI%x1f%ar%x1f%s",
131
+ "--numstat",
132
+ ];
133
+ if (opts.follow && opts.paths.length === 1)
134
+ args.push("--follow");
135
+ if (opts.limit > 0)
136
+ args.push(`-n${opts.limit}`);
137
+ if (opts.since)
138
+ args.push(`--since=${opts.since}`);
139
+ if (opts.author)
140
+ args.push(`--author=${opts.author}`);
141
+ if (opts.from)
142
+ args.push(`${opts.from}..HEAD`);
143
+ args.push("--");
144
+ for (const p of opts.paths)
145
+ args.push(p);
146
+ const execOpts = {
147
+ cwd: (_a = opts.cwd) !== null && _a !== void 0 ? _a : process.cwd(),
148
+ encoding: "utf-8",
149
+ timeout: 10000,
150
+ maxBuffer: 16 * 1024 * 1024,
151
+ };
152
+ let output;
153
+ try {
154
+ output = (0, node_child_process_1.execFileSync)("git", args, execOpts);
155
+ }
156
+ catch (_b) {
157
+ return [];
158
+ }
159
+ const records = output.split("\x1e").filter((r) => r.length > 0);
160
+ const commits = [];
161
+ for (const record of records) {
162
+ const lines = record.split("\n");
163
+ if (lines.length === 0)
164
+ continue;
165
+ const headerFields = lines[0].split("\x1f");
166
+ if (headerFields.length < 5)
167
+ continue;
168
+ const [hash, author, isoDate, relDate, subject] = headerFields;
169
+ const numstatLines = [];
170
+ let insertions = 0;
171
+ let deletions = 0;
172
+ for (let i = 1; i < lines.length; i++) {
173
+ const line = lines[i].trim();
174
+ if (!line)
175
+ continue;
176
+ const parts = line.split("\t");
177
+ if (parts.length < 3)
178
+ continue;
179
+ // Binary diffs report '-' for added/removed; treat as 0.
180
+ const added = parts[0] === "-" ? 0 : Number.parseInt(parts[0], 10);
181
+ const removed = parts[1] === "-" ? 0 : Number.parseInt(parts[1], 10);
182
+ const path = parts.slice(2).join("\t");
183
+ if (Number.isFinite(added))
184
+ insertions += added;
185
+ if (Number.isFinite(removed))
186
+ deletions += removed;
187
+ numstatLines.push({
188
+ added: Number.isFinite(added) ? added : 0,
189
+ removed: Number.isFinite(removed) ? removed : 0,
190
+ path,
191
+ });
192
+ }
193
+ commits.push({
194
+ hash,
195
+ shortHash: hash.slice(0, 7),
196
+ author,
197
+ isoDate,
198
+ relDate,
199
+ subject,
200
+ filesChanged: numstatLines.length,
201
+ insertions,
202
+ deletions,
203
+ numstatLines,
204
+ });
205
+ }
206
+ return commits;
207
+ }
118
208
  /**
119
209
  * Get untracked files (not yet added to git).
120
210
  * Returns absolute paths.