mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
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:
parent
1aba80d942
commit
a8627541cf
2 changed files with 78 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue