fix(impact): stop reporting 'exact' over unmodelled callable-value references

A function named in VALUE position — `bridge.accessor(Element.getNamespaceUri,
null, .{})`, `{ onClick: handler }`, a comparator handed to a sort — is
registered somewhere rather than called. The registration is modelled (a
`value-ref` site becomes a USES edge; Kythe `ref` vs `ref/call`, Joern
METHOD_REF), but the invocation THROUGH the stored value is not: it happens
later via a struct field, a registry lookup or comptime reflection.

impact()/context() nonetheless reported such a target as `epistemic: 'exact'`.
For lightpanda-io/browser that meant the DOM `Element.namespaceURI` accessor
came back with two internal callers, LOW risk and a claim of completeness —
worse than no answer, because 'exact' tells the reader not to look further.
tools.ts defines 'lower-bound' as "the walk provably missed callers", which is
precisely this case.

computeEpistemicBoundary now probes for inbound USES edges stamped with the
value-ref reason and hedges when it finds any, contributing a boundary note and
a new `causes.callableValueReferences` (unit: distinct referrer symbols).

Read from the graph, not from index metadata: unlike a dropped receiver — which
leaves no edge to find and therefore needs a persisted summary — a value
reference IS in the graph. So the signal needs no re-index and no analyzer
change, and it works on indexes written before this commit.

The cause gets its own slot rather than joining `dispatchBoundary`: a value
referrer is neither an implementation nor an interface-level consumer, and the
two differ in what the reader should do about them — a dispatch boundary is
irreducible, a callable value usually becomes traceable once the provider
models the store/load that carries it.

Language-neutral: every provider that emits a `value-ref` capture participates.
The writer and the reader now share VALUE_REF_EDGE_REASON, because drift
between them would fail silently — the probe would match nothing and every
answer would go back to claiming certainty.
This commit is contained in:
Navid EMAD 2026-09-08 05:21:45 +02:00
parent 0d1aed942f
commit ea2372e453
No known key found for this signature in database
6 changed files with 376 additions and 11 deletions

98
DECISIONS.md Normal file
View file

