fix(swift): resolve a method def to its own node when the labels disagree

Two classes in one Swift file each declaring `func run` collapsed onto one
node: every call in BOTH bodies was attributed to whichever `run` registered
first, which collected duplicate edges while its twin collected none. Renaming
one method fixed it; moving it to another file fixed it; so the collision was
name-keyed and per-file, not positional.

Root cause is a LABEL split, not a name. Swift's structure phase emits a type's
methods as `Function` nodes, while the scope extractor derives `Method` from the
`@declaration.method` anchor. Every key in `resolveDefGraphId` — qualified,
parameter-types, arity, shape — is label-scoped, so such a pair misses all of
them and lands on the bottom fallback, `simpleKey(filePath, name)`, which is
deliberately label-agnostic and first-write-wins.

Fixed at both ends:

  - Swift qualifies a method def as `<Type>.<method>`, matching the qualifier
    the structure phase already encoded in the node id. `class`, `struct` and
    `extension` all parse to `class_declaration`, so one ancestor walk covers
    them; a generic `class Box<T>` and an `extension Foo` wrapping a `user_type`
    both reduce to the bare owner name.
  - The bridge retries the qualified keys under the sibling callable label.
    Gated on the name containing a dot: `A.run` names one construct whatever the
    label, while a bare `run` is exactly the top-level-vs-method aliasing the
    label was added to prevent, so the original guarantee is untouched.

This also unmasked Swift's #2807 row. `let p = Outer()` had always bound
correctly — its edges were being credited to the wrong caller, so the
inference-typed receiver looked broken when it was not. `InferredField.run` now
emits `Outer.inner`, matching its control.

Verified on the full resolver + CFG suite: 3165 passed, 0 failed, against a
3164-passing baseline.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 15:10:34 +00:00
parent 1aba80d942
commit a8627541cf
2 changed files with 78 additions and 0 deletions

View file

@ -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 `<Type>.<method>` (#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<T>` 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(

View file

@ -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;
};