fix: stop impact()/route_map under-reporting blast radius (#2129, #1858, #1589/#1852) (#2136)

* fix(query): stop impact()/context() under-reporting blast radius (#2129, #1858)

Two read-side fixes to the "run impact before editing" safety workflow, both
about the tools rendering "I could not give a single confident answer" as
"no impact" — the most dangerous failure mode for a refactor-safety tool.

#2129 — ambiguous resolution no longer hides a real caller behind a bare
`impactedCount: 0`. When a bare name collides with several symbols, the resolver
returns `ambiguous`; previously the payload carried a flat `impactedCount: 0`,
so the real caller (which calls a *different* same-name node) was invisible
unless the user already knew to disambiguate. The ambiguous branch now runs a
bounded, summary-only BFS per candidate (capped at 6) and surfaces each
candidate's true count plus the top-level `maxImpactedCount` / `maxRisk`, ranked
most-impactful-first. `risk` stays `UNKNOWN` (ambiguity must not read as "safe"),
`impactedCount` stays 0 (no single resolved symbol). The BFS and edge storage
are unchanged — an empirical repro confirmed they are correct; the bug was
purely in how the ambiguous case reported. Disambiguation by uid still returns
the exact result.

#1858 — impact()/context() now carry an additive `epistemic` field. When the
queried symbol sits on an interface / indirection boundary (it implements or
extends an interface, or is one) whose consumers bind via a DI container or
dynamic dispatch, those callers are not traced to the concrete symbol, so the
count is a lower bound. The result is annotated `epistemic: 'lower-bound'` with a
human-readable `boundaries[]` note; a fully resolved leaf stays
`epistemic: 'exact'`. Aligned to the surviving numeric confidence model (the
0.85 IMPACT_RELATION_CONFIDENCE heritage floor), not the long-deleted
TIER_CONFIDENCE enum. Purely additive — no existing field or count changes.

Tests: impact-ambiguous-blast-radius (per-candidate surfacing + uid
disambiguation) and impact-epistemic-lower-bound (interface boundary →
lower-bound, resolved leaf → exact, context parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(routes): configurable fetch wrappers + faster consumer scan (#1589/#1852)

Closes the residual gap behind the now-merged #1852 (which fixed #1589): the
fetch-wrapper consumer scan only traced wrappers the parse phase auto-detected
as calling the bare global `fetch()`. A wrapper built on axios / a custom
client, or one named outside the built-in convention, was invisible — route_map
silently returned `consumers: []` (the exact "named outside convention → silent
zero" hole #1858 calls out as needing a backstop).

- Configurable wrappers: `.gitnexusrc` gains a `fetchWrappers: [...]` list
  (validated as identifier/member names, de-duped, capped, regex-safe), threaded
  AnalyzeOptions → PipelineOptions → routes phase. Configured names are unioned
  with the auto-detected ones; configured names alone now trigger the scan even
  when nothing was auto-detected.
- Perf (F3 from #1852's review): the cross-file scan built one RegExp per
  (file × wrapper) — O(files × wrappers). It now builds a single alternation
  regex per file (O(files)) and reuses file contents already read for handler
  extraction instead of re-reading them.

Tests: configurable-fetch-wrapper (axios-based `doRequest` wrapper — invisible
without config, traced with it) + .gitnexusrc `fetchWrappers` validation cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): harden the under-reporting fixes after adversarial review

Addresses findings from a reviewer-swarm pass over the two prior commits:

- CLI text false-safe (major): `formatImpactResult` (eval-server.ts) had no
  ambiguous branch, so `gitnexus impact <colliding-name>` printed "No
  dependencies found. This symbol appears isolated." for an ambiguous target —
  the exact false-safe #2129 exists to kill, defeating the JSON-layer fix at the
  text surface. Added an ambiguous branch (per-candidate blast radius +
  maxImpactedCount/maxRisk) and a lower-bound branch for both the zero-count and
  non-zero paths, mirroring the context formatter. Covered by new unit tests.
- Group fan-out dead work (major): impactByUid now passes skipEpistemic:true —
  the group cross-impact fan-out consumes only byDepth, so computing the #1858
  boundary per neighbor was wasted round-trips on the highest-volume path.
- Ambiguous all-UNKNOWN risk (minor): if every per-candidate probe fails, maxRisk
  now reports 'UNKNOWN' instead of falling to the 'LOW' seed (which would read as
  "safe").
- Candidate-probe cost (minor): the per-candidate summary BFS now sets
  skipEnrichment:true, bypassing the process/module aggregation passes it does
  not use.
- Epistemic latency (minor): computeEpistemicBoundary now runs concurrently with
  the impact BFS instead of as a trailing serial round-trip.
- Wrapper over-match (minor): the consumer-scan regex uses a `(?<![.\w$])`
  lookbehind instead of `\b`, so a bare configured name like `get` matches the
  free call `get('/x')` but not a member access `client.get(` (and `apiFetch`
  no longer matches `myApiFetch`).
- Boundary wording (nit): correct article ("a class" vs "an interface") and
  singular/plural ("1 implementation").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lint): drop unused describe import in new impact tests

The withTestLbugDB harness wraps describe internally, so the explicit
describe import was unused — unused-imports/no-unused-imports is an error
(not a warning) in the root eslint config, failing quality/lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): flag partialProbe when an ambiguous candidate probe fails (#2129 review F1)

The ambiguous-impact branch hoists maxRisk/maxImpactedCount so a colliding
name can't read as "isolated". But if a per-candidate BFS throws (e.g. DB
pool contention during the ≤6-way fan-out), it was recorded as
risk:'UNKNOWN', impactedCount:0 and silently masked by any benign sibling
success — maxRisk reduced to the benign tier and maxImpactedCount reflected
only successful probes. Track probeFailed and surface partialProbe:true
(additive, intentionally distinct from the traversal-interrupted `partial`
flag); formatImpactResult prints a lower-bound warning. Covered by a
formatter unit test (a natural in-harness probe throw is unreachable —
_runImpactBFS is fully self-catching under summaryOnly+skipEpistemic+
skipEnrichment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): report the full match count when ambiguous candidates are truncated (#2129 review F11)

The ambiguous candidate list is capped at AMBIGUOUS_MAX_CANDIDATES (6), but
the CLI headline read the truncated `candidates[]` length — so a name
matching 9 symbols printed "6 symbols share this name" while the JSON message
stated the true count. Add an additive `totalCandidates` field carrying the
full match count, include a "showing N of M" clause in the message when
truncated, and have formatImpactResult report the full count. Covered by
formatter unit tests for the truncated and non-truncated cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(query): run context() epistemic probe concurrently with methodMetadata (#1858 review F2)

impact() overlaps the #1858 boundary probe with its BFS, but _contextImpl
awaited computeEpistemicBoundary serially after every other query. Start the
probe right after `symKind` is known (the earliest point it can — symKind
depends on the incoming/outgoing round-trips) so it runs concurrently with the
methodMetadata fetch, and await it at result assembly. Output is unchanged
(covered by the existing epistemic context() tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): flag a leaf interface as lower-bound in context() (#1858 review F3)

context() passed `symKind` to computeEpistemicBoundary, but symKind collapses
a single-resolved Interface to 'Class' (resolvedLabel is '' on the
single-candidate path), so the `symType === 'Interface'` self-boundary branch
never fired and a directly-queried leaf interface (implements nothing, but
consumed) was under-reported as 'exact'. Pass an interface-preserving type
(`resolvedLabel || sym.type || symKind`) instead — enrichCandidateLabels runs
before the single-candidate early return and patches sym.type to 'Interface',
mirroring impact()'s derivation. impact() was already unaffected. Covered by a
new context()-on-a-leaf-interface test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(query): hoist epistemic relation-type lists + add USES to the allowlist (#1858/#2129 review F4, F5)

F4: promote computeEpistemicBoundary's function-local heritage/consumer
relation-type lists to module-level readonly constants
(EPISTEMIC_HERITAGE_RELATION_TYPES / EPISTEMIC_CONSUMER_RELATION_TYPES) next to
VALID_RELATION_TYPES / IMPACT_RELATION_CONFIDENCE, so a future heritage edge
type is visible to the probe. Kept as arrays (not Sets) because they bind as
Cypher params.

F5 (latent bug): USES is emitted (emit-references.ts) and already in the
default impact relTypes + context() queries, but was missing from
VALID_RELATION_TYPES — so impact({relationTypes:['USES']}) filtered to [] and
silently ran the full default traversal. Add it (0.5 confidence fallback,
matching FETCHES/WRAPS). Updates the security.test.ts allowlist assertions
(size 15→16, USES now valid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(query): document the _runImpactBFS enrichment skip-flag composition (#1858/#2129 review F6)

The three skip-flags (skipPerSymbolEnrichment / skipEpistemic / skipEnrichment)
suppress distinct sub-phases and compose implicitly. Add a JSDoc block at the
opts type listing what each suppresses, the three real call patterns, and the
key interaction (skipEnrichment makes skipPerSymbolEnrichment a no-op).
Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): genericize the shared string-array validation messages (#1589/#1852 review F7)

The shared `string-array` ValueKind hardcoded fetch-wrapper phrasing in three
messages (non-array, identifier-shape, empty-list). Since `source` already
names the config key, genericize all three so the shared normalizer carries no
fetchWrappers coupling — a future string-array config key gets sensible errors.
Test assertions updated to the new wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(query): type the ambiguous candidate summary + epistemicPromise (#1858/#2129 review F8)

The ambiguous per-candidate summary was read through `any`, so a rename of
_runImpactBFS's return fields would silently zero candidate counts. Name the
read shape ({impactedCount, risk, summary?.direct}) at the narrowing site, and
type epistemicPromise as the optional-epistemic union (the skip case's `{}`
subtype) — keeping computeEpistemicBoundary's own return precise (epistemic
required). Type-only; no runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(routes): trust validated fetchWrappers config, drop redundant re-filter (#1589/#1852 review F9)

`ctx.options.fetchWrappers` is already trimmed/shape-validated/de-duped/capped
in analyze-config.ts, so the routes-phase re-trim/re-typeof pre-pass was
redundant. Pass it straight through; the single Set-construction filter remains
to guard the auto-detected functionName values (which don't pass through
analyze-config). No behavior change — covered by the existing fetch-wrapper
route suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): make the wrapper-call boundary Unicode-aware (#1852 review F10)

The consumer-scan lookbehind used ASCII `\w`, so a configured bare wrapper name
preceded by a non-ASCII identifier character (`caféget('/x')`) satisfied the
boundary and produced a spurious FETCHES edge. Switch to the `u` flag with
Unicode property classes (`(?<![.\p{L}\p{N}_$])`). Covered by a fixture
consumer (`cafédoRequest('/api/things')`) asserting no spurious edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(routes): count wrapper-scan line numbers incrementally (#1852 review F12)

The wrapper consumer scan computed each match's line number via
content.substring(0, match.index).split('\n').length — an O(matchIndex)
allocation per match. Matches arrive in ascending index, so accumulate
newlines with a running counter instead. 1-based line numbers are byte-identical
(covered by the existing fetch-wrapper route suites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): keep the #1858 epistemic probe from skewing the impact-pagination mock

The impact-pagination mock counts every query containing `r.type IN` as a BFS
depth level. Once the #1858 epistemic boundary probe was parallelized with the
BFS (it fires `MATCH (x)-[r]->(iface) ... r.type IN $heritage` before the
frontier loop), that query was miscounted as depth-1, shifting the real depths
so multi-depth impactedCount read 50 instead of 200. Short-circuit the
epistemic queries (uniquely aliased `iface`) to empty in both mock setups so
only frontier queries count. Test-only; production is unaffected (the epistemic
query is a separate real query there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-10 11:25:48 +01:00 • committed by GitHub
parent bbcd981143
commit 042c1a0b50
18 changed files with 1044 additions and 29 deletions

View file

@ -56,6 +56,7 @@ type ValueKind =
| 'boolean'
| 'boolean-negate'
| 'string'
| 'string-array'
| 'numeric-string'
| 'embeddings'
| 'branch';
@ -99,6 +100,12 @@ const KEY_SPECS: Record<string, KeySpec> = {
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
// #1589/#1852 residual — extra fetch-wrapper function names to treat as HTTP
// consumers. The auto-detector only flags functions that call the bare global
// `fetch()`; a wrapper built on axios / a custom client, or named outside the
// built-in convention set, is otherwise invisible to route_map consumers.
// Listing it here adds it to the cross-file consumer scan.
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
};
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
@ -230,6 +237,41 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown =
}
return trimmed;
}
case 'string-array': {
// Generic shared validator — `source` already names the config key, so
// messages here stay key-agnostic (no fetch-wrapper coupling in the
// shared normalizer; #1589/#1852 review F7).
if (!Array.isArray(value)) {
throw new GitNexusRcError(`${source} must be an array of strings.`);
}
const names: string[] = [];
for (const item of value) {
if (typeof item !== 'string') {
throw new GitNexusRcError(`${source} entries must all be strings.`);
}
const trimmed = item.trim();
if (!trimmed) {
throw new GitNexusRcError(`${source} entries must not be empty.`);
}
assertNoHiddenChars(trimmed, source);
// Values may be interpolated into a RegExp downstream. Restrict to
// identifier / member-access shapes so a config value can never smuggle
// regex metacharacters into a consumer.
if (!/^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(trimmed)) {
throw new GitNexusRcError(
`${source} entry "${trimmed}" must be an identifier or member name ` +
`(letters, digits, _, $, . — e.g. "client.get").`,
);
}
names.push(trimmed);
}
if (names.length === 0) {
throw new GitNexusRcError(`${source} must list at least one string.`);
}
// De-duplicate and cap to a sane bound so a pathological config cannot
// blow up the consumer scan's alternation.
return Array.from(new Set(names)).slice(0, 100);
}
case 'numeric-string': {
// Mirror Commander's contract: these options reach the existing CLI
// validation as strings. Accept a JSON number or a string; normalize to a

View file

@ -663,6 +663,14 @@ export interface AnalyzeOptions {
embeddingBatchSize?: string;
embeddingSubBatchSize?: string;
embeddingDevice?: string;
/**
* Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
* residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
* the routes phase, where the cross-file consumer scan unions them with the
* auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
* outside the built-in convention still produces `route_map` consumers.
*/
fetchWrappers?: string[];
}
/**
@ -1137,6 +1145,9 @@ const analyzeCommandImpl = async (
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
// env / auto-formula fallback inside the pipeline.
workerPoolSize,
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
// forwarded to the routes phase consumer scan.
fetchWrappers: options.fetchWrappers,
},
{
onProgress: (_phase, percent, message) => {

View file

@ -189,7 +189,47 @@ export function formatImpactResult(result: any): string {
const byDepth = result.byDepth || {};
const total = result.impactedCount || 0;
// #2129 — an ambiguous bare name must not print the "isolated / safe to
// refactor" headline. Surface the per-candidate blast radius + the maximum,
// mirroring formatContextResult, so the real impact under whichever symbol the
// caller meant is visible on the text surface, not just in the JSON.
if (result.status === 'ambiguous') {
// #2129 review F11 — report the FULL match count (`totalCandidates`), not the
// truncated `candidates[]` length; note when the candidate list is capped.
const shown = result.candidates?.length ?? 0;
const total = result.totalCandidates ?? shown;
const countPhrase = total > shown ? `${total} symbols (showing ${shown})` : `${total} symbols`;
const lines = [
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
`Max blast radius ${result.maxImpactedCount ?? 0} (${result.maxRisk ?? 'UNKNOWN'} risk). ` +
`Disambiguate with --uid for one authoritative result:`,
];
for (const c of result.candidates || []) {
lines.push(
` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'} ` +
`[${c.impactedCount ?? 0} ${direction}, risk ${c.risk ?? 'UNKNOWN'}] (uid: ${c.uid})`,
);
}
// #2129 review F1 — a failed per-candidate probe makes the max a lower bound.
if (result.partialProbe) {
lines.push(
' ⚠️ One or more candidate probes failed — max blast radius / risk are lower bounds.',
);
}
return lines.join('\n');
}
if (total === 0) {
// #1858 — "isolated" is a confident claim. If an interface / indirection
// boundary is on the path, the true count is a lower bound, not zero;
// callers binding via DI / dynamic dispatch were not traced. Say so instead.
if (result.epistemic === 'lower-bound') {
const lines = [
`${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
];
for (const b of result.boundaries || []) lines.push(` • ${b}`);
return lines.join('\n');
}
return `${target?.name || '?'}: No ${direction} dependencies found. This symbol appears isolated.`;
}
@ -202,6 +242,14 @@ export function formatImpactResult(result: any): string {
if (result.partial) {
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
}
// #1858 — an interface / indirection boundary on the path makes this a lower
// bound; surface it so the count is not read as exhaustive.
if (result.epistemic === 'lower-bound') {
lines.push(
'⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):',
);
for (const b of result.boundaries || []) lines.push(` • ${b}`);
}
lines.push('');
const depthLabels: Record<number, string> = {

View file

@ -367,25 +367,77 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
// scan JS/TS consumer files for calls to those wrapper functions with
// URL-like string arguments and add them to allFetchCalls so
// processNextjsFetchRoutes can create FETCHES edges.
if (allFetchWrapperDefs && allFetchWrapperDefs.length > 0 && routeRegistry.size > 0) {
const wrapperNames = new Set(allFetchWrapperDefs.map((d) => d.functionName));
// Wrapper names come from two sources: functions the parse phase
// auto-detected as calling the bare global `fetch()`, plus any names the
// user declared in `.gitnexusrc` `fetchWrappers` (#1589/#1852 residual).
// Config names let an axios/custom-client wrapper — or one named outside the
// built-in convention — still produce route_map consumers; without them it
// silently falls back to `consumers: []`. Configured names alone are enough
// to run the scan even when nothing was auto-detected.
// Configured names are already validated/trimmed/de-duped/capped by
// analyze-config.ts — trusted as-is (#1589/#1852 review F9, dropped the
// redundant re-trim/re-filter). The single filter below guards only the
// auto-detected `functionName`s, which have no shape guarantee.
const configuredWrappers = ctx.options?.fetchWrappers ?? [];
const wrapperNames = new Set<string>(
[...(allFetchWrapperDefs ?? []).map((d) => d.functionName), ...configuredWrappers].filter(
(n): n is string => typeof n === 'string' && n.trim().length > 0,
),
);
if (wrapperNames.size > 0 && routeRegistry.size > 0) {
const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p));
if (jsFiles.length > 0 && wrapperNames.size > 0) {
const jsContents = await readFileContents(ctx.repoPath, jsFiles);
for (const [filePath, content] of jsContents) {
for (const name of wrapperNames) {
const regex = new RegExp(
`\\b${escapeRegex(name)}\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`,
'g',
);
let match;
while ((match = regex.exec(content)) !== null) {
allFetchCalls.push({
filePath,
fetchURL: match[1],
lineNumber: content.substring(0, match.index).split('\n').length,
});
if (jsFiles.length > 0) {
// Reuse contents already read for handler extraction; only read the
// remainder (mirrors the Expo block above). Avoids a second full read of
// files we already have in memory.
const unreadJsFiles = jsFiles.filter((p) => !handlerContents?.has(p));
const extraContents =
unreadJsFiles.length > 0
? await readFileContents(ctx.repoPath, unreadJsFiles)
: new Map<string, string>();
// One alternation regex over every wrapper name per file — O(files), not
// O(files × wrappers) (#1852 review F3). Names are escaped and grouped
// non-capturing so capture group 1 stays the URL. The left boundary is a
// negative lookbehind, not `\b`: a bare configured name like `get` must
// match the free call `get('/x')` but NOT a member access `client.get(`
// (a `.get(` on an unrelated object), and `apiFetch` must not match
// `myApiFetch`. Member-style wrappers are configured with the dot
// (`client.get`), where the `.` is part of the pattern. The `u` flag +
// Unicode property classes make the boundary cover non-ASCII identifier
// characters too — ASCII `\w` would let `caféget('/x')` match `get`
// (#1852 review F10).
const alternation = [...wrapperNames].map(escapeRegex).join('|');
const wrapperCallRegex = new RegExp(
`(?<![.\\p{L}\\p{N}_$])(?:${alternation})\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`,
'gu',
);
const scanContent = (filePath: string, content: string): void => {
wrapperCallRegex.lastIndex = 0;
// 1-based line number via a running newline counter: matches arrive in
// ascending index, so accumulate newlines incrementally instead of
// re-allocating `content.substring(0, match.index).split('\n')` on
// every match (#1852 review F12). Output is identical.
let line = 1;
let scanned = 0;
let match;
while ((match = wrapperCallRegex.exec(content)) !== null) {
for (; scanned < match.index; scanned++) {
if (content.charCodeAt(scanned) === 10 /* '\n' */) line++;
}
allFetchCalls.push({
filePath,
fetchURL: match[1],
lineNumber: line,
});
}
};
for (const [filePath, content] of extraContents) scanContent(filePath, content);
// Also scan already-read JS/TS handler files (a handler can itself
// consume another route through a wrapper).
if (handlerContents) {
for (const p of jsFiles) {
const cached = handlerContents.get(p);
if (cached !== undefined) scanContent(p, cached);
}
}
}

View file

@ -129,6 +129,15 @@ export interface PipelineOptions {
* `process.env` state across invocations. When undefined, the env var decides.
*/
keepLocalValueSymbols?: boolean;
/**
* Extra fetch-wrapper function names to treat as HTTP consumers, threaded
* from `.gitnexusrc` `fetchWrappers` via `AnalyzeOptions` (#1589/#1852
* residual). The routes phase unions these with the auto-detected `fetch()`
* wrappers when scanning for `route_map` consumers, so a wrapper named outside
* the built-in convention (or built on axios / a custom client) is still
* traced. Empty/undefined leaves behavior unchanged.
*/
fetchWrappers?: readonly string[];
}
// ── Phase registry ─────────────────────────────────────────────────────────

View file

@ -159,6 +159,13 @@ export interface AnalyzeOptions {
* removed); `undefined` defers to the env / auto-formula fallback.
*/
workerPoolSize?: number;
/**
* Extra fetch-wrapper function names to treat as HTTP consumers, forwarded to
* `PipelineOptions.fetchWrappers` (#1589/#1852 residual). Sourced from the CLI
* `.gitnexusrc` `fetchWrappers` list. `undefined`/empty leaves the route
* consumer scan unchanged.
*/
fetchWrappers?: string[];
}
export interface AnalyzeResult {
@ -641,6 +648,7 @@ export async function runFullAnalysis(
{
parseCache,
workerPoolSize: options.workerPoolSize,
fetchWrappers: options.fetchWrappers,
},
);

View file

@ -128,6 +128,13 @@ export const VALID_RELATION_TYPES = new Set([
'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
'METHOD_IMPLEMENTS',
'ACCESSES',
// Emitted by emit-references.ts / scope-resolution/graph-bridge/edges.ts and
// already part of the default impact relTypes + context() incoming queries.
// It was missing from this allowlist, so `impact({relationTypes:['USES']})`
// silently filtered to [] and fell back to the full default traversal
// (#2129/#1858 review F5). No IMPACT_RELATION_CONFIDENCE floor → 0.5 fallback,
// matching the FETCHES / WRAPS / HANDLES_ROUTE precedent below.
'USES',
'HANDLES_ROUTE',
'FETCHES',
'HANDLES_TOOL',
@ -135,6 +142,20 @@ export const VALID_RELATION_TYPES = new Set([
'WRAPS',
]);
/**
* Relation types the #1858 epistemic-boundary probe keys on. Kept as
* module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
* binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
* The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
* "statically verifiable, but the concrete binding past it is not".
*/
export const EPISTEMIC_HERITAGE_RELATION_TYPES: readonly string[] = [
'IMPLEMENTS',
'METHOD_IMPLEMENTS',
'EXTENDS',
];
export const EPISTEMIC_CONSUMER_RELATION_TYPES: readonly string[] = ['CALLS', 'USES', 'ACCESSES'];
/**
* Per-relation-type confidence floor for impact analysis.
*
@ -2565,6 +2586,30 @@ export class LocalBackend {
const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
const isMethodLike =
symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
// #1858 review F2 — start the epistemic boundary probe here (right after
// `symKind` is known) so it runs CONCURRENTLY with the methodMetadata fetch
// below, mirroring how _runImpactBFS overlaps it with the BFS. It is awaited
// at result assembly. (It cannot start earlier — `symKind` is only computed
// on this line, after the incoming/outgoing round-trips.)
//
// #1858 review F3 — pass an interface-preserving type, NOT `symKind`.
// `symKind` collapses a single-resolved Interface to 'Class' (resolvedLabel
// is '' on the single-candidate path), which would skip computeEpistemicBoundary's
// `symType === 'Interface'` self-boundary branch and under-report a leaf
// interface as 'exact'. `enrichCandidateLabels` runs BEFORE the single-candidate
// early return and patches `sym.type` from '' to 'Interface' (LadybugDB returns
// '' for labels()[0] on Interface/Class), so `sym.type` is the reliable signal
// here — mirroring impact()'s `resolvedLabel || symbol.type` derivation. Do not
// "fix" enrichment ordering; F3 depends on enrichment-before-early-return.
const epistemicSymType = (resolvedLabel || sym.type || symKind || '') as string;
const epistemicPromise = this.computeEpistemicBoundary(
repo,
symId,
epistemicSymType,
(sym.name || sym[1]) as string,
);
let methodMetadata: Record<string, unknown> | undefined;
if (isMethodLike) {
try {
@ -2597,6 +2642,13 @@ export class LocalBackend {
}
}
// #1858 — same epistemic boundary signal as impact(): when this symbol sits
// behind an interface / indirection boundary, callers binding via DI or
// dynamic dispatch are not reflected in `incoming`, so the view is a lower
// bound. Additive; never suppresses a field. Resolved from the probe started
// above (concurrent with methodMetadata).
const epistemic = await epistemicPromise;
return {
status: 'found',
symbol: {
@ -2609,6 +2661,7 @@ export class LocalBackend {
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
...(methodMetadata ? { methodMetadata } : {}),
},
...epistemic,
incoming: categorize(incomingRows),
outgoing: categorize(outgoingRows),
...(typedPropertyRows.length > 0
@ -3261,21 +3314,121 @@ export class LocalBackend {
}
if (outcome.kind === 'ambiguous') {
// #2129 — a bare name that collides with several symbols must NOT report a
// bare `impactedCount: 0`. The real blast radius lives under whichever
// candidate the caller meant; a flat zero here is precisely the silent
// under-report the "run impact before editing" workflow exists to prevent
// (the dropped caller calls a *different* same-name node, so it never shows
// up against the one the resolver happened to pick). Run a bounded,
// summary-only BFS per candidate so each one's true count + risk is
// visible, and surface the maximum at the top level so the headline can
// never read as "safe to refactor". Candidates arrive sorted by score.
const AMBIGUOUS_MAX_CANDIDATES = 6;
const probed = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
// `partialProbe` is intentionally a SECOND incompleteness flag, distinct
// from the traversal-interrupted `partial` flag used elsewhere: it means
// one or more per-candidate probes threw, so maxRisk / maxImpactedCount
// are lower bounds over the probes that succeeded (a failed candidate must
// not be masked by a benign sibling success).
let probeFailed = false;
const candidateSummaries = await Promise.all(
probed.map(async (c) => {
const cType = c.type || '';
const cRelTypes =
(cType === 'Class' || cType === 'Interface') &&
!hasExplicitRelationTypes &&
!relationTypes.includes('ACCESSES')
? [...relationTypes, 'ACCESSES']
: relationTypes;
// #1858/#2129 review F8 — name the shape the probe summary is read
// through (`_runImpactBFS` returns `Promise<any>`, so this is the
// narrowing cast) so a future rename of those fields fails tsc instead
// of silently zeroing candidate counts.
let summary: {
impactedCount: number;
risk: string;
summary?: { direct: number };
} | null = null;
try {
summary = await this._runImpactBFS(
repo,
{ id: c.id, name: c.name, filePath: c.filePath },
cType,
direction,
{
maxDepth,
relationTypes: cRelTypes,
includeTests,
minConfidence,
summaryOnly: true,
skipEpistemic: true,
skipEnrichment: true,
},
);
} catch (e) {
probeFailed = true;
logQueryError('impact:ambiguous-candidate', e);
}
return {
uid: c.id,
name: c.name,
kind: c.type,
filePath: c.filePath,
line: c.startLine,
score: Number(c.score.toFixed(2)),
impactedCount: summary?.impactedCount ?? 0,
risk: summary?.risk ?? 'UNKNOWN',
direct: summary?.summary?.direct ?? 0,
};
}),
);
// Rank by blast radius so the most-impactful interpretation is first, and
// hoist the maximum count/risk to the top level so the response cannot be
// misread as "no impact".
candidateSummaries.sort((a, b) => b.impactedCount - a.impactedCount);
const maxImpactedCount = candidateSummaries.reduce((m, c) => Math.max(m, c.impactedCount), 0);
const RISK_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
// If EVERY candidate probe failed (all 'UNKNOWN' — e.g. pool exhaustion
// under the fan-out), the worst real risk is genuinely unknown, not LOW.
// Reporting LOW here would re-introduce the false-safe signal. Only fall to
// the LOW seed when at least one candidate produced a real risk.
const anyKnownRisk = candidateSummaries.some((c) => RISK_ORDER.includes(c.risk));
const maxRisk = anyKnownRisk
? candidateSummaries.reduce(
(worst, c) => (RISK_ORDER.indexOf(c.risk) > RISK_ORDER.indexOf(worst) ? c.risk : worst),
'LOW',
)
: 'UNKNOWN';
const truncated = outcome.candidates.length > probed.length;
return {
status: 'ambiguous',
message: `Found ${outcome.candidates.length} symbols matching '${target}'. Use target_uid, file_path, or kind to disambiguate.`,
message:
`Found ${outcome.candidates.length} symbols matching '${target}'` +
(truncated
? ` (showing ${candidateSummaries.length} of ${outcome.candidates.length})`
: '') +
`. Blast radius differs per candidate (max ${maxImpactedCount} impacted at risk ${maxRisk}). ` +
`Disambiguate with target_uid (or file_path/kind) for a single authoritative result.`,
target: { name: target },
direction,
// Full match count — `candidates[]` is truncated to AMBIGUOUS_MAX_CANDIDATES,
// so consumers (CLI formatter) need this to report "N of M" honestly (#2129
// review F11; the CLI previously read the truncated array length).
totalCandidates: outcome.candidates.length,
// `impactedCount` stays 0 and `risk` stays UNKNOWN — there is no single
// resolved symbol, and UNKNOWN must NOT read as "safe to refactor". The
// real blast radius is surfaced per-candidate plus `maxImpactedCount` /
// `maxRisk` so a real caller can never hide behind the ambiguous zero
// (#2129).
impactedCount: 0,
risk: 'UNKNOWN',
candidates: outcome.candidates.map((c) => ({
uid: c.id,
name: c.name,
kind: c.type,
filePath: c.filePath,
line: c.startLine,
score: Number(c.score.toFixed(2)),
})),
maxImpactedCount,
maxRisk,
...(probeFailed ? { partialProbe: true } : {}),
...(truncated && { candidatesTruncated: true }),
candidates: candidateSummaries,
};
}
@ -3304,6 +3457,122 @@ export class LocalBackend {
});
}
/**
* #1858 — epistemic lower-bound detection.
*
* impact()/context() traverse only edges materialized in the graph. When the
* queried symbol sits on an interface / abstract boundary, callers that bind
* to the interface via DI, a container, or dynamic dispatch — rather than
* naming the concrete symbol — are not traced. The reported count is then a
* lower bound, not an exact figure. Instead of returning a confident count
* that silently omits those callers, annotate the result with
* `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
* resolved leaf with no indirection stays `epistemic: 'exact'`.
*
* Aligns with the numeric confidence model rather than the long-deleted
* TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
* (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
* `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
* concrete binding past it is not".
*
* Never throws: on query error it returns 'exact', so it can only add signal,
* never suppress a result.
*/
private async computeEpistemicBoundary(
repo: RepoHandle,
symId: string,
symType: string,
symName: string,
): Promise<{ epistemic: 'exact' | 'lower-bound'; boundaries?: string[] }> {
const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES;
const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES;
try {
// Discover the interface / abstract supertypes on the target's boundary.
// If the target is itself an interface, it is its own boundary node.
const boundary = new Map<string, { name: string; label: string }>();
if (symType === 'Interface') {
boundary.set(symId, { name: symName || '', label: 'Interface' });
}
const ifaceRows = await executeParameterized(
repo.lbugPath,
`MATCH (x)-[r:CodeRelation]->(iface)
WHERE x.id = $symId AND r.type IN $heritage
RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
LIMIT 25`,
{ symId, heritage: HERITAGE_TYPES },
).catch(() => []);
for (const r of ifaceRows) {
const id = (r.id ?? r[0]) as string;
if (id && !boundary.has(id)) {
boundary.set(id, {
name: (r.name ?? r[1] ?? '') as string,
label: (r.label ?? r[2] ?? 'Interface') as string,
});
}
}
if (boundary.size === 0) return { epistemic: 'exact' };
const ifaceIds = Array.from(boundary.keys());
// Count per interface id with scalar equality. A parameterized
// `iface.id IN $ids` combined with `COUNT(DISTINCT ...)` + implicit
// group-by returns no rows under the LadybugDB cypher subset, so query
// each boundary node individually (boundary is small — capped at 25).
const countByType = async (types: readonly string[]): Promise<Map<string, number>> => {
const m = new Map<string, number>();
await Promise.all(
ifaceIds.map(async (ifaceId) => {
const rows = await executeParameterized(
repo.lbugPath,
`MATCH (other)-[r:CodeRelation]->(iface)
WHERE iface.id = $ifaceId AND r.type IN $types
RETURN COUNT(DISTINCT other.id) AS cnt`,
{ ifaceId, types },
).catch(() => []);
const cnt =
rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0;
m.set(ifaceId, cnt);
}),
);
return m;
};
const [implCounts, consumerCounts] = await Promise.all([
countByType(HERITAGE_TYPES),
countByType(CONSUMER_TYPES),
]);
const boundaries: string[] = [];
for (const [id, info] of boundary) {
const impls = implCounts.get(id) ?? 0;
const consumers = consumerCounts.get(id) ?? 0;
// Flag only a genuine indirection risk: an interface that is actually
// consumed (callers bind to it) or that has multiple implementations
// (runtime dispatch is ambiguous). A concrete type implementing an
// interface nothing references is fully traced → stays exact.
if (consumers >= 1 || impls >= 2) {
const label = (info.label || 'Interface').toLowerCase();
const name = info.name || '(unnamed)';
const article = /^[aeiou]/.test(label) ? 'an' : 'a';
const parts: string[] = [];
if (impls >= 1)
parts.push(`${impls} ${impls === 1 ? 'implementation' : 'implementations'}`);
if (consumers >= 1)
parts.push(
`${consumers} interface-level ${consumers === 1 ? 'consumer' : 'consumers'}`,
);
boundaries.push(
`${name} is ${article} ${label} with ${parts.join(' and ')}; callers that bind via the ${label} ` +
`(e.g. a DI container or dynamic dispatch) are not traced to the concrete symbol — ` +
`actual impact may be higher.`,
);
}
}
if (boundaries.length === 0) return { epistemic: 'exact' };
return { epistemic: 'lower-bound', boundaries };
} catch {
return { epistemic: 'exact' };
}
}
/**
* Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
*/
@ -3320,11 +3589,28 @@ export class LocalBackend {
limit?: number;
offset?: number;
summaryOnly?: boolean;
// Enrichment/annotation suppression flags (#1858/#2129 review F6). Each
// suppresses a distinct sub-phase; they compose, and the real call sites are:
// - full impact()/context(): none set.
// - group cross-repo fan-out (impactByUid): skipPerSymbolEnrichment +
// skipEpistemic — the fan-out consumes only byDepth.
// - ambiguous #2129 per-candidate probe: skipEpistemic + skipEnrichment —
// needs only count + a count-based risk.
// skipPerSymbolEnrichment: drop the post-pagination per-symbol
// STEP_IN_PROCESS pass (keeps byDepth).
// skipEpistemic: skip the #1858 interface/indirection boundary probe.
// skipEnrichment: skip the process/module aggregation passes entirely;
// risk then derives from directCount/total only. NOTE: this also makes
// skipPerSymbolEnrichment a no-op (affectedProcesses stays empty), which
// is why the ambiguous probe sets only the two flags above.
skipPerSymbolEnrichment?: boolean;
skipEpistemic?: boolean;
skipEnrichment?: boolean;
},
): Promise<any> {
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
const skipPerSymbolEnrichment = opts.skipPerSymbolEnrichment ?? false;
const skipEnrichment = opts.skipEnrichment ?? false;
const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
const paginationLimit = hasExplicitLimit
? Math.max(1, Math.min(Math.trunc(opts.limit!), 10000))
@ -3344,6 +3630,22 @@ export class LocalBackend {
const symId = sym.id || sym[0];
// #1858 — kick off the epistemic boundary probe concurrently with the BFS.
// It depends only on symId/symType/symName (all known now) and touches no
// shared state, so its extra round-trip overlaps the traversal instead of
// adding to the serial path. `skipEpistemic` (ambiguous #2129 candidate
// probes, group fan-out) resolves to no field, preserving prior behavior.
// #1858/#2129 review F8 — the skip case adds no field, so `epistemic` is
// optional here (the union's `{}` subtype). computeEpistemicBoundary's own
// return keeps `epistemic` REQUIRED — only this promise widens to the skip
// subtype.
const epistemicPromise: Promise<{
epistemic?: 'exact' | 'lower-bound';
boundaries?: string[];
}> = opts.skipEpistemic
? Promise.resolve({})
: this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]) as string);
const impacted: any[] = [];
const visited = new Set<string>([symId]);
let frontier = [symId];
@ -3509,7 +3811,13 @@ export class LocalBackend {
// Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
if (impacted.length > 0) {
// `skipEnrichment` (ambiguous #2129 per-candidate probes) bypasses the
// process/module aggregation passes entirely — those probes need only the
// count + a count-based risk, so paying the bounded-but-real enrichment cost
// ~6× per ambiguous call is wasted. risk then derives from directCount /
// total only (processCount/moduleCount stay 0), an acceptable approximation
// for a disambiguation aid.
if (impacted.length > 0 && !skipEnrichment) {
// ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─
// Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
// process + entry point info in 1 round-trip per chunk. Converted to
@ -3776,6 +4084,10 @@ export class LocalBackend {
byDepthCounts[Number(depth)] = items.length;
}
// #1858 — await the epistemic boundary probe kicked off alongside the BFS
// above. Additive: leaves impactedCount and every existing field untouched.
const epistemic = await epistemicPromise;
const base = {
target: {
id: symId,
@ -3786,6 +4098,7 @@ export class LocalBackend {
direction,
impactedCount: impacted.length,
risk,
...epistemic,
...(!traversalComplete && { partial: true }),
summary: {
direct: directCount,
@ -3998,6 +4311,10 @@ export class LocalBackend {
includeTests: opts.includeTests,
minConfidence: opts.minConfidence,
skipPerSymbolEnrichment: true,
// Group cross-repo fan-out consumes only byDepth (cross-impact.ts), not
// the #1858 epistemic/boundaries fields — computing them per neighbor is
// dead work on the highest-volume path, so suppress them here too.
skipEpistemic: true,
});
} catch {
return null;

View file

@ -0,0 +1,6 @@
import { NextResponse } from 'next/server';
export async function GET() {
const things = [{ id: 1, name: 'Widget' }];
return NextResponse.json(things);
}

View file

@ -0,0 +1,11 @@
import axios from 'axios';
const API_BASE = process.env.API_BASE || '';
// A custom HTTP wrapper built on axios — it never calls the bare global
// `fetch()`, so the parse-phase auto-detector cannot flag it. Listing
// "doRequest" in `.gitnexusrc` `fetchWrappers` is what lets the routes phase
// trace its consumers (#1589/#1852 residual).
export async function doRequest(path: string, opts?: Record<string, unknown>) {
return axios.get(`${API_BASE}${path}`, opts);
}

View file

@ -0,0 +1,14 @@
// `cafédoRequest` is a DIFFERENT function whose name ends in the configured
// wrapper `doRequest`, preceded by a non-ASCII letter. The consumer scan's
// left boundary must treat `é` as an identifier character (Unicode-aware) and
// NOT match `doRequest` here — otherwise this produces a spurious FETCHES edge
// to /api/things (#1852 review F10).
declare function cafédoRequest(path: string): Promise<unknown>;
export default function Accented() {
const load = async () => {
const res = await cafédoRequest('/api/things');
return res;
};
return null;
}

View file

@ -0,0 +1,9 @@
import { doRequest } from '../lib/http';
export default function ThingsList() {
const loadThings = async () => {
const res = await doRequest('/api/things');
return res.data;
};
return null;
}

View file

@ -0,0 +1,123 @@
/**
* Integration test: impact() ambiguous-resolution blast radius (#2129)
*
* Reproduces the issue's graph shape: a small helper name (`classifyCard`)
* exists in two files. The "real" one is called by `syncContent` (+ another
* caller); a coincidental same-name helper elsewhere is called by `renderCard`.
*
* Before fix: impact("classifyCard", upstream) resolves the ambiguous bare name
* to `impactedCount: 0` with a flat candidate list — the real caller
* (`syncContent`) is silently dropped because it calls the *other* same-name
* node. After fix: the ambiguous response runs a bounded summary-only BFS per
* candidate, surfacing each one's true count + the maximum, so no real caller
* hides behind a bare zero. The BFS / edge storage are unchanged — disambiguation
* by uid still returns the exact caller.
*/
import { it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
const SYNC_LOGIC_ID = 'Function:src/sync-logic.ts:classifyCard';
const UI_HELPERS_ID = 'Function:src/ui-helpers.ts:classifyCard';
const SEED = [
// Two distinct functions named `classifyCard` in different files.
`CREATE (cc1:Function {id: '${SYNC_LOGIC_ID}', name: 'classifyCard', filePath: 'src/sync-logic.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
`CREATE (cc2:Function {id: '${UI_HELPERS_ID}', name: 'classifyCard', filePath: 'src/ui-helpers.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
// Real callers of the sync-logic classifyCard (the blast radius that was lost).
`CREATE (sc:Function {id: 'Function:src/actions.ts:syncContent', name: 'syncContent', filePath: 'src/actions.ts', startLine: 10, endLine: 120, isExported: true, content: '', description: ''})`,
`CREATE (ss:Function {id: 'Function:src/actions.ts:scheduleSync', name: 'scheduleSync', filePath: 'src/actions.ts', startLine: 130, endLine: 160, isExported: true, content: '', description: ''})`,
// Caller of the coincidental ui-helpers classifyCard.
`CREATE (rc:Function {id: 'Function:src/ui-helpers.ts:renderCard', name: 'renderCard', filePath: 'src/ui-helpers.ts', startLine: 20, endLine: 40, isExported: true, content: '', description: ''})`,
`MATCH (a:Function {id:'Function:src/actions.ts:syncContent'}), (b:Function {id:'${SYNC_LOGIC_ID}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'direct', step:0}]->(b)`,
`MATCH (a:Function {id:'Function:src/actions.ts:scheduleSync'}), (b:Function {id:'${SYNC_LOGIC_ID}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'direct', step:0}]->(b)`,
`MATCH (a:Function {id:'Function:src/ui-helpers.ts:renderCard'}), (b:Function {id:'${UI_HELPERS_ID}'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'direct', step:0}]->(b)`,
];
withTestLbugDB(
'impact-ambiguous-blast-radius',
(handle) => {
let backend: LocalBackend;
beforeAll(() => {
backend = (handle as any)._backend;
});
it('surfaces per-candidate blast radius instead of a bare impactedCount:0', async () => {
const result = await backend.callTool('impact', {
target: 'classifyCard',
direction: 'upstream',
});
expect(result.status).toBe('ambiguous');
expect(Array.isArray(result.candidates)).toBe(true);
expect(result.candidates).toHaveLength(2);
// The fix: the maximum real blast radius is hoisted to the top level so
// the response can never be misread as "safe to refactor".
expect(result.maxImpactedCount).toBeGreaterThanOrEqual(2);
// Each candidate carries its own true count — the dropped caller is no
// longer hidden behind the ambiguous zero.
const syncLogic = result.candidates.find((c: any) =>
String(c.filePath).includes('sync-logic'),
);
const uiHelpers = result.candidates.find((c: any) =>
String(c.filePath).includes('ui-helpers'),
);
expect(syncLogic).toBeDefined();
expect(uiHelpers).toBeDefined();
expect(syncLogic.impactedCount).toBeGreaterThanOrEqual(2);
expect(uiHelpers.impactedCount).toBeGreaterThanOrEqual(1);
// Candidates are ranked by blast radius (most-impactful interpretation
// first) so the dangerous one leads.
expect(result.candidates[0].impactedCount).toBeGreaterThanOrEqual(
result.candidates[1].impactedCount,
);
});
it('disambiguation by uid returns the exact dropped caller (BFS unchanged)', async () => {
const result = await backend.callTool('impact', {
target: 'classifyCard',
target_uid: SYNC_LOGIC_ID,
direction: 'upstream',
});
expect(result.status).not.toBe('ambiguous');
expect(result.impactedCount).toBeGreaterThanOrEqual(2);
const names = Object.values(result.byDepth as Record<string, any[]>)
.flat()
.map((d: any) => d.name);
expect(names).toContain('syncContent');
expect(names).toContain('scheduleSync');
});
},
{
seed: SEED,
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 5, nodes: 6, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);

View file

@ -0,0 +1,123 @@
/**
* Integration test: epistemic lower-bound flag (#1858)
*
* When a symbol sits behind an interface / indirection boundary, callers that
* bind via a DI container or dynamic dispatch are not traced to the concrete
* symbol — so impact()/context() report a *lower bound*, not an exact figure.
* Instead of a silent confident zero, the result is annotated
* `epistemic: 'lower-bound'` with a human-readable boundary note. A fully
* resolved leaf with no indirection stays `epistemic: 'exact'`.
*
* Graph shape (the canonical Symfony/DI case from #1858 / the #1589 comment):
* SignupController --CALLS--> Logger (interface)
* EmailLogger --IMPLEMENTS--> Logger
* FileLogger --IMPLEMENTS--> Logger
* The controller binds to the *interface*; the concrete impl is wired by the
* container, so impact("EmailLogger", upstream) finds no direct caller — but
* must flag that the true blast radius is higher.
*/
import { it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
const SEED = [
// Interface + two implementations + an interface-level consumer.
`CREATE (iface:Interface {id: 'Interface:src/Logger.ts:Logger', name: 'Logger', filePath: 'src/Logger.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`,
`CREATE (email:Class {id: 'Class:src/EmailLogger.ts:EmailLogger', name: 'EmailLogger', filePath: 'src/EmailLogger.ts', startLine: 1, endLine: 20, isExported: true, content: '', description: ''})`,
`CREATE (file:Class {id: 'Class:src/FileLogger.ts:FileLogger', name: 'FileLogger', filePath: 'src/FileLogger.ts', startLine: 1, endLine: 20, isExported: true, content: '', description: ''})`,
`CREATE (ctrl:Class {id: 'Class:src/SignupController.ts:SignupController', name: 'SignupController', filePath: 'src/SignupController.ts', startLine: 1, endLine: 30, isExported: true, content: '', description: ''})`,
`MATCH (a:Class {id:'Class:src/EmailLogger.ts:EmailLogger'}), (b:Interface {id:'Interface:src/Logger.ts:Logger'}) CREATE (a)-[:CodeRelation {type:'IMPLEMENTS', confidence:0.85, reason:'implements', step:0}]->(b)`,
`MATCH (a:Class {id:'Class:src/FileLogger.ts:FileLogger'}), (b:Interface {id:'Interface:src/Logger.ts:Logger'}) CREATE (a)-[:CodeRelation {type:'IMPLEMENTS', confidence:0.85, reason:'implements', step:0}]->(b)`,
`MATCH (a:Class {id:'Class:src/SignupController.ts:SignupController'}), (b:Interface {id:'Interface:src/Logger.ts:Logger'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'interface-call', step:0}]->(b)`,
// A fully-resolved leaf with no indirection — must stay `exact`.
`CREATE (leaf:Function {id: 'Function:src/util.ts:formatDate', name: 'formatDate', filePath: 'src/util.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
`CREATE (caller:Function {id: 'Function:src/page.ts:renderHeader', name: 'renderHeader', filePath: 'src/page.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`,
`MATCH (a:Function {id:'Function:src/page.ts:renderHeader'}), (b:Function {id:'Function:src/util.ts:formatDate'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`,
];
withTestLbugDB(
'impact-epistemic-lower-bound',
(handle) => {
let backend: LocalBackend;
beforeAll(() => {
backend = (handle as any)._backend;
});
it('flags impact() on a concrete impl behind an interface as lower-bound', async () => {
const result = await backend.callTool('impact', {
target: 'EmailLogger',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.epistemic).toBe('lower-bound');
expect(Array.isArray(result.boundaries)).toBe(true);
expect(result.boundaries.join(' ')).toContain('Logger');
});
it('flags impact() on the interface itself as lower-bound', async () => {
const result = await backend.callTool('impact', {
target: 'Logger',
direction: 'upstream',
});
expect(result.epistemic).toBe('lower-bound');
});
it('keeps a fully-resolved leaf exact (no false boundary)', async () => {
const result = await backend.callTool('impact', {
target: 'formatDate',
direction: 'upstream',
});
expect(result.epistemic).toBe('exact');
expect(result.boundaries).toBeUndefined();
// The real caller is still reported — the flag is additive, not lossy.
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
});
it('context() carries the same epistemic signal', async () => {
const result = await backend.callTool('context', {
name: 'EmailLogger',
file_path: 'src/EmailLogger.ts',
});
expect(result.status).toBe('found');
expect(result.epistemic).toBe('lower-bound');
});
it('context() on a leaf interface itself is lower-bound (#1858 review F3)', async () => {
// Logger is a leaf interface — it implements/extends nothing, so the only
// boundary signal is computeEpistemicBoundary's symType==='Interface'
// self-branch. Before the F3 fix, context() collapsed symKind to 'Class'
// and this returned 'exact'.
const result = await backend.callTool('context', { name: 'Logger' });
expect(result.status).toBe('found');
expect(result.epistemic).toBe('lower-bound');
});
},
{
seed: SEED,
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 6, nodes: 6, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);

View file

@ -0,0 +1,54 @@
/**
* Integration test: configurable fetch wrappers (#1589/#1852 residual)
*
* The parse-phase auto-detector only flags functions that call the bare global
* `fetch()`. A wrapper built on axios / a custom client — like `doRequest` in
* this fixture — is invisible to it, so `route_map` silently reports
* `consumers: []` (exactly the "named outside convention" hole #1858 calls out).
*
* Declaring the wrapper name in `.gitnexusrc` `fetchWrappers` (threaded here via
* PipelineOptions) lets the routes-phase consumer scan trace it, producing the
* FETCHES edge. The control run (no config) proves the gap; the configured run
* proves the fix.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
const FIXTURE = path.join(FIXTURES, 'configurable-fetch-wrapper');
describe('Configurable fetch wrapper consumer extraction', () => {
let withConfig: PipelineResult;
let withoutConfig: PipelineResult;
beforeAll(async () => {
withoutConfig = await runPipelineFromRepo(FIXTURE, () => {});
withConfig = await runPipelineFromRepo(FIXTURE, () => {}, {
fetchWrappers: ['doRequest'],
});
}, 60000);
it('does NOT trace an axios-based wrapper without configuration (the gap)', () => {
const edges = getRelationships(withoutConfig, 'FETCHES');
const thingsEdge = edges.find((e) => e.target === '/api/things');
expect(thingsEdge).toBeUndefined();
});
it('traces the configured wrapper as a route consumer', () => {
const edges = getRelationships(withConfig, 'FETCHES');
const thingsEdge = edges.find(
(e) => e.sourceFilePath.includes('ThingsList') && e.target === '/api/things',
);
expect(thingsEdge).toBeDefined();
});
it('does NOT match a configured bare name inside a longer non-ASCII identifier (#1852 review F10)', () => {
// Accented.tsx calls `cafédoRequest('/api/things')` — `doRequest` preceded by
// the non-ASCII letter `é`. The Unicode-aware lookbehind must reject it.
const edges = getRelationships(withConfig, 'FETCHES');
const spurious = edges.find(
(e) => e.sourceFilePath.includes('Accented') && e.target === '/api/things',
);
expect(spurious).toBeUndefined();
});
});

View file

@ -137,6 +137,28 @@ describe('analyze-config (.gitnexusrc support, #243)', () => {
expect(() => loadAnalyzeConfig(dir)).toThrow(/control or hidden/);
});
// ── fetchWrappers (#1589/#1852 residual) ───────────────────────────
it('normalizes a fetchWrappers string array (de-duped)', async () => {
await writeRc(JSON.stringify({ fetchWrappers: ['doRequest', 'apiClient.get', 'doRequest'] }));
expect(loadAnalyzeConfig(dir)).toEqual({ fetchWrappers: ['doRequest', 'apiClient.get'] });
});
it('rejects a non-array fetchWrappers value', async () => {
await writeRc(JSON.stringify({ fetchWrappers: 'doRequest' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an array of strings/);
});
it('rejects a fetchWrappers entry with regex / non-identifier characters', async () => {
await writeRc(JSON.stringify({ fetchWrappers: ['do(.*)Request'] }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an identifier or member name/);
});
it('rejects an empty fetchWrappers array', async () => {
await writeRc(JSON.stringify({ fetchWrappers: [] }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/at least one string/);
});
// ── validateBranchName ─────────────────────────────────────────────
it('validateBranchName trims and accepts normal branch names', () => {

View file

@ -213,6 +213,162 @@ describe('formatImpactResult', () => {
expect(formatImpactResult({ error: 'bad request' })).toContain('Error: bad request');
});
it('surfaces per-candidate blast radius for an ambiguous result, never the "isolated" headline (#2129)', () => {
const result = formatImpactResult({
status: 'ambiguous',
target: { name: 'classifyCard' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
maxImpactedCount: 3,
maxRisk: 'MEDIUM',
candidates: [
{
uid: 'Function:src/sync-logic.ts:classifyCard',
name: 'classifyCard',
kind: 'Function',
filePath: 'src/sync-logic.ts',
line: 1,
impactedCount: 3,
risk: 'MEDIUM',
},
{
uid: 'Function:src/ui-helpers.ts:classifyCard',
name: 'classifyCard',
kind: 'Function',
filePath: 'src/ui-helpers.ts',
line: 1,
impactedCount: 1,
risk: 'LOW',
},
],
});
// Must NOT print the false-safe "isolated" headline.
expect(result).not.toContain('isolated');
expect(result).toContain('AMBIGUOUS');
expect(result).toContain('Max blast radius 3');
// Both candidates + their real counts are visible.
expect(result).toContain('src/sync-logic.ts');
expect(result).toContain('[3 upstream');
expect(result).toContain('--uid');
// No probe failed here → no lower-bound warning.
expect(result).not.toContain('candidate probes failed');
});
it('warns that the max is a lower bound when a candidate probe failed (#2129 review F1)', () => {
const result = formatImpactResult({
status: 'ambiguous',
target: { name: 'classifyCard' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
maxImpactedCount: 2,
maxRisk: 'LOW',
partialProbe: true,
candidates: [
{
uid: 'A',
name: 'classifyCard',
kind: 'Function',
filePath: 'src/a.ts',
line: 1,
impactedCount: 2,
risk: 'LOW',
},
{
uid: 'B',
name: 'classifyCard',
kind: 'Function',
filePath: 'src/b.ts',
line: 1,
impactedCount: 0,
risk: 'UNKNOWN',
},
],
});
expect(result).not.toContain('isolated');
expect(result).toContain('candidate probes failed');
expect(result).toContain('lower bound');
// The honest max is still shown.
expect(result).toContain('Max blast radius 2');
});
it('reports the full match count when the candidate list is truncated (#2129 review F11)', () => {
const result = formatImpactResult({
status: 'ambiguous',
target: { name: 'handle' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
maxImpactedCount: 5,
maxRisk: 'HIGH',
totalCandidates: 9,
candidatesTruncated: true,
candidates: Array.from({ length: 6 }, (_, i) => ({
uid: `U${i}`,
name: 'handle',
kind: 'Function',
filePath: `src/h${i}.ts`,
line: 1,
impactedCount: i,
risk: 'LOW',
})),
});
// Full count (9), not the truncated array length (6).
expect(result).toContain('9 symbols');
expect(result).toContain('showing 6');
});
it('shows a plain count when the candidate list is not truncated', () => {
const result = formatImpactResult({
status: 'ambiguous',
target: { name: 'foo' },
direction: 'upstream',
impactedCount: 0,
risk: 'UNKNOWN',
maxImpactedCount: 1,
maxRisk: 'LOW',
totalCandidates: 2,
candidates: [
{
uid: 'A',
name: 'foo',
kind: 'Function',
filePath: 'src/a.ts',
line: 1,
impactedCount: 1,
risk: 'LOW',
},
{
uid: 'B',
name: 'foo',
kind: 'Function',
filePath: 'src/b.ts',
line: 1,
impactedCount: 0,
risk: 'LOW',
},
],
});
expect(result).toContain('2 symbols');
expect(result).not.toContain('showing');
});
it('surfaces the lower-bound boundary note when epistemic is lower-bound (#1858)', () => {
const result = formatImpactResult({
target: { kind: 'Class', name: 'EmailLogger' },
direction: 'upstream',
impactedCount: 0,
risk: 'LOW',
epistemic: 'lower-bound',
boundaries: ['Logger is an interface with 2 implementations; callers bind via DI.'],
byDepth: {},
});
expect(result).not.toContain('isolated');
expect(result.toLowerCase()).toContain('lower bound');
expect(result).toContain('Logger is an interface');
});
it('returns error with suggestion when provided', () => {
const result = formatImpactResult({
error: 'Impact analysis failed',

View file

@ -56,6 +56,11 @@ function setupMultiDepthHub(d1Count: number, d2Count: number) {
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
if (query.includes('STEP_IN_PROCESS')) return [];
if (query.includes('MEMBER_OF')) return [];
// The #1858 epistemic-boundary probe (computeEpistemicBoundary) runs
// concurrently with the BFS and also matches `r.type IN`, but targets the
// `iface` alias. Return empty so it stays `epistemic: 'exact'` and does not
// consume a depth slot from the frontier counter below.
if (query.includes('iface')) return [];
if (query.includes('r.type IN')) {
depth++;
const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0;
@ -82,6 +87,9 @@ function setupHubSymbol(count: number) {
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
if (query.includes('STEP_IN_PROCESS')) return [];
if (query.includes('MEMBER_OF')) return [];
// See setupMultiDepthHub — keep the #1858 epistemic probe from matching the
// `r.type IN` caller branch below.
if (query.includes('iface')) return [];
if (query.includes('r.type IN')) {
const res: any[] = [];
for (let i = 0; i < count; i++) {

View file

@ -17,7 +17,7 @@ import {
describe('VALID_RELATION_TYPES', () => {
it('contains all expected relation types', () => {
expect(VALID_RELATION_TYPES.size).toBe(15);
expect(VALID_RELATION_TYPES.size).toBe(16);
for (const t of [
'CALLS',
'IMPORTS',
@ -29,6 +29,9 @@ describe('VALID_RELATION_TYPES', () => {
'OVERRIDES',
'METHOD_IMPLEMENTS',
'ACCESSES',
// USES is an emitted edge type (emit-references.ts) used in the default
// impact relTypes + context queries; added to the allowlist in F5.
'USES',
'HANDLES_ROUTE',
'FETCHES',
'HANDLES_TOOL',
@ -41,7 +44,6 @@ describe('VALID_RELATION_TYPES', () => {
it('rejects invalid relation types', () => {
expect(VALID_RELATION_TYPES.has('CONTAINS')).toBe(false);
expect(VALID_RELATION_TYPES.has('USES')).toBe(false);
expect(VALID_RELATION_TYPES.has('calls')).toBe(false); // case-sensitive
expect(VALID_RELATION_TYPES.has('DROP_TABLE')).toBe(false);
});