@ -0,0 +1,98 @@
# DECISIONS — Zig callable-value references (D1) + false-confidence epistemic (D2)
Unattended run, 2026-09-08. Branch `fix/zig-value-ref-edges`, forked from
`origin/main` @ `0d1aed94`.
Task: a Zig function referenced as a VALUE in a binding table is absent from the
graph, and `impact` reports that absence as `epistemic: "exact"`. Downstream
rejection: lightpanda-io/browser#3399.
---
## Baseline (measured, not quoted)
Local build reports **1.6.11** (expected: `package.json` on main says 1.6.11 and
the 1.6.12-rc.* tags are CI-published without a committed bump).
```
cd ~/code/GitNexus/gitnexus && $HOME/.local/share/mise/installs/node/26.8.1/bin/npm run build # exit 0
rm -rf ~/code/browser/.gitnexus
cd ~/code/browser && node ~/code/GitNexus/gitnexus/dist/cli/index.js analyze --index-only --skip-agents-md --no-stats
```
Index: **30,222 nodes | 71,070 edges | 997 clusters | 1155 flows** (34.7 s).
| probe (`impact … -d upstream`) | impactedCount | direct | risk | epistemic |
| --- | --- | --- | --- | --- |
| `getNamespaceUri` @ `src/browser/webapi/Element.zig` | 5 | 2 | LOW | `exact` |
| `getTagNameLower` @ `src/browser/webapi/Element.zig` | 30 | 10 | HIGH | `exact` |
`context getNamespaceUri` baseline `incoming.calls`: exactly the two internal
callers, `Element.lookupNamespaceURIForElement` and
`Element.lookupPrefixForElement`. The `JsApi` binding at `Element.zig:2296`
(`pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});`)
is absent.
My baseline is **identical** to the indicative npm 1.6.12-rc.7 numbers quoted in
the brief (5/2/LOW/exact and 30/10/HIGH), so the two artifacts agree and the
success criteria carry over unchanged.
---
## Decisions
### D2 — false confidence (`epistemic: "exact"` over unmodelled value references)
**D2-1. Signal source: the GRAPH, not new index metadata.** The `value-ref` →
`USES` edge already carries `reason = 'scope-resolution: value-ref'`, and
`reason` is an existing column of `CodeRelation`. `computeEpistemicBoundary`
probes for inbound edges with that reason and hedges when it finds any.
*Rejected:* a new `RepoMeta` summary in the shape of `unresolvedReceiverMembers`
(a `summarize*` builder + a persisted field + a reader). That is the established
pattern, but it is the pattern for facts that leave NO trace in the graph — a
dropped receiver has no edge to find. A value reference is the opposite: the
reference is modelled, only the invocation through it is not. Reading a fact
that is already in the graph out of a side-channel would add an analyzer change,
a metadata field and a re-index requirement for no extra information.
Consequence: this probe needs no re-index and works on indexes written today.
**D2-2. Its own cause slot, `causes.callableValueReferences`.**
*Rejected:* folding the count into `causes.dispatchBoundary`. That slot is
documented as "implementations plus interface-level consumers"; a value referrer
is neither, and this file's own comments are emphatic that a consumer branching
on the numbers must not be misled about which cause dominates. The distinction
is also actionable: a dispatch boundary is irreducible, a callable value usually
is not (it becomes traceable once the provider models the store/load).
*Also rejected:* a note with no count (the `convexDispatch` precedent, which
sets `dispatch: 0`). That precedent exists because endpoint metadata *cannot*
count omitted symbols. Here the count is available and real, so publishing zero
would be inventing an absence.
**D2-3. Unit = distinct referrer SYMBOLS, capped at 50.** The question the count
serves is "how many places does this value escape from"; a table registering the
same callable twice is still one table. The `LIMIT 50` bounds the work on a
promiscuous target — the note only needs to justify "at least N".
**D2-4. Upstream only.** A reference INTO a symbol says nothing about what that
symbol reaches, so a `downstream` walk is not shortened by it. Same gate the
`convexDispatch` probe already uses.
**D2-5. Shared constant `VALUE_REF_EDGE_REASON`** in a new leaf module
`scope-resolution/value-ref-edges.ts`, imported by the writer
(`passes/property-dispatch.ts`) and the reader (`mcp/local/local-backend.ts`).
*Rejected:* re-typing the literal in the reader. The failure mode of drift is
SILENT — the query matches nothing and every answer goes back to claiming
certainty, which is the defect itself. *Rejected:* importing
`property-dispatch.ts` for the string, which would drag the scope-resolution
emit graph into the MCP backend.
**D2 verified** (read-side only, no re-index needed) against the baseline index:
`impact expectTrue -f src/browser/tests/testing.js -d upstream` — the one JS file
in the browser repo, whose functions ARE registered as object-literal values —
moved `exact` → `lower-bound`, `causes.callableValueReferences: 1`,
`impactedCount` unchanged at 0. `getTagNameLower` unchanged (`exact`).
Test: `test/integration/impact-callable-value-references.test.ts` (5 cases,
including three controls: an ordinary CALLS-only target, a non-value-ref `USES`
edge, and the downstream direction).

View file

@ -43,6 +43,7 @@ import { tryEmitEdge, type CalleeIdCaptureCtx } from '../graph-bridge/edges.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
import { findCallableBindingInScope } from '../scope/walkers.js';
import { VALUE_REF_EDGE_REASON } from '../value-ref-edges.js';
/**
* Keys registered by more than this many distinct functions are skipped —
@ -89,15 +90,7 @@ export function emitPropertyDispatchCalls(
const def = findCallableBindingInScope(site.inScope, site.name, scopes);
if (def === undefined) continue;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
def,
'scope-resolution: value-ref',
seen,
);
const ok = tryEmitEdge(graph, scopes, nodeLookup, site, def, VALUE_REF_EDGE_REASON, seen);
if (ok) usesEmitted++;
if (site.propertyKey === undefined) continue;

View file

@ -0,0 +1,14 @@
/**
* The `reason` text stamped on the USES edge a `value-ref` site emits.
*
* A leaf module on purpose. Two modules need this string and they sit on
* opposite sides of the product: `passes/property-dispatch.ts` writes it at
* analysis time, and `mcp/local/local-backend.ts` reads it back at query time
* to decide whether an answer is `exact`. Re-typing the literal in the reader
* would make the epistemic signal fail SILENTLY the day the writer's text is
* reworded — the query would simply match nothing and every answer would go
* back to claiming certainty, which is the exact defect (#3399) this constant
* exists to close. Importing `property-dispatch.ts` for it instead would drag
* the whole scope-resolution emit graph into the MCP backend for one string.
*/
export const VALUE_REF_EDGE_REASON = 'scope-resolution: value-ref';

