gitnexus 1.6.8-rc.6 → 1.6.8-rc.7

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.
@@ -78,6 +78,12 @@ const KEY_SPECS = {
78
78
  embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
79
79
  embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
80
80
  embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
81
+ // #1589/#1852 residual — extra fetch-wrapper function names to treat as HTTP
82
+ // consumers. The auto-detector only flags functions that call the bare global
83
+ // `fetch()`; a wrapper built on axios / a custom client, or named outside the
84
+ // built-in convention set, is otherwise invisible to route_map consumers.
85
+ // Listing it here adds it to the cross-file consumer scan.
86
+ fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
81
87
  };
82
88
  /** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
83
89
  const NESTED_KEY = 'analyze';
@@ -195,6 +201,39 @@ const normalizeValue = (kind, value, key) => {
195
201
  }
196
202
  return trimmed;
197
203
  }
204
+ case 'string-array': {
205
+ // Generic shared validator — `source` already names the config key, so
206
+ // messages here stay key-agnostic (no fetch-wrapper coupling in the
207
+ // shared normalizer; #1589/#1852 review F7).
208
+ if (!Array.isArray(value)) {
209
+ throw new GitNexusRcError(`${source} must be an array of strings.`);
210
+ }
211
+ const names = [];
212
+ for (const item of value) {
213
+ if (typeof item !== 'string') {
214
+ throw new GitNexusRcError(`${source} entries must all be strings.`);
215
+ }
216
+ const trimmed = item.trim();
217
+ if (!trimmed) {
218
+ throw new GitNexusRcError(`${source} entries must not be empty.`);
219
+ }
220
+ assertNoHiddenChars(trimmed, source);
221
+ // Values may be interpolated into a RegExp downstream. Restrict to
222
+ // identifier / member-access shapes so a config value can never smuggle
223
+ // regex metacharacters into a consumer.
224
+ if (!/^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(trimmed)) {
225
+ throw new GitNexusRcError(`${source} entry "${trimmed}" must be an identifier or member name ` +
226
+ `(letters, digits, _, $, . — e.g. "client.get").`);
227
+ }
228
+ names.push(trimmed);
229
+ }
230
+ if (names.length === 0) {
231
+ throw new GitNexusRcError(`${source} must list at least one string.`);
232
+ }
233
+ // De-duplicate and cap to a sane bound so a pathological config cannot
234
+ // blow up the consumer scan's alternation.
235
+ return Array.from(new Set(names)).slice(0, 100);
236
+ }
198
237
  case 'numeric-string': {
199
238
  // Mirror Commander's contract: these options reach the existing CLI
200
239
  // validation as strings. Accept a JSON number or a string; normalize to a
@@ -101,6 +101,14 @@ export interface AnalyzeOptions {
101
101
  embeddingBatchSize?: string;
102
102
  embeddingSubBatchSize?: string;
103
103
  embeddingDevice?: string;
104
+ /**
105
+ * Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
106
+ * residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
107
+ * the routes phase, where the cross-file consumer scan unions them with the
108
+ * auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
109
+ * outside the built-in convention still produces `route_map` consumers.
110
+ */
111
+ fetchWrappers?: string[];
104
112
  }
