fix(review): resolve qualified value references through their owner, and stop

hedging on registrations the analyzer already followed

Five findings from the review bot on #3219; four valid, all addressed.

1. QUALIFIED VALUE REFERENCES RESOLVED BY TAIL NAME (the serious one).
   `bridge.accessor(Element.getNamespaceUri, …)` was resolved with
   `findCallableBindingInScope(site.inScope, site.name, …)`, which never sees
   the receiver and gives LOCAL bindings precedence. Reproduced:

       const Element = @This();
       pub fn getThing(...)                 // main.getThing
       pub const JsApi = struct {
           fn getThing(...)                 // JsApi.getThing
           pub const thing = bridge.accessor(Element.getThing, null, .{});
       };

   emitted `USES JsApi → JsApi.getThing` — a WRONG edge, which is worse than
   the missing edge this PR set out to fix.

   `resolveValueRefTarget` now resolves a site carrying an explicit receiver
   through `findClassBindingInScope` → `findOwnedMember` (the machinery
   `receiver-bound-calls` already uses), gated on `CALL_TARGET_TYPES` because
   `findOwnedMember` also answers with fields. A bare site keeps the lexical
   walk, which is what an unqualified name means. When the owner cannot be
   resolved the site is DECLINED rather than falling back: declining costs a
   reference, falling back mints a confident edge to the wrong function, and
   the missing reference is now reported as `lower-bound` anyway.

   Cost on lightpanda-io/browser: value-ref edges 3,169 → 2,799 (−12%). Those
   370 were tail-name coincidences, not registrations — all 94 `Element.zig`
   `JsApi` entries survive, the cross-container case resolves and is correctly
   attributed (`IntersectionObserverEntry.JsApi` → `IntersectionObserverEntry.
   getTarget#0`, not the enclosing observer), and both acceptance probes are
   unchanged: `getNamespaceUri` 7/3/LOW/lower-bound, `getTagNameLower`
   31/10/HIGH/exact.

2. A FAILED PROBE READ AS "NO BOUNDARY". `.catch(() => [])` turned an
   unanswerable query into count 0 and no note, so `exact` could be published
   on the strength of a question that was never asked. It now returns `null`
   and emits a boundary note. This file's own `loadMeta` comment states the
   rule: a probe failing must never read as certainty.

3. NOT EVERY VALUE REFERENCE IS AN UNMODELLED INVOCATION. Where
   `emitPropertyDispatchCalls` sweep 2 synthesized the dispatch (reason
   `property-dispatch`), the walk did NOT provably miss the caller, so hedging
   was noise over an answer that was computed — and a signal that fires on
   every JS/TS hook table stops carrying information. A second probe excludes
   those targets. Zig never sets a property key, so the motivating case is
   untouched. The exclusion is symbol-level, not per-edge, because the graph
   does not record which registration produced which synthesized call; that
   residual is documented at the call site.

4. `LIMIT 50` SILENTLY UNDERSTATED THE COUNT. `rows.length` over a capped row
   set published a ceiling as the documented symbol count. Replaced with
   `COUNT(DISTINCT other.id)` — bounded work without a bounded answer, the
   shape `countByType` in the same file already uses.

5. THE TEST DID NOT PIN THE CALLER COUNT its own comment promised. Added
   `expect(result.impactedCount).toBe(2)`.

New regression tests: qualified references bind the written owner and not the
nearer lexical match; an unresolvable receiver emits nothing rather than a
wrong edge; a dispatch-modelled registration stays `exact`; a probe that cannot
run hedges instead of claiming certainty.

Known limitation, pinned by a test rather than left implicit: when a `@This()`
alias's NAME differs from its container's (`const Element = @This();` inside
`main.zig`), the receiver does not resolve and the reference is declined. The
provider has `rewriteZigThisAlias` for this, but it is applied to type nodes
and extending it to reference receivers would change existing CALL-site
behaviour. Lightpanda's `const Foo = @This();` in `Foo.zig` convention makes
the names coincide, which is why the corpus is unaffected.
This commit is contained in:
Navid EMAD 2026-09-08 08:25:43 +02:00
parent fda651dd75
commit 3a58722c1a
No known key found for this signature in database
8 changed files with 387 additions and 16 deletions

