diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index e3d398695..d15e38ef7 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -53,6 +53,41 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; +/** + * Name of the type that lexically owns `node` — the nearest enclosing + * `class_declaration` (which in tree-sitter-swift is also how `struct` and + * `extension` parse) or `protocol_declaration`. + * + * Qualifies a method def as `.` (#2807 follow-up). Swift's + * structure phase already keys the graph node that way (`A.run#1`), but the + * resolver-side def carried only `run`, and the bridge's every label-scoped key + * is built from the def's name — so two classes in one file each declaring + * `func run` fell through to the label-agnostic simple key, which is + * first-write-wins. The result: EVERY call in both bodies was attributed to + * whichever `run` registered first, which collected duplicate edges while its + * twin collected none. Renaming one method, or moving it to another file, made + * both resolve — which is what identified the collision as name-keyed and + * per-file rather than positional. + * + * An `extension Foo` wraps the extended type in a `user_type`, and a generic + * `class Box` carries its parameters in the same field, so the dotted tail is + * taken and any generic argument list dropped — the spelling has to match the + * owner the structure phase used to build the node id. + */ +function swiftEnclosingTypeName(node: SyntaxNode): string | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (cur.type === 'class_declaration' || cur.type === 'protocol_declaration') { + const nameNode = cur.childForFieldName('name'); + if (nameNode === null) return null; + const tail = nameNode.text.trim().split('<')[0]!.split('.').pop()?.trim() ?? ''; + return tail.length > 0 ? tail : null; + } + cur = cur.parent; + } + return null; +} + /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -247,6 +282,25 @@ export function emitSwiftScopeCaptures( } } + // ── Qualify a method/constructor def with its owning type (#2807). ── + // Emitted before the `@scope.function` branch below, which pushes and + // `continue`s; a Swift `function_declaration` matches both patterns. + if (grouped['@declaration.qualified_name'] === undefined) { + const ownerTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); + const declaredName = grouped['@declaration.name']?.text; + const declNode = ownerTag === undefined ? null : nodeMap[ownerTag]; + if (ownerTag !== undefined && declaredName !== undefined && declNode != null) { + const owner = swiftEnclosingTypeName(declNode); + if (owner !== null) { + grouped['@declaration.qualified_name'] = syntheticCapture( + '@declaration.qualified_name', + declNode, + `${owner}.${declaredName}`, + ); + } + } + } + // ── @scope.function: arity + receiver + signature bindings. ────── if (grouped['@scope.function'] !== undefined) { const fnNodeForArity = nodeIfType( diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 6f942d88b..4803bd1e1 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -268,11 +268,35 @@ export function resolveDefGraphId( const nsPrefix = def.namespacePrefix; const nameForms = nsPrefix !== undefined && nsPrefix.length > 0 ? [`${nsPrefix}.${qn}`, qn] : [qn]; + // A def and its graph node describe the same construct, but they do not + // always agree on the LABEL: some structure phases emit a type's methods as + // `Function` nodes while the scope extractor derives `Method` from the + // `@declaration.method` anchor. Every key above is label-scoped, so such a + // pair misses all of them and lands on the label-agnostic simple key at the + // bottom of this function — which is first-write-wins, so two same-named + // methods in ONE file both resolved to whichever was registered first. That + // silently misattributed every call in the second method's body to the + // first (#2807 follow-up; measured in Swift, where `class A { func run }` + + // `class B { func run }` gave A.run both bodies' edges and B.run none). + // + // Crossing the two callable labels is sound ONLY for a name that carries + // its owner: `A.run` names exactly one construct whatever the label, while + // a bare `run` is precisely the aliasing the label was added to prevent + // (a top-level `save` vs a class's `save`). Hence the dot gate — it keeps + // the original guarantee intact for unqualified names. + const siblingLabel: NodeLabel | undefined = + defType === 'Method' ? 'Function' : defType === 'Function' ? 'Method' : undefined; const lookupTagged = (tag: string): string | undefined => { for (const form of nameForms) { const hit = nodeLookup.get(qualifiedKey(filePath, defType, `${form}${tag}`)); if (hit !== undefined) return hit; } + if (siblingLabel === undefined) return undefined; + for (const form of nameForms) { + if (!form.includes('.')) continue; + const hit = nodeLookup.get(qualifiedKey(filePath, siblingLabel, `${form}${tag}`)); + if (hit !== undefined) return hit; + } return undefined; };