diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f4b87..ad5c569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - TypeScript and JavaScript **class fields are now reported as properties instead of methods**. A plain field like `public fonts: Fonts;` previously extracted as a method, misrepresenting class shape and letting calls to same-named functions resolve to data fields (a boolean field named `isArray` was soaking up `Array.isArray(...)` call edges). Fields holding arrow functions or function expressions (`onClick = () => {…}`, including wrapped ones like `onScroll = throttle(() => {…})`) correctly remain methods and their bodies are still analyzed. Field initializers are analyzed too, so `history = createHistory()` records its call — and JavaScript class fields, which previously produced no symbol at all, now appear in the graph. Re-index a project to benefit. (#808) (TypeScript, JavaScript) - Callback registration through `this` now resolves precisely in TypeScript and JavaScript: `window.addEventListener("online", this.onOfflineStatusToggle)` or an API object like `{ mutateElement: this.mutateElement }` produces a reference edge to the **enclosing class's own method** — never a same-named method on an unrelated class, and never a data field. Builds on the callback-registration support below. (#808) (TypeScript, JavaScript) +- Callback-registration coverage deepened across four more shapes: a `this.` registration whose method lives on a **base class** now resolves through the inheritance chain (`bus.on("submit", this.handleSubmit)` in a subclass links to the parent's `handleSubmit`); Java and Kotlin **method references to other classes** (`Handlers::onMessage`, `OtherClass::handle`) resolve across files, with `this::` and `super::` scoped to the defining class and references through a variable deliberately left out; and Swift bare callback names now match only the **enclosing type's** methods (implicit `self`), eliminating a class of wrong edges where a parameter like `request` linked to a same-named method on an unrelated type. (Java, Kotlin, Swift, TypeScript, JavaScript) - CodeGraph now sees where a function is **registered as a callback**, not just where it's called. A function name passed as an argument (`signal(SIGINT, handler)`, `qsort(…, compare)`, `addEventListener(…, onBlur)`), assigned to a function pointer or field (`ops->recv_cb = my_cb`, `OnClick := Handler`), or placed in a struct initializer or handler table (`{ .recv_cb = my_cb }`, `{ "get", getCommand }`) now produces a reference edge from the registration site to the function — so `codegraph_callers` and `codegraph_impact` surface callback wiring that previously looked like dead code. Works across all supported languages, including the language-specific forms: C/C++ `&fn`, Java `Class::method`, Kotlin `::fn`, Swift `#selector`, Objective-C `@selector`, Ruby `method(:fn)`, Scala eta-expansion, and Delphi/Pascal `@Handler` and `OnClick := Handler` event wiring. Callers output labels these "via callback registration". Resolution is deliberately conservative: an ambiguous name produces no edge rather than a wrong one. Re-index a project to benefit. Thanks @zmcrazy. (#756) - The `codegraph_node` MCP tool can now **read a whole source file like the built-in Read tool — only faster, served from the index**. Pass a file path with no symbol and it returns that file's current source with line numbers (the same `` shape Read produces, so an assistant can edit straight from it), narrowable with `offset`/`limit` exactly like Read, plus a one-line note of which files depend on it (the file's blast radius). Use it anywhere you'd reach for Read on an indexed source file. Pass `symbolsOnly: true` for just the file's structure. Configuration/data files (`.yml` / `.properties`) are summarized by key only, never dumped, so secrets in them are never surfaced. The agent-facing guidance was also retuned so assistants reach for codegraph while *implementing* a change (not only when answering questions), since one codegraph call returns the same bytes plus the blast radius, faster than re-reading the file. - New `codegraph upgrade` command updates CodeGraph to the latest release in place — it detects how you installed (the standalone `install.sh` / `install.ps1` bundle, npm, or npx) and does the right thing for each, on macOS, Linux, and Windows. Use `codegraph upgrade --check` to see whether an update is available without installing, or `codegraph upgrade ` to move to a specific version. After upgrading it reminds you to re-index your projects so they pick up the newer engine's improvements. (#679) diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts index 67dd348..d7f9b58 100644 --- a/__tests__/function-ref.test.ts +++ b/__tests__/function-ref.test.ts @@ -457,6 +457,122 @@ describe('Function-as-value capture (#756)', () => { } }); + it('INHERITED this.X: resolves on a supertype via the second pass, never on unrelated classes', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-inherit-')); + fs.writeFileSync( + path.join(tmpDir, 'base.ts'), + 'export class FormBase { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'unrelated.ts'), + 'export class Unrelated { handleSubmit(): void {} }\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'login.ts'), + [ + "import { FormBase } from './base';", + 'declare const bus: { on(ev: string, cb: () => void): void };', + 'export class LoginForm extends FormBase {', + ' wire(): void { bus.on("submit", this.handleSubmit); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const handleSubmits = cg.getNodesByName('handleSubmit'); + const baseM = handleSubmits.find((n) => n.qualifiedName.includes('FormBase'))!; + const unrelatedM = handleSubmits.find((n) => n.qualifiedName.includes('Unrelated'))!; + + const intoBase = cg.getIncomingEdges(baseM.id).filter((e) => e.metadata?.fnRef === true); + expect(intoBase).toHaveLength(1); + expect(cg.getNode(intoBase[0]!.source)?.name).toBe('wire'); + expect( + cg.getIncomingEdges(unrelatedM.id).filter((e) => e.metadata?.fnRef === true) + ).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('JAVA: Type::method cross-file, this::/super:: scoped, variable:: yields nothing', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-java-')); + fs.writeFileSync( + path.join(tmpDir, 'Handlers.java'), + [ + 'package com.example;', + 'public class Handlers {', + ' public static void onMessage(int x) { System.out.println(x); }', + '}', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'BaseForm.java'), + ['package com.example;', 'public class BaseForm {', ' void baseHandler(int x) {}', '}'].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'Main.java'), + [ + 'package com.example;', + 'import com.example.Handlers;', + 'import java.util.function.IntConsumer;', + 'public class Main extends BaseForm {', + ' static void registerHandler(IntConsumer cb) { cb.accept(1); }', + ' void run0() {}', + ' void crossFile() { registerHandler(Handlers::onMessage); }', + ' void thisRef() { registerHandler(this::run0); }', + ' void superRef() { registerHandler(super::baseHandler); }', + ' void varRef(Main m) { registerHandler(m::run0); }', + '}', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + expect(sourceNames(cg, fnRefEdgesInto(cg, 'onMessage'))).toEqual(['crossFile']); + expect(sourceNames(cg, fnRefEdgesInto(cg, 'baseHandler'))).toEqual(['superRef']); + // this::run0 resolves class-scoped; m::run0 (variable receiver) must NOT + // add a second edge — exactly one source. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'run0'))).toEqual(['thisRef']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('SWIFT SCOPING: bare ids hit only the enclosing type’s methods; top-level bare hits functions only', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-swiftscope-')); + fs.writeFileSync( + path.join(tmpDir, 'main.swift'), + [ + 'func register(_ cb: (Int) -> Void) { cb(1) }', + 'class Monitor {', + ' func report(_ x: Int) {}', + ' func wire() { register(report) }', // implicit self → Monitor::report + '}', + 'class Other {', + // `report` here is a PARAMETER; Monitor::report must not win. + ' func use(report: (Int) -> Void) { register(report) }', + '}', + 'func topLevel() { register(report) }', // no implicit self → no method target + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const edges = fnRefEdgesInto(cg, 'report'); + expect(sourceNames(cg, edges)).toEqual(['wire']); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + it('C UNGATED TABLES: a command table names handlers defined in OTHER files (redis pattern)', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ctable-')); // Handler defined in its own file… diff --git a/docs/design/function-ref-capture.md b/docs/design/function-ref-capture.md index 159176c..bb32505 100644 --- a/docs/design/function-ref-capture.md +++ b/docs/design/function-ref-capture.md @@ -171,25 +171,39 @@ Index cost on redis: +6% time, +5% db size. without local-scope tracking — the data-flow frontier deliberately left uncovered. ~1-2 per 20 sampled edges on callback-heavy repos; the file-level dependency is real in every observed case. -- **Swift single same-named method collisions** (`request(self, didFailTask: - task…)` where one `task` method exists): the overload-family rule only - refuses when ≥2 same-named methods share the file. Alamofire-style - API-mirrored param naming keeps a residual; needs same-type scoping (v2). +- **Swift same-class param collisions** (`eventMonitor?.request(self, + didFailTask: task…)` where the enclosing type ALSO has a `task` method): + enclosing-type scoping (implicit self — methods match only the from-symbol's + own type, top-level bare ids never match methods) eliminated the CROSS-class + collision class on Alamofire (−44 wrong edges), but a parameter named after + a method of the SAME type is statically indistinguishable from an + implicit-self method value. Residual, documented. - **Pascal paren-less calls** (`Result := DoInitialize`): captured as references (Pascal can't distinguish a procedure VALUE from a paren-less CALL without types). The dependency direction is correct and these calls were previously invisible entirely (#791) — strictly more truth, imperfect label. -- **Java/Kotlin cross-file method refs** (`OtherClass::method` without the - defining class imported as a simple name): gated away; same-file and - `this::m` forms work. +- **Java/Kotlin method refs through a VARIABLE** (`subscriber::onNext`, + `m::run0`): receiver type unknown statically — deliberately no edge (the + obj.method class). RxJava's baseline bare capture was resolving these to + same-named same-file methods (a test method "registering" an anonymous + class's `onNext`); the qualified rework drops them. `Type::method` resolves + cross-file (scope gated on same-file types ∪ imported names, incl. the last + segment of dotted JVM imports); `this::m` / `super::m` ride the + class-scoped + supertype path. +- **Kotlin companion-object members** extract UNQUALIFIED (node `handle`, not + `KtHandlers::Companion::handle` — pre-existing extraction shape), so + `KtHandlers::handle` refs to companion members stay silent rather than + guess. Fix belongs in kotlin companion extraction. - **Swift cross-file bare references**: Swift sees module-wide symbols without - imports, so cross-file bare callbacks only resolve when repo-unique. + imports, so cross-file bare callbacks only resolve when repo-unique + (functions; methods are enclosing-type-only). Cross-TYPE `#selector` + targets (rare — target-action is normally self) are scoped away too. - **PHP string callables**, **Ruby bare symbols** outside `method(:sym)`, **`obj.method` member values** where `obj` isn't `this`/`self`: deferred. -- **TS/JS `this.X` to inherited members**: the class-scoped resolver matches - the enclosing class's OWN members only — `this.handleClick` defined on a - superclass yields no edge (would need the supertype walk; deliberate v1). - Reading a getter into a local (`const s = this.snapshot`) produces a +- **`this.X` inherited members resolve through the supertype pass** + (`resolveDeferredThisMemberRefs`, depth-capped BFS over implements/extends, + runs after edges persist — same lifecycle as the #750 conformance pass). + Reading a getter into a local (`const s = this.snapshot`) still produces a references edge to the getter — a true dependency with an imperfect "registration" flavor. diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index d7a1250..76e3f57 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 20; +export const EXTRACTION_VERSION = 21; diff --git a/src/extraction/function-ref.ts b/src/extraction/function-ref.ts index dfb9eb5..2970cd4 100644 --- a/src/extraction/function-ref.ts +++ b/src/extraction/function-ref.ts @@ -553,35 +553,66 @@ function normalizeSpecial( source: string ): Array<{ name: string; node: SyntaxNode }> { switch (type) { - // Java `Main::targetCb` / `this::run0` — last identifier child is the method. + // Java method references. Receiver decides the resolution route (#808): + // `this::run0` / `super::close` → `this.` (class-scoped resolver; + // super rides the inherited-member supertype pass) + // `Type::method` (capitalized) → qualified `Type::method` (suffix- + // matched against that type's members, cross-file capable) + // `variable::method` → nothing (receiver type unknown statically — + // the deferred obj.method class) case 'method_reference': { let last: SyntaxNode | null = null; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (child && child.type === 'identifier') last = child; } - return last ? [{ name: getNodeText(last, source), node: last }] : []; - } - - // Kotlin `::targetCb` — the simple_identifier child. - case 'callable_reference': { - for (let i = 0; i < node.namedChildCount; i++) { - const child = node.namedChild(i); - if (child && child.type === 'simple_identifier') { - return [{ name: getNodeText(child, source), node: child }]; - } + if (!last) return []; + const m = getNodeText(last, source); + const text = getNodeText(node, source); + if (text.startsWith('this::') || text.startsWith('super::')) { + return [{ name: `this.${m}`, node: last }]; + } + const recv = text.match(/^([A-Z][A-Za-z0-9_]*)\s*::/); + if (recv) { + // `Type::method` — but `Type::new` (constructor ref) has no method + // node to land on; let the stoplist drop it via the bare name. + return m === 'new' ? [] : [{ name: `${recv[1]}::${m}`, node: last }]; } return []; } + // Kotlin `::targetCb` (one part) / `OtherClass::handle` (two parts — + // receiver is a type_identifier; lowercase receivers are variables, the + // deferred obj.method class). + case 'callable_reference': { + let receiver: SyntaxNode | null = null; + let member: SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'type_identifier') receiver = child; + if (child.type === 'simple_identifier') member = child; + } + if (!member) return []; + const m = getNodeText(member, source); + if (!receiver) return [{ name: m, node: member }]; // ::topLevelFn + const recvText = getNodeText(receiver, source); + return /^[A-Z]/.test(recvText) + ? [{ name: `${recvText}::${m}`, node: member }] + : []; // variable::method — unknown receiver type + } + // Kotlin `this::fire` parses as navigation_expression with a `::fire` - // navigation_suffix. Ordinary `a.b` navigation MUST yield nothing. + // navigation_suffix — route through the class-scoped `this.` resolver. + // Ordinary `a.b` navigation (and any non-`this` receiver) MUST yield + // nothing. case 'navigation_expression': { + if (!getNodeText(node, source).startsWith('this::')) return []; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (child && child.type === 'navigation_suffix' && getNodeText(child, source).startsWith('::')) { const id = child.namedChild(child.namedChildCount - 1); - if (id) return [{ name: getNodeText(id, source), node: id }]; + if (id) return [{ name: `this.${getNodeText(id, source)}`, node: id }]; } } return []; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 6126814..a3e827f 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -435,19 +435,34 @@ export class TreeSitterExtractor { if (isGeneratedFile(this.filePath)) return; const definedHere = new Set(); + const definedTypes = new Set(); for (const n of this.nodes) { if (n.kind === 'function' || n.kind === 'method') definedHere.add(n.name); + if ( + n.kind === 'class' || n.kind === 'struct' || n.kind === 'interface' || + n.kind === 'enum' || n.kind === 'trait' || n.kind === 'protocol' + ) { + definedTypes.add(n.name); + } } // Import-binding names only (all binding emitters push kind 'imports'). // Deliberately NOT 'references': those carry type-annotation and // interface-member names, which let local variables that share a type - // member's name slip through the gate (excalidraw A/B finding). + // member's name slip through the gate (excalidraw A/B finding). A dotted + // import (JVM `import com.example.OtherClass`) also contributes its LAST + // segment — the simple name Java/Kotlin code uses in `OtherClass::method` + // references. const SIMPLE_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + const DOTTED_NAME = /^[A-Za-z_$][A-Za-z0-9_$.]*\.([A-Za-z_$][A-Za-z0-9_$]*)$/; const importedNames = new Set(); for (const r of this.unresolvedReferences) { - if (r.referenceKind === 'imports' && SIMPLE_NAME.test(r.referenceName)) { + if (r.referenceKind !== 'imports') continue; + if (SIMPLE_NAME.test(r.referenceName)) { importedNames.add(r.referenceName); + } else { + const dotted = r.referenceName.match(DOTTED_NAME); + if (dotted) importedNames.add(dotted[1]!); } } @@ -468,21 +483,37 @@ export class TreeSitterExtractor { ) { continue; } - // C-family file-scope initializers skip the gate (constant-expression - // context — a bare identifier there is a function address, never a - // variable; see FnRefSpec.ungatedModes). Local initializers and - // everything else require a same-file/import match. - const skipGate = ungated?.has(c.mode) === true && atFileScope; - // Qualified C++ member-pointers (`Widget::on_click`) and TS/JS - // `this.` candidates gate on the member name; everything else - // on the full name. - const gateName = c.name.startsWith('this.') - ? c.name.slice(5) - : c.name.includes('::') - ? c.name.slice(c.name.lastIndexOf('::') + 2) - : c.name; - if (!skipGate && !definedHere.has(gateName) && !importedNames.has(gateName)) { - continue; + // Gate policy by candidate shape: + // - `this.`: ALWAYS flush — the member may be inherited from a + // class in another file (definedHere can't see it), volume is + // naturally bounded by real `this.X` expressions, and resolution is + // strictly class-scoped (own members or the validated supertype + // pass), so nothing fuzzy can leak. + // - `Scope::member` (C++ member-pointers, Java/Kotlin type-qualified + // method refs): the SCOPE name must be a type defined here or an + // imported name (covers `OtherClass::method` cross-file), or the + // member matches the plain gate (back-compat for C++ same-file). + // - C-family file-scope initializers skip the gate entirely + // (constant-expression context — see FnRefSpec.ungatedModes). + // - everything else: name ∈ same-file functions/methods ∪ imports. + if (!c.name.startsWith('this.')) { + const skipGate = ungated?.has(c.mode) === true && atFileScope; + if (!skipGate) { + if (c.name.includes('::')) { + const scopeName = c.name.slice(0, c.name.indexOf('::')); + const memberName = c.name.slice(c.name.lastIndexOf('::') + 2); + if ( + !definedTypes.has(scopeName) && + !importedNames.has(scopeName) && + !definedHere.has(memberName) && + !importedNames.has(memberName) + ) { + continue; + } + } else if (!definedHere.has(c.name) && !importedNames.has(c.name)) { + continue; + } + } } const key = `${c.fromNodeId}|${c.name}`; if (seen.has(key)) continue; diff --git a/src/index.ts b/src/index.ts index 2e2eef8..35855e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -382,6 +382,9 @@ export class CodeGraph { // interface). Needs the implements/extends edges the main pass just // built, so it runs after resolution (#750). this.resolver.resolveChainedCallsViaConformance(); + // Same lifecycle for `this.` callback registrations whose + // member is inherited from a supertype (#808). + this.resolver.resolveDeferredThisMemberRefs(); } // Refresh planner stats + checkpoint the WAL after bulk writes. @@ -503,6 +506,9 @@ export class CodeGraph { // receiver conforms to (protocol-extension / inherited). Needs the // implements/extends edges built above (#750). this.resolver.resolveChainedCallsViaConformance(); + // Same lifecycle for `this.` callback registrations whose + // member is inherited from a supertype (#808). + this.resolver.resolveDeferredThisMemberRefs(); } // Refresh planner stats + checkpoint the WAL after bulk writes. diff --git a/src/resolution/index.ts b/src/resolution/index.ts index c2b7561..1e49202 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -207,6 +207,11 @@ export class ReferenceResolver { // once implements/extends edges exist, to resolve methods on a supertype the // receiver conforms to (#750). private deferredChainRefs: UnresolvedRef[] = []; + // `this.` function-as-value refs whose member is NOT on the + // enclosing class itself — possibly inherited. Collected in-memory for the + // same reason as deferredChainRefs and drained by + // resolveDeferredThisMemberRefs once implements/extends edges exist (#808). + private deferredThisMemberRefs: UnresolvedRef[] = []; // Per-`.razor`/`.cshtml`-file `@using` namespace set (own directives + folder // `_Imports.razor`, cascading to the project root). Used to disambiguate a // markup type ref to the right C# namespace. @@ -422,6 +427,10 @@ export class ReferenceResolver { return result; }, + getNodeById: (id: string) => { + return this.queries.getNodeById(id); + }, + getSupertypes: (typeName: string, language) => { // Union the `implements`/`extends` targets of every same-named type node. // Matching by simple name (not id) reconciles a type declared in one node @@ -1214,7 +1223,13 @@ export class ReferenceResolver { n.filePath === ref.filePath && n.id !== ref.fromNodeId ); - if (candidates.length === 0) return null; + if (candidates.length === 0) { + // Not on the class itself — possibly INHERITED. implements/extends + // edges don't exist yet in this pass, so retry in the supertype pass + // (resolveDeferredThisMemberRefs) instead of giving up. + this.deferredThisMemberRefs.push(ref); + return null; + } const target = candidates.reduce((a, b) => (a.startLine <= b.startLine ? a : b)); return { original: ref, @@ -1224,6 +1239,80 @@ export class ReferenceResolver { }; } + /** + * Second pass for `this.` refs whose member wasn't on the enclosing + * class itself (#808): once implements/extends edges exist, walk the + * class's supertypes (transitively, depth-capped) and resolve the member on + * the nearest one that declares it — `this.handleSubmit` registered in a + * subclass resolves to `FormBase::handleSubmit`. Validated targets only + * (function/method kind, same language family); no match → no edge. + * Mirrors resolveChainedCallsViaConformance's lifecycle. Returns the number + * of newly-created edges. + */ + resolveDeferredThisMemberRefs(): number { + const deferred = this.deferredThisMemberRefs; + this.deferredThisMemberRefs = []; + if (deferred.length === 0) return 0; + + this.clearCaches(); + const resolved: ResolvedRef[] = []; + for (const ref of deferred) { + const member = ref.referenceName.slice('this.'.length); + const fromNode = this.queries.getNodeById(ref.fromNodeId); + if (!fromNode || !member) continue; + const sep = fromNode.qualifiedName.lastIndexOf('::'); + if (sep <= 0) continue; + const classPrefix = fromNode.qualifiedName.slice(0, sep); + const className = classPrefix.includes('::') + ? classPrefix.slice(classPrefix.lastIndexOf('::') + 2) + : classPrefix; + + // BFS up the supertype graph by simple name. + const seen = new Set([className]); + let frontier = this.context.getSupertypes?.(className, ref.language) ?? []; + let target: Node | null = null; + for (let depth = 0; depth < 5 && frontier.length > 0 && !target; depth++) { + const next: string[] = []; + for (const superName of frontier) { + if (seen.has(superName)) continue; + seen.add(superName); + const members = this.context + .getNodesByName(member) + .filter( + (n) => + (n.kind === 'function' || n.kind === 'method') && + sameLanguageFamily(n.language, ref.language) && + (n.qualifiedName === `${superName}::${member}` || + n.qualifiedName.endsWith(`::${superName}::${member}`)) + ); + if (members.length > 0) { + target = members.reduce((a, b) => (a.startLine <= b.startLine ? a : b)); + break; + } + next.push(...(this.context.getSupertypes?.(superName, ref.language) ?? [])); + } + frontier = next; + } + + if (target) { + resolved.push({ + original: ref, + targetNodeId: target.id, + confidence: 0.85, + resolvedBy: 'function-ref', + }); + } + } + if (resolved.length === 0) return 0; + + const edges = this.createEdges(resolved); + if (edges.length > 0) { + this.queries.insertEdges(edges); + this.clearCaches(); + } + return edges.length; + } + private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { if (!result) return result; const tgt = this.getLanguageFromNodeId(result.targetNodeId); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0584fc9..9b91aea 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -227,7 +227,7 @@ export function matchFunctionRef( }; } - const candidates = context + let candidates = context .getNodesByName(ref.referenceName) .filter( (n) => @@ -237,6 +237,35 @@ export function matchFunctionRef( ); if (candidates.length === 0) return null; + // Swift implicit-self: a bare identifier can name a METHOD only of the + // ENCLOSING type (`Button(action: handleTap)` written inside that type) — + // a same-named method on any OTHER class is a parameter collision + // (Alamofire: a `request` parameter resolving to EventMonitor::request). + // Scope method candidates to the from-symbol's type; top-level code has no + // implicit self, so method targets are excluded there entirely. Free + // functions are unaffected. + if (ref.language === 'swift' && candidates.some((n) => n.kind === 'method')) { + const fromNode = context.getNodeById?.(ref.fromNodeId); + const sep = fromNode ? fromNode.qualifiedName.lastIndexOf('::') : -1; + const classPrefix = fromNode && sep > 0 ? fromNode.qualifiedName.slice(0, sep) : null; + candidates = candidates.filter((n) => { + if (n.kind !== 'method') return true; + if (!classPrefix) return false; + const mSep = n.qualifiedName.lastIndexOf('::'); + if (mSep <= 0) return false; + const methodPrefix = n.qualifiedName.slice(0, mSep); + // Accept exact-scope matches plus suffix relationships either way, so + // extension-declared members (`Holder::m`) still match a nested + // from-scope (`Module::Holder::wire`) and vice versa. + return ( + methodPrefix === classPrefix || + methodPrefix.endsWith(`::${classPrefix}`) || + classPrefix.endsWith(`::${methodPrefix}`) + ); + }); + if (candidates.length === 0) return null; + } + // Same-file definition wins — the extraction gate guarantees most survivors // have one, and it's the dominant C pattern (static callback registered in // a same-file ops struct). diff --git a/src/resolution/types.ts b/src/resolution/types.ts index ca08b63..71366a1 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -91,6 +91,13 @@ export interface ResolutionContext { * method). Optional so external/test contexts compile without it. */ getSupertypes?(typeName: string, language: Language): string[]; + /** + * Look up a node by its id. Lets matchers derive the FROM-symbol's + * enclosing-class scope (Swift implicit-self method scoping, `this.X` + * member resolution). Optional so external/test contexts compile + * without it. + */ + getNodeById?(id: string): Node | null; /** Get cached import mappings for a file */ getImportMappings(filePath: string, language: Language): ImportMapping[]; /**