grepmax 0.17.23 → 0.17.24

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.
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+ /**
3
+ * Navigation-precision fixture — the measurement gate for the type-position
4
+ * reference cluster (chunker type-position edges, Phase 10 graph-distance,
5
+ * `gmax dead` precision). Both standing plans defer that cluster behind one
6
+ * unmet condition: a `trace --inbound` / `dead` truth set that EXISTS and
7
+ * MOVES. This is that instrument.
8
+ *
9
+ * WHAT IT MEASURES. The call graph is built from tree-sitter call-expression
10
+ * captures, so `referenced_symbols` holds call/construct sites (`foo()`,
11
+ * `new Foo()`) but NOT type-position uses (`: Foo`, `<Foo>`, `extends Foo`,
12
+ * `x as Foo`). `gmax trace --inbound Foo` and `gmax dead Foo` both read that
13
+ * graph, so a type used purely in annotations looks like it has no callers —
14
+ * trace under-reports, dead false-positives. This harness quantifies the gap by
15
+ * contrast: call-position recall (the baseline the graph achieves on the shapes
16
+ * it does capture) vs type-position recall (≈0 today).
17
+ *
18
+ * IT IS A GAUGE, NOT A PASS/FAIL GATE. Call-position recall is already <100%
19
+ * (chunk rollup, uncaptured shapes) — that is the status quo, not a regression,
20
+ * so the run always exits 0. The load-bearing number is type-position recall:
21
+ * low today = standing evidence to build chunker type-position edges; re-run
22
+ * after building them to confirm it moved. (For a true regression gate on the
23
+ * call graph, see the caller-count guard in eval-graph-sanity.ts.)
24
+ *
25
+ * HOW IT STAYS HONEST (no rot). Ground truth is derived live: for each symbol,
26
+ * `git grep` every reference and classify each file as a call-position caller or
27
+ * a type-position-only caller; `getCallers` is the measured set. The only
28
+ * hand-annotation is each symbol's character (callable vs type-only). The
29
+ * harness excludes its own file from both grep and the graph so listing the
30
+ * fixture symbols here can't contaminate the measurement. Run after a reindex.
31
+ *
32
+ * Usage:
33
+ * npx tsx src/eval-graph-nav.ts # table output
34
+ * npx tsx src/eval-graph-nav.ts --json # machine-readable
35
+ */
36
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
37
+ if (k2 === undefined) k2 = k;
38
+ var desc = Object.getOwnPropertyDescriptor(m, k);
39
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
40
+ desc = { enumerable: true, get: function() { return m[k]; } };
41
+ }
42
+ Object.defineProperty(o, k2, desc);
43
+ }) : (function(o, m, k, k2) {
44
+ if (k2 === undefined) k2 = k;
45
+ o[k2] = m[k];
46
+ }));
47
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
48
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
49
+ }) : function(o, v) {
50
+ o["default"] = v;
51
+ });
52
+ var __importStar = (this && this.__importStar) || (function () {
53
+ var ownKeys = function(o) {
54
+ ownKeys = Object.getOwnPropertyNames || function (o) {
55
+ var ar = [];
56
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
57
+ return ar;
58
+ };
59
+ return ownKeys(o);
60
+ };
61
+ return function (mod) {
62
+ if (mod && mod.__esModule) return mod;
63
+ var result = {};
64
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
65
+ __setModuleDefault(result, mod);
66
+ return result;
67
+ };
68
+ })();
69
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
70
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
71
+ return new (P || (P = Promise))(function (resolve, reject) {
72
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
73
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
74
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
75
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
76
+ });
77
+ };
78
+ var _a;
79
+ var _b;
80
+ Object.defineProperty(exports, "__esModule", { value: true });
81
+ (_a = (_b = process.env).GMAX_WORKER_COUNT) !== null && _a !== void 0 ? _a : (_b.GMAX_WORKER_COUNT = "1");
82
+ const node_child_process_1 = require("node:child_process");
83
+ const path = __importStar(require("node:path"));
84
+ const config_1 = require("./config");
85
+ const graph_builder_1 = require("./lib/graph/graph-builder");
86
+ const vector_db_1 = require("./lib/store/vector-db");
87
+ const exit_1 = require("./lib/utils/exit");
88
+ const filter_builder_1 = require("./lib/utils/filter-builder");
89
+ const GMAX_ROOT = path.resolve(__dirname, "..");
90
+ const SELF = "src/eval-graph-nav.ts"; // exclude from grep + graph (self-reference)
91
+ // Files that embed the fixture symbols as *string* data (template-literal
92
+ // source, `.toContain("…")` assertions) rather than real type/call references.
93
+ // grep can't tell those apart from genuine uses, so they would manufacture
94
+ // phantom "expected callers" that no correct graph ever surfaces. Excluded from
95
+ // both the grep truth and the getCallers set so the exclusion stays symmetric.
96
+ const FIXTURE_FILES = new Set([SELF, "tests/graph-edges.type-position.test.ts"]);
97
+ const rel = (p) => p.replace(`${GMAX_ROOT}/`, "");
98
+ const INBOUND_SYMBOLS = [
99
+ // ── Baselines: called/constructed, so the graph captures them. ──────────────
100
+ {
101
+ symbol: "getWorkerPool",
102
+ character: "callable",
103
+ note: "plain function call sites",
104
+ },
105
+ {
106
+ symbol: "GraphBuilder",
107
+ character: "callable",
108
+ note: "class via `new GraphBuilder(...)`",
109
+ },
110
+ {
111
+ symbol: "withQueryTimeout",
112
+ character: "callable",
113
+ note: "wrapper function call sites",
114
+ },
115
+ {
116
+ symbol: "isFileCached",
117
+ character: "callable",
118
+ note: "predicate function call sites",
119
+ },
120
+ // ── The gap: interfaces / type aliases used only in `: T` / `<T>` position. ──
121
+ {
122
+ symbol: "SearchResponse",
123
+ character: "type-only",
124
+ note: "return/param annotation across search + eval",
125
+ },
126
+ {
127
+ symbol: "VectorRecord",
128
+ character: "type-only",
129
+ note: "row interface annotated across index/store/worker",
130
+ },
131
+ {
132
+ symbol: "GraphNode",
133
+ character: "type-only",
134
+ note: "graph row interface in dead/formatter consumers",
135
+ },
136
+ {
137
+ symbol: "NeighborHit",
138
+ character: "type-only",
139
+ note: "traversal interface, type position only",
140
+ },
141
+ {
142
+ symbol: "CallerTree",
143
+ character: "type-only",
144
+ note: "recursive caller interface, type position only",
145
+ },
146
+ {
147
+ symbol: "EdgeDirection",
148
+ character: "type-only",
149
+ note: "string-literal type alias, pure type position",
150
+ },
151
+ // ── Python (mlx-embed-server/server.py): proves Shape 6 closes the gap in a
152
+ // grammar with no `type_identifier` node. ─────────────────────────────────
153
+ {
154
+ symbol: "EmbedResponse",
155
+ character: "callable",
156
+ note: "Python: Pydantic model via `EmbedResponse(...)` construct site",
157
+ },
158
+ {
159
+ symbol: "EmbedRequest",
160
+ character: "type-only",
161
+ note: "Python: Pydantic model in `request: EmbedRequest` annotation only",
162
+ },
163
+ ];
164
+ // Dead-precision truth is derived (LIVE iff grep finds any use beyond def/import).
165
+ const DEAD_SYMBOLS = [
166
+ "getWorkerPool", // LIVE baseline (call users) — dead must read LIVE
167
+ "GraphBuilder", // LIVE baseline (construct users)
168
+ "DeadResult", // non-exported, type-only users → today DEAD (false positive)
169
+ "EdgeDirection", // exported, type-only users → today PUBLIC_EXPORT (masked)
170
+ "ResolvedCaller", // exported, type-only users → today PUBLIC_EXPORT (masked)
171
+ "EmbedRequest", // Python: annotation-only Pydantic model — the canonical false-dead, now LIVE via Shape 6
172
+ ];
173
+ function classify(symbol, text) {
174
+ const s = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
175
+ if (/\bimport\b/.test(text))
176
+ return "import";
177
+ if (new RegExp(`\\b(interface|type|class|function|enum)\\s+${s}\\b`).test(text))
178
+ return "def";
179
+ if (new RegExp(`\\b(const|let|var)\\s+${s}\\b\\s*[:=]`).test(text))
180
+ return "def";
181
+ if (new RegExp(`(\\bnew\\s+${s}\\b|\\b${s}\\s*\\()`).test(text))
182
+ return "call";
183
+ if (new RegExp(`([:<,|&(]\\s*${s}\\b|\\bextends\\s+${s}\\b|\\bimplements\\s+${s}\\b|\\bas\\s+${s}\\b|\\b${s}\\s*[[<])`).test(text))
184
+ return "type";
185
+ return "other";
186
+ }
187
+ /**
188
+ * Live ground truth for `symbol`: files that reference it from a call position
189
+ * vs a type-only position. Definition-only / import-only files are not callers.
190
+ * A file with any call site counts as a call caller even if it also uses the
191
+ * symbol as a type (the graph should find it via the call). The harness's own
192
+ * file is excluded so the fixture list can't contaminate the truth.
193
+ */
194
+ function referenceTruth(symbol) {
195
+ let raw;
196
+ try {
197
+ raw = (0, node_child_process_1.execFileSync)("git", [
198
+ "grep",
199
+ "-nwF",
200
+ symbol,
201
+ "--",
202
+ "*.ts",
203
+ "*.py",
204
+ ...[...FIXTURE_FILES].map((f) => `:!${f}`),
205
+ ], { cwd: GMAX_ROOT, encoding: "utf-8" });
206
+ }
207
+ catch (_a) {
208
+ return { callFiles: new Set(), typeOnlyFiles: new Set() };
209
+ }
210
+ const callFiles = new Set();
211
+ const typeFiles = new Set();
212
+ for (const line of raw.split("\n")) {
213
+ if (!line)
214
+ continue;
215
+ const parts = line.split(":");
216
+ const file = parts[0];
217
+ const text = parts.slice(2).join(":");
218
+ const kind = classify(symbol, text);
219
+ if (kind === "call")
220
+ callFiles.add(file);
221
+ else if (kind === "type")
222
+ typeFiles.add(file);
223
+ }
224
+ const typeOnlyFiles = new Set([...typeFiles].filter((f) => !callFiles.has(f)));
225
+ return { callFiles, typeOnlyFiles };
226
+ }
227
+ /** getCallers file set, excluding the harness's own (possibly stale) chunks. */
228
+ function callerFiles(builder, symbol) {
229
+ return __awaiter(this, void 0, void 0, function* () {
230
+ const callers = yield builder.getCallers(symbol);
231
+ return new Set(callers.map((g) => rel(g.file)).filter((f) => !FIXTURE_FILES.has(f)));
232
+ });
233
+ }
234
+ function recall(found, expected) {
235
+ let hit = 0;
236
+ for (const f of expected)
237
+ if (found.has(f))
238
+ hit++;
239
+ return { hit, total: expected.size };
240
+ }
241
+ function runInbound(builder) {
242
+ return __awaiter(this, void 0, void 0, function* () {
243
+ const out = [];
244
+ for (const c of INBOUND_SYMBOLS) {
245
+ const { callFiles, typeOnlyFiles } = referenceTruth(c.symbol);
246
+ const found = yield callerFiles(builder, c.symbol);
247
+ out.push({
248
+ symbol: c.symbol,
249
+ character: c.character,
250
+ call: recall(found, callFiles),
251
+ type: recall(found, typeOnlyFiles),
252
+ missedType: [...typeOnlyFiles].filter((f) => !found.has(f)),
253
+ note: c.note,
254
+ });
255
+ }
256
+ return out;
257
+ });
258
+ }
259
+ function runDead(db, builder) {
260
+ return __awaiter(this, void 0, void 0, function* () {
261
+ const table = yield db.ensureTable();
262
+ const out = [];
263
+ for (const symbol of DEAD_SYMBOLS) {
264
+ const { callFiles, typeOnlyFiles } = referenceTruth(symbol);
265
+ const trueLive = callFiles.size + typeOnlyFiles.size > 0;
266
+ const defRows = yield table
267
+ .query()
268
+ .select(["is_exported"])
269
+ .where(`array_contains(defined_symbols, '${(0, filter_builder_1.escapeSqlString)(symbol)}')`)
270
+ .limit(1)
271
+ .toArray();
272
+ const found = yield callerFiles(builder, symbol);
273
+ let status;
274
+ if (defRows.length === 0)
275
+ status = "NO_DEF";
276
+ else if (found.size > 0)
277
+ status = "LIVE";
278
+ else
279
+ status = defRows[0].is_exported ? "PUBLIC_EXPORT" : "DEAD";
280
+ let verdict;
281
+ if (trueLive && status === "LIVE")
282
+ verdict = "correct";
283
+ else if (!trueLive && status === "DEAD")
284
+ verdict = "correct";
285
+ else if (trueLive && status === "DEAD")
286
+ verdict = "false-dead";
287
+ else if (trueLive && status === "PUBLIC_EXPORT")
288
+ verdict = "masked-by-export";
289
+ else
290
+ verdict = "over-live";
291
+ out.push({ symbol, trueLive, status, verdict });
292
+ }
293
+ return out;
294
+ });
295
+ }
296
+ function pool(rows) {
297
+ const hit = rows.reduce((a, r) => a + r.hit, 0);
298
+ const total = rows.reduce((a, r) => a + r.total, 0);
299
+ return { hit, total, pct: total === 0 ? 0 : (100 * hit) / total };
300
+ }
301
+ function main() {
302
+ return __awaiter(this, void 0, void 0, function* () {
303
+ const jsonMode = process.argv.includes("--json") || process.env.GMAX_EVAL_JSON === "1";
304
+ const db = new vector_db_1.VectorDB(config_1.PATHS.lancedbDir);
305
+ const builder = new graph_builder_1.GraphBuilder(db, GMAX_ROOT);
306
+ const inbound = yield runInbound(builder);
307
+ const dead = yield runDead(db, builder);
308
+ yield db.close();
309
+ const callBaseline = pool(inbound.filter((r) => r.character === "callable").map((r) => r.call));
310
+ const typeGap = pool(inbound.filter((r) => r.character === "type-only").map((r) => r.type));
311
+ const falseDead = dead.filter((r) => r.verdict === "false-dead").length;
312
+ const maskedByExport = dead.filter((r) => r.verdict === "masked-by-export").length;
313
+ const summary = {
314
+ callPositionRecall: callBaseline,
315
+ typePositionRecall: typeGap,
316
+ deadFalsePositives: falseDead,
317
+ deadMaskedByExport: maskedByExport,
318
+ };
319
+ if (jsonMode) {
320
+ process.stdout.write(`${JSON.stringify({ summary, inbound, dead }, null, 2)}\n`);
321
+ yield (0, exit_1.gracefulExit)(0);
322
+ return;
323
+ }
324
+ const pct = (r) => r.total === 0
325
+ ? " n/a"
326
+ : `${Math.round((100 * r.hit) / r.total)}%`.padStart(4);
327
+ console.log(`Navigation-precision fixture (trace --inbound / dead)`);
328
+ console.log(`root: ${GMAX_ROOT}\n`);
329
+ console.log(`Inbound caller recall (getCallers vs grep truth, by reference position):`);
330
+ for (const r of inbound) {
331
+ const tag = r.character === "callable" ? "callable " : "type-only";
332
+ console.log(` ${r.symbol.padEnd(16)} [${tag}] ` +
333
+ `call ${pct(r.call)} (${String(r.call.hit).padStart(2)}/${String(r.call.total).padStart(2)}) ` +
334
+ `type ${pct(r.type)} (${String(r.type.hit).padStart(2)}/${String(r.type.total).padStart(2)})` +
335
+ (r.character === "type-only" && r.missedType.length
336
+ ? ` misses: ${r.missedType.slice(0, 3).join(", ")}${r.missedType.length > 3 ? ", …" : ""}`
337
+ : ""));
338
+ }
339
+ console.log(`\nDead-code precision (truth derived from grep):`);
340
+ for (const r of dead) {
341
+ const flag = r.verdict === "false-dead"
342
+ ? " ← FALSE POSITIVE (really LIVE)"
343
+ : r.verdict === "masked-by-export"
344
+ ? " ← type-only users hidden behind PUBLIC EXPORT"
345
+ : "";
346
+ console.log(` ${r.symbol.padEnd(16)} truth=${(r.trueLive ? "LIVE" : "DEAD").padEnd(4)} reported=${r.status.padEnd(13)}${flag}`);
347
+ }
348
+ console.log(`\nHeadline` +
349
+ `\n call-position recall (baseline): ${callBaseline.hit}/${callBaseline.total} (${Math.round(callBaseline.pct)}%)` +
350
+ `\n type-position recall (the gap): ${typeGap.hit}/${typeGap.total} (${Math.round(typeGap.pct)}%)` +
351
+ `\n dead false-positives: ${falseDead} · dead masked-by-export: ${maskedByExport}`);
352
+ const delta = Math.round(callBaseline.pct - typeGap.pct);
353
+ if (callBaseline.pct < 40) {
354
+ console.log(`\nVerdict: INCONCLUSIVE — call-position baseline is unexpectedly low (${Math.round(callBaseline.pct)}%); ` +
355
+ `the index may be stale. Reindex (gmax index) and re-run.`);
356
+ }
357
+ else if (typeGap.pct < 50) {
358
+ console.log(`\nVerdict: GAP OPEN (${delta} pts) — navigation recovers call sites at ${Math.round(callBaseline.pct)}% ` +
359
+ `but type-position references at only ${Math.round(typeGap.pct)}%. Standing evidence to build chunker ` +
360
+ `type-position edges; re-run after to confirm the gap closes.`);
361
+ }
362
+ else {
363
+ console.log(`\nVerdict: GAP CLOSING — type-position recall is up to ${Math.round(typeGap.pct)}% ` +
364
+ `(call baseline ${Math.round(callBaseline.pct)}%).`);
365
+ }
366
+ yield (0, exit_1.gracefulExit)(0);
367
+ });
368
+ }
369
+ if (require.main === module) {
370
+ main().catch((e) => {
371
+ console.error(e);
372
+ process.exit(1);
373
+ });
374
+ }
@@ -1,20 +1,28 @@
1
1
  "use strict";