View file

@ -129,6 +129,7 @@ import type { UnresolvedReceiverSummary } from '../../core/ingestion/scope-resol
import type { UndecidedSatisfactionSummary } from '../../core/ingestion/scope-resolution/undecided-satisfaction.js';
import { scopeExtractionFailureTotal } from '../../core/ingestion/scope-resolution/scope-extraction-failures.js';
import { lookupCount } from '../../core/ingestion/scope-resolution/summary-maps.js';
import { VALUE_REF_EDGE_REASON } from '../../core/ingestion/scope-resolution/value-ref-edges.js';
import {
DEFERRED_IMPORT_REASON_SUFFIX,
TYPE_ONLY_IMPORT_REASON_SUFFIX,
@ -709,6 +710,31 @@ export interface EpistemicCauses {
* "nothing was undecided", and a re-index is what tells the two apart.
*/
readonly undecidedSatisfaction: number;
/**
* Symbols that name this callable in VALUE position rather than calling it
* (#3399) — a registration table (`bridge.accessor(Element.getNamespaceUri,
* …)`), a callback argument, a function pointer stored in a field.
*
* Unit: SYMBOLS — distinct referrers, the same unit and the same reason as
* `dispatchBoundary`: the reference edge is per-site but the walk's question
* is "who else might reach this", and a referrer that names the callable
* twice is still one place the value escapes from.
*
* Kept separate from `dispatchBoundary` even though both describe dispatch
* the walk cannot follow. That slot counts implementations and
* interface-level consumers found by the heritage probe; these are neither,
* and folding them in would tell a consumer branching on the numbers that an
* interface boundary exists where there is none. The distinction is also the
* actionable one: a dispatch boundary is irreducible, whereas a callable
* value CAN often be followed once the language models the store/load that
* carries it.
*
* The reference itself IS modelled — that is what makes it countable. What is
* missing is the invocation through the value: it happens later, through a
* struct field, a registry lookup, or comptime reflection, and no CALLS edge
* connects the eventual call site back to this symbol.
*/
readonly callableValueReferences: number;
}
function epistemicFrom(dropped: {
@ -718,6 +744,7 @@ function epistemicFrom(dropped: {
undecided: number;
dispatch: number;
scopeExtraction: number;
callableValueReferences: number;
}): {
epistemic: 'exact' | 'lower-bound';
boundaries?: string[];
@ -736,6 +763,7 @@ function epistemicFrom(dropped: {
dispatchBoundary: dropped.dispatch,
externalBoundary: dropped.external,
undecidedSatisfaction: 0,
callableValueReferences: dropped.callableValueReferences,
},
}
: { epistemic: 'exact' }
@ -752,6 +780,7 @@ function epistemicFrom(dropped: {
dispatchBoundary: dropped.dispatch,
externalBoundary: dropped.external,
undecidedSatisfaction: dropped.undecided,
callableValueReferences: dropped.callableValueReferences,
},
};
}
@ -851,6 +880,61 @@ function undecidedSatisfactionBoundaries(
return { notes, undecided };
}
/**
* Boundary evidence for callables named in VALUE position (#3399).
*
* `bridge.accessor(Element.getNamespaceUri, null, .{})`, `{ onClick: handler }`,
* `qsort(xs, n, sz, compareItems)` — each REGISTERS a function somewhere
* instead of calling it. The registration is modelled (`value-ref` → a USES
* edge, Kythe `ref` / Joern `METHOD_REF`); the invocation through the stored
* value is not, because it happens later through a struct field, a registry
* lookup or comptime reflection.
*
* That gap is precisely `tools.ts`'s definition of `lower-bound` — "the walk
* provably missed callers" — and it was previously reported as `exact`. A
* public DOM accessor bound into a JS bridge table came back LOW/exact with two
* internal callers, which is worse than no answer: `lower-bound` invites the
* reader to look further, `exact` tells them not to bother.
*
* Counted as DISTINCT REFERRERS rather than sites: the question the count
* serves is "how many places does this value escape from", and a table that
* registers the same callable twice is still one table.
*
* The probe reads the edge's `reason`, which is why writer and reader share
* {@link VALUE_REF_EDGE_REASON}. Language-neutral by construction — every
* provider that emits a `value-ref` capture participates, and one that emits
* none simply gets no rows.
*/
async function callableValueReferenceBoundaries(
lbugPath: string,
symId: string,
): Promise<{ notes: string[]; referrers: number }> {
// Rows, not `COUNT(...)`: the LIMIT bounds the work on a promiscuous
// registration target, and the note only needs to say "at least N".
const rows = await executeParameterized(
lbugPath,
`MATCH (other)-[r:CodeRelation]->(sym)
WHERE sym.id = $symId AND r.type = 'USES' AND r.reason = $reason
RETURN DISTINCT other.id AS id
ORDER BY id
LIMIT 50`,
{ symId, reason: VALUE_REF_EDGE_REASON },
).catch(() => []);
const referrers = rows.length;
if (referrers === 0) return { notes: [], referrers: 0 };
const one = referrers === 1;
return {
referrers,
notes: [
`${referrers} ${one ? 'symbol references' : 'symbols reference'} this callable as a VALUE ` +
`rather than calling it (a registration table, a callback argument, a stored function ` +
`pointer). The reference is recorded, but the call made THROUGH that value is not: it is ` +
`dispatched later from wherever the value is stored. Callers reached that way are absent ` +
`from this result — actual impact may be higher.`,
],
};
}
interface RepoHandle {
id: string; // unique key = repo name (basename)
name: string;
@ -6994,6 +7078,19 @@ export class LocalBackend {
direction === 'downstream'
? Promise.resolve(undefined)
: queryConvexDispatchMetadata(repo.lbugPath, symId, symName, symType);
// #3399 — callables named in value position. Upstream only: the question
// "who can reach this symbol" is the one a registration makes unanswerable.
// A downstream walk asks what THIS symbol reaches, which a reference INTO
// it does not affect.
//
// Issued alongside the heritage probe rather than after it, and read into
// `droppedBoundaries` below, so it hedges even when that probe finds
// nothing AND when it throws — a value reference is an independent reason
// a count is short, exactly as the receiver drops above are.
const valueRefPromise =
direction === 'downstream'
? Promise.resolve({ notes: [] as string[], referrers: 0 })
: callableValueReferenceBoundaries(repo.lbugPath, symId);
const interfaceRowsPromise = executeParameterized(
repo.lbugPath,
`MATCH (x)-[r:CodeRelation]->(iface)
@ -7014,12 +7111,14 @@ export class LocalBackend {
: []),
]);
const convexDispatch = await convexDispatchPromise;
const valueRefDrops = await valueRefPromise;
const droppedBoundaries = {
...receiverDrops,
notes: [
...receiverDrops.notes,
...scopeExtractionDrops.notes,
...undecidedDrops.notes,
...valueRefDrops.notes,
...(convexDispatch === undefined ? [] : [convexDispatch.boundary]),
],
undecided: undecidedDrops.undecided,
@ -7028,6 +7127,7 @@ export class LocalBackend {
// inventing one from the presence of a note.
dispatch: 0,
scopeExtraction: scopeExtractionDrops.files,
callableValueReferences: valueRefDrops.referrers,
};
try {
// Discover the interface / abstract supertypes on the target's boundary.
@ -7119,6 +7219,7 @@ export class LocalBackend {
dispatchBoundary: droppedBoundaries.dispatch + dispatchBoundarySymbols,
externalBoundary: droppedBoundaries.external,
undecidedSatisfaction: droppedBoundaries.undecided,
callableValueReferences: droppedBoundaries.callableValueReferences,
},
};
} catch {

View file

@ -293,12 +293,13 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries the same epistemic envelope impact() returns:
- epistemic: 'exact' | 'lower-bound' — 'lower-bound' means callers exist that this view provably does not list.
- boundaries: string[] — one plain-language sentence per reason. Prose for humans; branch on causes instead.
- causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — machine-readable WHY. Every field counts MISSING THINGS, never sentences:
- causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction, callableValueReferences } — machine-readable WHY. Every field counts MISSING THINGS, never sentences:
- causes.scopeExtractionFiles (unit: files) > 0 — scope extraction still failed after the fallback pass, so scope-resolution edges from those files are absent. A value of 0 does not prove completeness when epistemic is 'lower-bound' because an older or unverified index has no measured file count. Re-run \`gitnexus analyze --force\`; if the reason persists, inspect the extraction warnings.
- causes.receiverTyping (unit: call sites) > 0 — RESOLVER GAP: the analyzer dropped that many call sites on this name because it could not type the receiver, so they are missing from incoming. Do not read an absent caller as proof none exists.
- causes.externalBoundary (unit: call sites) > 0 — the calls left the indexed program (System.out.println, fetch(...)). NOT a defect: no in-graph node could have been reached. An epistemic:'exact' result can carry this.
- causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary static analysis cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols.
- causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not decide whether a type satisfies an interface, so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Usually fixable by making the missing dependency available to analysis.
- causes.callableValueReferences (unit: symbols) > 0 — that many symbols name this callable as a VALUE instead of calling it (a registration table, a callback argument, a stored function pointer). The reference is in the graph as a USES edge; the call made THROUGH the value is not, because it is dispatched later from wherever the value was stored. incoming.calls is therefore a floor. Follow the USES edges to find the registration, then the code that reads it.
REQUIRES RE-INDEX: causes.scopeExtractionFiles, causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result.
@ -490,13 +491,14 @@ Output includes:
- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list. An item carries staticGated:true only when the edge that reached it is provably unreachable at compile time from the indexed source (today: Zig calls inside an 'if (CONST_FALSE)' body or the else of 'if (CONST_TRUE)'); the field is absent when the edge is live or the language does not model it. Traversal and risk do NOT filter or rank on it: it is metadata for the caller to weigh.
- epistemic: 'exact' | 'lower-bound' — whether impactedCount is the whole story. 'lower-bound' means the walk provably missed callers, so the count is a floor. Absent only on skipped probes (ambiguous-candidate lists, group fan-out).
- boundaries: string[] — one plain-language sentence per reason the count is short. Prose for humans; branch on causes instead.
- causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences:
- causes: { scopeExtractionFiles, receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction, callableValueReferences } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences:
- causes.scopeExtractionFiles (unit: files) > 0 — scope extraction still failed after the fallback pass, so scope-resolution edges from those files are absent. A value of 0 does not prove completeness when epistemic is 'lower-bound' because an older or unverified index has no measured file count. Re-run \`gitnexus analyze --force\`; if the reason persists, inspect the extraction warnings.
- causes.receiverTyping (unit: call sites) > 0 — the RESOLVER GAP signal: the analyzer dropped that many call sites because it could not establish the receiver's type (unresolved constructor, factory, chained expression). Those callers are absent from byDepth. Treat the result as incomplete: grep the symbol name before deleting or renaming.
- causes.externalBoundary (unit: call sites) > 0 — those calls left the indexed program (System.out.println, fetch(...), os.environ.*). NOT a defect and NOT a reason the count is short: there is no in-graph node any edge could have reached. An epistemic:'exact' result can carry this.
- causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary a static walk cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols.
- causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not DECIDE whether a type satisfies an interface (a type in a required signature named a package it could not resolve), so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Distinct from every cause above, which count decided facts that could not be attributed; this one counts questions never answered. It is the only cause that shortens a result WITHOUT leaving a trace in the graph, so an unhedged zero on a symbol reached only through such an interface would otherwise read as 'nobody calls this'. Usually fixable: it most often means a dependency is missing from the analyzed tree.
- causes.callableValueReferences (unit: symbols) > 0 — that many symbols name this callable as a VALUE rather than calling it: 'bridge.accessor(Element.getNamespaceUri, ...)', '{ onClick: handler }', a comparator handed to a sort. The registration IS modelled (a USES edge); the invocation through the stored value is NOT, because it happens later via a struct field, a registry lookup or comptime reflection. So impactedCount is a floor and a LOW risk verdict on such a symbol is a floor too. Unlike dispatchBoundary this is often reducible — it usually means the language provider does not yet follow that store/load — but until it is, do NOT read an empty or small caller set as 'safe to change'. Read from the graph, so it needs no index-time metadata.
REQUIRES RE-INDEX: causes.scopeExtractionFiles, causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result.

View file

@ -0,0 +1,157 @@
/**
* Integration test: a callable named in VALUE position makes `impact` a lower
* bound (#3399).
*
* The rejected behaviour, in one sentence: `Element.getNamespaceUri` — the DOM
* `Element.namespaceURI` accessor — is bound into a JS bridge table as
* `bridge.accessor(Element.getNamespaceUri, null, .{})`, and `impact` answered
* "2 callers, LOW risk, epistemic: exact". Everything about that answer except
* the number 2 was wrong, and `exact` is the part that made it unusable: it
* tells the reader not to look further.
*
* The seed below is the shape that matters, not the language. A registration
* edge (`USES`, reason `scope-resolution: value-ref`) says a function was
* handed somewhere as a value. Where the value goes next — a struct field, a
* registry lookup, comptime reflection — is not modelled, so no CALLS edge
* connects the eventual invocation back to the target. `tools.ts` defines
* `lower-bound` as "the walk provably missed callers", and that is exactly this
* situation.
*
* WHY the assertions are what they are:
* - `impactedCount` must NOT move. Hedging is not inventing callers; a fix
* that made the number go up would be a different (and unearned) claim.
* - a plain CALLS-only target in the SAME index must stay `exact`, or the
* hedge is noise sprayed over every query and carries no information.
* - the cause has its own slot rather than being folded into
* `dispatchBoundary`: an agent branching on the numbers would otherwise be
* told an interface boundary exists where there is none, and would draw the
* opposite conclusion about whether the gap is reducible.
*/
import { it, expect, beforeAll, vi } from 'vitest';
import path from 'node:path';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { VALUE_REF_EDGE_REASON } from '../../src/core/ingestion/scope-resolution/value-ref-edges.js';
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
return {
...actual,
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
};
});
const { listRegisteredRepos, saveMeta } = await import('../../src/storage/repo-manager.js');
const SEED = [
// The registered accessor, with one ordinary in-file caller so the walk has
// something real to report. This mirrors Element.zig: `getNamespaceUri` genuinely
// has internal callers, and it is the REGISTRATION that the answer omits.
`CREATE (:Method {id: 'Method:webapi/Element.zig:Element.getNamespaceUri', name: 'getNamespaceUri', filePath: 'webapi/Element.zig', startLine: 439, endLine: 442, isExported: true, content: '', description: ''})`,
`CREATE (:Method {id: 'Method:webapi/Element.zig:Element.lookupPrefixForElement', name: 'lookupPrefixForElement', filePath: 'webapi/Element.zig', startLine: 490, endLine: 520, isExported: true, content: '', description: ''})`,
`MATCH (a:Method {id:'Method:webapi/Element.zig:Element.lookupPrefixForElement'}), (b:Method {id:'Method:webapi/Element.zig:Element.getNamespaceUri'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'scope-resolution: local-call', step:0}]->(b)`,
// The binding table entry: `pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});`
// A registration, NOT an invocation — hence USES, not CALLS (Kythe `ref` vs
// `ref/call`; Joern METHOD_REF).
`CREATE (:Struct {id: 'Struct:webapi/Element.zig:Element.JsApi', name: 'JsApi', filePath: 'webapi/Element.zig', startLine: 2281, endLine: 2400, content: '', description: ''})`,
`MATCH (a:Struct {id:'Struct:webapi/Element.zig:Element.JsApi'}), (b:Method {id:'Method:webapi/Element.zig:Element.getNamespaceUri'}) CREATE (a)-[:CodeRelation {type:'USES', confidence:0.85, reason:'${VALUE_REF_EDGE_REASON}', step:0}]->(b)`,
// Control: same file, same shape of caller, but nothing registers it as a
// value. This is `getTagNameLower` — it must come back `exact`.
`CREATE (:Method {id: 'Method:webapi/Element.zig:Element.getTagNameLower', name: 'getTagNameLower', filePath: 'webapi/Element.zig', startLine: 400, endLine: 410, isExported: true, content: '', description: ''})`,
`MATCH (a:Method {id:'Method:webapi/Element.zig:Element.lookupPrefixForElement'}), (b:Method {id:'Method:webapi/Element.zig:Element.getTagNameLower'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.85, reason:'scope-resolution: local-call', step:0}]->(b)`,
// Second control: an ordinary USES edge that is NOT a value registration (a
// type reference). The probe keys on the reason, so this must not hedge —
// otherwise every type mention in the repo would downgrade its target.
`CREATE (:Method {id: 'Method:webapi/Element.zig:Element.getInnerText', name: 'getInnerText', filePath: 'webapi/Element.zig', startLine: 600, endLine: 610, isExported: true, content: '', description: ''})`,
`MATCH (a:Struct {id:'Struct:webapi/Element.zig:Element.JsApi'}), (b:Method {id:'Method:webapi/Element.zig:Element.getInnerText'}) CREATE (a)-[:CodeRelation {type:'USES', confidence:0.85, reason:'scope-resolution: type-reference', step:0}]->(b)`,
];
withTestLbugDB(
'impact-callable-value-references',
(handle) => {
let backend: LocalBackend;
beforeAll(() => {
backend = (handle as any)._backend;
});
it('downgrades a registered accessor to lower-bound without inventing callers', async () => {
const result: any = await backend.callTool('impact', {
target: 'getNamespaceUri',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
// The registration is a real inbound edge, so it IS traversed and counted
// — but the call THROUGH the registered value is not, which is why the
// count still cannot be the whole story.
expect(result.epistemic).toBe('lower-bound');
expect(result.causes.callableValueReferences).toBe(1);
// Its own slot: an agent must not read this as an interface boundary.
expect(result.causes.dispatchBoundary).toBe(0);
expect(result.boundaries.join(' ')).toContain('as a VALUE');
});
it('leaves a symbol with only ordinary calls exact', async () => {
const result: any = await backend.callTool('impact', {
target: 'getTagNameLower',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.epistemic).toBe('exact');
});
it('does not hedge on a USES edge that is not a value registration', async () => {
const result: any = await backend.callTool('impact', {
target: 'getInnerText',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
// A type reference is a use, not an escape: nothing can be invoked
// through it, so the answer stays complete.
expect(result.epistemic).toBe('exact');
});
it('hedges context() for the same reason it hedges impact()', async () => {
const result: any = await backend.callTool('context', { name: 'getNamespaceUri' });
expect(result).not.toHaveProperty('error');
expect(result.epistemic).toBe('lower-bound');
expect(result.causes.callableValueReferences).toBe(1);
});
it('stays exact downstream — a reference INTO a symbol says nothing about what it reaches', async () => {
const result: any = await backend.callTool('impact', {
target: 'getNamespaceUri',
direction: 'downstream',
});
expect(result).not.toHaveProperty('error');
expect(result.epistemic).toBe('exact');
});
},
{
seed: SEED,
poolAdapter: true,
afterSetup: async (h) => {
// Without a completeness receipt EVERY answer is hedged ("scope-extraction
// completeness was not recorded"), which would make the controls below
// pass for the wrong reason and prove nothing about this probe.
// `saveMeta` is the only writer production uses.
await saveMeta(path.dirname(h.dbPath), { scopeExtractionReceipt: 1 } as any);
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: h.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 1, nodes: 5, communities: 0, processes: 0 },
},
] as any);
const backend = new LocalBackend();
await backend.init();
(h as any)._backend = backend;
},
},
);