105
113
  /**
106
114
  * Whether the post-index skill step should run.
@@ -864,6 +864,9 @@ const analyzeCommandImpl = async (inputPath, cliOptions) => {
864
864
  // GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
865
865
  // env / auto-formula fallback inside the pipeline.
866
866
  workerPoolSize,
867
+ // Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
868
+ // forwarded to the routes phase consumer scan.
869
+ fetchWrappers: options.fetchWrappers,
867
870
  }, {
868
871
  onProgress: (_phase, percent, message) => {
869
872
  updateBar(percent, message);
@@ -155,7 +155,43 @@ export function formatImpactResult(result) {
155
155
  const direction = result.direction;
156
156
  const byDepth = result.byDepth || {};
157
157
  const total = result.impactedCount || 0;
158
+ // #2129 — an ambiguous bare name must not print the "isolated / safe to
159
+ // refactor" headline. Surface the per-candidate blast radius + the maximum,
160
+ // mirroring formatContextResult, so the real impact under whichever symbol the
161
+ // caller meant is visible on the text surface, not just in the JSON.
162
+ if (result.status === 'ambiguous') {
163
+ // #2129 review F11 — report the FULL match count (`totalCandidates`), not the
164
+ // truncated `candidates[]` length; note when the candidate list is capped.
165
+ const shown = result.candidates?.length ?? 0;
166
+ const total = result.totalCandidates ?? shown;
167
+ const countPhrase = total > shown ? `${total} symbols (showing ${shown})` : `${total} symbols`;
168
+ const lines = [
169
+ `${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
170
+ `Max blast radius ${result.maxImpactedCount ?? 0} (${result.maxRisk ?? 'UNKNOWN'} risk). ` +
171
+ `Disambiguate with --uid for one authoritative result:`,
172
+ ];
173
+ for (const c of result.candidates || []) {
174
+ lines.push(` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'} ` +
175
+ `[${c.impactedCount ?? 0} ${direction}, risk ${c.risk ?? 'UNKNOWN'}] (uid: ${c.uid})`);
176
+ }
177
+ // #2129 review F1 — a failed per-candidate probe makes the max a lower bound.
178
+ if (result.partialProbe) {
179
+ lines.push(' ⚠️ One or more candidate probes failed — max blast radius / risk are lower bounds.');
180
+ }
181
+ return lines.join('\n');
182
+ }
158
183
  if (total === 0) {
184
+ // #1858 — "isolated" is a confident claim. If an interface / indirection
185
+ // boundary is on the path, the true count is a lower bound, not zero;
186
+ // callers binding via DI / dynamic dispatch were not traced. Say so instead.
187
+ if (result.epistemic === 'lower-bound') {
188
+ const lines = [
189
+ `${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
190
+ ];
191
+ for (const b of result.boundaries || [])
192
+ lines.push(` • ${b}`);
193
+ return lines.join('\n');
194
+ }
159
195
  return `${target?.name || '?'}: No ${direction} dependencies found. This symbol appears isolated.`;
160
196
  }
161
197
  const lines = [];
@@ -164,6 +200,13 @@ export function formatImpactResult(result) {
164
200
  if (result.partial) {
165
201
  lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
166
202
  }
203
+ // #1858 — an interface / indirection boundary on the path makes this a lower
204
+ // bound; surface it so the count is not read as exhaustive.
205
+ if (result.epistemic === 'lower-bound') {
206
+ lines.push('⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):');
207
+ for (const b of result.boundaries || [])
208
+ lines.push(` • ${b}`);
209
+ }
167
210
  lines.push('');
168
211
  const depthLabels = {
169
212
  1: 'WILL BREAK (direct)',
@@ -297,22 +297,72 @@ export const routesPhase = {
297
297
  // scan JS/TS consumer files for calls to those wrapper functions with
298
298
  // URL-like string arguments and add them to allFetchCalls so
299
299
  // processNextjsFetchRoutes can create FETCHES edges.
300
- if (allFetchWrapperDefs && allFetchWrapperDefs.length > 0 && routeRegistry.size > 0) {
301
- const wrapperNames = new Set(allFetchWrapperDefs.map((d) => d.functionName));
300
+ // Wrapper names come from two sources: functions the parse phase
301
+ // auto-detected as calling the bare global `fetch()`, plus any names the
302
+ // user declared in `.gitnexusrc` `fetchWrappers` (#1589/#1852 residual).
303
+ // Config names let an axios/custom-client wrapper — or one named outside the
304
+ // built-in convention — still produce route_map consumers; without them it
305
+ // silently falls back to `consumers: []`. Configured names alone are enough
306
+ // to run the scan even when nothing was auto-detected.
307
+ // Configured names are already validated/trimmed/de-duped/capped by
308
+ // analyze-config.ts — trusted as-is (#1589/#1852 review F9, dropped the
309
+ // redundant re-trim/re-filter). The single filter below guards only the
310
+ // auto-detected `functionName`s, which have no shape guarantee.
311
+ const configuredWrappers = ctx.options?.fetchWrappers ?? [];
312
+ const wrapperNames = new Set([...(allFetchWrapperDefs ?? []).map((d) => d.functionName), ...configuredWrappers].filter((n) => typeof n === 'string' && n.trim().length > 0));
313
+ if (wrapperNames.size > 0 && routeRegistry.size > 0) {
302
314
  const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p));
303
- if (jsFiles.length > 0 && wrapperNames.size > 0) {
304
- const jsContents = await readFileContents(ctx.repoPath, jsFiles);
305
- for (const [filePath, content] of jsContents) {
306
- for (const name of wrapperNames) {
307
- const regex = new RegExp(`\\b${escapeRegex(name)}\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`, 'g');
308
- let match;
309
- while ((match = regex.exec(content)) !== null) {
310
- allFetchCalls.push({
311
- filePath,
312
- fetchURL: match[1],
313
- lineNumber: content.substring(0, match.index).split('\n').length,
314
- });
315
+ if (jsFiles.length > 0) {
316
+ // Reuse contents already read for handler extraction; only read the
317
+ // remainder (mirrors the Expo block above). Avoids a second full read of
318
+ // files we already have in memory.
319
+ const unreadJsFiles = jsFiles.filter((p) => !handlerContents?.has(p));
320
+ const extraContents = unreadJsFiles.length > 0
321
+ ? await readFileContents(ctx.repoPath, unreadJsFiles)
322
+ : new Map();
323
+ // One alternation regex over every wrapper name per file — O(files), not
324
+ // O(files × wrappers) (#1852 review F3). Names are escaped and grouped
325
+ // non-capturing so capture group 1 stays the URL. The left boundary is a
326
+ // negative lookbehind, not `\b`: a bare configured name like `get` must
327
+ // match the free call `get('/x')` but NOT a member access `client.get(`
328
+ // (a `.get(` on an unrelated object), and `apiFetch` must not match
329
+ // `myApiFetch`. Member-style wrappers are configured with the dot
330
+ // (`client.get`), where the `.` is part of the pattern. The `u` flag +
331
+ // Unicode property classes make the boundary cover non-ASCII identifier
332
+ // characters too — ASCII `\w` would let `caféget('/x')` match `get`
333
+ // (#1852 review F10).
334
+ const alternation = [...wrapperNames].map(escapeRegex).join('|');
335
+ const wrapperCallRegex = new RegExp(`(?<![.\\p{L}\\p{N}_$])(?:${alternation})\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`, 'gu');
336
+ const scanContent = (filePath, content) => {
337
+ wrapperCallRegex.lastIndex = 0;
338
+ // 1-based line number via a running newline counter: matches arrive in
339
+ // ascending index, so accumulate newlines incrementally instead of
340
+ // re-allocating `content.substring(0, match.index).split('\n')` on
341
+ // every match (#1852 review F12). Output is identical.
342
+ let line = 1;
343
+ let scanned = 0;
344
+ let match;
345
+ while ((match = wrapperCallRegex.exec(content)) !== null) {
346
+ for (; scanned < match.index; scanned++) {
347
+ if (content.charCodeAt(scanned) === 10 /* '\n' */)
348
+ line++;
315
349
  }