2
2
  /**
3
- * Phase 0 sanity check for Bundle B G1' (graph-as-recall-recovery).
3
+ * Graph sanity checks.
4
4
  *
5
- * For each platform hard-miss case (BeyondError, ErrorCodes, resolveActor,
6
- * errorHandler), mirror the searcher's dense+FTS+RRF pipeline to produce
7
- * the post-fusion top-200 candidate pool, then count how many of those
8
- * 200 chunks have `referenced_symbols` containing the target symbol.
5
+ * Default mode caller-count guard (regression net for the chunker-v2 /
6
+ * call-site-dedup fix). Before that fix, `splitIfTooBig` copied a parent
7
+ * chunk's full `referenced_symbols` onto every sub-chunk, so `getCallers`
8
+ * multiplied chunk rows: a symbol with 3 real call sites reported 66 callers
9
+ * (22x). For a set of known gmax symbols this asserts `getCallers`' raw count
10
+ * stays within a small multiple of grep-truth (word-boundary line matches
11
+ * across tracked source). Exits nonzero if any symbol blows past the ceiling.
12
+ * Run after a reindex so the index matches the working tree.
9
13
  *
10
- * ≥1 hit per case = 1-hop outbound graph walk can recover the target
11
- * (the seed chunk's defined symbol points at the target via the call
12
- * graph) → worth building the recovery layer.
14
+ * npx tsx src/eval-graph-sanity.ts
13
15
  *
14
- * 0 hits across all 4 = the signal isn't in the graph at this seed
15
- * depth stop and report the negative result.
16
+ * Platform mode (--platform) the original Phase 0 probe for Bundle B G1'
17
+ * (graph-as-recall-recovery). For each platform hard-miss case (BeyondError,
18
+ * ErrorCodes, resolveActor, errorHandler), mirror the searcher's dense+FTS+RRF
19
+ * pipeline to the post-fusion top-200 pool, then count how many of those chunks
20
+ * carry the target in `referenced_symbols`. ≥1 hit = a 1-hop outbound graph
21
+ * walk can recover the target → worth building the recovery layer; 0 across all
22
+ * 4 = the signal isn't in the graph at this depth. Requires
23
+ * ~/Development/beyond/platform to be indexed.
16
24
  *
17
- * Run: `npx tsx src/eval-graph-sanity.ts`
25
+ * npx tsx src/eval-graph-sanity.ts --platform
18
26
  */