View file

@ -228,3 +228,114 @@ record.
Every fork above was decided and recorded. No gate failed three times; no gate
needed a baseline edit.
---
## Review round 1 — `gitnexus-check` bot on PR #3219
Five findings. I reproduced each against the code before deciding; four were
valid and are fixed, one was valid and is fixed differently than proposed.
**R1-1 (valid, fixed) — a failed probe read as "no boundary".**
`callableValueReferenceBoundaries` did `.catch(() => [])`, so a query that could
not run produced count 0 and no note, and `epistemicFrom` could then publish
`exact`. That is the exact failure this feature exists to remove, and this
file's own `loadMeta` comment states the rule: "a probe failing must never read
as certainty". Now: `.catch(() => null)`, and `null` emits a boundary note
("could not be run") with count 0. Three outcomes, three answers — cannot run →
hedge; measured zero → no hedge; followed → no hedge.
Test: `hedges — never reports exact — when the probe itself cannot run`, which
injects a rejection into that one query (keyed on the bound `$reason` param) and
leaves every other query on the real database.
**R1-2 (valid, REPRODUCED, fixed) — qualified references resolved by tail name.**
The bot claimed `Element.getNamespaceUri` could bind a lexically nearer
same-named callable. I built the fixture and it reproduced exactly:
const Element = @This();
pub fn getThing(...) // Method:src/main.zig:main.getThing#0
pub const JsApi = struct {
fn getThing(...) // Method:src/main.zig:JsApi.getThing#0
pub const thing = bridge.accessor(Element.getThing, null, .{});
};
→ `USES JsApi → Method:src/main.zig:JsApi.getThing#0`. A **wrong** edge, which
is worse than the missing edge this PR set out to fix.
Fixed in the language-neutral pass: `resolveValueRefTarget` in
`property-dispatch.ts` resolves a site with an explicit receiver through
`findClassBindingInScope` → `findOwnedMember` (the machinery
`receiver-bound-calls` already uses), gated on `CALL_TARGET_TYPES` because
`findOwnedMember` also answers with fields. A bare site keeps the lexical walk,
which is what an unqualified name means.
*Rejected: falling back to the lexical walk when the receiver does not resolve.*
It would have kept ~370 more edges, but it reinstates the wrong-edge case
exactly where the evidence is weakest. Declining costs a reference; falling back
mints a confident edge to the wrong function, and a reference that is now
missing is reported as `lower-bound` rather than as certainty.
Measured cost of that choice on the real corpus: value-ref edges
**3,169 → 2,799** (−370, −12%). Verified those 370 were tail-name coincidences,
not real registrations:
- all 94 `Element.zig` `JsApi` registrations survive;
- the cross-container case resolves and is correctly attributed —
`IntersectionObserverEntry.JsApi → IntersectionObserverEntry.getTarget#0`, not
the enclosing `IntersectionObserver`;
- source-level census of the corpus: 2,010 qualified `bridge.*` registrations,
1,812 self-owner + 198 cross-container, and the sampled cross-container ones
all resolve.
- both acceptance probes are unchanged by the fix: `getNamespaceUri` 7 / 3 /
LOW / `lower-bound`, `getTagNameLower` 31 / 10 / HIGH / `exact`.
*Known limitation, deliberately not fixed:* when a `@This()` alias's NAME
differs from its container's (`const Element = @This();` inside `main.zig`), the
receiver does not resolve and the reference is declined. The provider has
`rewriteZigThisAlias` for exactly this, but it is applied to TYPE nodes and
extending it to reference receivers would change existing CALL-site behaviour —
out of scope here. Lightpanda's convention (`const Foo = @This();` in `Foo.zig`)
makes the names coincide, which is why the corpus is unaffected. The safe
behaviour is pinned by
`declines a qualified reference whose receiver cannot be resolved`.
**R1-3 (valid, fixed as proposed) — the test did not assert the caller count.**
Its own comment promised the count would not move; only the epistemic envelope
was asserted. Added `expect(result.impactedCount).toBe(2)` — the bot's proposed
value, confirmed by running it.
**R1-4 (valid, fixed) — not every value reference is an unmodelled invocation.**
`emitPropertyDispatchCalls` sweep 2 synthesizes CALLS (reason
`property-dispatch`) for member calls through a registered property key. Where
that happened the walk did NOT provably miss the caller, so hedging is noise
over an answer that was computed — and a signal that fires on every JS/TS hook
table stops carrying information. A second probe now excludes targets with an
inbound `property-dispatch` CALLS edge. Zig never sets a property key, so the
motivating case is untouched.
*Accepted residual, documented in the code:* the exclusion is symbol-level, not
per-edge — the graph does not record which registration produced which
synthesized call — so a target with a mix of followed and unfollowed
registrations is not hedged. That is the one place this errs toward confidence;
the alternative errs on every hook table in every JS codebase.
**R1-5 (valid, fixed) — `LIMIT 50` silently understated the count.**
`rows.length` over a capped row set published a ceiling as if it were the
documented symbol count. Replaced with `COUNT(DISTINCT other.id)` — bounded work
without a bounded answer, the shape `countByType` in the same file already uses.
The cap is gone rather than merely disclosed.
`tools.ts` cause documentation updated for all three new behaviours (exact
count, dispatch-modelled zero, probe-failure zero-with-note).
### Gates after review round 1
- Full `vitest run`: **20,634 passed / 10 failed / 20,644 total** — the SAME 10
pre-existing failures verified against `origin/main` earlier, no new ones.
- One earlier run showed an 11th failure (`fts-extension-e2e` #2841). That was
self-inflicted: I ran the browser `analyze` and cypher queries CONCURRENTLY
with the suite. Run alone that test passes and two *network-dependent*
self-heal tests fail instead; a clean full run with nothing else touching the
machine reproduces exactly the 10. Recorded because it is the trap the
machine notes already describe for `cli-e2e` / `analyze-index-lock-concurrency`.
- Bench `--check` re-run after the receiver fix: `scope-capture` (15 languages),
`receiver-resolution`, `zig-cross-file-resolution`, `callable-value-flow`,
`scope-emission`, `python-scope` — all PASS, no baseline edited.

View file

@ -17,9 +17,11 @@
* registries only consult pre-finalize local bindings — imported names live
* in finalized bindings (the same reason free calls need
* `emitFreeCallFallback`) — so `resolveReferenceSites` skips `value-ref`
* sites and this pass resolves them post-finalize via
* `findCallableBindingInScope` (Function/Method/Constructor only — the
* callable gate that keeps `{ port: DEFAULT_PORT }` from emitting anything).
* sites and this pass resolves them post-finalize (see
* `resolveValueRefTarget` — Function/Method/Constructor only, the callable
* gate that keeps `{ port: DEFAULT_PORT }` from emitting anything, and
* receiver-aware so a qualified reference binds the owner it was written
* with).
*
* Precision posture (mirrors `emitInterfaceDispatchFor`):
* - reason `'property-dispatch'` keeps synthesized CALLS auditable;
@ -36,14 +38,20 @@
* emits the capture participates) and generic member-call sites.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
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 {
findCallableBindingInScope,
findClassBindingInScope,
findOwnedMember,
} from '../scope/walkers.js';
import { VALUE_REF_EDGE_REASON } from '../value-ref-edges.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import { CALL_TARGET_TYPES } from '../../model/symbol-table.js';
/**
* Keys registered by more than this many distinct functions are skipped —
@ -66,11 +74,56 @@ export const MAX_PROPERTY_DISPATCH_FANOUT = (() => {
/** Below the 0.85 resolved baseline; same discount idea as interface-dispatch. */
export const PROPERTY_DISPATCH_CONFIDENCE = 0.7;
/**
* Resolve a `value-ref` site to the callable it names.
*
* Two shapes, and the difference matters:
*
* - BARE (`{ handler: onClick }`, `register(onTick)`) — the name is resolved
* up the lexical chain, which is what an unqualified name means.
*
* - QUALIFIED (`bridge.accessor(Element.getNamespaceUri, …)`) — the source
* WROTE the owner, so the lexical chain is the wrong instrument. It gives
* local bindings precedence (`walkScopeChain`), so a nested container
* holding its own `getNamespaceUri` answers first and the registration is
* attached to a DIFFERENT function than the one written. That is a wrong
* edge, not a missing one — strictly worse for a tool whose value is that
* its edges can be trusted — and it is reachable in any language that
* allows a same-named callable in a nested container, Zig included.
*
* So a qualified site resolves through its receiver: name the owner, then take
* the member off that owner. If the owner cannot be resolved, or resolves but
* owns no such callable, this DECLINES rather than falling back to the lexical
* walk. Declining costs a reference; falling back would mint a confident edge
* to the wrong target, and `impact` now reports the missing reference as
* `lower-bound` rather than as certainty.
*
* `CALL_TARGET_TYPES`, not a hand-rolled label set: `findOwnedMember` also
* answers with FIELDS, and a field named like the member would otherwise
* register as if it were the callable.
*/
function resolveValueRefTarget(
site: ReferenceSite,
scopes: ScopeResolutionIndexes,
model: SemanticModel,
): SymbolDefinition | undefined {
const receiverName = site.explicitReceiver?.name;
if (receiverName === undefined) {
return findCallableBindingInScope(site.inScope, site.name, scopes);
}
const owner = findClassBindingInScope(site.inScope, receiverName, scopes);
if (owner === undefined) return undefined;
const member = findOwnedMember(owner.nodeId, site.name, model);
if (member === undefined || !CALL_TARGET_TYPES.has(member.type)) return undefined;
return member;
}
export function emitPropertyDispatchCalls(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
model: SemanticModel,
calleeIdSink?: CalleeIdSink,
): {
usesEmitted: number;
@ -87,7 +140,7 @@ export function emitPropertyDispatchCalls(
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
if (site.kind !== 'value-ref') continue;
const def = findCallableBindingInScope(site.inScope, site.name, scopes);
const def = resolveValueRefTarget(site, scopes, model);
if (def === undefined) continue;
const ok = tryEmitEdge(graph, scopes, nodeLookup, site, def, VALUE_REF_EDGE_REASON, seen);

View file

@ -1210,6 +1210,7 @@ export function runScopeResolution(
indexes,
emitParsedFiles,
postHeritageNodeLookup,
readonlyModel,
calleeIdAccumulator,
);
if (propertyDispatch.skippedKeys > 0) {

View file

@ -733,6 +733,14 @@ export interface EpistemicCauses {
* 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.
*
* Zero when the property-dispatch pass DID synthesize that invocation
* (`x.<key>()` through a registered object-literal key): the walk followed
* the registration, so nothing was missed and the result stays `exact`.
*
* Also zero — WITH a boundary note — when the probe itself could not run.
* The note is the signal there; the count is not, which is why a reader must
* branch on `epistemic` first and read the causes as explanation.
*/
readonly callableValueReferences: number;
}
@ -900,6 +908,16 @@ function undecidedSatisfactionBoundaries(
* serves is "how many places does this value escape from", and a table that
* registers the same callable twice is still one table.
*
* NOT every value reference is a gap. Where the property-dispatch pass
* synthesized the invocation side, the walk followed it and the answer stays
* `exact` — see the second probe below.
*
* Three failure modes, three different answers, none of them silence:
* - the query cannot run → hedge, count 0 (a probe that did not answer
* is not evidence of completeness);
* - the query returns nothing → no hedge (a real, measured zero);
* - the reference was followed → no hedge (nothing was missed).
*
* 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
@ -909,19 +927,76 @@ 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".
// `COUNT(DISTINCT …)`, not a capped row list. A `LIMIT n` here would make the
// published cause silently understate a target with more than n
// registrations — and this number is documented as "how many symbols", so a
// reader comparing its magnitude against `receiverTyping` would be comparing
// a truth to a ceiling. Aggregating in the database keeps the work bounded
// without capping the answer; scalar `sym.id` equality plus an implicit
// group-by is the shape `countByType` below already relies on.
//
// `null`, not `[]`, on failure: see below — an empty result set and an
// unanswerable query must not be the same value.
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`,
RETURN COUNT(DISTINCT other.id) AS cnt`,
{ symId, reason: VALUE_REF_EDGE_REASON },
).catch(() => []);
const referrers = rows.length;
if (referrers === 0) return { notes: [], referrers: 0 };
).catch(() => null);
// A probe that could not run must never read as certainty — the same rule the
// `loadMeta` read above states, and the whole reason this function exists.
// Returning zero here would publish `exact` on the strength of a query that
// never answered.
if (rows === null) {
return {
referrers: 0,
notes: [
'The callable-value-reference probe could not be run against this index, so whether ' +
'this symbol is registered somewhere as a value is unknown. Treat the caller list as ' +
'incomplete until it can be re-checked.',
],
};
}
const referrers = rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0;
if (!Number.isFinite(referrers) || referrers <= 0) return { notes: [], referrers: 0 };
// Registrations whose invocation side the analyzer ALREADY synthesized are
// not a gap. `emitPropertyDispatchCalls` sweep 2 connects `x.<key>()` member
// calls to every function registered under `<key>` and stamps those edges
// `property-dispatch`; where that happened, the walk did not "provably miss"
// the caller and `lower-bound` would be noise sprayed over an answer the
// analyzer actually computed. Zig — the case this was built for — never sets
// a property key (no object-literal key to dispatch through), so it is never
// excluded here; the exclusion exists to keep TypeScript/JavaScript hook
// tables that ARE followed from being downgraded.
//
// Symbol-level, not per-edge: the graph does not record which registration
// produced which synthesized call. A target with a mix of followed and
// unfollowed registrations is therefore NOT hedged, which is the one place
// this errs toward confidence. Preferred over the alternative — hedging every
// property-value registration in every JS/TS codebase — because a signal that
// fires on everything stops carrying information, and the unfollowed half
// still has the `property-dispatch` fan-out cap warning behind it.
const dispatched = await executeParameterized(
lbugPath,
`MATCH (other)-[r:CodeRelation]->(sym)
WHERE sym.id = $symId AND r.type = 'CALLS' AND r.reason = 'property-dispatch'
RETURN COUNT(r) AS cnt`,
{ symId },
).catch(() => null);
// Failure here is NOT a reason to skip the hedge: we already know a value
// reference exists, and being unable to prove it was followed leaves the
// conservative answer standing.
const dispatchedCount =
dispatched === null || dispatched.length === 0
? 0
: Number((dispatched[0] as any).cnt ?? (dispatched[0] as any)[0] ?? 0);
if (Number.isFinite(dispatchedCount) && dispatchedCount > 0) {
return { notes: [], referrers: 0 };
}
const one = referrers === 1;
return {
referrers,

View file

@ -299,7 +299,7 @@ COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries
- 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.
- 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. It is 0 when the analyzer DID synthesize the dispatch through a registered property key, because then nothing was missed; a 0 alongside epistemic 'lower-bound' can also mean the probe itself could not run — read boundaries for which.
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.
@ -498,7 +498,7 @@ Output includes:
- 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.
- 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'. It is an exact count, not a capped sample. It is 0 when the analyzer DID synthesize the dispatch through a registered property key (nothing was missed, and epistemic stays 'exact' on that account); a 0 alongside epistemic 'lower-bound' can instead mean the probe could not run at all, so read boundaries to tell those apart. 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

@ -39,6 +39,18 @@ pub fn describe(self: *Element) u8 {
return self.getTagNameLower();
}
// ── Owner discrimination ────────────────────────────────────────────────────
// Shadowed below by a same-named sibling inside `JsApi`. The registration
// writes `Element.getLocalName`, so THIS is the one it must bind.
pub fn getLocalName(self: *Element) u8 {
return self._namespace;
}
fn tick(self: *Element) u8 {
return self._namespace;
}
// ── The binding table ───────────────────────────────────────────────────────
pub const JsApi = struct {
@ -54,6 +66,24 @@ pub const JsApi = struct {
fn _tagName(self: *Element) u8 {
return self.getTagNameLower();
}
// A sibling with the SAME simple name as the file-struct method above.
// `walkScopeChain` gives a local binding precedence over the enclosing
// scope, so a registration resolved by TAIL NAME alone binds here — the
// wrong function, silently. Resolving `Element.getLocalName` through its
// written owner is what keeps them apart.
fn getLocalName(self: *Element) u8 {
return 0;
}
pub const localName = bridge.accessor(Element.getLocalName, null, .{});
// A receiver this index cannot resolve. There is a file-level `tick`, and
// tail-name resolution would happily bind it even though the source says
// the function belongs to something else entirely. Declining is the only
// safe answer: a missing reference is recoverable, a confident wrong edge
// is not.
pub const ticker = bridge.accessor(unresolvable_ns.tick, null, .{});
};
// ── Const binding initialiser ───────────────────────────────────────────────

View file

@ -33,6 +33,27 @@ 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';
/**
* Fail ONLY the value-reference probe, leaving every other query on the real
* database. Keyed on the bound `$reason` param rather than the query text, so
* it cannot accidentally match a different query that happens to mention USES.
*/
let failValueRefProbe = false;
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/lbug/pool-adapter.js')>();
return {
...actual,
executeParameterized: (...args: any[]) => {
const params = args[2] as { reason?: string } | undefined;
if (failValueRefProbe && params?.reason === 'scope-resolution: value-ref') {
return Promise.reject(new Error('simulated: index unreadable'));
}
return (actual as any).executeParameterized(...args);
},
};
});
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
return {
@ -68,6 +89,19 @@ const SEED = [
// 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)`,
// Third control: a registration whose invocation the analyzer DID synthesize.
// `emitPropertyDispatchCalls` sweep 2 connects `x.<key>()` member calls to
// every function registered under `<key>` and stamps those edges
// `property-dispatch`. Where that happened the walk followed the
// registration, so hedging would be noise over an answer that was actually
// computed. This is the JS/TS hook-table shape, not the Zig one — Zig has no
// object-literal key to dispatch through and so is never excluded.
`CREATE (:Function {id: 'Function:hooks/registry.js:onClick', name: 'onClick', filePath: 'hooks/registry.js', startLine: 5, endLine: 8, isExported: true, content: '', description: ''})`,
`CREATE (:Function {id: 'Function:hooks/registry.js:registerAll', name: 'registerAll', filePath: 'hooks/registry.js', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
`CREATE (:Function {id: 'Function:hooks/consumer.js:runHandlers', name: 'runHandlers', filePath: 'hooks/consumer.js', startLine: 1, endLine: 6, isExported: true, content: '', description: ''})`,
`MATCH (a:Function {id:'Function:hooks/registry.js:registerAll'}), (b:Function {id:'Function:hooks/registry.js:onClick'}) CREATE (a)-[:CodeRelation {type:'USES', confidence:0.85, reason:'${VALUE_REF_EDGE_REASON}', step:0}]->(b)`,
`MATCH (a:Function {id:'Function:hooks/consumer.js:runHandlers'}), (b:Function {id:'Function:hooks/registry.js:onClick'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.7, reason:'property-dispatch', step:0}]->(b)`,
];
withTestLbugDB(
@ -84,6 +118,11 @@ withTestLbugDB(
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
// Pinned, not implied: hedging must not INVENT callers. The seed gives
// this symbol exactly two inbound edges — one CALLS, one value-ref USES —
// and both are traversed. If a later change made the hedge also widen the
// walk, every other assertion here would still pass.
expect(result.impactedCount).toBe(2);
// 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.
@ -121,6 +160,41 @@ withTestLbugDB(
expect(result.causes.callableValueReferences).toBe(1);
});
it('stays exact when the analyzer already synthesized the dispatch', async () => {
// `onClick` is registered as a value AND reached by a synthesized
// property-dispatch CALLS edge. The walk did not "provably miss" that
// caller, so `lower-bound` would be wrong — and a signal that fires on
// every hook table in every JS codebase carries no information.
const result: any = await backend.callTool('impact', {
target: 'onClick',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.epistemic).toBe('exact');
});
it('hedges — never reports exact — when the probe itself cannot run', async () => {
// The failure mode this whole feature exists to remove is silence reading
// as certainty. A probe that threw has not established that the symbol is
// unregistered; swallowing the error into a zero would publish `exact` on
// the strength of a question that was never answered.
failValueRefProbe = true;
try {
const result: any = await backend.callTool('impact', {
target: 'getTagNameLower', // the control: exact when the probe works
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.epistemic).toBe('lower-bound');
expect(result.boundaries.join(' ')).toContain('could not be run');
// No count is claimed — the note is the signal, and inventing a
// magnitude from a failed query would be the same error inverted.
expect(result.causes.callableValueReferences).toBe(0);
} finally {
failValueRefProbe = false;
}
});
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',

View file

@ -248,9 +248,14 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => {
describe('callable values (#3399)', () => {
let uses: string[];
let valueRefs: string[];
let valueRefTargetIds: string[];
beforeAll(() => {
const edges = getRelationships(result, 'USES');
uses = edgeSet(edges);
valueRefTargetIds = edges
.filter((e) => e.rel.reason === 'scope-resolution: value-ref')
.map((e) => e.rel.targetId)
.sort();
// The reason text is spelled out rather than imported from
// `VALUE_REF_EDGE_REASON`, deliberately and as `typescript-value-refs.test.ts`
// already does: `impact`'s epistemic probe matches this exact string in
@ -307,6 +312,28 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => {
expect(valueRefs.filter((v) => v.endsWith(' → getTagNameLower'))).toEqual([]);
});
it('binds a QUALIFIED reference to the owner that was written, not the nearest lexical match', () => {
// `JsApi` declares its own `getLocalName` next to
// `bridge.accessor(Element.getLocalName, …)`. `walkScopeChain` gives that
// local binding precedence, so resolving the registration by tail name
// alone attaches it to the SIBLING — a confidently wrong edge, which is a
// worse failure than the missing edge this whole change is about. The
// written receiver is the only thing that tells them apart.
const local = valueRefTargetIds.filter((id) => id.endsWith('.getLocalName#0'));
expect(local).toEqual(['Method:src/webapi/Element.zig:Element.getLocalName#0']);
expect(local).not.toContain('Method:src/webapi/Element.zig:JsApi.getLocalName#0');
});
it('declines a qualified reference whose receiver cannot be resolved', () => {
// `bridge.accessor(unresolvable_ns.tick, …)` names an owner this index
// does not have, while a file-level `tick` sits in the lexical chain
// waiting to be mis-bound. Emitting nothing is the safe direction: the
// shortfall then shows up as `epistemic: "lower-bound"` rather than as a
// confident edge pointing at the wrong function.
expect(valueRefTargetIds.filter((id) => id.includes('.tick#'))).toEqual([]);
expect(valueRefs).not.toContain('JsApi → tick');
});
it('does not mint a value reference for the CALLEE of an ordinary call', () => {
// `register(onTick)` must produce ONE value reference (the argument), not
// two: without binding the callee to the `function:` field the same rule