350
+ allFetchCalls.push({
351
+ filePath,
352
+ fetchURL: match[1],
353
+ lineNumber: line,
354
+ });
355
+ }
356
+ };
357
+ for (const [filePath, content] of extraContents)
358
+ scanContent(filePath, content);
359
+ // Also scan already-read JS/TS handler files (a handler can itself
360
+ // consume another route through a wrapper).
361
+ if (handlerContents) {
362
+ for (const p of jsFiles) {
363
+ const cached = handlerContents.get(p);
364
+ if (cached !== undefined)
365
+ scanContent(p, cached);
316
366
  }
317
367
  }
318
368
  }
@@ -104,6 +104,15 @@ export interface PipelineOptions {
104
104
  * `process.env` state across invocations. When undefined, the env var decides.
105
105
  */
106
106
  keepLocalValueSymbols?: boolean;
107
+ /**
108
+ * Extra fetch-wrapper function names to treat as HTTP consumers, threaded
109
+ * from `.gitnexusrc` `fetchWrappers` via `AnalyzeOptions` (#1589/#1852
110
+ * residual). The routes phase unions these with the auto-detected `fetch()`
111
+ * wrappers when scanning for `route_map` consumers, so a wrapper named outside
112
+ * the built-in convention (or built on axios / a custom client) is still
113
+ * traced. Empty/undefined leaves behavior unchanged.
114
+ */
115
+ fetchWrappers?: readonly string[];
107
116
  }
108
117
  /**
109
118
  * All pipeline phases with their dependency relationships.
@@ -88,6 +88,13 @@ export interface AnalyzeOptions {
88
88
  * removed); `undefined` defers to the env / auto-formula fallback.
89
89
  */
90
90
  workerPoolSize?: number;
91
+ /**
92
+ * Extra fetch-wrapper function names to treat as HTTP consumers, forwarded to
93
+ * `PipelineOptions.fetchWrappers` (#1589/#1852 residual). Sourced from the CLI
94
+ * `.gitnexusrc` `fetchWrappers` list. `undefined`/empty leaves the route
95
+ * consumer scan unchanged.
96
+ */
97
+ fetchWrappers?: string[];
91
98
  }
92
99
  export interface AnalyzeResult {
93
100
  repoName: string;
@@ -416,6 +416,7 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
416
416
  }, {
417
417
  parseCache,
418
418
  workerPoolSize: options.workerPoolSize,
419
+ fetchWrappers: options.fetchWrappers,
419
420
  });