19
27
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
28
  if (k2 === undefined) k2 = k;
@@ -62,12 +70,15 @@ var _a, _b;
62
70
  var _c;
63
71
  Object.defineProperty(exports, "__esModule", { value: true });
64
72
  (_a = (_c = process.env).GMAX_WORKER_COUNT) !== null && _a !== void 0 ? _a : (_c.GMAX_WORKER_COUNT = "1");
73
+ const node_child_process_1 = require("node:child_process");
65
74
  const path = __importStar(require("node:path"));
66
75
  const config_1 = require("./config");
76
+ const callsites_1 = require("./lib/graph/callsites");
77
+ const graph_builder_1 = require("./lib/graph/graph-builder");
67
78
  const vector_db_1 = require("./lib/store/vector-db");
68
79
  const exit_1 = require("./lib/utils/exit");
69
- const pool_1 = require("./lib/workers/pool");
70
80
  const filter_builder_1 = require("./lib/utils/filter-builder");
81
+ const pool_1 = require("./lib/workers/pool");
71
82
  const PLATFORM_ROOT = path.join((_b = process.env.HOME) !== null && _b !== void 0 ? _b : "", "Development/beyond/platform");
72
83
  const HARD_MISS_TARGETS = [
73
84
  "BeyondError",
@@ -104,7 +115,9 @@ function probe(target) {
104
115
  const table = yield db.ensureTable();
105
116
  const pool = (0, pool_1.getWorkerPool)();
106
117
  const { dense } = yield pool.encodeQuery(target);
107
- const pathPrefix = PLATFORM_ROOT.endsWith("/") ? PLATFORM_ROOT : `${PLATFORM_ROOT}/`;
118
+ const pathPrefix = PLATFORM_ROOT.endsWith("/")
119
+ ? PLATFORM_ROOT
120
+ : `${PLATFORM_ROOT}/`;
108
121
  const where = `path LIKE '${(0, filter_builder_1.escapeSqlString)(pathPrefix)}%'`;
109
122
  const columns = [
110
123
  "id",
@@ -136,14 +149,12 @@ function probe(target) {
136
149
  const candidateScores = new Map();
137
150
  const docMap = new Map();
138
151
  vectorRows.forEach((doc, rank) => {
139
- const key = doc.id ||
140
- `${doc.path}:${doc.chunk_index}`;
152
+ const key = doc.id || `${doc.path}:${doc.chunk_index}`;
141
153
  docMap.set(key, doc);
142
154
  candidateScores.set(key, (candidateScores.get(key) || 0) + 1.0 / (RRF_K + rank + 1));
143
155
  });
144
156
  ftsRows.forEach((doc, rank) => {
145
- const key = doc.id ||
146
- `${doc.path}:${doc.chunk_index}`;
157
+ const key = doc.id || `${doc.path}:${doc.chunk_index}`;
147
158
  if (!docMap.has(key))
148
159
  docMap.set(key, doc);
149
160
  candidateScores.set(key, (candidateScores.get(key) || 0) + 1.0 / (RRF_K + rank + 1));
@@ -189,7 +200,7 @@ function probe(target) {
189
200
  };
190
201
  });
191
202
  }
192
- function main() {
203
+ function platformProbe() {
193
204
  return __awaiter(this, void 0, void 0, function* () {
194
205
  console.log(`Phase 0 sanity check — graph reachability on platform hard-miss cases`);
195
206
  console.log(`pathPrefix: ${PLATFORM_ROOT}`);
@@ -214,7 +225,115 @@ function main() {
214
225
  console.log(`\nVerdict: ${anyReachable ? "BUILD (≥1 case has graph signal in top-200)" : "ABORT (graph is empty at this depth — pick a different mechanism)"}`);
215
226
  console.log(`\nJSON:`);
216
227
  console.log(JSON.stringify(summary, null, 2));
217
- yield (0, exit_1.gracefulExit)(0);
228
+ });
229
+ }
230
+ // ---------------------------------------------------------------------------
231
+ // Caller-count guard (default mode)
232
+ // ---------------------------------------------------------------------------
233
+ const GMAX_ROOT = path.resolve(__dirname, "..");
234
+ /**
235
+ * Distinctive gmax symbols with a spread of real usage. All sit comfortably
236
+ * below getCallers' internal `.limit(100)`, so the cap can never silently mask
237
+ * an explosion — if the chunker regresses, the raw count blows past the
238
+ * ceiling rather than getting clamped to 100. Names are intentionally unusual
239
+ * (no bare words) so `git grep -w` truth isn't polluted by unrelated matches.
240
+ */
241
+ const GUARD_SYMBOLS = [
242
+ "resolveCallSites",
243
+ "findCallSiteSnippet",
244
+ "withQueryTimeout",
245
+ "isFileCached",
246
+ "getWorkerPool",
247
+ "isBuiltinCallee",
248
+ "findDependents",
249
+ "buildFileSubgraph",
250
+ ];
251
+ /**
252
+ * getCallers returns one chunk-row per referencing chunk. Healthy, that count
253
+ * is ≤ the number of source lines mentioning the symbol (several mentions in
254
+ * one chunk collapse to one row; def/import lines aren't call references). So
255
+ * the healthy ratio is ≤ ~1. A small multiple absorbs index-vs-worktree skew
256
+ * and chunk-boundary splits while still catching the 22x chunker-v2 blowup.
257
+ */
258
+ const CEILING_MULTIPLE = 3;
259
+ /** Mirror of getCallers' internal `.limit(100)` — at the cap the guard is blind. */
260
+ const CALLER_LIMIT = 100;
261
+ /**
262
+ * grep-truth: word-boundary line + file counts for `symbol` across tracked
263
+ * `.ts` (repo-wide, so experiments/ and scripts/ count too — they're indexed).
264
+ * `git grep` skips gitignored paths (dist/), matching the indexed corpus.
265
+ */
266
+ function grepTruth(symbol) {
267
+ let out;
268
+ try {
269
+ out = (0, node_child_process_1.execFileSync)("git", ["grep", "-Fwc", symbol, "--", "*.ts"], {
270
+ cwd: GMAX_ROOT,
271
+ encoding: "utf-8",
272
+ });
273
+ }
274
+ catch (_a) {
275
+ // git grep exits 1 when there are zero matches.
276
+ return { lines: 0, files: 0 };
277
+ }
278
+ let lines = 0;
279
+ let files = 0;
280
+ for (const row of out.split("\n")) {
281
+ const idx = row.lastIndexOf(":");
282
+ if (idx < 0)
283
+ continue;
284
+ const n = Number(row.slice(idx + 1));
285
+ if (Number.isFinite(n) && n > 0) {
286
+ lines += n;
287
+ files++;
288
+ }
289
+ }
290
+ return { lines, files };
291
+ }
292
+ function callerCountGuard() {
293
+ return __awaiter(this, void 0, void 0, function* () {
294
+ const db = new vector_db_1.VectorDB(config_1.PATHS.lancedbDir);
295
+ yield db.ensureTable();
296
+ const builder = new graph_builder_1.GraphBuilder(db, GMAX_ROOT);
297
+ const fileCache = new Map();
298
+ console.log(`Caller-count guard — getCallers vs grep-truth call sites`);
299
+ console.log(`root: ${GMAX_ROOT}`);
300
+ console.log(`ceiling = ${CEILING_MULTIPLE}x grep lines · getCallers cap = ${CALLER_LIMIT}`);
301
+ console.log(`(run after a reindex so the index matches the working tree)\n`);
302
+ let allOK = true;
303
+ for (const sym of GUARD_SYMBOLS) {
304
+ const { lines: grepLines, files: grepFiles } = grepTruth(sym);
305
+ const callers = yield builder.getCallers(sym);
306
+ const raw = callers.length;
307
+ const deduped = (0, callsites_1.resolveCallSites)(callers.map((c) => ({ symbol: c.symbol, file: c.file, line: c.line })), sym, fileCache).length;
308
+ const ceiling = CEILING_MULTIPLE * Math.max(1, grepLines);
309
+ const capped = raw >= CALLER_LIMIT;
310
+ const ratio = grepLines > 0 ? raw / grepLines : raw;
311
+ // capped is a failure too: the fixture grew too common, so the cap now
312
+ // hides explosions — swap it for a lower-usage symbol.
313
+ const ok = !capped && raw <= ceiling;
314
+ if (!ok)
315
+ allOK = false;
316
+ console.log(`${ok ? "✓" : "✗"} ${sym.padEnd(20)} ` +
317
+ `callers=${String(raw).padStart(3)} (dedup ${String(deduped).padStart(3)}) ` +
318
+ `grep=${String(grepLines).padStart(3)}L/${String(grepFiles).padStart(2)}f ` +
319
+ `ratio=${ratio.toFixed(2)} ceiling=${ceiling}` +
320
+ (capped ? " [CAPPED — fixture too common]" : ""));
321
+ }
322
+ console.log(`\nVerdict: ${allOK ? "PASS (no caller-count explosion)" : "FAIL (getCallers exceeds grep-truth ceiling — chunker symbol dedup regressed)"}`);
323
+ yield db.close();
324
+ return allOK;
325
+ });
326
+ }
327
+ function main() {
328
+ return __awaiter(this, void 0, void 0, function* () {
329
+ const platform = process.argv.includes("--platform");
330
+ if (platform) {
331
+ yield platformProbe();
332
+ yield (0, exit_1.gracefulExit)(0);
333
+ return;
334
+ }
335
+ const ok = yield callerCountGuard();
336
+ yield (0, exit_1.gracefulExit)(ok ? 0 : 1);
218
337
  });
219
338
  }
220
339
  if (require.main === module) {
@@ -17,7 +17,11 @@ class GraphBuilder {
17
17
  constructor(db, pathPrefix, excludePrefixes) {
18
18
  this.db = db;
19
19
  // Normalize to ensure trailing slash for LIKE queries
20
- this.pathPrefix = pathPrefix ? (pathPrefix.endsWith("/") ? pathPrefix : `${pathPrefix}/`) : undefined;
20
+ this.pathPrefix = pathPrefix
21
+ ? pathPrefix.endsWith("/")
22
+ ? pathPrefix
23
+ : `${pathPrefix}/`
24
+ : undefined;
21
25
  this.excludePrefixes = (excludePrefixes !== null && excludePrefixes !== void 0 ? excludePrefixes : []).map((p) => p.endsWith("/") ? p : `${p}/`);
22
26
  }
23
27
  scopeWhere(condition) {
@@ -37,14 +41,22 @@ class GraphBuilder {
37
41
  return __awaiter(this, void 0, void 0, function* () {
38
42
  const table = yield this.db.ensureTable();
39
43
  const escaped = (0, filter_builder_1.escapeSqlString)(symbol);
40
- // Find chunks where referenced_symbols contains the symbol
44
+ // Find chunks that reference the symbol from a call position
45
+ // (referenced_symbols) OR a type position (type_referenced_symbols). The
46
+ // two are stored separately so type edges never inflate the call-edge count
47
+ // used for ranking; navigation unions them.
41
48
  const rows = yield table
42
49
  .query()
43
50
  .select([
44
- "path", "start_line", "defined_symbols", "referenced_symbols",
45
- "role", "parent_symbol", "complexity",
51
+ "path",
52
+ "start_line",
53
+ "defined_symbols",
54
+ "referenced_symbols",
55
+ "role",
56
+ "parent_symbol",
57
+ "complexity",
46
58
  ])
47
- .where(this.scopeWhere(`array_contains(referenced_symbols, '${escaped}')`))
59
+ .where(this.scopeWhere(`(array_contains(referenced_symbols, '${escaped}') OR array_contains(type_referenced_symbols, '${escaped}'))`))
48
60
  .limit(100)
49
61
  .toArray();
50
62
  return rows.map((row) => this.mapRowToNode(row, symbol, "caller"));
@@ -82,8 +94,13 @@ class GraphBuilder {
82
94
  const centerRows = yield table
83
95
  .query()
84
96
  .select([
85
- "path", "start_line", "defined_symbols", "referenced_symbols",
86
- "role", "parent_symbol", "complexity",
97
+ "path",
98
+ "start_line",
99
+ "defined_symbols",
100
+ "referenced_symbols",
101
+ "role",
102
+ "parent_symbol",
103
+ "complexity",
87
104
  ])
88
105
  .where(this.scopeWhere(`array_contains(defined_symbols, '${escaped}')`))
89
106
  .limit(1)
@@ -165,7 +182,12 @@ class GraphBuilder {
165
182
  }
166
183
  const visited = new Set([symbol]);
167
184
  const callerTree = yield this.expandCallers(graph.callers, depth - 1, visited);
168
- return { center: graph.center, callerTree, callees: graph.callees, importers };
185
+ return {
186
+ center: graph.center,
187
+ callerTree,
188
+ callees: graph.callees,
189
+ importers,
190
+ };
169
191
  });
170
192
  }
171
193
  expandCallers(callers, remainingDepth, visited) {
@@ -22,8 +22,10 @@ const TEST_FILE_RE = /\.(test|spec)\.[cm]?[jt]sx?$/i;
22
22
  const NATIVE_TEST_DIR_RE = /(^|\/)\w+Tests?(\/|$)/;
23
23
  const NATIVE_TEST_FILE_RE = /Tests?\.(swift|kt|java)$/;
24
24
  function isTestPath(filePath) {
25
- return TEST_DIR_RE.test(filePath) || TEST_FILE_RE.test(filePath)
26
- || NATIVE_TEST_DIR_RE.test(filePath) || NATIVE_TEST_FILE_RE.test(filePath);
25
+ return (TEST_DIR_RE.test(filePath) ||
26
+ TEST_FILE_RE.test(filePath) ||
27
+ NATIVE_TEST_DIR_RE.test(filePath) ||
28
+ NATIVE_TEST_FILE_RE.test(filePath));
27
29
  }
28
30
  const arrow_1 = require("../utils/arrow");
29
31
  /**
@@ -214,7 +216,7 @@ function findDependents(symbols_1, vectorDb_1, projectRoot_1, excludePaths_1) {
214
216
  const rows = yield table
215
217
  .query()
216
218
  .select(["path"])
217
- .where(`array_contains(referenced_symbols, '${(0, filter_builder_1.escapeSqlString)(sym)}') AND ${pathScope}`)
219
+ .where(`(array_contains(referenced_symbols, '${(0, filter_builder_1.escapeSqlString)(sym)}') OR array_contains(type_referenced_symbols, '${(0, filter_builder_1.escapeSqlString)(sym)}')) AND ${pathScope}`)
218
220
  .limit(200)
219
221
  .toArray();
220
222
  for (const row of rows) {