diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts index b39ff8689..f771206fc 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts @@ -10,6 +10,7 @@ import type { CallableFlowInvokeSite, CallableFlowOperand, CallableFlowSite, + NodeLabel, ParsedFile, ScopeId, SymbolDefinition, @@ -747,19 +748,49 @@ function buildGraphTargetIndex( const out = new Map(); const byAnchor = buildGraphCallableAnchorIndex(graph); for (const def of scopes.defs.byId.values()) { - if (!isCallable(def) && providerTarget?.(def) !== true) continue; + const callableDef = isCallable(def) || providerTarget?.(def) === true; + // #2693: a closure bound to a name (`val f = { }`) is a callable the def + // type cannot see. The scope-resolution layer declares such a binding with + // its VALUE label (Kotlin/Swift `Property`, Dart `Variable`) while #2687 + // makes the graph emit a single `Function` node for it. Only the graph + // knows, so value bindings are resolved first and admitted below on the + // label of the node they actually reach. + if (!callableDef && !VALUE_BINDING_DEF_TYPES.has(def.type)) continue; const anchorKey = definitionAnchorKey(def); const anchored = anchorKey === undefined ? undefined : byAnchor.get(anchorKey); const id = anchored?.length === 1 ? anchored[0] : resolveDefGraphId(def.filePath, def, nodeLookup); + if (id === undefined) continue; + // A genuine constant keeps its own `Const`/`Property`/`Variable` node, so + // `resolveDefGraphId`'s qualified key hits before its label-agnostic + // `simpleKey` fallback can reach a same-named callable — only a binding + // whose own value node was replaced by a callable one gets through here. + if (!callableDef && !isCallableGraphNode(graph, id)) continue; // Overloads can intentionally share one graph node ID. Index by the // definition identity so contextual signature narrowing still sees the // complete overload set before a selected target collapses to graph ID. - if (id !== undefined) out.set(def.nodeId, { id, def }); + out.set(def.nodeId, { id, def }); } return out; } +/** + * Value-binding labels whose initializer can be a callable. Kept separate from + * `isCallable` because these are admitted on graph-node evidence, never on the + * def type alone. + */ +const VALUE_BINDING_DEF_TYPES: ReadonlySet = new Set([ + 'Const', + 'Property', + 'Static', + 'Variable', +]); + +function isCallableGraphNode(graph: KnowledgeGraph, id: string): boolean { + const label = graph.getNode(id)?.label; + return label === 'Function' || label === 'Method' || label === 'Constructor'; +} + interface CanonicalCallableTargets { /** Definition identity remains the key; declaration keys may point at the definition target. */ readonly targets: ReadonlyMap; diff --git a/gitnexus/test/integration/closure-binding-labels.test.ts b/gitnexus/test/integration/closure-binding-labels.test.ts index e37362f10..148ce6b2e 100644 --- a/gitnexus/test/integration/closure-binding-labels.test.ts +++ b/gitnexus/test/integration/closure-binding-labels.test.ts @@ -13,14 +13,16 @@ * these rely on the #2687 pre-scan collapsing the pair; a regression there * would surface here as a twin rather than a wrong label. * - * The label alone does not make `f()` resolve — free-call resolution runs off - * the per-language scope-resolution queries. Go, Python and C++ now also carry a + * The label alone does not make `f()` resolve. Go, Python and C++ carry a * `@declaration.function` capture anchored on the inner closure literal, so - * calls resolve there too (asserted in the second describe). Kotlin, Swift and - * Dart still lack a `@scope.function` whose range matches the closure literal — - * Kotlin deliberately scopes `lambda_literal` as a BLOCK (#1757) — and an - * unaligned declaration anchor mis-attributes callers, so those three keep the - * label fix only. + * free-call resolution finds the def directly. Kotlin, Swift and Dart cannot + * take that route — they lack a `@scope.function` whose range matches the + * closure literal (Kotlin deliberately scopes `lambda_literal` as a BLOCK, + * #1757) and an unaligned declaration anchor mis-attributes callers. + * + * #2693 resolves those three through `callable-value-flow` instead: the graph + * node this file asserts IS the evidence that admits the binding as a callable + * target, so a regression in the labels above now also breaks call resolution. */ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; @@ -156,9 +158,18 @@ const callTargetsFor = async (filename: string, source: string): Promise { - // The label change alone is not enough: each language also needs a - // `@declaration.function` anchored on the inner closure literal, so the def is - // owned by the closure's own scope and free-call resolution can find it. + // Two independent routes reach the same outcome. + // + // Go, Python and C++ take the DECLARATION route: a `@declaration.function` + // anchored on the inner closure literal, so the def is owned by the closure's + // own scope and free-call resolution finds it directly. + // + // Kotlin, Swift and Dart cannot — an unaligned declaration anchor + // mis-attributes callers, and Kotlin scopes `lambda_literal` as a BLOCK on + // purpose (#1757, smart casts). They take the CALLABLE-VALUE-FLOW route + // instead (#2693): their capture layer already emits a `seed` naming the + // binding as its own callable, and `buildGraphTargetIndex` admits the + // binding because the graph node #2687 created for it is a `Function`. it('Go: Handler(1) resolves', async () => { const targets = await callTargetsFor( @@ -186,4 +197,48 @@ describeIfWorkerBuilt('calls to a closure binding resolve to its Function node', expect(targets).toContain('Function:main.cpp:handler'); }); + + it('Kotlin: handler(1) resolves', async () => { + const targets = await callTargetsFor( + 'App.kt', + 'val handler = { x: Int -> x }\n\nfun caller(): Int {\n return handler(1)\n}\n', + ); + + expect(targets).toContain('Function:App.kt:handler'); + }); + + it('Swift: handler(1) resolves', async () => { + const targets = await callTargetsFor( + 'App.swift', + 'let handler = { (x: Int) -> Int in return x }\n\nfunc caller() -> Int {\n return handler(1)\n}\n', + ); + + expect(targets).toContain('Function:App.swift:handler'); + }); +}); + +describeIfWorkerBuilt('a non-callable value binding stays edge-free', () => { + // The suppression must key on an actual callable value. Widening + // `buildGraphTargetIndex` to admit value bindings whose graph node is a + // `Function` is safe only because a genuine constant keeps its own + // `Const`/`Property`/`Variable` node, so `resolveDefGraphId`'s qualified key + // hits before the label-agnostic `simpleKey` fallback can reach a callable. + + it('Kotlin: a constant sharing its name with a function mints no CALLS to the constant', async () => { + const targets = await callTargetsFor( + 'Shadow.kt', + 'val maxSize = 10\n\nfun size(): Int {\n return maxSize\n}\n', + ); + + expect(targets).toEqual([]); + }); + + it('Kotlin: a property initialised from a call is not itself callable', async () => { + const targets = await callTargetsFor( + 'Made.kt', + 'fun make(): Int = 1\n\nval made = make()\n\nfun caller(): Int {\n return made\n}\n', + ); + + expect(targets).toEqual(['Function:Made.kt:make']); + }); });