420
421
  // ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
421
422
  progress('lbug', 60, 'Loading into LadybugDB...');
@@ -16,6 +16,15 @@ export declare function isTestFilePath(filePath: string): boolean;
16
16
  export declare const VALID_NODE_LABELS: Set<string>;
17
17
  /** Valid relation types for impact analysis filtering */
18
18
  export declare const VALID_RELATION_TYPES: Set<string>;
19
+ /**
20
+ * Relation types the #1858 epistemic-boundary probe keys on. Kept as
21
+ * module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
22
+ * binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
23
+ * The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
24
+ * "statically verifiable, but the concrete binding past it is not".
25
+ */
26
+ export declare const EPISTEMIC_HERITAGE_RELATION_TYPES: readonly string[];
27
+ export declare const EPISTEMIC_CONSUMER_RELATION_TYPES: readonly string[];
19
28
  /**
20
29
  * Per-relation-type confidence floor for impact analysis.
21
30
  *
@@ -431,6 +440,28 @@ export declare class LocalBackend {
431
440
  private rename;
432
441
  private impact;
433
442
  private _impactImpl;
443
+ /**
444
+ * #1858 — epistemic lower-bound detection.
445
+ *
446
+ * impact()/context() traverse only edges materialized in the graph. When the
447
+ * queried symbol sits on an interface / abstract boundary, callers that bind
448
+ * to the interface via DI, a container, or dynamic dispatch — rather than
449
+ * naming the concrete symbol — are not traced. The reported count is then a
450
+ * lower bound, not an exact figure. Instead of returning a confident count
451
+ * that silently omits those callers, annotate the result with
452
+ * `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
453
+ * resolved leaf with no indirection stays `epistemic: 'exact'`.
454
+ *
455
+ * Aligns with the numeric confidence model rather than the long-deleted
456
+ * TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
457
+ * (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
458
+ * `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
459
+ * concrete binding past it is not".
460
+ *
461
+ * Never throws: on query error it returns 'exact', so it can only add signal,
462
+ * never suppress a result.
463
+ */
464
+ private computeEpistemicBoundary;
434
465
  /**
435
466
  * Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
436
467
  */
@@ -96,12 +96,32 @@ export const VALID_RELATION_TYPES = new Set([
96
96
  'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
97
97
  'METHOD_IMPLEMENTS',
98
98
  'ACCESSES',
99
+ // Emitted by emit-references.ts / scope-resolution/graph-bridge/edges.ts and
100
+ // already part of the default impact relTypes + context() incoming queries.
101
+ // It was missing from this allowlist, so `impact({relationTypes:['USES']})`
102
+ // silently filtered to [] and fell back to the full default traversal
103
+ // (#2129/#1858 review F5). No IMPACT_RELATION_CONFIDENCE floor → 0.5 fallback,
104
+ // matching the FETCHES / WRAPS / HANDLES_ROUTE precedent below.
105
+ 'USES',
99
106
  'HANDLES_ROUTE',
100
107
  'FETCHES',
101
108
  'HANDLES_TOOL',
102
109
  'ENTRY_POINT_OF',
103
110
  'WRAPS',
104
111
  ]);
112
+ /**
113
+ * Relation types the #1858 epistemic-boundary probe keys on. Kept as
114
+ * module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
115
+ * binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
116
+ * The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
117
+ * "statically verifiable, but the concrete binding past it is not".
118
+ */
119
+ export const EPISTEMIC_HERITAGE_RELATION_TYPES = [
120
+ 'IMPLEMENTS',
121
+ 'METHOD_IMPLEMENTS',
122
+ 'EXTENDS',
123
+ ];
124
+ export const EPISTEMIC_CONSUMER_RELATION_TYPES = ['CALLS', 'USES', 'ACCESSES'];
105
125
  /**
106
126
  * Per-relation-type confidence floor for impact analysis.
107
127
  *
@@ -2100,6 +2120,23 @@ export class LocalBackend {
2100
2120
  // Method/Function/Constructor enrichment: fetch method-specific properties
2101
2121
  const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
2102
2122
  const isMethodLike = symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
2123
+ // #1858 review F2 — start the epistemic boundary probe here (right after
2124
+ // `symKind` is known) so it runs CONCURRENTLY with the methodMetadata fetch
2125
+ // below, mirroring how _runImpactBFS overlaps it with the BFS. It is awaited
2126
+ // at result assembly. (It cannot start earlier — `symKind` is only computed
2127
+ // on this line, after the incoming/outgoing round-trips.)
2128
+ //
2129
+ // #1858 review F3 — pass an interface-preserving type, NOT `symKind`.
2130
+ // `symKind` collapses a single-resolved Interface to 'Class' (resolvedLabel
2131
+ // is '' on the single-candidate path), which would skip computeEpistemicBoundary's
2132
+ // `symType === 'Interface'` self-boundary branch and under-report a leaf
2133
+ // interface as 'exact'. `enrichCandidateLabels` runs BEFORE the single-candidate
2134
+ // early return and patches `sym.type` from '' to 'Interface' (LadybugDB returns
2135
+ // '' for labels()[0] on Interface/Class), so `sym.type` is the reliable signal
2136
+ // here — mirroring impact()'s `resolvedLabel || symbol.type` derivation. Do not
2137
+ // "fix" enrichment ordering; F3 depends on enrichment-before-early-return.
2138
+ const epistemicSymType = (resolvedLabel || sym.type || symKind || '');
2139
+ const epistemicPromise = this.computeEpistemicBoundary(repo, symId, epistemicSymType, (sym.name || sym[1]));
2103
2140
  let methodMetadata;
2104
2141
  if (isMethodLike) {
2105
2142
  try {
@@ -2130,6 +2167,12 @@ export class LocalBackend {
2130
2167
  /* method metadata unavailable — omit silently */
2131
2168
  }
2132
2169
  }
2170
+ // #1858 — same epistemic boundary signal as impact(): when this symbol sits
2171
+ // behind an interface / indirection boundary, callers binding via DI or
2172
+ // dynamic dispatch are not reflected in `incoming`, so the view is a lower
2173
+ // bound. Additive; never suppresses a field. Resolved from the probe started
2174
+ // above (concurrent with methodMetadata).
2175
+ const epistemic = await epistemicPromise;
2133
2176
  return {
2134
2177
  status: 'found',
2135
2178
  symbol: {
@@ -2142,6 +2185,7 @@ export class LocalBackend {
2142
2185
  ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
2143
2186
  ...(methodMetadata ? { methodMetadata } : {}),
2144
2187
  },
2188
+ ...epistemic,
2145
2189
  incoming: categorize(incomingRows),
2146
2190
  outgoing: categorize(outgoingRows),
2147
2191
  ...(typedPropertyRows.length > 0
@@ -2675,21 +2719,103 @@ export class LocalBackend {
2675
2719
  };
2676
2720
  }
2677
2721
  if (outcome.kind === 'ambiguous') {
2678
- return {
2679
- status: 'ambiguous',
2680
- message: `Found ${outcome.candidates.length} symbols matching '${target}'. Use target_uid, file_path, or kind to disambiguate.`,
2681
- target: { name: target },
2682
- direction,
2683
- impactedCount: 0,
2684
- risk: 'UNKNOWN',
2685
- candidates: outcome.candidates.map((c) => ({
2722
+ // #2129 — a bare name that collides with several symbols must NOT report a
2723
+ // bare `impactedCount: 0`. The real blast radius lives under whichever
2724
+ // candidate the caller meant; a flat zero here is precisely the silent
2725
+ // under-report the "run impact before editing" workflow exists to prevent
2726
+ // (the dropped caller calls a *different* same-name node, so it never shows
2727
+ // up against the one the resolver happened to pick). Run a bounded,
2728
+ // summary-only BFS per candidate so each one's true count + risk is
2729
+ // visible, and surface the maximum at the top level so the headline can
2730
+ // never read as "safe to refactor". Candidates arrive sorted by score.
2731
+ const AMBIGUOUS_MAX_CANDIDATES = 6;
2732
+ const probed = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
2733
+ // `partialProbe` is intentionally a SECOND incompleteness flag, distinct
2734
+ // from the traversal-interrupted `partial` flag used elsewhere: it means
2735
+ // one or more per-candidate probes threw, so maxRisk / maxImpactedCount
2736
+ // are lower bounds over the probes that succeeded (a failed candidate must
2737
+ // not be masked by a benign sibling success).
2738
+ let probeFailed = false;
2739
+ const candidateSummaries = await Promise.all(probed.map(async (c) => {
2740
+ const cType = c.type || '';
2741
+ const cRelTypes = (cType === 'Class' || cType === 'Interface') &&
2742
+ !hasExplicitRelationTypes &&
2743
+ !relationTypes.includes('ACCESSES')
2744
+ ? [...relationTypes, 'ACCESSES']
2745
+ : relationTypes;
2746
+ // #1858/#2129 review F8 — name the shape the probe summary is read
2747
+ // through (`_runImpactBFS` returns `Promise<any>`, so this is the
2748
+ // narrowing cast) so a future rename of those fields fails tsc instead
2749
+ // of silently zeroing candidate counts.
2750
+ let summary = null;
2751
+ try {
2752
+ summary = await this._runImpactBFS(repo, { id: c.id, name: c.name, filePath: c.filePath }, cType, direction, {
2753
+ maxDepth,
2754
+ relationTypes: cRelTypes,
2755
+ includeTests,
2756
+ minConfidence,
2757
+ summaryOnly: true,
2758
+ skipEpistemic: true,
2759
+ skipEnrichment: true,
2760
+ });
2761
+ }
2762
+ catch (e) {
2763
+ probeFailed = true;
2764
+ logQueryError('impact:ambiguous-candidate', e);
2765
+ }
2766
+ return {
2686
2767
  uid: c.id,
2687
2768
  name: c.name,
2688
2769
  kind: c.type,
2689
2770
  filePath: c.filePath,
2690
2771
  line: c.startLine,
2691
2772
  score: Number(c.score.toFixed(2)),
2692
- })),
2773
+ impactedCount: summary?.impactedCount ?? 0,
2774
+ risk: summary?.risk ?? 'UNKNOWN',
2775
+ direct: summary?.summary?.direct ?? 0,
2776
+ };
2777
+ }));
2778
+ // Rank by blast radius so the most-impactful interpretation is first, and
2779
+ // hoist the maximum count/risk to the top level so the response cannot be
2780
+ // misread as "no impact".
2781
+ candidateSummaries.sort((a, b) => b.impactedCount - a.impactedCount);
2782
+ const maxImpactedCount = candidateSummaries.reduce((m, c) => Math.max(m, c.impactedCount), 0);
2783
+ const RISK_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
2784
+ // If EVERY candidate probe failed (all 'UNKNOWN' — e.g. pool exhaustion
2785
+ // under the fan-out), the worst real risk is genuinely unknown, not LOW.
2786
+ // Reporting LOW here would re-introduce the false-safe signal. Only fall to
2787
+ // the LOW seed when at least one candidate produced a real risk.
2788
+ const anyKnownRisk = candidateSummaries.some((c) => RISK_ORDER.includes(c.risk));
2789
+ const maxRisk = anyKnownRisk
2790
+ ? candidateSummaries.reduce((worst, c) => (RISK_ORDER.indexOf(c.risk) > RISK_ORDER.indexOf(worst) ? c.risk : worst), 'LOW')
2791
+ : 'UNKNOWN';
2792
+ const truncated = outcome.candidates.length > probed.length;
2793
+ return {
2794
+ status: 'ambiguous',
2795
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'` +
2796
+ (truncated
2797
+ ? ` (showing ${candidateSummaries.length} of ${outcome.candidates.length})`
2798
+ : '') +
2799
+ `. Blast radius differs per candidate (max ${maxImpactedCount} impacted at risk ${maxRisk}). ` +
2800
+ `Disambiguate with target_uid (or file_path/kind) for a single authoritative result.`,
2801
+ target: { name: target },
2802
+ direction,
2803
+ // Full match count — `candidates[]` is truncated to AMBIGUOUS_MAX_CANDIDATES,
2804
+ // so consumers (CLI formatter) need this to report "N of M" honestly (#2129
2805
+ // review F11; the CLI previously read the truncated array length).
2806
+ totalCandidates: outcome.candidates.length,
2807
+ // `impactedCount` stays 0 and `risk` stays UNKNOWN — there is no single
2808
+ // resolved symbol, and UNKNOWN must NOT read as "safe to refactor". The
2809
+ // real blast radius is surfaced per-candidate plus `maxImpactedCount` /
2810
+ // `maxRisk` so a real caller can never hide behind the ambiguous zero
2811
+ // (#2129).
2812
+ impactedCount: 0,
2813
+ risk: 'UNKNOWN',
2814
+ maxImpactedCount,
2815
+ maxRisk,
2816
+ ...(probeFailed ? { partialProbe: true } : {}),
2817
+ ...(truncated && { candidatesTruncated: true }),
2818
+ candidates: candidateSummaries,
2693
2819
  };
2694
2820
  }
2695
2821
  const sym = {
@@ -2713,12 +2839,109 @@ export class LocalBackend {
2713
2839
  summaryOnly: params.summaryOnly,
2714
2840
  });
2715
2841
  }
2842
+ /**
2843
+ * #1858 — epistemic lower-bound detection.
2844
+ *
2845
+ * impact()/context() traverse only edges materialized in the graph. When the
2846
+ * queried symbol sits on an interface / abstract boundary, callers that bind
2847
+ * to the interface via DI, a container, or dynamic dispatch — rather than
2848
+ * naming the concrete symbol — are not traced. The reported count is then a
2849
+ * lower bound, not an exact figure. Instead of returning a confident count
2850
+ * that silently omits those callers, annotate the result with
2851
+ * `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
2852
+ * resolved leaf with no indirection stays `epistemic: 'exact'`.
2853
+ *
2854
+ * Aligns with the numeric confidence model rather than the long-deleted
2855
+ * TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
2856
+ * (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
2857
+ * `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
2858
+ * concrete binding past it is not".
2859
+ *
2860
+ * Never throws: on query error it returns 'exact', so it can only add signal,
2861
+ * never suppress a result.
2862
+ */
2863
+ async computeEpistemicBoundary(repo, symId, symType, symName) {
2864
+ const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES;
2865
+ const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES;
2866
+ try {
2867
+ // Discover the interface / abstract supertypes on the target's boundary.
2868
+ // If the target is itself an interface, it is its own boundary node.
2869
+ const boundary = new Map();
2870
+ if (symType === 'Interface') {
2871
+ boundary.set(symId, { name: symName || '', label: 'Interface' });
2872
+ }
2873
+ const ifaceRows = await executeParameterized(repo.lbugPath, `MATCH (x)-[r:CodeRelation]->(iface)
2874
+ WHERE x.id = $symId AND r.type IN $heritage
2875
+ RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
2876
+ LIMIT 25`, { symId, heritage: HERITAGE_TYPES }).catch(() => []);
2877
+ for (const r of ifaceRows) {
2878
+ const id = (r.id ?? r[0]);
2879
+ if (id && !boundary.has(id)) {
2880
+ boundary.set(id, {
2881
+ name: (r.name ?? r[1] ?? ''),
2882
+ label: (r.label ?? r[2] ?? 'Interface'),
2883
+ });
2884
+ }
2885
+ }
2886
+ if (boundary.size === 0)
2887
+ return { epistemic: 'exact' };
2888
+ const ifaceIds = Array.from(boundary.keys());
2889
+ // Count per interface id with scalar equality. A parameterized
2890
+ // `iface.id IN $ids` combined with `COUNT(DISTINCT ...)` + implicit
2891
+ // group-by returns no rows under the LadybugDB cypher subset, so query
2892
+ // each boundary node individually (boundary is small — capped at 25).
2893
+ const countByType = async (types) => {
2894
+ const m = new Map();
2895
+ await Promise.all(ifaceIds.map(async (ifaceId) => {
2896
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (other)-[r:CodeRelation]->(iface)
2897
+ WHERE iface.id = $ifaceId AND r.type IN $types
2898
+ RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }).catch(() => []);
2899
+ const cnt = rows.length > 0 ? Number(rows[0].cnt ?? rows[0][0] ?? 0) : 0;
2900
+ m.set(ifaceId, cnt);
2901
+ }));
2902
+ return m;
2903
+ };
2904
+ const [implCounts, consumerCounts] = await Promise.all([
2905
+ countByType(HERITAGE_TYPES),
2906
+ countByType(CONSUMER_TYPES),
2907
+ ]);
2908
+ const boundaries = [];
2909
+ for (const [id, info] of boundary) {
2910
+ const impls = implCounts.get(id) ?? 0;
2911
+ const consumers = consumerCounts.get(id) ?? 0;
2912
+ // Flag only a genuine indirection risk: an interface that is actually
2913
+ // consumed (callers bind to it) or that has multiple implementations
2914
+ // (runtime dispatch is ambiguous). A concrete type implementing an
2915
+ // interface nothing references is fully traced → stays exact.
2916
+ if (consumers >= 1 || impls >= 2) {
2917
+ const label = (info.label || 'Interface').toLowerCase();
2918
+ const name = info.name || '(unnamed)';
2919
+ const article = /^[aeiou]/.test(label) ? 'an' : 'a';
2920
+ const parts = [];
2921
+ if (impls >= 1)
2922
+ parts.push(`${impls} ${impls === 1 ? 'implementation' : 'implementations'}`);
2923
+ if (consumers >= 1)
2924
+ parts.push(`${consumers} interface-level ${consumers === 1 ? 'consumer' : 'consumers'}`);
2925
+ boundaries.push(`${name} is ${article} ${label} with ${parts.join(' and ')}; callers that bind via the ${label} ` +
2926
+ `(e.g. a DI container or dynamic dispatch) are not traced to the concrete symbol — ` +
2927
+ `actual impact may be higher.`);
2928
+ }
2929
+ }
2930
+ if (boundaries.length === 0)
2931
+ return { epistemic: 'exact' };
2932
+ return { epistemic: 'lower-bound', boundaries };
2933
+ }
2934
+ catch {
2935
+ return { epistemic: 'exact' };
2936
+ }
2937
+ }
2716
2938
  /**
2717
2939
  * Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
2718
2940
  */
2719
2941
  async _runImpactBFS(repo, sym, symType, direction, opts) {
2720
2942
  const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
2721
2943
  const skipPerSymbolEnrichment = opts.skipPerSymbolEnrichment ?? false;
2944
+ const skipEnrichment = opts.skipEnrichment ?? false;
2722
2945
  const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
2723
2946
  const paginationLimit = hasExplicitLimit
2724
2947
  ? Math.max(1, Math.min(Math.trunc(opts.limit), 10000))
@@ -2735,6 +2958,18 @@ export class LocalBackend {
2735
2958
  const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
2736
2959
  const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
2737
2960
  const symId = sym.id || sym[0];
2961
+ // #1858 — kick off the epistemic boundary probe concurrently with the BFS.
2962
+ // It depends only on symId/symType/symName (all known now) and touches no
2963
+ // shared state, so its extra round-trip overlaps the traversal instead of
2964
+ // adding to the serial path. `skipEpistemic` (ambiguous #2129 candidate
2965
+ // probes, group fan-out) resolves to no field, preserving prior behavior.
2966
+ // #1858/#2129 review F8 — the skip case adds no field, so `epistemic` is
2967
+ // optional here (the union's `{}` subtype). computeEpistemicBoundary's own
2968
+ // return keeps `epistemic` REQUIRED — only this promise widens to the skip
2969
+ // subtype.
2970
+ const epistemicPromise = opts.skipEpistemic
2971
+ ? Promise.resolve({})
2972
+ : this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]));
2738
2973
  const impacted = [];
2739
2974
  const visited = new Set([symId]);
2740
2975
  let frontier = [symId];
@@ -2872,7 +3107,13 @@ export class LocalBackend {
2872
3107
  // Max number of chunks to process to avoid unbounded DB round-trips.
2873
3108
  // Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
2874
3109
  const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
2875
- if (impacted.length > 0) {
3110
+ // `skipEnrichment` (ambiguous #2129 per-candidate probes) bypasses the
3111
+ // process/module aggregation passes entirely — those probes need only the
3112
+ // count + a count-based risk, so paying the bounded-but-real enrichment cost
3113
+ // ~6× per ambiguous call is wasted. risk then derives from directCount /
3114
+ // total only (processCount/moduleCount stay 0), an acceptable approximation
3115
+ // for a disambiguation aid.
3116
+ if (impacted.length > 0 && !skipEnrichment) {
2876
3117
  // ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─
2877
3118
  // Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
2878
3119
  // process + entry point info in 1 round-trip per chunk. Converted to
@@ -3093,6 +3334,9 @@ export class LocalBackend {
3093
3334
  for (const [depth, items] of Object.entries(grouped)) {
3094
3335
  byDepthCounts[Number(depth)] = items.length;
3095
3336
  }
3337
+ // #1858 — await the epistemic boundary probe kicked off alongside the BFS
3338
+ // above. Additive: leaves impactedCount and every existing field untouched.
3339
+ const epistemic = await epistemicPromise;
3096
3340
  const base = {
3097
3341
  target: {
3098
3342
  id: symId,
@@ -3103,6 +3347,7 @@ export class LocalBackend {
3103
3347
  direction,
3104
3348
  impactedCount: impacted.length,
3105
3349
  risk,
3350
+ ...epistemic,
3106
3351
  ...(!traversalComplete && { partial: true }),
3107
3352
  summary: {
3108
3353
  direct: directCount,
@@ -3292,6 +3537,10 @@ export class LocalBackend {
3292
3537
  includeTests: opts.includeTests,
3293
3538
  minConfidence: opts.minConfidence,
3294
3539
  skipPerSymbolEnrichment: true,
3540
+ // Group cross-repo fan-out consumes only byDepth (cross-impact.ts), not
3541
+ // the #1858 epistemic/boundaries fields — computing them per neighbor is
3542
+ // dead work on the highest-volume path, so suppress them here too.
3543
+ skipEpistemic: true,
3295
3544
  });
3296
3545
  }
3297
3546
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.6",
3
+ "version": "1.6.8-rc.7",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",