From f6b7fadf0b18a2f1a7413549a1b6f13b1a228977 Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 16:28:53 +0800 Subject: [PATCH 1/9] fix(resolution): stop typed external receivers and nested member calls from fabricating method edges When a receiver's local declaration or type annotation provides a concrete type (such as built-in Map/Set, an external package, or a non-matching class), failure to validate a project method on that type now stays unresolved instead of falling through to unique method-name guessing. In addition, TS/JS member-expression extraction now preserves static receiver chains (e.g. \holder.values.get\, \ his.store.get\, \ his.mailer.send\) in both the WASM extractor and the native Rust kernel. Multi-segment TS/JS receivers enforce an exclusive precision boundary in the resolver, preventing dropped-receiver calls from fabricating caller edges or self-edges on same-named project methods. Validated project receivers continue to resolve normally. Fixes #1566 --- CHANGELOG.md | 4 + __tests__/extraction.test.ts | 44 +++++++++ __tests__/kernel-tsjs-parity.test.ts | 12 ++- __tests__/resolution.test.ts | 125 ++++++++++++++++++++++++ codegraph-kernel/src/tsjs/extractors.rs | 50 ++++++++-- src/extraction/extraction-version.ts | 2 +- src/extraction/tree-sitter.ts | 59 ++++++++++- src/resolution/name-matcher.ts | 31 ++++-- 8 files changed, 308 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..0476bd01a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- Calls to built-in and external methods such as `Map.get`, `Map.set`, and `Map.has` in TypeScript/JavaScript no longer resolve to unrelated same-named project methods, and nested property calls (`this.store.get()`, `holder.values.get()`) no longer fabricate caller edges or self-edges when their target cannot be statically proven. Re-index after upgrading. (#1566) + ## [1.6.0] - 2026-08-26 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index ad0ba2374..c6b534eb7 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -11918,4 +11918,48 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => { expect(result.nodes.some((n) => n.kind === 'class' && n.name === 'Widget')).toBe(true); expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'size')).toBe(true); }); + + describe('TypeScript/JavaScript nested receiver extraction (#1566)', () => { + it('preserves static receiver chains and leaves dynamic expressions as bare method names', () => { + const src = ` +export class TestClass { + private store = new Map(); + testMethod(holder: any) { + // Simple local receiver + const values = new Map(); + values.get("key"); + + // Nested member chains + holder.values.get("key"); + this.store.get("key"); + this.mailer.send("msg"); + a.b.c.d("call"); + + // Direct this call + this.testMethod(null); + + // Dynamic / computed / call-result expressions + holder[key].get("key"); + factory().get("key"); + } +} +`; + const result = extractFromSource('test.ts', src, 'typescript'); + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + const refNames = refs.map((r) => r.referenceName); + + expect(refNames).toContain('values.get'); + expect(refNames).toContain('holder.values.get'); + expect(refNames).toContain('this.store.get'); + expect(refNames).toContain('this.mailer.send'); + expect(refNames).toContain('a.b.c.d'); + expect(refNames).toContain('testMethod'); // direct this.testMethod -> bare method + + // Dynamic / computed expressions do not fabricate static chains + expect(refNames.filter((r) => r === 'get').length).toBe(2); // holder[key].get and factory().get + expect(refNames.some((r) => r.includes('factory().'))).toBe(false); + expect(refNames.some((r) => r.includes('key]'))).toBe(false); + }); + }); }); + diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index c16d41ff0..c89d98616 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -97,7 +97,17 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => { const file = path.join(FIXTURE_DIR, 'torture.tsx'); - assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx'); + const content = fs.readFileSync(file, 'utf8'); + assertParity('fixtures/torture.tsx', content, 'tsx'); + + // Semantic check (#1566): BaseService::list preserves `this.cache.get` instead of bare `get` + const wasmRes = extractFromSource('fixtures/torture.tsx', content, 'tsx'); + const kernelRes = tryKernelExtract('fixtures/torture.tsx', content, 'tsx')!; + for (const res of [wasmRes, kernelRes]) { + const calls = res.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + expect(calls.map((c) => c.referenceName)).toContain('this.cache.get'); + expect(calls.map((c) => c.referenceName)).not.toContain('get'); + } }); it('torture fixture (js): field methods, wrappers, vuex module shape', () => { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index decaadee5..8f6cc9fc4 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3075,6 +3075,131 @@ export function callFromImportedFile(): void { }, 30000); }); + describe('TypeScript built-in receiver resolution and nested receiver safety (#1566)', () => { + // Calls to built-in Map.get/set/has (and other external/standard types) must + // not resolve to unrelated same-named project methods when the project + // defines a single method with each name. + // - Simple local receiver `values.get()`: once receiver typing identifies `Map`, + // failure to find a project method stays unresolved rather than falling + // through to confidence-0.7 unique method name guessing. + // - Nested receiver `holder.values.get()` / `this.store.get()`: the static member + // chain is preserved and multi-segment TS/JS receivers stay unresolved + // when receiver type cannot be proven, eliminating false edges and self-edges. + // - Positive controls: `cache.get()` on constructed `new LRUCache()` and typed + // parameter `cache: LRUCache` continue to resolve correctly to `LRUCache::get`. + it('built-in Map methods and nested receivers do not link to LRUCache, while true project instances resolve correctly', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'repro.ts'), + `export class LRUCache { + private store = new Map(); + + get(key: string): string | undefined { + return this.store.get(key); + } + + set(key: string, value: string): void { + this.store.set(key, value); + } + + has(key: string): boolean { + return this.store.has(key); + } +} + +export function useLocalMap(): boolean { + const values = new Map(); + values.set("answer", "42"); + values.get("answer"); + return values.has("answer"); +} + +export function useNestedMap( + holder: { values: Map }, +): string | undefined { + return holder.values.get("answer"); +} + +export function useProjectCache(cache: LRUCache): boolean { + cache.set("answer", "42"); + cache.get("answer"); + return cache.has("answer"); +} + +export function useConstructedProjectCache(): boolean { + const cache = new LRUCache(); + cache.set("answer", "42"); + cache.get("answer"); + return cache.has("answer"); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('repro.ts'); + const lruGet = allNodes.find((n) => n.kind === 'method' && n.name === 'get'); + const lruSet = allNodes.find((n) => n.kind === 'method' && n.name === 'set'); + const lruHas = allNodes.find((n) => n.kind === 'method' && n.name === 'has'); + + expect(lruGet).toBeDefined(); + expect(lruSet).toBeDefined(); + expect(lruHas).toBeDefined(); + + // 1. Callers of LRUCache::get: only true project callers (positive controls) + const callersGet = await cg.getCallers(lruGet!.id); + const getCallerNames = callersGet.map((c) => c.node.name).sort(); + expect(getCallerNames).toEqual(['useConstructedProjectCache', 'useProjectCache']); + expect(getCallerNames).not.toContain('useLocalMap'); + expect(getCallerNames).not.toContain('useNestedMap'); + expect(getCallerNames).not.toContain('get'); // No self-edge from this.store.get + + // 2. Callers of LRUCache::set + const callersSet = await cg.getCallers(lruSet!.id); + const setCallerNames = callersSet.map((c) => c.node.name).sort(); + expect(setCallerNames).toEqual(['useConstructedProjectCache', 'useProjectCache']); + expect(setCallerNames).not.toContain('useLocalMap'); + expect(setCallerNames).not.toContain('set'); + + // 3. Callers of LRUCache::has + const callersHas = await cg.getCallers(lruHas!.id); + const hasCallerNames = callersHas.map((c) => c.node.name).sort(); + expect(hasCallerNames).toEqual(['useConstructedProjectCache', 'useProjectCache']); + expect(hasCallerNames).not.toContain('useLocalMap'); + expect(hasCallerNames).not.toContain('has'); + + // 4. Callees of useLocalMap and useNestedMap: zero calls to LRUCache methods + const useLocalMapFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useLocalMap'); + const useNestedMapFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useNestedMap'); + + const localMapCallees = await cg.getCallees(useLocalMapFn!.id); + expect(localMapCallees.filter((c) => c.node.qualifiedName.startsWith('LRUCache'))).toHaveLength(0); + + const nestedMapCallees = await cg.getCallees(useNestedMapFn!.id); + expect(nestedMapCallees.filter((c) => c.node.qualifiedName.startsWith('LRUCache'))).toHaveLength(0); + + // 5. Positive recall controls: useConstructedProjectCache and useProjectCache have method call edges + const useProjectCacheFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useProjectCache'); + const projectCacheCallees = await cg.getCallees(useProjectCacheFn!.id); + const projectMethodCallees = projectCacheCallees.filter((c) => c.node.kind === 'method').map((c) => c.node.name).sort(); + expect(projectMethodCallees).toEqual(['get', 'has', 'set']); + + const constructedCacheCallees = await cg.getCallees( + allNodes.find((n) => n.kind === 'function' && n.name === 'useConstructedProjectCache')!.id + ); + const constructedMethodCallees = constructedCacheCallees.filter((c) => c.node.kind === 'method').map((c) => c.node.name).sort(); + expect(constructedMethodCallees).toEqual(['get', 'has', 'set']); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + }); + describe('Object-literal namespace members (#1573)', () => { // `export const api = { call() {…}, get: () => {…} }` used as the module's // API surface: the members are plain functions with bare names inside the diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 3e4814736..56c05f3fc 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1064,20 +1064,16 @@ impl<'t> Walker<'t> { if is_literal_receiver(r.kind()) { return; } - } - let recv_ident = receiver.filter(|r| { - matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier") - }); - if let Some(r) = recv_ident { - let receiver_name = self.text(r); - if !matches!(receiver_name, "self" | "this" | "cls" | "super") { - callee_name = format!("{receiver_name}.{method_name}"); + if let Some(chain) = get_static_member_chain(r, self.src) { + if !matches!(chain.as_str(), "self" | "this" | "cls" | "super") { + callee_name = format!("{chain}.{method_name}"); + } else { + callee_name = method_name.to_string(); + } } else { callee_name = method_name.to_string(); } } else { - // (the call-receiver re-encode branches are other - // languages'; TS/JS keeps the bare method name) callee_name = method_name.to_string(); } } @@ -1335,3 +1331,37 @@ fn collapse_ws(s: &str) -> String { } out } + +/// Recursively extract a static dotted member chain (`a.b.c`, `this.field`, `holder.values`) +/// from an AST node, or return None if any part of the chain is dynamic, computed, +/// or a call expression (#1566). +fn get_static_member_chain<'t>(node: Node<'t>, source: &'t str) -> Option { + match node.kind() { + "identifier" | "property_identifier" | "simple_identifier" | "field_identifier" + | "this" | "super" => { + source.get(node.byte_range()).map(|s| s.to_string()) + } + "member_expression" => { + let object = node + .child_by_field_name("object") + .or_else(|| node.named_child(0))?; + let property = node + .child_by_field_name("property") + .or_else(|| node.child_by_field_name("field")) + .or_else(|| node.named_child(1))?; + let obj_text = get_static_member_chain(object, source)?; + let prop_kind = property.kind(); + if matches!( + prop_kind, + "property_identifier" | "identifier" | "private_property_identifier" + ) { + let prop_text = source.get(property.byte_range())?; + Some(format!("{obj_text}.{prop_text}")) + } else { + None + } + } + _ => None, + } +} + diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 07ccbb964..8ddab13b8 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 = 25; +export const EXTRACTION_VERSION = 26; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index c34dc4716..8f8cd6e2f 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -388,6 +388,40 @@ const LITERAL_RECEIVER_TYPES = new Set([ 'dictionary', 'dict_literal', 'object', 'tuple', 'set', ]); +/** + * Recursively extract a static dotted member chain (`a.b.c`, `this.field`, `holder.values`) + * from an AST node, or return null if any part of the chain is dynamic, computed, + * or a call expression (#1566). + */ +function getStaticMemberChain(node: SyntaxNode, source: string): string | null { + if ( + node.type === 'identifier' || + node.type === 'property_identifier' || + node.type === 'simple_identifier' || + node.type === 'field_identifier' || + node.type === 'this' || + node.type === 'super' + ) { + return getNodeText(node, source); + } + if (node.type === 'member_expression') { + const object = getChildByField(node, 'object') || node.namedChild(0); + const property = getChildByField(node, 'property') || getChildByField(node, 'field') || node.namedChild(1); + if (!object || !property) return null; + const objText = getStaticMemberChain(object, source); + const propText = + property.type === 'property_identifier' || + property.type === 'identifier' || + property.type === 'private_property_identifier' + ? getNodeText(property, source) + : null; + if (objText && propText) { + return `${objText}.${propText}`; + } + } + return null; +} + export class TreeSitterExtractor { private filePath: string; private language: Language; @@ -4445,7 +4479,30 @@ export class TreeSitterExtractor { return; } const SKIP_RECEIVERS = new Set(['self', 'this', 'cls', 'super']); - if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) { + if ( + (this.language === 'typescript' || + this.language === 'javascript' || + this.language === 'tsx' || + this.language === 'jsx' || + this.language === 'arkts') && + receiver + ) { + // TS/JS/ArkTS: preserve static member expression chains + // (`holder.values.get`, `this.store.get`, `this.mailer.send`) + // so resolution does not lose receiver context and fall back to + // bare-name guessing (#1566/#1496). Dynamic/computed/call-result + // receivers keep the bare method name. + const chain = getStaticMemberChain(receiver, this.source); + if (chain) { + if (!SKIP_RECEIVERS.has(chain)) { + calleeName = `${chain}.${methodName}`; + } else { + calleeName = methodName; + } + } else { + calleeName = methodName; + } + } else if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) { const receiverName = getNodeText(receiver, this.source); if (!SKIP_RECEIVERS.has(receiverName)) { calleeName = `${receiverName}.${methodName}`; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..d84b6d5c8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1826,9 +1826,12 @@ export function matchMethodCall( 'instance-method', importedFqn, )); - if (typedMatch) { - return typedMatch; - } + // Precision boundary: when receiver typing identifies a concrete type for + // the receiver variable, resolution must succeed on that type. If the type + // does not declare the method in the project (e.g. built-in Map/Set, an + // external package, or non-matching class), the call stays unresolved + // rather than falling through to bare-name method guessing (#1566/#1108). + return typedMatch; } } @@ -1881,12 +1884,28 @@ export function matchMethodCall( 'instance-method', importedFqn, )); - if (typedMatch) { - return typedMatch; - } + return typedMatch; } } + // TypeScript / JavaScript / ArkTS chained receiver `a.b.method()` / `this.field.method()` (#1566): + // When the receiver is a multi-segment chain, resolve only through validated + // type inference or exact object/class match above. Chained TS/JS receivers must + // never fall through to the bare-name / method-name uniqueness guessing in + // Strategy 2/3 below — that is how `holder.values.get()` or `this.store.get()` + // fabricated dependencies on unrelated project methods (or self-edges). + if ( + (ref.language === 'typescript' || + ref.language === 'javascript' || + ref.language === 'tsx' || + ref.language === 'jsx' || + ref.language === 'arkts') && + dotMatch && + objectOrClass!.includes('.') + ) { + return null; + } + // Object-literal namespace receiver (#1573): `api.call()` where `api` is a // same-file `const api = { call() {…}, get: () => {…} }`. Its members are // plain functions with bare names inside the constant's extent — no From d47ce930897eb0d903348b2116c4d3069d97c749 Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 17:20:22 +0800 Subject: [PATCH 2/9] fix(resolution): handle dynamic receivers, typed receiver conformance retry, and scoped this.field recall (#1566) --- __tests__/extraction.test.ts | 6 +- __tests__/resolution.test.ts | 79 ++++++++++- codegraph-kernel/src/tsjs/extractors.rs | 4 +- src/extraction/tree-sitter.ts | 5 +- src/index.ts | 6 + src/resolution/index.ts | 64 ++++++++- src/resolution/name-matcher.ts | 167 ++++++++++++++++++++++++ src/resolution/resolver-pool.ts | 5 +- 8 files changed, 323 insertions(+), 13 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index c6b534eb7..b6a82e303 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -11920,7 +11920,7 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => { }); describe('TypeScript/JavaScript nested receiver extraction (#1566)', () => { - it('preserves static receiver chains and leaves dynamic expressions as bare method names', () => { + it('preserves static receiver chains and leaves dynamic expressions silent', () => { const src = ` export class TestClass { private store = new Map(); @@ -11955,8 +11955,8 @@ export class TestClass { expect(refNames).toContain('a.b.c.d'); expect(refNames).toContain('testMethod'); // direct this.testMethod -> bare method - // Dynamic / computed expressions do not fabricate static chains - expect(refNames.filter((r) => r === 'get').length).toBe(2); // holder[key].get and factory().get + // Dynamic / computed expressions do not emit bare methodName refs (#1566 Blocker A) + expect(refNames).not.toContain('get'); expect(refNames.some((r) => r.includes('factory().'))).toBe(false); expect(refNames.some((r) => r.includes('key]'))).toBe(false); }); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 8f6cc9fc4..6491fba46 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3122,6 +3122,14 @@ export function useNestedMap( return holder.values.get("answer"); } +export function useDynamicReceivers( + holder: Record, + key: string, +): void { + holder[key].get("answer"); + factory().get("answer"); +} + export function useProjectCache(cache: LRUCache): boolean { cache.set("answer", "42"); cache.get("answer"); @@ -3134,6 +3142,20 @@ export function useConstructedProjectCache(): boolean { cache.get("answer"); return cache.has("answer"); } + +export class Mailer { + send(message: string): void {} +} + +export class Service { + private mailer: Mailer; + private store = new Map(); + + run(): void { + this.mailer.send("hello"); + this.store.get("key"); + } +} ` ); @@ -3154,7 +3176,9 @@ export function useConstructedProjectCache(): boolean { expect(getCallerNames).toEqual(['useConstructedProjectCache', 'useProjectCache']); expect(getCallerNames).not.toContain('useLocalMap'); expect(getCallerNames).not.toContain('useNestedMap'); + expect(getCallerNames).not.toContain('useDynamicReceivers'); expect(getCallerNames).not.toContain('get'); // No self-edge from this.store.get + expect(getCallerNames).not.toContain('run'); // Service.run calling this.store.get does NOT link to LRUCache // 2. Callers of LRUCache::set const callersSet = await cg.getCallers(lruSet!.id); @@ -3170,9 +3194,10 @@ export function useConstructedProjectCache(): boolean { expect(hasCallerNames).not.toContain('useLocalMap'); expect(hasCallerNames).not.toContain('has'); - // 4. Callees of useLocalMap and useNestedMap: zero calls to LRUCache methods + // 4. Callees of useLocalMap, useNestedMap, useDynamicReceivers: zero calls to LRUCache methods const useLocalMapFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useLocalMap'); const useNestedMapFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useNestedMap'); + const useDynamicFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useDynamicReceivers'); const localMapCallees = await cg.getCallees(useLocalMapFn!.id); expect(localMapCallees.filter((c) => c.node.qualifiedName.startsWith('LRUCache'))).toHaveLength(0); @@ -3180,6 +3205,9 @@ export function useConstructedProjectCache(): boolean { const nestedMapCallees = await cg.getCallees(useNestedMapFn!.id); expect(nestedMapCallees.filter((c) => c.node.qualifiedName.startsWith('LRUCache'))).toHaveLength(0); + const dynamicCallees = await cg.getCallees(useDynamicFn!.id); + expect(dynamicCallees.filter((c) => c.node.qualifiedName.startsWith('LRUCache'))).toHaveLength(0); + // 5. Positive recall controls: useConstructedProjectCache and useProjectCache have method call edges const useProjectCacheFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useProjectCache'); const projectCacheCallees = await cg.getCallees(useProjectCacheFn!.id); @@ -3191,6 +3219,55 @@ export function useConstructedProjectCache(): boolean { ); const constructedMethodCallees = constructedCacheCallees.filter((c) => c.node.kind === 'method').map((c) => c.node.name).sort(); expect(constructedMethodCallees).toEqual(['get', 'has', 'set']); + + // 6. Scoped typed field recall: this.mailer.send() -> Mailer::send (#1566/#1496) + const mailerSend = allNodes.find((n) => n.kind === 'method' && n.name === 'send' && n.qualifiedName.startsWith('Mailer')); + expect(mailerSend).toBeDefined(); + const mailerSendCallers = await cg.getCallers(mailerSend!.id); + expect(mailerSendCallers.map((c) => c.node.name)).toContain('run'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('retries unresolved project typed receiver calls via conformance second pass (#1566 Blocker B)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-conformance-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `export class BaseService { + run(): void { + console.log("running base service"); + } +} + +export class DerivedService extends BaseService {} + +export function useDerived(): void { + const svc = new DerivedService(); + svc.run(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('service.ts'); + const baseRun = allNodes.find((n) => n.kind === 'method' && n.name === 'run' && n.qualifiedName.startsWith('BaseService')); + expect(baseRun).toBeDefined(); + + const callers = await cg.getCallers(baseRun!.id); + const callerNames = callers.map((c) => c.node.name); + expect(callerNames).toContain('useDerived'); + + const useDerivedFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useDerived'); + const callees = await cg.getCallees(useDerivedFn!.id); + const calleeMethods = callees.filter((c) => c.node.kind === 'method').map((c) => c.node.name); + expect(calleeMethods).toContain('run'); } finally { if (cg) { cg.close(); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 56c05f3fc..96a655329 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1071,7 +1071,9 @@ impl<'t> Walker<'t> { callee_name = method_name.to_string(); } } else { - callee_name = method_name.to_string(); + // Dynamic/computed/call-result receiver has no static receiver identity. + // DO NOT degrade to bare method name (#1566). + return; } } else { callee_name = method_name.to_string(); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 8f8cd6e2f..8ed914bf8 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -4491,7 +4491,8 @@ export class TreeSitterExtractor { // (`holder.values.get`, `this.store.get`, `this.mailer.send`) // so resolution does not lose receiver context and fall back to // bare-name guessing (#1566/#1496). Dynamic/computed/call-result - // receivers keep the bare method name. + // receivers have no static receiver identity and do NOT degrade + // to a bare method name that could fabricate a project edge. const chain = getStaticMemberChain(receiver, this.source); if (chain) { if (!SKIP_RECEIVERS.has(chain)) { @@ -4500,7 +4501,7 @@ export class TreeSitterExtractor { calleeName = methodName; } } else { - calleeName = methodName; + return; } } else if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) { const receiverName = getNodeText(receiver, this.source); diff --git a/src/index.ts b/src/index.ts index 90397c55c..94f976ef5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -655,6 +655,10 @@ export class CodeGraph { const tDeferred = Date.now(); await this.resolver.resolveDeferredThisMemberRefs(); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] deferredThisMember: ${Date.now() - tDeferred}ms`); + // Same lifecycle for typed receiver calls whose method lives on a supertype (#1566). + const tTypedReceiver = Date.now(); + await this.resolver.resolveTypedReceiverCallsViaConformance(); + if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] typedReceiverConformance: ${Date.now() - tTypedReceiver}ms`); } // Refresh planner stats + checkpoint the WAL after bulk writes. @@ -991,6 +995,8 @@ export class CodeGraph { // Same lifecycle for `this.` callback registrations whose // member is inherited from a supertype (#808). await this.resolver.resolveDeferredThisMemberRefs(); + // Same lifecycle for typed receiver calls whose method lives on a supertype (#1566). + await this.resolver.resolveTypedReceiverCallsViaConformance(); } // Refresh planner stats + checkpoint the WAL after bulk writes. diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 7b4bccc18..47fcdbf22 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -16,7 +16,7 @@ import { FrameworkResolver, ImportMapping, } from './types'; -import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; +import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, shouldDeferTypedReceiver, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; @@ -217,6 +217,11 @@ export class ReferenceResolver { // same reason as deferredChainRefs and drained by // resolveDeferredThisMemberRefs once implements/extends edges exist (#808). private deferredThisMemberRefs: UnresolvedRef[] = []; + // Typed receiver call refs whose inferred receiver is a project aggregate type + // (class/interface/struct), whose method was not on the class itself and may be + // inherited. Drained by resolveTypedReceiverCallsViaConformance once + // implements/extends edges exist (#1566). + private deferredTypedReceiverRefs: 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. @@ -1047,6 +1052,16 @@ export class ReferenceResolver { PHP_PROP_SHAPE.test(ref.referenceName) ) { this.deferredChainRefs.push(ref); + } else if ( + // Typed local receiver / field receiver call whose declared receiver type + // is a project aggregate type (class, interface, struct, etc.): its method + // may be inherited from a supertype, resolvable once implements/extends + // edges exist (#1566). Definitive misses (built-in / external types like + // Map, Promise, URL) are not project aggregates and do not defer. + ref.referenceKind === 'calls' && + shouldDeferTypedReceiver(ref, this.context) + ) { + this.deferredTypedReceiverRefs.push(ref); } return null; } @@ -1450,6 +1465,7 @@ export class ReferenceResolver { unresolved: UnresolvedRef[]; deferredChain: UnresolvedRef[]; deferredThisMember: UnresolvedRef[]; + deferredTypedReceiver: UnresolvedRef[]; byMethod: Record; } { this.warmCaches(); @@ -1481,6 +1497,7 @@ export class ReferenceResolver { unresolved, deferredChain: this.deferredChainRefs.splice(0), deferredThisMember: this.deferredThisMemberRefs.splice(0), + deferredTypedReceiver: this.deferredTypedReceiverRefs.splice(0), byMethod, }; } @@ -1496,12 +1513,17 @@ export class ReferenceResolver { /** * Re-queue deferred post-pass refs produced by resolver workers, preserving * their admission order so resolveChainedCallsViaConformance / - * resolveDeferredThisMemberRefs process them exactly as the sequential path - * would have. + * resolveDeferredThisMemberRefs / resolveTypedReceiverCallsViaConformance + * process them exactly as the sequential path would have. */ - appendDeferredFromWorkers(deferredChain: UnresolvedRef[], deferredThisMember: UnresolvedRef[]): void { + appendDeferredFromWorkers( + deferredChain: UnresolvedRef[], + deferredThisMember: UnresolvedRef[], + deferredTypedReceiver: UnresolvedRef[] = [] + ): void { this.deferredChainRefs.push(...deferredChain); this.deferredThisMemberRefs.push(...deferredThisMember); + this.deferredTypedReceiverRefs.push(...deferredTypedReceiver); } /** @@ -1661,7 +1683,7 @@ export class ReferenceResolver { if (inFlight.mode === 'pool') { const settled = await inFlight.settled; if (settled.ok) { - this.appendDeferredFromWorkers(settled.out.deferredChain, settled.out.deferredThisMember); + this.appendDeferredFromWorkers(settled.out.deferredChain, settled.out.deferredThisMember, settled.out.deferredTypedReceiver); return { resolved: settled.out.resolved, unresolved: settled.out.unresolved, @@ -2417,6 +2439,38 @@ export class ReferenceResolver { return edges.length; } + /** + * Second resolution pass for typed receiver calls whose method may be defined + * on a supertype the receiver extends/implements (#1566). + * Operates on leftover unresolved calls whose receiver type is a project aggregate + * (e.g. `Derived extends Base`, `const d = new Derived()`, `d.run()`). + * Runs after implements/extends edges exist, so resolveMethodOnType can walk them. + * Returns the number of newly-created edges. + */ + async resolveTypedReceiverCallsViaConformance(): Promise { + const deferred = this.deferredTypedReceiverRefs; + this.deferredTypedReceiverRefs = []; + if (deferred.length === 0) return 0; + + this.clearCaches(); + const maybeYield = createYielder(); + const resolved: ResolvedRef[] = []; + for (const ref of deferred) { + await maybeYield(); + const match = matchMethodCall(ref, this.context); + const gated = this.gateLanguage(match, ref); + if (gated) resolved.push(gated); + } + 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 d84b6d5c8..169577e5a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1222,6 +1222,148 @@ function inferJavaFieldReceiverType( return lastPart; } +/** + * Infer the declared or initialized type of a `this.` receiver in TypeScript/JavaScript (#1566/#1496). + * Looks up the property/field on the enclosing class within the call site's file. + */ +export function inferTsJsFieldReceiverType( + receiverName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + if (!receiverName.startsWith('this.')) return null; + const fieldName = receiverName.slice('this.'.length); + if (!fieldName || fieldName.includes('.')) return null; + + const inFile = context.getNodesInFile(ref.filePath); + if (inFile.length === 0) return null; + + // Find the class enclosing the call line (tightest match by latest start). + let enclosing: Node | null = null; + for (const n of inFile) { + if (n.kind !== 'class' && n.kind !== 'interface') continue; + if (n.language !== ref.language && !sameLanguageFamily(n.language, ref.language)) continue; + const end = n.endLine ?? n.startLine; + if (n.startLine <= ref.line && end >= ref.line) { + if (!enclosing || n.startLine >= enclosing.startLine) enclosing = n; + } + } + if (!enclosing) return null; + + const enclosingEnd = enclosing.endLine ?? enclosing.startLine; + const field = inFile.find( + (n) => + (n.kind === 'property' || n.kind === 'field') && + n.name === fieldName && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) && + n.startLine >= enclosing.startLine && + (n.endLine ?? n.startLine) <= enclosingEnd, + ); + if (!field) return null; + + if (field.signature && field.signature !== field.name) { + // Signature format: " " (from TS property extraction) + const beforeName = field.signature.slice(0, field.signature.lastIndexOf(field.name)).trim(); + if (beforeName) { + const typeNoGenerics = beforeName.replace(/<[^>]*>/g, '').trim(); + const typeNoArray = typeNoGenerics.replace(/\[\s*\]/g, '').trim(); + const parts = typeNoArray.split(/[.\s]+/).filter(Boolean); + const lastPart = parts[parts.length - 1]; + if (lastPart && /^[A-Z]/.test(lastPart)) { + return lastPart; + } + } + } + + // If un-annotated, check field declaration line for `= new Type(...)` + const lines = context.getFileLines ? context.getFileLines(ref.filePath) : null; + if (lines && field.startLine <= lines.length) { + const lineText = lines[field.startLine - 1]; + if (lineText) { + const initMatch = lineText.match(/=\s*new\s+([A-Z][a-zA-Z0-9_$]*)/); + if (initMatch && initMatch[1]) { + return initMatch[1]; + } + } + } + + return null; +} + +const PROJECT_AGGREGATE_KINDS = new Set([ + 'class', + 'interface', + 'struct', + 'trait', + 'protocol', + 'union', + 'enum', +]); + +/** + * Check if a type name corresponds to a project aggregate type (class, interface, struct, etc.) + * in the project graph (#1566). + */ +export function isProjectAggregateType( + typeName: string, + language: string, + context: ResolutionContext, +): boolean { + const nodes = context.getNodesByName(typeName); + return nodes.some( + (n) => PROJECT_AGGREGATE_KINDS.has(n.kind) && (n.language === language || sameLanguageFamily(n.language, language)) + ); +} + +/** + * Extract the inferred type of a call receiver from local variables or class fields (#1566). + */ +export function getInferredReceiverType(ref: UnresolvedRef, context: ResolutionContext): string | null { + if (ref.referenceKind !== 'calls') return null; + const dotMatch = ref.referenceName.match(/^([a-zA-Z0-9_$.]+)\.([a-zA-Z0-9_$]+)$/); + const luaColonMatch = ref.language === 'lua' ? ref.referenceName.match(/^([a-zA-Z0-9_]+):([a-zA-Z0-9_]+)$/) : null; + const rDollarMatch = ref.language === 'r' ? ref.referenceName.match(/^([a-zA-Z0-9_.]+)\$([a-zA-Z0-9_.]+)$/) : null; + const match = dotMatch || luaColonMatch || rDollarMatch; + if (!match) return null; + const [, objectOrClass] = match; + if (!objectOrClass) return null; + + if ( + (ref.language === 'typescript' || + ref.language === 'javascript' || + ref.language === 'tsx' || + ref.language === 'jsx' || + ref.language === 'arkts') && + dotMatch && + objectOrClass.startsWith('this.') + ) { + return inferTsJsFieldReceiverType(objectOrClass, ref, context); + } + + if (dotMatch || luaColonMatch || rDollarMatch) { + if (ref.language === 'cpp') { + return inferCppReceiverType(objectOrClass, ref, context); + } + if (ref.language === 'java' || ref.language === 'kotlin') { + const local = inferLocalReceiverType(objectOrClass, ref, context); + if (local) return local; + return inferJavaFieldReceiverType(objectOrClass, ref, context); + } + return inferLocalReceiverType(objectOrClass, ref, context); + } + return null; +} + +/** + * Returns true if a call reference was typed with a project aggregate type and should be retried + * during the conformance second pass after inheritance edges exist (#1566). + */ +export function shouldDeferTypedReceiver(ref: UnresolvedRef, context: ResolutionContext): boolean { + const inferred = getInferredReceiverType(ref, context); + if (!inferred) return false; + return isProjectAggregateType(inferred, ref.language, context); +} + // ── Local-variable receiver-type inference (#1108) ────────────────────────── // // Instance calls through a local variable (`const lg = new Logger(); lg.log()`) @@ -1888,6 +2030,31 @@ export function matchMethodCall( } } + // TypeScript / JavaScript / ArkTS: receiver may be `this.` (#1566/#1496). + // Look up the declared/initialized type of the field on the enclosing class. + if ( + (ref.language === 'typescript' || + ref.language === 'javascript' || + ref.language === 'tsx' || + ref.language === 'jsx' || + ref.language === 'arkts') && + dotMatch && + objectOrClass!.startsWith('this.') + ) { + const inferredType = inferTsJsFieldReceiverType(objectOrClass!, ref, context); + if (inferredType) { + const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( + inferredType, + methodName!, + ref, + context, + 0.9, + 'instance-method', + )); + return typedMatch; + } + } + // TypeScript / JavaScript / ArkTS chained receiver `a.b.method()` / `this.field.method()` (#1566): // When the receiver is a multi-segment chain, resolve only through validated // type inference or exact object/class match above. Chained TS/JS receivers must diff --git a/src/resolution/resolver-pool.ts b/src/resolution/resolver-pool.ts index eac808ba4..8ffc17479 100644 --- a/src/resolution/resolver-pool.ts +++ b/src/resolution/resolver-pool.ts @@ -28,6 +28,7 @@ export interface ChunkResult { unresolved: UnresolvedRef[]; deferredChain: UnresolvedRef[]; deferredThisMember: UnresolvedRef[]; + deferredTypedReceiver: UnresolvedRef[]; byMethod: Record; } @@ -168,6 +169,7 @@ export class ResolverPool { unresolved: msg.unresolved!, deferredChain: msg.deferredChain!, deferredThisMember: msg.deferredThisMember!, + deferredTypedReceiver: msg.deferredTypedReceiver ?? [], byMethod: msg.byMethod!, }); } else if (msg.type === 'synth-result' && msg.id !== undefined) { @@ -254,12 +256,13 @@ export class ResolverPool { ); } const chunks = await Promise.all(chunkPromises); - const out: ChunkResult = { resolved: [], unresolved: [], deferredChain: [], deferredThisMember: [], byMethod: {} }; + const out: ChunkResult = { resolved: [], unresolved: [], deferredChain: [], deferredThisMember: [], deferredTypedReceiver: [], byMethod: {} }; for (const c of chunks) { out.resolved.push(...c.resolved); out.unresolved.push(...c.unresolved); out.deferredChain.push(...c.deferredChain); out.deferredThisMember.push(...c.deferredThisMember); + out.deferredTypedReceiver.push(...c.deferredTypedReceiver); for (const [k, v] of Object.entries(c.byMethod)) out.byMethod[k] = (out.byMethod[k] || 0) + v; } return out; From 4003ca9dd95b7a8a572968ce7bd63031b1a0884c Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 17:21:53 +0800 Subject: [PATCH 3/9] docs(changelog): refine #1566 notes and cleanup formatting --- CHANGELOG.md | 2 +- __tests__/extraction.test.ts | 1 - codegraph-kernel/src/tsjs/extractors.rs | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0476bd01a..218119e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes -- Calls to built-in and external methods such as `Map.get`, `Map.set`, and `Map.has` in TypeScript/JavaScript no longer resolve to unrelated same-named project methods, and nested property calls (`this.store.get()`, `holder.values.get()`) no longer fabricate caller edges or self-edges when their target cannot be statically proven. Re-index after upgrading. (#1566) +- Calls on built-in and external types (such as `Map.get`, `Map.set`, `Map.has`) in TypeScript/JavaScript no longer resolve to unrelated same-named project methods, static nested member calls (`holder.values.get()`, `this.store.get()`) preserve context rather than guessing or fabricating self-edges, dynamic/computed receivers (`holder[key].get()`, `factory().get()`) no longer emit phantom project calls, and inherited methods on typed instances continue to resolve accurately via conformance retry. Re-index after upgrading. (#1566) ## [1.6.0] - 2026-08-26 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index b6a82e303..06813207a 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -11962,4 +11962,3 @@ export class TestClass { }); }); }); - diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 96a655329..fae4540a6 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1366,4 +1366,3 @@ fn get_static_member_chain<'t>(node: Node<'t>, source: &'t str) -> Option None, } } - From a1e5d378205a545f85b6419beed8ba2c1f94aed3 Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 18:22:32 +0800 Subject: [PATCH 4/9] fix(resolution): anchor typed receiver resolution and conformance to project type nodes (#1566) - Extract canonical method call reference parser (parseMethodCallReference) covering dot, C++ explicit operator, scope, Lua single colon, and R dollar syntax - Bind inferred receiver types strictly to visible project type nodes in graph (bindProjectReceiverType) rather than loose type names - Restructure typed receiver conformance retry as node-anchored BFS traversal following extends/implements graph edges - Add constructor parameter property and direct ownership checks to avoid intermediate class hijacking of this. receivers - Add comprehensive positive, negative, same-name duplicate, and external SDK isolation regression tests --- __tests__/kernel-tsjs-parity.test.ts | 25 ++ __tests__/object-literal-methods.test.ts | 12 +- __tests__/resolution.test.ts | 179 +++++++++ __tests__/sync.test.ts | 27 ++ src/resolution/index.ts | 113 +++++- src/resolution/name-matcher.ts | 462 +++++++++++++++++------ src/resolution/resolver-pool.ts | 6 +- src/resolution/types.ts | 10 + 8 files changed, 683 insertions(+), 151 deletions(-) diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index c89d98616..ec829e75e 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -110,6 +110,31 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { } }); + it('dynamic receiver extraction parity and silence (#1566)', () => { + const src = ` +function factory() { return { get: () => 1 }; } +export function dynamic(holder: any, key: string) { + holder[key].get("x"); + factory().get("x"); +} +export function staticChain(holder: any) { + holder.values.get("x"); +} +`; + assertParity('fixtures/dynamic-parity.ts', src, 'typescript'); + const wasmRes = extractFromSource('fixtures/dynamic-parity.ts', src, 'typescript'); + const kernelRes = tryKernelExtract('fixtures/dynamic-parity.ts', src, 'typescript')!; + for (const res of [wasmRes, kernelRes]) { + const calls = res.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); + const names = calls.map((c) => c.referenceName); + expect(names).toContain('holder.values.get'); + expect(names).toContain('factory'); + expect(names).not.toContain('get'); + expect(names.some((n) => n.includes('factory().'))).toBe(false); + expect(names.some((n) => n.includes('key]'))).toBe(false); + } + }); + it('torture fixture (js): field methods, wrappers, vuex module shape', () => { const file = path.join(FIXTURE_DIR, 'torture.js'); assertParity('fixtures/torture.js', fs.readFileSync(file, 'utf8'), 'javascript'); diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts index 1722ad2d0..795c0e3b5 100644 --- a/__tests__/object-literal-methods.test.ts +++ b/__tests__/object-literal-methods.test.ts @@ -51,11 +51,12 @@ describe('object-literal method extraction', () => { expect(fnNames).toContain('switchOrganization'); expect(fnNames).toContain('reset'); - // Each action's body was walked: fetchUser references its sibling `reset`, - // so an in-store calls edge will resolve once the pipeline runs. + // Each action's body was walked: inner call `get()` is extracted, while + // the dynamic chained `.reset()` on a call-result receiver is safely silenced (#1566). const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!; const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id); - expect(fetchUserRefs.map((r) => r.referenceName)).toContain('reset'); + expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get'); + expect(fetchUserRefs.map((r) => r.referenceName)).not.toContain('reset'); // The action's body wasn't mis-attributed to the file scope (the reason we // skip the generic body-visit for the store-factory call). @@ -166,10 +167,9 @@ describe('object-literal method resolution (end-to-end)', () => { const fetchUserCallers = cg.getCallers(fetchUser!.id).map((c) => c.node.name); expect(fetchUserCallers).toContain('loginFlow'); - // Chained getState() call: hardReset -> reset, AND in-store sibling: fetchUser -> reset + // Dynamic chained getState().reset() and get().reset() calls do not emit bare references (#1566) const resetCallers = cg.getCallers(reset!.id).map((c) => c.node.name); - expect(resetCallers).toContain('hardReset'); - expect(resetCallers).toContain('fetchUser'); + expect(resetCallers).not.toContain('hardReset'); cg.close(); }); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 6491fba46..ea664d4e9 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3275,6 +3275,185 @@ export function useDerived(): void { fs.rmSync(tmpDir, { recursive: true, force: true }); } }, 30000); + + it('built-in Map receiver does not resolve to unrelated same-named project class Map decoy (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-map-decoy-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'decoy.ts'), + `export class BaseMap { + get(key: string): string | undefined { + return undefined; + } +} +export class Map extends BaseMap {} +` + ); + fs.writeFileSync( + path.join(tmpDir, 'use.ts'), + `export function useBuiltinMap(): string | undefined { + const values = new Map(); + return values.get("x"); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('use.ts'); + const useFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useBuiltinMap'); + expect(useFn).toBeDefined(); + + const callees = await cg.getCallees(useFn!.id); + expect(callees.filter((c) => c.node.name === 'get')).toHaveLength(0); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('external SDK import does not bind to unrelated project same-named class decoy (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-sdk-decoy-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'decoy.ts'), + `export class BaseClient { + run(): void {} +} +export class ExternalClient extends BaseClient { + send(): void {} +} +` + ); + fs.writeFileSync( + path.join(tmpDir, 'use.ts'), + `import { ExternalClient } from "external-sdk"; +export function useExternal(client: ExternalClient): void { + client.send(); + client.run(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('use.ts'); + const useFn = allNodes.find((n) => n.kind === 'function' && n.name === 'useExternal'); + expect(useFn).toBeDefined(); + + const callees = await cg.getCallees(useFn!.id); + expect(callees.filter((c) => c.node.name === 'send' || c.node.name === 'run')).toHaveLength(0); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('distinct same-named Engine classes maintain node-anchored conformance paths (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-engine-paths-')); + let cg: CodeGraph | undefined; + try { + fs.mkdirSync(path.join(tmpDir, 'a'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'b'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'a', 'engine.ts'), + `export class BaseA { + run(): void {} +} +export class Engine extends BaseA {} +` + ); + fs.writeFileSync( + path.join(tmpDir, 'b', 'engine.ts'), + `export class BaseB { + run(): void {} +} +export class Engine extends BaseB {} +` + ); + fs.writeFileSync( + path.join(tmpDir, 'use.ts'), + `import { Engine } from "./a/engine"; +export function useEngineA(): void { + const engine = new Engine(); + engine.run(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodesA = cg.getNodesInFile('a/engine.ts'); + const baseARun = allNodesA.find((n) => n.kind === 'method' && n.name === 'run' && n.qualifiedName.startsWith('BaseA')); + expect(baseARun).toBeDefined(); + + const allNodesB = cg.getNodesInFile('b/engine.ts'); + const baseBRun = allNodesB.find((n) => n.kind === 'method' && n.name === 'run' && n.qualifiedName.startsWith('BaseB')); + expect(baseBRun).toBeDefined(); + + const callersA = await cg.getCallers(baseARun!.id); + expect(callersA.map((c) => c.node.name)).toContain('useEngineA'); + + const callersB = await cg.getCallers(baseBRun!.id); + expect(callersB.map((c) => c.node.name)).not.toContain('useEngineA'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('nested class field within method does not hijack outer class this.field receiver (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-nested-class-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `export class Mailer { + send(message: string): void {} +} +export class Decoy { + send(message: string): void {} +} +export class Service { + run(): void { + class Local { + mailer: Decoy; + } + this.mailer.send("hello"); + } + mailer: Mailer; +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('service.ts'); + const mailerSend = allNodes.find((n) => n.kind === 'method' && n.name === 'send' && n.qualifiedName.startsWith('Mailer')); + const decoySend = allNodes.find((n) => n.kind === 'method' && n.name === 'send' && n.qualifiedName.startsWith('Decoy')); + expect(mailerSend).toBeDefined(); + expect(decoySend).toBeDefined(); + + const mailerCallers = await cg.getCallers(mailerSend!.id); + expect(mailerCallers.map((c) => c.node.name)).toContain('run'); + + const decoyCallers = await cg.getCallers(decoySend!.id); + expect(decoyCallers.map((c) => c.node.name)).not.toContain('run'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); }); describe('Object-literal namespace members (#1573)', () => { diff --git a/__tests__/sync.test.ts b/__tests__/sync.test.ts index 3ce6a0a86..83416b6d7 100644 --- a/__tests__/sync.test.ts +++ b/__tests__/sync.test.ts @@ -880,4 +880,31 @@ describe('Scoped sync parity (#watcher-scoped)', () => { expect(readmitted.filesAdded).toBe(1); expect(cg.searchNodes('gamma').length).toBe(1); }); + + it('incremental sync resolves deferred typed receiver calls to inherited methods (#1566)', async () => { + // Initial index has BaseService + fs.writeFileSync( + path.join(testDir, 'src', 'base.ts'), + `export class BaseService { run(): number { return 1; } }` + ); + await cg.sync(); + + // Then add derived.ts with DerivedService extends BaseService and call d.run() + fs.writeFileSync( + path.join(testDir, 'src', 'derived.ts'), + `import { BaseService } from './base'; +export class DerivedService extends BaseService {} +export function useDerived(): number { + const d = new DerivedService(); + return d.run(); +}` + ); + await cg.sync(); + + const useDerivedFn = cg.getNodesByName('useDerived')[0]; + expect(useDerivedFn).toBeDefined(); + const callees = await cg.getCallees(useDerivedFn!.id); + const calleeMethods = callees.filter((c) => c.node.kind === 'method').map((c) => c.node.name); + expect(calleeMethods).toContain('run'); + }); }); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 47fcdbf22..39a8f0934 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -15,8 +15,9 @@ import { ResolutionContext, FrameworkResolver, ImportMapping, + DeferredTypedReceiverRef, } from './types'; -import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, shouldDeferTypedReceiver, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; +import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, buildTypedReceiverDeferral, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; @@ -221,7 +222,7 @@ export class ReferenceResolver { // (class/interface/struct), whose method was not on the class itself and may be // inherited. Drained by resolveTypedReceiverCallsViaConformance once // implements/extends edges exist (#1566). - private deferredTypedReceiverRefs: UnresolvedRef[] = []; + private deferredTypedReceiverRefs: DeferredTypedReceiverRef[] = []; // 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. @@ -1052,16 +1053,15 @@ export class ReferenceResolver { PHP_PROP_SHAPE.test(ref.referenceName) ) { this.deferredChainRefs.push(ref); - } else if ( + } else if (ref.referenceKind === 'calls') { // Typed local receiver / field receiver call whose declared receiver type // is a project aggregate type (class, interface, struct, etc.): its method // may be inherited from a supertype, resolvable once implements/extends - // edges exist (#1566). Definitive misses (built-in / external types like - // Map, Promise, URL) are not project aggregates and do not defer. - ref.referenceKind === 'calls' && - shouldDeferTypedReceiver(ref, this.context) - ) { - this.deferredTypedReceiverRefs.push(ref); + // edges exist (#1566). + const deferral = buildTypedReceiverDeferral(ref, this.context); + if (deferral) { + this.deferredTypedReceiverRefs.push(deferral); + } } return null; } @@ -1465,7 +1465,7 @@ export class ReferenceResolver { unresolved: UnresolvedRef[]; deferredChain: UnresolvedRef[]; deferredThisMember: UnresolvedRef[]; - deferredTypedReceiver: UnresolvedRef[]; + deferredTypedReceiver: DeferredTypedReceiverRef[]; byMethod: Record; } { this.warmCaches(); @@ -1519,7 +1519,7 @@ export class ReferenceResolver { appendDeferredFromWorkers( deferredChain: UnresolvedRef[], deferredThisMember: UnresolvedRef[], - deferredTypedReceiver: UnresolvedRef[] = [] + deferredTypedReceiver: DeferredTypedReceiverRef[] ): void { this.deferredChainRefs.push(...deferredChain); this.deferredThisMemberRefs.push(...deferredThisMember); @@ -2440,11 +2440,12 @@ export class ReferenceResolver { } /** - * Second resolution pass for typed receiver calls whose method may be defined - * on a supertype the receiver extends/implements (#1566). - * Operates on leftover unresolved calls whose receiver type is a project aggregate - * (e.g. `Derived extends Base`, `const d = new Derived()`, `d.run()`). - * Runs after implements/extends edges exist, so resolveMethodOnType can walk them. + * Second resolution pass for typed receiver calls whose method is inherited + * from a supertype the receiver extends/implements (#1566). + * Operates on leftover unresolved calls whose receiver type was bound to a specific + * project aggregate node (e.g. `Derived extends Base`, `const d = new Derived()`, `d.run()`). + * Runs after implements/extends edges exist, performing a NODE-anchored BFS + * strictly along the type node's graph edges without name-based supertype merging. * Returns the number of newly-created edges. */ async resolveTypedReceiverCallsViaConformance(): Promise { @@ -2455,12 +2456,84 @@ export class ReferenceResolver { this.clearCaches(); const maybeYield = createYielder(); const resolved: ResolvedRef[] = []; - for (const ref of deferred) { + + for (const item of deferred) { await maybeYield(); - const match = matchMethodCall(ref, this.context); - const gated = this.gateLanguage(match, ref); - if (gated) resolved.push(gated); + const rootNode = this.queries.getNodeById(item.receiverTypeNodeId); + if (!rootNode) continue; + + let frontierNodes: Node[] = [rootNode]; + const seenNodeIds = new Set([rootNode.id]); + let targetMethod: Node | null = null; + let ambiguous = false; + + for (let depth = 0; depth < 5 && frontierNodes.length > 0 && !targetMethod && !ambiguous; depth++) { + const nextFrontier: Node[] = []; + const depthTargets: Node[] = []; + + for (const typeNode of frontierNodes) { + for (const edge of this.queries.getOutgoingEdges(typeNode.id, ['implements', 'extends'])) { + const superNode = this.queries.getNodeById(edge.target); + if (!superNode || seenNodeIds.has(superNode.id)) continue; + seenNodeIds.add(superNode.id); + if (!SUPERTYPE_BEARING_KINDS.has(superNode.kind)) continue; + + // Direct member lookup on the exact supertype node: + // 1. Through 'contains' edges + for (const c of this.queries.getOutgoingEdges(superNode.id, ['contains'])) { + const m = this.queries.getNodeById(c.target); + if ( + m && + m.name === item.methodName && + (m.kind === 'function' || m.kind === 'method') && + (m.language === item.ref.language || sameLanguageFamily(m.language, item.ref.language)) + ) { + depthTargets.push(m); + } + } + + // 2. Direct matches in supertype file / qualified name + if (depthTargets.length === 0) { + const directMatches = this.context.getNodesByName(item.methodName).filter( + (m) => + (m.kind === 'function' || m.kind === 'method') && + m.filePath === superNode.filePath && + (m.language === item.ref.language || sameLanguageFamily(m.language, item.ref.language)) && + (m.qualifiedName === `${superNode.qualifiedName}::${item.methodName}` || + m.qualifiedName === `${superNode.qualifiedName}.${item.methodName}` || + (m.startLine >= superNode.startLine && + (m.endLine ?? m.startLine) <= (superNode.endLine ?? superNode.startLine))) + ); + depthTargets.push(...directMatches); + } + + nextFrontier.push(superNode); + } + } + + // Deduplicate targets found at this depth + const uniqueTargets = [...new Map(depthTargets.map((t) => [t.id, t])).values()]; + if (uniqueTargets.length === 1) { + targetMethod = uniqueTargets[0]!; + break; + } else if (uniqueTargets.length > 1) { + ambiguous = true; + break; + } + + frontierNodes = nextFrontier; + } + + if (targetMethod && !ambiguous) { + resolved.push({ + original: item.ref, + targetNodeId: targetMethod.id, + confidence: 0.9, + resolvedBy: 'instance-method', + }); + } } + if (resolved.length === 0) return 0; const edges = this.createEdges(resolved); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 169577e5a..d54755307 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5,7 +5,8 @@ */ import { Language, Node } from '../types'; -import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; +import { UnresolvedRef, ResolvedRef, ResolutionContext, DeferredTypedReceiverRef } from './types'; +import { resolveImportPath } from './import-resolver'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -1222,9 +1223,24 @@ function inferJavaFieldReceiverType( return lastPart; } +const SUPERTYPE_BEARING_KINDS = new Set([ + 'class', + 'struct', + 'interface', + 'trait', + 'protocol', + 'enum', + 'union', + 'type_alias', +]); + +function normalizePathForComparison(p: string): string { + return p.replace(/\\/g, '/').toLowerCase(); +} + /** * Infer the declared or initialized type of a `this.` receiver in TypeScript/JavaScript (#1566/#1496). - * Looks up the property/field on the enclosing class within the call site's file. + * Looks up the property/field on the enclosing class within the call site's file, verifying direct ownership. */ export function inferTsJsFieldReceiverType( receiverName: string, @@ -1256,10 +1272,38 @@ export function inferTsJsFieldReceiverType( (n.kind === 'property' || n.kind === 'field') && n.name === fieldName && (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) && - n.startLine >= enclosing.startLine && - (n.endLine ?? n.startLine) <= enclosingEnd, + (n.qualifiedName === `${enclosing!.qualifiedName}::${fieldName}` || + n.qualifiedName === `${enclosing!.qualifiedName}.${fieldName}` || + (n.startLine >= enclosing!.startLine && + (n.endLine ?? n.startLine) <= enclosingEnd && + !inFile.some( + (other) => + other.id !== enclosing!.id && + (other.kind === 'class' || other.kind === 'interface') && + other.startLine >= enclosing!.startLine && + (other.endLine ?? other.startLine) <= enclosingEnd && + n.startLine >= other.startLine && + (n.endLine ?? n.startLine) <= (other.endLine ?? other.startLine) + ))) ); - if (!field) return null; + if (!field) { + const lines = context.getFileLines ? context.getFileLines(ref.filePath) : null; + if (lines) { + const start = Math.max(0, enclosing.startLine - 1); + const end = Math.min(lines.length, enclosingEnd); + const paramPropRegex = new RegExp( + `\\b(?:private|protected|public|readonly)\\s+(?:(?:private|protected|public|readonly)\\s+)?${fieldName}\\s*:\\s*([A-Z][a-zA-Z0-9_$]*)`, + ); + for (let i = start; i < end; i++) { + const line = lines[i]; + if (line && line.length <= 10_000) { + const m = line.match(paramPropRegex); + if (m && m[1]) return m[1]; + } + } + } + return null; + } if (field.signature && field.signature !== field.name) { // Signature format: " " (from TS property extraction) @@ -1290,78 +1334,275 @@ export function inferTsJsFieldReceiverType( return null; } -const PROJECT_AGGREGATE_KINDS = new Set([ - 'class', - 'interface', - 'struct', - 'trait', - 'protocol', - 'union', - 'enum', -]); - /** - * Check if a type name corresponds to a project aggregate type (class, interface, struct, etc.) - * in the project graph (#1566). + * Bind an inferred type name to an authoritative visible project type node in the project graph (#1566). + * Returns the exact Node or null if external / unresolvable / ambiguous. */ -export function isProjectAggregateType( - typeName: string, - language: string, +export function bindProjectReceiverType( + inferredType: string, + ref: UnresolvedRef, context: ResolutionContext, -): boolean { - const nodes = context.getNodesByName(typeName); - return nodes.some( - (n) => PROJECT_AGGREGATE_KINDS.has(n.kind) && (n.language === language || sameLanguageFamily(n.language, language)) +): Node | null { + const isWebFamily = + ref.language === 'typescript' || + ref.language === 'tsx' || + ref.language === 'javascript' || + ref.language === 'jsx' || + ref.language === 'arkts'; + + if (isWebFamily) { + // Case A: same-file aggregate node (checked in file or by name with matching filePath) + const sameFileMatch = + context.getNodesInFile(ref.filePath).find( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + n.name === inferredType && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ) ?? + context.getNodesByName(inferredType).find( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + normalizePathForComparison(n.filePath) === normalizePathForComparison(ref.filePath) && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ); + if (sameFileMatch) { + return sameFileMatch; + } + + // Case B / C: check imports in caller file + const imports = context.getImportMappings ? context.getImportMappings(ref.filePath, ref.language) : []; + const mapping = imports.find((i) => i.localName === inferredType); + if (mapping) { + const resolvedPath = + mapping.resolvedPath || + resolveImportPath(mapping.source, ref.filePath, ref.language, context); + if (resolvedPath) { + const normResolved = normalizePathForComparison(resolvedPath); + const targetNodes = context.getNodesInFile(resolvedPath).concat( + context.getNodesByName(mapping.exportedName || mapping.localName) + ); + const importedNode = targetNodes.find( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + (n.name === mapping.exportedName || n.name === mapping.localName || mapping.isDefault) && + normalizePathForComparison(n.filePath) === normResolved && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ); + if (importedNode) return importedNode; + } + // Case C: External SDK import (resolvedPath is null / undefined) + return null; + } + + // Case D: Built-in / unimported global type (e.g. `new Map()`) + return null; + } + + // Non-web languages (Java, Kotlin, C++, C#, Go, Rust, Swift, etc.): + const inFile = context.getNodesInFile(ref.filePath); + const sameFileMatch = inFile.find( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + n.name === inferredType && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) ); + if (sameFileMatch) return sameFileMatch; + + const imports = context.getImportMappings ? context.getImportMappings(ref.filePath, ref.language) : []; + const mapping = imports.find((i) => i.localName === inferredType); + if (mapping) { + const resolvedPath = + mapping.resolvedPath || + resolveImportPath(mapping.source, ref.filePath, ref.language, context); + if (resolvedPath) { + const normResolved = normalizePathForComparison(resolvedPath); + const importedNode = context + .getNodesByName(mapping.exportedName || mapping.localName) + .find( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + normalizePathForComparison(n.filePath) === normResolved && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ); + if (importedNode) return importedNode; + } + } + + const candidates = context + .getNodesByName(inferredType) + .filter( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ); + if (candidates.length === 1) { + return candidates[0]!; + } + + return null; } /** - * Extract the inferred type of a call receiver from local variables or class fields (#1566). + * Resolve a method directly declared on a specific type node (no supertype walk) (#1566). */ -export function getInferredReceiverType(ref: UnresolvedRef, context: ResolutionContext): string | null { +export function resolveMethodOnTypeNode( + typeNode: Node, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, + confidence: number = 0.9, + resolvedBy: ResolvedRef['resolvedBy'] = 'instance-method', +): ResolvedRef | null { + const methodCandidates = context + .getNodesByName(methodName) + .filter( + (m) => + (m.kind === 'method' || m.kind === 'function') && + m.filePath === typeNode.filePath && + (m.language === typeNode.language || sameLanguageFamily(m.language, typeNode.language)) && + (m.qualifiedName === `${typeNode.qualifiedName}::${methodName}` || + m.qualifiedName === `${typeNode.qualifiedName}.${methodName}` || + (m.startLine >= typeNode.startLine && (m.endLine ?? m.startLine) <= (typeNode.endLine ?? typeNode.startLine))) + ); + + if (methodCandidates.length === 1) { + return { + original: ref, + targetNodeId: methodCandidates[0]!.id, + confidence, + resolvedBy, + }; + } + + const exact = methodCandidates.find((m) => m.qualifiedName === `${typeNode.qualifiedName}::${methodName}`); + if (exact) { + return { + original: ref, + targetNodeId: exact.id, + confidence, + resolvedBy, + }; + } + + return null; +} + +export interface ParsedMethodCall { + receiver: string; + methodName: string; + syntax: 'dot' | 'cpp-operator' | 'scope' | 'lua-colon' | 'r-dollar'; + inferableReceiver: boolean; +} + +/** + * Canonical parser for method call reference shapes (#1566). + */ +export function parseMethodCallReference(ref: UnresolvedRef): ParsedMethodCall | null { if (ref.referenceKind !== 'calls') return null; - const dotMatch = ref.referenceName.match(/^([a-zA-Z0-9_$.]+)\.([a-zA-Z0-9_$]+)$/); - const luaColonMatch = ref.language === 'lua' ? ref.referenceName.match(/^([a-zA-Z0-9_]+):([a-zA-Z0-9_]+)$/) : null; - const rDollarMatch = ref.language === 'r' ? ref.referenceName.match(/^([a-zA-Z0-9_.]+)\$([a-zA-Z0-9_.]+)$/) : null; - const match = dotMatch || luaColonMatch || rDollarMatch; - if (!match) return null; - const [, objectOrClass] = match; - if (!objectOrClass) return null; - if ( - (ref.language === 'typescript' || - ref.language === 'javascript' || - ref.language === 'tsx' || - ref.language === 'jsx' || - ref.language === 'arkts') && - dotMatch && - objectOrClass.startsWith('this.') - ) { - return inferTsJsFieldReceiverType(objectOrClass, ref, context); + // C++ explicit operator call `a.operator+(b)` reaches the resolver as `a.operator+` (#1247) + if (ref.language === 'cpp') { + const opMatch = ref.referenceName.match(/^([\w.]+)\.(operator[^\w\s.]+)$/); + if (opMatch && opMatch[1] && opMatch[2]) { + return { + receiver: opMatch[1], + methodName: opMatch[2], + syntax: 'cpp-operator', + inferableReceiver: true, + }; + } } - if (dotMatch || luaColonMatch || rDollarMatch) { - if (ref.language === 'cpp') { - return inferCppReceiverType(objectOrClass, ref, context); + // Lua/Luau method calls use a single colon (`lg:log`) + if (ref.language === 'lua' || ref.language === 'luau') { + const luaMatch = ref.referenceName.match(/^([\w.]+):(\w+)$/); + if (luaMatch && luaMatch[1] && luaMatch[2]) { + return { + receiver: luaMatch[1], + methodName: luaMatch[2], + syntax: 'lua-colon', + inferableReceiver: true, + }; } - if (ref.language === 'java' || ref.language === 'kotlin') { - const local = inferLocalReceiverType(objectOrClass, ref, context); - if (local) return local; - return inferJavaFieldReceiverType(objectOrClass, ref, context); + } + + // R uses `$` (`lg$log`) + if (ref.language === 'r') { + const rMatch = ref.referenceName.match(/^([\w.]+)\$(\w+)$/); + if (rMatch && rMatch[1] && rMatch[2]) { + return { + receiver: rMatch[1], + methodName: rMatch[2], + syntax: 'r-dollar', + inferableReceiver: true, + }; } - return inferLocalReceiverType(objectOrClass, ref, context); } + + // Plain dot: `obj.method`, `builder.Services.AddCoreServices`, or Objective-C selectors `setX:y:` + const dotMatch = ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/); + if (dotMatch && dotMatch[1] && dotMatch[2]) { + return { + receiver: dotMatch[1], + methodName: dotMatch[2], + syntax: 'dot', + inferableReceiver: true, + }; + } + + // Scope: `Class::method` + const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/); + if (colonMatch && colonMatch[1] && colonMatch[2]) { + return { + receiver: colonMatch[1], + methodName: colonMatch[2], + syntax: 'scope', + inferableReceiver: false, + }; + } + return null; } /** - * Returns true if a call reference was typed with a project aggregate type and should be retried - * during the conformance second pass after inheritance edges exist (#1566). + * Builds a structured deferred typed receiver record for the conformance second pass (#1566). */ -export function shouldDeferTypedReceiver(ref: UnresolvedRef, context: ResolutionContext): boolean { - const inferred = getInferredReceiverType(ref, context); - if (!inferred) return false; - return isProjectAggregateType(inferred, ref.language, context); +export function buildTypedReceiverDeferral( + ref: UnresolvedRef, + context: ResolutionContext, +): DeferredTypedReceiverRef | null { + const parsed = parseMethodCallReference(ref); + if (!parsed || !parsed.inferableReceiver) return null; + + let inferredType: string | null = null; + const isWebFamily = + ref.language === 'typescript' || + ref.language === 'javascript' || + ref.language === 'tsx' || + ref.language === 'jsx' || + ref.language === 'arkts'; + + if (isWebFamily && parsed.receiver.startsWith('this.')) { + inferredType = inferTsJsFieldReceiverType(parsed.receiver, ref, context); + } else if (ref.language === 'cpp') { + inferredType = inferCppReceiverType(parsed.receiver, ref, context); + } else if (ref.language === 'java' || ref.language === 'kotlin') { + inferredType = inferLocalReceiverType(parsed.receiver, ref, context) ?? inferJavaFieldReceiverType(parsed.receiver, ref, context); + } else { + inferredType = inferLocalReceiverType(parsed.receiver, ref, context); + } + + if (!inferredType) return null; + + const typeNode = bindProjectReceiverType(inferredType, ref, context); + if (!typeNode || !SUPERTYPE_BEARING_KINDS.has(typeNode.kind)) return null; + + return { + ref, + receiverTypeNodeId: typeNode.id, + receiverTypeName: typeNode.name, + methodName: parsed.methodName, + }; } // ── Local-variable receiver-type inference (#1108) ────────────────────────── @@ -1871,38 +2112,6 @@ export function matchMethodCall( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { - // Parse method call patterns like "obj.method" or "Class::method". The method - // part allows trailing `:` keywords so Objective-C selectors resolve - // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other - // languages' method refs, so this is a no-op for them. - // The receiver allows dots (`builder.Services.AddCoreServices`) so a CHAINED - // call resolves by its last segment — Strategy 3 below name-matches the method - // (with its existing single-candidate / receiver-overlap guards). Without this - // a multi-dot extension-method call (C# DI `builder.Services.AddCoreServices()`, - // `Guard.Against.X()`) matched no pattern and never resolved. - // C++ explicit operator call `a.operator+(b)` reaches the resolver as - // `a.operator+` (#1247) — the operator's symbol chars (`+`, `==`, `[]`, `()`) - // fail the \w method part of the plain pattern, so admit them explicitly. - // Names like `operatorTable` stay on the plain pattern (tried first); the - // operator form requires at least one non-word char after `operator`, and - // every downstream strategy compares the method part by exact string - // equality, so a stray match can't invent an edge. - const dotMatch = - ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/) ?? - (ref.language === 'cpp' - ? ref.referenceName.match(/^([\w.]+)\.(operator[^\w\s.]+)$/) - : null); - const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/); - // Lua/Luau method calls use a single colon (`lg:log`); R uses `$` (`lg$log`). - // Recognize these receiver/method separators so local-variable receiver-type - // inference (#1108) applies to them too — extraction already emits the ref in - // this shape, but the resolver otherwise only understood `.` and `::`. - const luaColonMatch = (ref.language === 'lua' || ref.language === 'luau') - ? ref.referenceName.match(/^([\w.]+):(\w+)$/) - : null; - const rDollarMatch = ref.language === 'r' - ? ref.referenceName.match(/^([\w.]+)\$(\w+)$/) - : null; // PHP property receiver: `$this->prop->method()` reaches the resolver as // `this->prop.method` (the extractor records the receiver's raw text with the @@ -1930,15 +2139,22 @@ export function matchMethodCall( ); } - const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch; - if (!match) { + const parsed = parseMethodCallReference(ref); + if (!parsed) { return null; } - const [, objectOrClass, methodName] = match; - // A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose - // receiver type we can try to infer from its local declaration. - const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch; + const objectOrClass = parsed.receiver; + const methodName = parsed.methodName; + const dotMatch = parsed.syntax === 'dot' || parsed.syntax === 'cpp-operator'; + const inferableReceiver = parsed.inferableReceiver; + + const isWebFamily = + ref.language === 'typescript' || + ref.language === 'javascript' || + ref.language === 'tsx' || + ref.language === 'jsx' || + ref.language === 'arkts'; // Infer the receiver's type from its local declaration/initializer in the // enclosing scope, then resolve the method on that type (#1108). C++ keeps its @@ -1948,9 +2164,20 @@ export function matchMethodCall( if (inferableReceiver) { const inferredType = nmTimedT('mc-infer', ref, () => ref.language === 'cpp' - ? inferCppReceiverType(objectOrClass!, ref, context) - : inferLocalReceiverType(objectOrClass!, ref, context)); + ? inferCppReceiverType(objectOrClass, ref, context) + : inferLocalReceiverType(objectOrClass, ref, context)); if (inferredType) { + if (isWebFamily) { + const typeNode = bindProjectReceiverType(inferredType, ref, context); + if (!typeNode) { + return null; + } + const typedMatch = nmTimedT('mc-rmot', ref, () => + resolveMethodOnTypeNode(typeNode, methodName, ref, context, 0.9, 'instance-method') + ); + return typedMatch; + } + // Java/Kotlin: when two classes share the simple name, the file's import // pins WHICH one (#314). Other languages disambiguate by call-site file. const importedFqn = @@ -1961,7 +2188,7 @@ export function matchMethodCall( : undefined; const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( inferredType, - methodName!, + methodName, ref, context, 0.9, @@ -1988,8 +2215,8 @@ export function matchMethodCall( // fabricated a dependency on an unrelated local interface's same-named // method. Chained Go receivers were never emitted before #1276, so there // is no prior recall to preserve on the fallback path. - if (ref.language === 'go' && dotMatch && objectOrClass!.includes('.')) { - return matchGoFieldChainCall(objectOrClass!, methodName!, ref, context); + if (ref.language === 'go' && dotMatch && objectOrClass.includes('.')) { + return matchGoFieldChainCall(objectOrClass, methodName, ref, context); } // Rust call through a field of the enclosing type — `self.inner.run()`, @@ -2000,8 +2227,8 @@ export function matchMethodCall( // type — or to the calling method itself, a self-edge the source doesn't // contain — whenever the field's type was external or merely shared a // method name with something nearby. - if (ref.language === 'rust' && dotMatch && objectOrClass!.startsWith('self.')) { - return matchRustSelfFieldCall(objectOrClass!.slice('self.'.length), methodName!, ref, context); + if (ref.language === 'rust' && dotMatch && objectOrClass.startsWith('self.')) { + return matchRustSelfFieldCall(objectOrClass.slice('self.'.length), methodName, ref, context); } // Java/Kotlin: receiver may be a field whose name doesn't match the type by @@ -2010,7 +2237,7 @@ export function matchMethodCall( // the method on that type. Covers Spring `@Resource`/`@Autowired` field // injection where the field type is the concrete bean class. if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) { - const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context); + const inferredType = inferJavaFieldReceiverType(objectOrClass, ref, context); if (inferredType) { // When two classes share the same simple name, the caller file's // import is the only signal that names WHICH one — pass the @@ -2019,7 +2246,7 @@ export function matchMethodCall( const importedFqn = imports.find((i) => i.localName === inferredType)?.source; const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( inferredType, - methodName!, + methodName, ref, context, 0.9, @@ -2032,25 +2259,16 @@ export function matchMethodCall( // TypeScript / JavaScript / ArkTS: receiver may be `this.` (#1566/#1496). // Look up the declared/initialized type of the field on the enclosing class. - if ( - (ref.language === 'typescript' || - ref.language === 'javascript' || - ref.language === 'tsx' || - ref.language === 'jsx' || - ref.language === 'arkts') && - dotMatch && - objectOrClass!.startsWith('this.') - ) { - const inferredType = inferTsJsFieldReceiverType(objectOrClass!, ref, context); + if (isWebFamily && dotMatch && objectOrClass.startsWith('this.')) { + const inferredType = inferTsJsFieldReceiverType(objectOrClass, ref, context); if (inferredType) { - const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( - inferredType, - methodName!, - ref, - context, - 0.9, - 'instance-method', - )); + const typeNode = bindProjectReceiverType(inferredType, ref, context); + if (!typeNode) { + return null; + } + const typedMatch = nmTimedT('mc-rmot', ref, () => + resolveMethodOnTypeNode(typeNode, methodName, ref, context, 0.9, 'instance-method') + ); return typedMatch; } } diff --git a/src/resolution/resolver-pool.ts b/src/resolution/resolver-pool.ts index 8ffc17479..18f9bb28b 100644 --- a/src/resolution/resolver-pool.ts +++ b/src/resolution/resolver-pool.ts @@ -14,7 +14,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import type { Edge, UnresolvedReference } from '../types'; -import type { ResolvedRef, UnresolvedRef } from './types'; +import type { ResolvedRef, UnresolvedRef, DeferredTypedReceiverRef } from './types'; import { memoryBudgetBytes } from './memory-budget'; /** One synthesis pass's output: its edge list + worker-measured wall clock. */ @@ -28,7 +28,7 @@ export interface ChunkResult { unresolved: UnresolvedRef[]; deferredChain: UnresolvedRef[]; deferredThisMember: UnresolvedRef[]; - deferredTypedReceiver: UnresolvedRef[]; + deferredTypedReceiver: DeferredTypedReceiverRef[]; byMethod: Record; } @@ -169,7 +169,7 @@ export class ResolverPool { unresolved: msg.unresolved!, deferredChain: msg.deferredChain!, deferredThisMember: msg.deferredThisMember!, - deferredTypedReceiver: msg.deferredTypedReceiver ?? [], + deferredTypedReceiver: msg.deferredTypedReceiver!, byMethod: msg.byMethod!, }); } else if (msg.type === 'synth-result' && msg.id !== undefined) { diff --git a/src/resolution/types.ts b/src/resolution/types.ts index bc80e2fc7..9594a9d52 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -45,6 +45,16 @@ export interface ResolvedRef { resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref'; } +/** + * Deferred typed-receiver call reference for node-anchored conformance post-pass (#1566) + */ +export interface DeferredTypedReceiverRef { + ref: UnresolvedRef; + receiverTypeNodeId: string; + receiverTypeName: string; + methodName: string; +} + /** * Result of resolution attempt */ From 86849c7cece369050000ff6dac889c22bdeb122d Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 18:31:24 +0800 Subject: [PATCH 5/9] docs(changelog): polish #1566 release note formatting and line lookups --- CHANGELOG.md | 2 +- src/resolution/name-matcher.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 218119e9e..bbdab56eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes -- Calls on built-in and external types (such as `Map.get`, `Map.set`, `Map.has`) in TypeScript/JavaScript no longer resolve to unrelated same-named project methods, static nested member calls (`holder.values.get()`, `this.store.get()`) preserve context rather than guessing or fabricating self-edges, dynamic/computed receivers (`holder[key].get()`, `factory().get()`) no longer emit phantom project calls, and inherited methods on typed instances continue to resolve accurately via conformance retry. Re-index after upgrading. (#1566) +- Calls on built-in and external TypeScript/JavaScript receivers (such as `Map.get`, `Map.set`, `Map.has`) no longer resolve to unrelated same-named project methods, static nested member calls (`holder.values.get()`, `this.store.get()`) preserve context rather than fabricating self-edges, dynamic/computed receivers remain unlinked when their target cannot be proven, and inherited methods on typed instances continue to resolve accurately — re-index after upgrading. (#1566) ## [1.6.0] - 2026-08-26 diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d54755307..7bf797767 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1286,8 +1286,8 @@ export function inferTsJsFieldReceiverType( (n.endLine ?? n.startLine) <= (other.endLine ?? other.startLine) ))) ); + const lines = context.getFileLines ? context.getFileLines(ref.filePath) : null; if (!field) { - const lines = context.getFileLines ? context.getFileLines(ref.filePath) : null; if (lines) { const start = Math.max(0, enclosing.startLine - 1); const end = Math.min(lines.length, enclosingEnd); @@ -1320,7 +1320,6 @@ export function inferTsJsFieldReceiverType( } // If un-annotated, check field declaration line for `= new Type(...)` - const lines = context.getFileLines ? context.getFileLines(ref.filePath) : null; if (lines && field.startLine <= lines.length) { const lineText = lines[field.startLine - 1]; if (lineText) { From 2ad9d9413ea36e76b84c5f27b1e8823f539cbf41 Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 19:03:23 +0800 Subject: [PATCH 6/9] fix(resolution): preserve scoped store calls without bare-name fallback (#1566) --- CHANGELOG.md | 2 +- __tests__/extraction.test.ts | 5 +- __tests__/kernel-tsjs-parity.test.ts | 9 +- __tests__/object-literal-methods.test.ts | 145 +++++++++++++++++++-- codegraph-kernel/src/tsjs/extractors.rs | 18 ++- src/extraction/tree-sitter.ts | 32 ++++- src/resolution/import-resolver.ts | 7 ++ src/resolution/name-matcher.ts | 154 +++++++++++++++++++++++ 8 files changed, 354 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbdab56eb..ef05fe291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes -- Calls on built-in and external TypeScript/JavaScript receivers (such as `Map.get`, `Map.set`, `Map.has`) no longer resolve to unrelated same-named project methods, static nested member calls (`holder.values.get()`, `this.store.get()`) preserve context rather than fabricating self-edges, dynamic/computed receivers remain unlinked when their target cannot be proven, and inherited methods on typed instances continue to resolve accurately — re-index after upgrading. (#1566) +- Calls on built-in and external TypeScript/JavaScript receivers (such as `Map.get`, `Map.set`, `Map.has`) no longer resolve to unrelated same-named project methods; static and call-result receiver context is preserved so proven project members still resolve, while computed or otherwise unproven receivers stay unlinked — re-index after upgrading. (#1566) ## [1.6.0] - 2026-08-26 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 06813207a..ea23d1f3f 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -11955,9 +11955,10 @@ export class TestClass { expect(refNames).toContain('a.b.c.d'); expect(refNames).toContain('testMethod'); // direct this.testMethod -> bare method - // Dynamic / computed expressions do not emit bare methodName refs (#1566 Blocker A) + // Dynamic / computed expressions do not emit bare methodName refs (#1566/#647) + expect(refNames).toContain('factory().get'); + expect(refNames).toContain('factory'); expect(refNames).not.toContain('get'); - expect(refNames.some((r) => r.includes('factory().'))).toBe(false); expect(refNames.some((r) => r.includes('key]'))).toBe(false); }); }); diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index ec829e75e..6a1c99901 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -110,7 +110,7 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { } }); - it('dynamic receiver extraction parity and silence (#1566)', () => { + it('dynamic receiver extraction parity and silence (#1566/#647)', () => { const src = ` function factory() { return { get: () => 1 }; } export function dynamic(holder: any, key: string) { @@ -120,6 +120,9 @@ export function dynamic(holder: any, key: string) { export function staticChain(holder: any) { holder.values.get("x"); } +export function storeCall(useStore: any) { + useStore.getState().reset(); +} `; assertParity('fixtures/dynamic-parity.ts', src, 'typescript'); const wasmRes = extractFromSource('fixtures/dynamic-parity.ts', src, 'typescript'); @@ -128,9 +131,11 @@ export function staticChain(holder: any) { const calls = res.unresolvedReferences.filter((r) => r.referenceKind === 'calls'); const names = calls.map((c) => c.referenceName); expect(names).toContain('holder.values.get'); + expect(names).toContain('factory().get'); expect(names).toContain('factory'); + expect(names).toContain('useStore.getState().reset'); expect(names).not.toContain('get'); - expect(names.some((n) => n.includes('factory().'))).toBe(false); + expect(names).not.toContain('reset'); expect(names.some((n) => n.includes('key]'))).toBe(false); } }); diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts index 795c0e3b5..acc5c6fbd 100644 --- a/__tests__/object-literal-methods.test.ts +++ b/__tests__/object-literal-methods.test.ts @@ -51,18 +51,17 @@ describe('object-literal method extraction', () => { expect(fnNames).toContain('switchOrganization'); expect(fnNames).toContain('reset'); - // Each action's body was walked: inner call `get()` is extracted, while - // the dynamic chained `.reset()` on a call-result receiver is safely silenced (#1566). + // Each action's body was walked: fetchUser references its sibling `reset` via `get().reset`, + // so an in-store calls edge will resolve once the pipeline runs. const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!; const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id); - expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get'); - expect(fetchUserRefs.map((r) => r.referenceName)).not.toContain('reset'); + expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get().reset'); // The action's body wasn't mis-attributed to the file scope (the reason we // skip the generic body-visit for the store-factory call). const fileNode = result.nodes.find((n) => n.kind === 'file')!; const fileRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fileNode.id); - expect(fileRefs.map((r) => r.referenceName)).not.toContain('reset'); + expect(fileRefs.map((r) => r.referenceName)).not.toContain('get().reset'); }); it('extracts actions through a middleware wrapper (create(persist(...)))', () => { @@ -167,9 +166,141 @@ describe('object-literal method resolution (end-to-end)', () => { const fetchUserCallers = cg.getCallers(fetchUser!.id).map((c) => c.node.name); expect(fetchUserCallers).toContain('loginFlow'); - // Dynamic chained getState().reset() and get().reset() calls do not emit bare references (#1566) + // Chained getState() call: hardReset -> reset, AND in-store sibling: fetchUser -> reset const resetCallers = cg.getCallers(reset!.id).map((c) => c.node.name); - expect(resetCallers).not.toContain('hardReset'); + expect(resetCallers).toContain('hardReset'); + expect(resetCallers).toContain('fetchUser'); + + cg.close(); + }); + + it('isolates store actions from top-level and sibling store decoys (#1566/#647)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-store-decoy-')); + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"t","dependencies":{"zustand":"^4"}}\n'); + fs.writeFileSync( + path.join(tmpDir, 'store.ts'), + `import { create } from 'zustand'\n` + + `export function reset() { return 'top-level decoy'; }\n` + + `export const otherStore = create(() => ({\n` + + ` reset: () => {},\n` + + `}))\n` + + `export const useStore = create((set, get) => ({\n` + + ` fetchUser: async () => { get().reset() },\n` + + ` reset: () => set({}),\n` + + `}))\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'caller.ts'), + `import { useStore } from './store'\n` + + `export function hardReset() {\n` + + ` useStore.getState().reset()\n` + + `}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const fns = cg.getNodesByKind('function'); + const storeFns = fns.filter((n) => n.filePath.endsWith('store.ts')); + const topLevelReset = storeFns.find((n) => n.name === 'reset' && n.startLine === 2); + const otherStoreReset = storeFns.find((n) => n.name === 'reset' && n.startLine === 4); + const useStoreReset = storeFns.find((n) => n.name === 'reset' && n.startLine === 8); + + expect(topLevelReset).toBeDefined(); + expect(otherStoreReset).toBeDefined(); + expect(useStoreReset).toBeDefined(); + + const useStoreResetCallers = cg.getCallers(useStoreReset!.id).map((c) => c.node.name); + expect(useStoreResetCallers).toContain('hardReset'); + expect(useStoreResetCallers).toContain('fetchUser'); + + const topLevelCallers = cg.getCallers(topLevelReset!.id).map((c) => c.node.name); + expect(topLevelCallers).not.toContain('hardReset'); + expect(topLevelCallers).not.toContain('fetchUser'); + + const otherStoreCallers = cg.getCallers(otherStoreReset!.id).map((c) => c.node.name); + expect(otherStoreCallers).not.toContain('hardReset'); + expect(otherStoreCallers).not.toContain('fetchUser'); + + cg.close(); + }); + + it('isolates declared factory inside store action from sibling actions (#1566/#647)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-store-factory-')); + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"t","dependencies":{"zustand":"^4"}}\n'); + fs.writeFileSync( + path.join(tmpDir, 'store.ts'), + `import { create } from 'zustand'\n` + + `function factory() {\n` + + ` return { reset() {} }\n` + + `}\n` + + `export const useStore = create((set, get) => ({\n` + + ` reset: () => set({}),\n` + + ` run: () => {\n` + + ` factory().reset()\n` + + ` },\n` + + `}))\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const fns = cg.getNodesByKind('function'); + const storeReset = fns.find((n) => n.name === 'reset' && n.startLine === 6); + const runFn = fns.find((n) => n.name === 'run'); + const factoryFn = fns.find((n) => n.name === 'factory'); + + expect(storeReset).toBeDefined(); + expect(runFn).toBeDefined(); + expect(factoryFn).toBeDefined(); + + // run calls factory() + const factoryCallers = cg.getCallers(factoryFn!.id).map((c) => c.node.name); + expect(factoryCallers).toContain('run'); + + // run does NOT call useStore.reset + const storeResetCallers = cg.getCallers(storeReset!.id).map((c) => c.node.name); + expect(storeResetCallers).not.toContain('run'); + + cg.close(); + }); + + it('isolates generic call-result receivers without store container (#1566/#647)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-generic-call-result-')); + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `class Decoy {\n` + + ` get() { return 1; }\n` + + ` reset() {}\n` + + `}\n` + + `function factory() {\n` + + ` return {};\n` + + `}\n` + + `export function useFactory() {\n` + + ` factory().get();\n` + + `}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const fns = cg.getNodesByKind('function'); + const methods = cg.getNodesByKind('method'); + const useFactory = fns.find((n) => n.name === 'useFactory'); + const factory = fns.find((n) => n.name === 'factory'); + const decoyGet = methods.find((n) => n.name === 'get'); + + expect(useFactory).toBeDefined(); + expect(factory).toBeDefined(); + expect(decoyGet).toBeDefined(); + + // useFactory calls factory() + const factoryCallers = cg.getCallers(factory!.id).map((c) => c.node.name); + expect(factoryCallers).toContain('useFactory'); + + // useFactory does NOT call Decoy.get + const decoyGetCallers = cg.getCallers(decoyGet!.id).map((c) => c.node.name); + expect(decoyGetCallers).not.toContain('useFactory'); cg.close(); }); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index fae4540a6..c4a193d0f 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1070,8 +1070,10 @@ impl<'t> Walker<'t> { } else { callee_name = method_name.to_string(); } + } else if let Some(call_chain) = get_static_call_result_chain(r, self.src) { + callee_name = format!("{call_chain}.{method_name}"); } else { - // Dynamic/computed/call-result receiver has no static receiver identity. + // Dynamic/computed receiver has no static receiver identity. // DO NOT degrade to bare method name (#1566). return; } @@ -1366,3 +1368,17 @@ fn get_static_member_chain<'t>(node: Node<'t>, source: &'t str) -> Option None, } } + +/// Extract a static call-result chain (`factory()`, `useStore.getState()`, `a.b.c()`) +/// from an AST node, or return None if any part of the chain is dynamic, computed, +/// nested call-of-call, or has no static syntax identity (#1566/#647). +fn get_static_call_result_chain<'t>(node: Node<'t>, source: &'t str) -> Option { + if node.kind() != "call_expression" { + return None; + } + let func = node + .child_by_field_name("function") + .or_else(|| node.named_child(0))?; + let fn_chain = get_static_member_chain(func, source)?; + Some(format!("{fn_chain}()")) +} diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 8ed914bf8..784d4ef7c 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -422,6 +422,22 @@ function getStaticMemberChain(node: SyntaxNode, source: string): string | null { return null; } +/** + * Extract a static call-result chain (`factory()`, `useStore.getState()`, `a.b.c()`) + * from an AST node, or return null if any part of the chain is dynamic, computed, + * nested call-of-call, or has no static syntax identity (#1566/#647). + */ +function getStaticCallResultChain(node: SyntaxNode, source: string): string | null { + if (node.type !== 'call_expression') return null; + const fn = getChildByField(node, 'function') || node.namedChild(0); + if (!fn) return null; + const fnChain = getStaticMemberChain(fn, source); + if (fnChain) { + return `${fnChain}()`; + } + return null; +} + export class TreeSitterExtractor { private filePath: string; private language: Language; @@ -4489,10 +4505,11 @@ export class TreeSitterExtractor { ) { // TS/JS/ArkTS: preserve static member expression chains // (`holder.values.get`, `this.store.get`, `this.mailer.send`) - // so resolution does not lose receiver context and fall back to - // bare-name guessing (#1566/#1496). Dynamic/computed/call-result - // receivers have no static receiver identity and do NOT degrade - // to a bare method name that could fabricate a project edge. + // and static call-result member chains (`useStore.getState().reset`, + // `get().reset`, `factory().get`) so resolution does not lose + // receiver context and fall back to bare-name guessing (#1566/#1496/#647). + // Dynamic / computed receivers (e.g. `holder[key].get()`) have no + // static receiver identity and return early to avoid fabricating project edges. const chain = getStaticMemberChain(receiver, this.source); if (chain) { if (!SKIP_RECEIVERS.has(chain)) { @@ -4501,7 +4518,12 @@ export class TreeSitterExtractor { calleeName = methodName; } } else { - return; + const callResultChain = getStaticCallResultChain(receiver, this.source); + if (callResultChain) { + calleeName = `${callResultChain}.${methodName}`; + } else { + return; + } } } else if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) { const receiverName = getNodeText(receiver, this.source); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 60c7b3008..030ff4c5c 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -13,6 +13,7 @@ import { resolveWorkspaceImport } from './workspace-packages'; import { resolveMethodOnType, resolveObjectLiteralMember, + parseCallResultMemberReference, localReceiverTypePatterns, normalizeInferredTypeName, } from './name-matcher'; @@ -1554,6 +1555,12 @@ export function resolveViaImport( // landed on the constant — every cross-file caller of the method // went missing. Resolve the member by containment instead. if (targetNode.kind === 'constant' || targetNode.kind === 'variable') { + const callResult = parseCallResultMemberReference(ref); + if (callResult) { + const literalMember = resolveObjectLiteralMember(targetNode, callResult.methodName, ref, context, 0.9, 'import'); + if (literalMember) return literalMember; + return null; + } const member = ref.referenceName.slice(imp.localName.length + 1).split('.')[0]; if (member) { const literalMember = resolveObjectLiteralMember(targetNode, member, ref, context, 0.9, 'import'); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7bf797767..d63d3d257 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1563,6 +1563,142 @@ export function parseMethodCallReference(ref: UnresolvedRef): ParsedMethodCall | return null; } +export interface ParsedCallResultMember { + calleeChain: string; + methodName: string; +} + +/** + * Parse call-result member references like `useStore.getState().reset`, `get().reset`, `factory().get` (#1566/#647). + */ +export function parseCallResultMemberReference(ref: UnresolvedRef): ParsedCallResultMember | null { + if (ref.referenceKind !== 'calls') return null; + const m = ref.referenceName.match(/^(.+)\(\)\.([A-Za-z_$][\w$]*)$/); + if (!m || !m[1] || !m[2]) return null; + const calleeChain = m[1]; + const methodName = m[2]; + + // calleeChain must be a simple identifier or a dotted chain of identifiers (e.g. `useStore.getState` or `get` or `factory`) + if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(calleeChain)) { + return null; + } + return { calleeChain, methodName }; +} + +/** + * Resolve a call-result member chain (`useStore.getState().reset` or `get().reset`) + * in TypeScript/JavaScript/TSX/JSX/ArkTS (#1566/#647). + * Strictly scoped to proven object-literal / store containers; never falls back to generic name guessing. + */ +export function resolveTsJsCallResultMember( + parsed: ParsedCallResultMember, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null { + const { calleeChain, methodName } = parsed; + + // Case 1: Dotted root call, e.g. `useStore.getState().reset` or `api.client().call` + if (calleeChain.includes('.')) { + const rootName = calleeChain.split('.')[0]!; + + // Find the container constant/variable named rootName + // A: Same file + let container: Node | null = null; + const inFile = context.getNodesInFile(ref.filePath); + container = + inFile.find( + (n) => + (n.kind === 'constant' || n.kind === 'variable') && + n.name === rootName && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ) ?? null; + + // B: Imported from another file + if (!container) { + const imports = context.getImportMappings ? context.getImportMappings(ref.filePath, ref.language) : []; + const mapping = imports.find((i) => i.localName === rootName); + if (mapping) { + const resolvedPath = + mapping.resolvedPath || + resolveImportPath(mapping.source, ref.filePath, ref.language, context); + if (resolvedPath) { + const normResolved = normalizePathForComparison(resolvedPath); + const targetNodes = context.getNodesInFile(resolvedPath).concat( + context.getNodesByName(mapping.exportedName || mapping.localName) + ); + container = + targetNodes.find( + (n) => + (n.kind === 'constant' || n.kind === 'variable') && + (n.name === mapping.exportedName || n.name === mapping.localName || mapping.isDefault) && + normalizePathForComparison(n.filePath) === normResolved && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ) ?? null; + } + } + } + + if (container) { + return resolveObjectLiteralMember(container, methodName, ref, context, 0.9, 'instance-method'); + } + return null; + } + + // Case 2: Bare root call, e.g. `get().reset` or `factory().get` + const rootName = calleeChain; + + // Check if rootName is a defined project callable in caller's scope (e.g. `function factory() {}`) + // If so, `factory().reset()` is a call on the result of an external/declared factory function, + // NOT a store-internal callback like `get()`, so it must NOT resolve to a sibling store action. + const inFile = context.getNodesInFile(ref.filePath); + const isDeclaredFunction = inFile.some( + (n) => (n.kind === 'function' || n.kind === 'method') && n.name === rootName + ); + if (isDeclaredFunction) { + return null; + } + + // If rootName is not a declared function (e.g. `get` from store factory callback parameter), + // locate the tightest enclosing constant/variable container around ref.fromNodeId + let fromNode: Node | null = null; + if (ref.fromNodeId) { + fromNode = inFile.find((n) => n.id === ref.fromNodeId) ?? null; + } + if (!fromNode) { + // Fallback by line if fromNodeId not in file nodes list + for (const n of inFile) { + const end = n.endLine ?? n.startLine; + if (n.startLine <= ref.line && end >= ref.line) { + if (!fromNode || n.startLine >= fromNode.startLine) { + fromNode = n; + } + } + } + } + if (!fromNode) return null; + + // Find all constant/variable containers in the same file that enclose fromNode + const fromEnd = fromNode.endLine ?? fromNode.startLine; + const containingContainers = inFile.filter( + (n) => + (n.kind === 'constant' || n.kind === 'variable') && + n.startLine <= fromNode!.startLine && + (n.endLine ?? n.startLine) >= fromEnd + ); + + if (containingContainers.length === 0) return null; + + // Sort by smallest range (tightest enclosing container) + containingContainers.sort((a, b) => { + const rangeA = (a.endLine ?? a.startLine) - a.startLine; + const rangeB = (b.endLine ?? b.startLine) - b.startLine; + return rangeA - rangeB; + }); + + const tightestContainer = containingContainers[0]!; + return resolveObjectLiteralMember(tightestContainer, methodName, ref, context, 0.9, 'instance-method'); +} + /** * Builds a structured deferred typed receiver record for the conformance second pass (#1566). */ @@ -3027,6 +3163,24 @@ export function matchReference( // 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C# / Swift / // Go / Scala / Dart / Objective-C) — `Foo.getInstance().bar()` encoded as + // Web-family call-result member chain (`useStore.getState().reset`, `get().reset`, `factory().get`) + // Must resolve exclusively against proven store/object-literal containers; unproven chains stay + // unlinked and must NEVER fall through to generic name guessing (#1566/#647). + const isWebFamily = + ref.language === 'typescript' || + ref.language === 'javascript' || + ref.language === 'tsx' || + ref.language === 'jsx' || + ref.language === 'arkts'; + + const callResult = parseCallResultMemberReference(ref); + if (callResult) { + if (isWebFamily) { + return nmTimed('tsjsCallResult', ref, () => resolveTsJsCallResultMember(callResult, ref, context)); + } + } + + // 1b. Dotted call chain with factory / singleton receiver (Java, Kotlin, C#, Swift, Go...) // `Foo.getInstance().bar`, Go's bare-factory `New().Method()` as `New().Method`, // Scala's companion factory, Dart's static factory / factory-constructor, or // ObjC's chained message send `[[Foo create] doIt]` encoded as `Foo.create().doIt` From 232ed8f4e45457539e73b8c12a269f304f6d9249 Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 19:24:09 +0800 Subject: [PATCH 7/9] fix(resolution): harden typed receiver lexical ownership (#1566) --- __tests__/resolution.test.ts | 194 +++++++++++++++++++++++++++++++++ src/resolution/index.ts | 5 +- src/resolution/name-matcher.ts | 108 ++++++++++++------ src/resolution/types.ts | 5 + 4 files changed, 277 insertions(+), 35 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index ea664d4e9..79abe97e3 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3454,6 +3454,200 @@ export class Service { fs.rmSync(tmpDir, { recursive: true, force: true }); } }, 30000); + + it('typed Service receiver must not resolve to Local::send (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-local-method-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `export class Service { + setup(): void { + class Local { + send(): void {} + } + } +} + +export function useService(service: Service): void { + service.send(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('service.ts'); + const localSend = allNodes.find((n) => n.kind === 'method' && n.name === 'send'); + expect(localSend).toBeDefined(); + + const callers = await cg.getCallers(localSend!.id); + expect(callers.map((c) => c.node.name)).not.toContain('useService'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('typed Service receiver must not resolve nested function save (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-nested-fn-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `export class Service { + setup(): void { + function save(): void {} + } +} + +export function useService(service: Service): void { + service.save(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('service.ts'); + const nestedSave = allNodes.find((n) => (n.kind === 'function' || n.kind === 'method') && n.name === 'save'); + expect(nestedSave).toBeDefined(); + + const callers = await cg.getCallers(nestedSave!.id); + expect(callers.map((c) => c.node.name)).not.toContain('useService'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('same-file local Worker must not bind top-level Worker (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-shadow-worker-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'worker.ts'), + `export class Worker { + run(): void {} +} + +export function use(): void { + class Worker { + stop(): void {} + } + + const worker = new Worker(); + worker.run(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('worker.ts'); + const topLevelRun = allNodes.find((n) => n.kind === 'method' && n.name === 'run' && n.qualifiedName.startsWith('Worker')); + expect(topLevelRun).toBeDefined(); + + const callers = await cg.getCallers(topLevelRun!.id); + expect(callers.map((c) => c.node.name)).not.toContain('use'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('nested Local constructor parameter property does not hijack Service constructor (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-nested-ctor-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `export class Mailer { + send(): void {} +} + +export class Decoy { + send(): void {} +} + +export class Service { + run(): void { + class Local { + constructor(private mailer: Decoy) {} + } + + this.mailer.send(); + } + + constructor(private mailer: Mailer) {} +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('service.ts'); + const mailerSend = allNodes.find((n) => n.kind === 'method' && n.name === 'send' && n.qualifiedName.startsWith('Mailer')); + const decoySend = allNodes.find((n) => n.kind === 'method' && n.name === 'send' && n.qualifiedName.startsWith('Decoy')); + expect(mailerSend).toBeDefined(); + expect(decoySend).toBeDefined(); + + const mailerCallers = await cg.getCallers(mailerSend!.id); + expect(mailerCallers.map((c) => c.node.name)).toContain('run'); + + const decoyCallers = await cg.getCallers(decoySend!.id); + expect(decoyCallers.map((c) => c.node.name)).not.toContain('run'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('nested Local constructor parameter property alone does not fabricate Service.mailer (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-nested-ctor-alone-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `export class Decoy { + send(): void {} +} + +export class Service { + run(): void { + class Local { + constructor(private mailer: Decoy) {} + } + + this.mailer.send(); + } +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('service.ts'); + const decoySend = allNodes.find((n) => n.kind === 'method' && n.name === 'send' && n.qualifiedName.startsWith('Decoy')); + expect(decoySend).toBeDefined(); + + const decoyCallers = await cg.getCallers(decoySend!.id); + expect(decoyCallers.map((c) => c.node.name)).not.toContain('run'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); }); describe('Object-literal namespace members (#1573)', () => { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 39a8f0934..80070e70c 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -18,7 +18,7 @@ import { DeferredTypedReceiverRef, } from './types'; import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, buildTypedReceiverDeferral, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; -import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; +import { resolveViaImport, resolveJvmImport, resolveImportPath, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; @@ -649,6 +649,9 @@ export class ReferenceResolver { return mappings; }, + resolveImportPath: (importPath: string, fromFile: string, language: Language) => + resolveImportPath(importPath, fromFile, language, this.context), + getProjectAliases: () => { if (this.projectAliases === undefined) { this.projectAliases = loadProjectAliases(this.projectRoot); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d63d3d257..40d7d5c9f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6,7 +6,6 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, DeferredTypedReceiverRef } from './types'; -import { resolveImportPath } from './import-resolver'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -1289,16 +1288,27 @@ export function inferTsJsFieldReceiverType( const lines = context.getFileLines ? context.getFileLines(ref.filePath) : null; if (!field) { if (lines) { - const start = Math.max(0, enclosing.startLine - 1); - const end = Math.min(lines.length, enclosingEnd); - const paramPropRegex = new RegExp( - `\\b(?:private|protected|public|readonly)\\s+(?:(?:private|protected|public|readonly)\\s+)?${fieldName}\\s*:\\s*([A-Z][a-zA-Z0-9_$]*)`, + const constructors = inFile.filter( + (n) => + (n.kind === 'method' || n.kind === 'function') && + n.name === 'constructor' && + (n.qualifiedName === `${enclosing!.qualifiedName}::constructor` || + n.qualifiedName === `${enclosing!.qualifiedName}.constructor`) ); - for (let i = start; i < end; i++) { - const line = lines[i]; - if (line && line.length <= 10_000) { - const m = line.match(paramPropRegex); - if (m && m[1]) return m[1]; + + if (constructors.length === 1) { + const ctor = constructors[0]!; + const ctorStart = Math.max(0, ctor.startLine - 1); + const ctorEnd = Math.min(lines.length, ctor.endLine ?? ctor.startLine); + const paramPropRegex = new RegExp( + `\\b(?:private|protected|public|readonly)\\s+(?:(?:private|protected|public|readonly)\\s+)?${fieldName}\\s*:\\s*([A-Z][a-zA-Z0-9_$]*)`, + ); + for (let i = ctorStart; i < ctorEnd; i++) { + const line = lines[i]; + if (line && line.length <= 10_000) { + const m = line.match(paramPropRegex); + if (m && m[1]) return m[1]; + } } } } @@ -1351,21 +1361,44 @@ export function bindProjectReceiverType( if (isWebFamily) { // Case A: same-file aggregate node (checked in file or by name with matching filePath) - const sameFileMatch = - context.getNodesInFile(ref.filePath).find( + const normCallerPath = normalizePathForComparison(ref.filePath); + const inFileCandidates = context + .getNodesInFile(ref.filePath) + .filter( (n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.name === inferredType && (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) - ) ?? - context.getNodesByName(inferredType).find( - (n) => - SUPERTYPE_BEARING_KINDS.has(n.kind) && - normalizePathForComparison(n.filePath) === normalizePathForComparison(ref.filePath) && - (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) ); - if (sameFileMatch) { - return sameFileMatch; + const sameFileCandidates = + inFileCandidates.length > 0 + ? inFileCandidates + : context.getNodesByName(inferredType).filter( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + normalizePathForComparison(n.filePath) === normCallerPath && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ); + + if (sameFileCandidates.length === 1) { + return sameFileCandidates[0]!; + } else if (sameFileCandidates.length > 1) { + // Disambiguate if ref.fromNodeId is inside exactly one candidate's enclosing scope + if (ref.fromNodeId) { + const fromNode = context.getNodesInFile(ref.filePath).find((n) => n.id === ref.fromNodeId); + if (fromNode) { + const fromEnd = fromNode.endLine ?? fromNode.startLine; + const scopedCandidates = sameFileCandidates.filter( + (c) => + c.startLine >= fromNode.startLine && + (c.endLine ?? c.startLine) <= fromEnd + ); + if (scopedCandidates.length === 1) { + return scopedCandidates[0]!; + } + } + } + return null; } // Case B / C: check imports in caller file @@ -1373,8 +1406,8 @@ export function bindProjectReceiverType( const mapping = imports.find((i) => i.localName === inferredType); if (mapping) { const resolvedPath = - mapping.resolvedPath || - resolveImportPath(mapping.source, ref.filePath, ref.language, context); + mapping.resolvedPath ?? + context.resolveImportPath?.(mapping.source, ref.filePath, ref.language); if (resolvedPath) { const normResolved = normalizePathForComparison(resolvedPath); const targetNodes = context.getNodesInFile(resolvedPath).concat( @@ -1399,20 +1432,24 @@ export function bindProjectReceiverType( // Non-web languages (Java, Kotlin, C++, C#, Go, Rust, Swift, etc.): const inFile = context.getNodesInFile(ref.filePath); - const sameFileMatch = inFile.find( + const sameFileCandidates = inFile.filter( (n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.name === inferredType && (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) ); - if (sameFileMatch) return sameFileMatch; + if (sameFileCandidates.length === 1) { + return sameFileCandidates[0]!; + } else if (sameFileCandidates.length > 1) { + return null; + } const imports = context.getImportMappings ? context.getImportMappings(ref.filePath, ref.language) : []; const mapping = imports.find((i) => i.localName === inferredType); if (mapping) { const resolvedPath = - mapping.resolvedPath || - resolveImportPath(mapping.source, ref.filePath, ref.language, context); + mapping.resolvedPath ?? + context.resolveImportPath?.(mapping.source, ref.filePath, ref.language); if (resolvedPath) { const normResolved = normalizePathForComparison(resolvedPath); const importedNode = context @@ -1452,6 +1489,11 @@ export function resolveMethodOnTypeNode( confidence: number = 0.9, resolvedBy: ResolvedRef['resolvedBy'] = 'instance-method', ): ResolvedRef | null { + const acceptedQualifiedNames = new Set([ + `${typeNode.qualifiedName}::${methodName}`, + `${typeNode.qualifiedName}.${methodName}`, + ]); + const methodCandidates = context .getNodesByName(methodName) .filter( @@ -1459,9 +1501,7 @@ export function resolveMethodOnTypeNode( (m.kind === 'method' || m.kind === 'function') && m.filePath === typeNode.filePath && (m.language === typeNode.language || sameLanguageFamily(m.language, typeNode.language)) && - (m.qualifiedName === `${typeNode.qualifiedName}::${methodName}` || - m.qualifiedName === `${typeNode.qualifiedName}.${methodName}` || - (m.startLine >= typeNode.startLine && (m.endLine ?? m.startLine) <= (typeNode.endLine ?? typeNode.startLine))) + acceptedQualifiedNames.has(m.qualifiedName) ); if (methodCandidates.length === 1) { @@ -1473,11 +1513,11 @@ export function resolveMethodOnTypeNode( }; } - const exact = methodCandidates.find((m) => m.qualifiedName === `${typeNode.qualifiedName}::${methodName}`); - if (exact) { + const uniqueIds = new Set(methodCandidates.map((m) => m.id)); + if (uniqueIds.size === 1) { return { original: ref, - targetNodeId: exact.id, + targetNodeId: methodCandidates[0]!.id, confidence, resolvedBy, }; @@ -1619,8 +1659,8 @@ export function resolveTsJsCallResultMember( const mapping = imports.find((i) => i.localName === rootName); if (mapping) { const resolvedPath = - mapping.resolvedPath || - resolveImportPath(mapping.source, ref.filePath, ref.language, context); + mapping.resolvedPath ?? + context.resolveImportPath?.(mapping.source, ref.filePath, ref.language); if (resolvedPath) { const normResolved = normalizePathForComparison(resolvedPath); const targetNodes = context.getNodesInFile(resolvedPath).concat( diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 9594a9d52..cb07b1b99 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -142,6 +142,11 @@ export interface ResolutionContext { getNodeById?(id: string): Node | null; /** Get cached import mappings for a file */ getImportMappings(filePath: string, language: Language): ImportMapping[]; + /** + * Resolve an import specifier to an on-disk file path. + * Optional so minimal test contexts compile without it. + */ + resolveImportPath?(importPath: string, fromFile: string, language: Language): string | null; /** * Project import-path aliases (tsconfig/jsconfig `paths`). Returns * `null` when the project doesn't define any. Cached per resolver From da1df34845121470bb70812c1ebe0cec0e91c67a Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 19:40:06 +0800 Subject: [PATCH 8/9] fix(resolution): enforce lexical ownership in conformance retry (#1566) --- __tests__/resolution.test.ts | 113 +++++++++++++++++++++++++++++++++ src/resolution/index.ts | 35 +++++----- src/resolution/name-matcher.ts | 56 +++++++--------- 3 files changed, 154 insertions(+), 50 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 79abe97e3..29ef9a385 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3648,6 +3648,119 @@ export class Service { fs.rmSync(tmpDir, { recursive: true, force: true }); } }, 30000); + + it('inherited BaseService with nested Local::run must not resolve (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-inherited-local-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `export class BaseService { + setup(): void { + class Local { + run(): void {} + } + } +} + +export class DerivedService extends BaseService {} + +export function useDerived(service: DerivedService): void { + service.run(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('service.ts'); + const localRun = allNodes.find((n) => n.kind === 'method' && n.name === 'run'); + expect(localRun).toBeDefined(); + + const callers = await cg.getCallers(localRun!.id); + expect(callers.map((c) => c.node.name)).not.toContain('useDerived'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('inherited BaseService with nested function run must not resolve (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-inherited-fn-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'service.ts'), + `export class BaseService { + setup(): void { + function run(): void {} + } +} + +export class DerivedService extends BaseService {} + +export function useDerived(service: DerivedService): void { + service.run(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('service.ts'); + const nestedRun = allNodes.find((n) => (n.kind === 'function' || n.kind === 'method') && n.name === 'run'); + expect(nestedRun).toBeDefined(); + + const callers = await cg.getCallers(nestedRun!.id); + expect(callers.map((c) => c.node.name)).not.toContain('useDerived'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('deeper nested Worker inside inner function is not visible to outer use (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-deeper-worker-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'worker.ts'), + `export class Worker { + run(): void {} +} + +export function use(): void { + function inner(): void { + class Worker { + stop(): void {} + } + } + + const worker = new Worker(); + worker.stop(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('worker.ts'); + const innerStop = allNodes.find((n) => n.kind === 'method' && n.name === 'stop'); + expect(innerStop).toBeDefined(); + + const callers = await cg.getCallers(innerStop!.id); + expect(callers.map((c) => c.node.name)).not.toContain('use'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); }); describe('Object-literal namespace members (#1573)', () => { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 80070e70c..633d49479 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -17,7 +17,7 @@ import { ImportMapping, DeferredTypedReceiverRef, } from './types'; -import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, buildTypedReceiverDeferral, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; +import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, getDirectCallableCandidatesOnTypeNode, buildTypedReceiverDeferral, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; import { resolveViaImport, resolveJvmImport, resolveImportPath, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; @@ -2481,8 +2481,10 @@ export class ReferenceResolver { seenNodeIds.add(superNode.id); if (!SUPERTYPE_BEARING_KINDS.has(superNode.kind)) continue; + const perSuperTargets: Node[] = []; + // Direct member lookup on the exact supertype node: - // 1. Through 'contains' edges + // 1. Through 'contains' edges (strongest node-level ownership evidence) for (const c of this.queries.getOutgoingEdges(superNode.id, ['contains'])) { const m = this.queries.getNodeById(c.target); if ( @@ -2491,24 +2493,23 @@ export class ReferenceResolver { (m.kind === 'function' || m.kind === 'method') && (m.language === item.ref.language || sameLanguageFamily(m.language, item.ref.language)) ) { - depthTargets.push(m); + perSuperTargets.push(m); } } - // 2. Direct matches in supertype file / qualified name - if (depthTargets.length === 0) { - const directMatches = this.context.getNodesByName(item.methodName).filter( - (m) => - (m.kind === 'function' || m.kind === 'method') && - m.filePath === superNode.filePath && - (m.language === item.ref.language || sameLanguageFamily(m.language, item.ref.language)) && - (m.qualifiedName === `${superNode.qualifiedName}::${item.methodName}` || - m.qualifiedName === `${superNode.qualifiedName}.${item.methodName}` || - (m.startLine >= superNode.startLine && - (m.endLine ?? m.startLine) <= (superNode.endLine ?? superNode.startLine))) - ); - depthTargets.push(...directMatches); - } + // 2. Direct exact qualified ownership fallback (reusing canonical direct ownership helper) + perSuperTargets.push( + ...getDirectCallableCandidatesOnTypeNode( + superNode, + item.methodName, + item.ref, + this.context, + ) + ); + + // Deduplicate targets found for this specific supertype node + const uniquePerSuper = [...new Map(perSuperTargets.map((t) => [t.id, t])).values()]; + depthTargets.push(...uniquePerSuper); nextFrontier.push(superNode); } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 40d7d5c9f..edd94acca 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1383,21 +1383,6 @@ export function bindProjectReceiverType( if (sameFileCandidates.length === 1) { return sameFileCandidates[0]!; } else if (sameFileCandidates.length > 1) { - // Disambiguate if ref.fromNodeId is inside exactly one candidate's enclosing scope - if (ref.fromNodeId) { - const fromNode = context.getNodesInFile(ref.filePath).find((n) => n.id === ref.fromNodeId); - if (fromNode) { - const fromEnd = fromNode.endLine ?? fromNode.startLine; - const scopedCandidates = sameFileCandidates.filter( - (c) => - c.startLine >= fromNode.startLine && - (c.endLine ?? c.startLine) <= fromEnd - ); - if (scopedCandidates.length === 1) { - return scopedCandidates[0]!; - } - } - } return null; } @@ -1479,22 +1464,22 @@ export function bindProjectReceiverType( } /** - * Resolve a method directly declared on a specific type node (no supertype walk) (#1566). + * Find all callable candidate nodes directly owned by a specific type node (no supertype walk) (#1566). + * Direct ownership is strictly verified via exact qualifiedName (`${typeNode.qualifiedName}::${methodName}` + * or `${typeNode.qualifiedName}.${methodName}`). */ -export function resolveMethodOnTypeNode( +export function getDirectCallableCandidatesOnTypeNode( typeNode: Node, methodName: string, - ref: UnresolvedRef, + _ref: UnresolvedRef, context: ResolutionContext, - confidence: number = 0.9, - resolvedBy: ResolvedRef['resolvedBy'] = 'instance-method', -): ResolvedRef | null { +): Node[] { const acceptedQualifiedNames = new Set([ `${typeNode.qualifiedName}::${methodName}`, `${typeNode.qualifiedName}.${methodName}`, ]); - const methodCandidates = context + return context .getNodesByName(methodName) .filter( (m) => @@ -1503,21 +1488,26 @@ export function resolveMethodOnTypeNode( (m.language === typeNode.language || sameLanguageFamily(m.language, typeNode.language)) && acceptedQualifiedNames.has(m.qualifiedName) ); +} - if (methodCandidates.length === 1) { - return { - original: ref, - targetNodeId: methodCandidates[0]!.id, - confidence, - resolvedBy, - }; - } +/** + * Resolve a method directly declared on a specific type node (no supertype walk) (#1566). + */ +export function resolveMethodOnTypeNode( + typeNode: Node, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, + confidence: number = 0.9, + resolvedBy: ResolvedRef['resolvedBy'] = 'instance-method', +): ResolvedRef | null { + const candidates = getDirectCallableCandidatesOnTypeNode(typeNode, methodName, ref, context); + const unique = [...new Map(candidates.map((m) => [m.id, m])).values()]; - const uniqueIds = new Set(methodCandidates.map((m) => m.id)); - if (uniqueIds.size === 1) { + if (unique.length === 1) { return { original: ref, - targetNodeId: methodCandidates[0]!.id, + targetNodeId: unique[0]!.id, confidence, resolvedBy, }; From 6e643023f1176c39c652cd5552860743823b441b Mon Sep 17 00:00:00 2001 From: zyyyyynnn <1974447317@qq.com> Date: Mon, 31 Aug 2026 19:58:44 +0800 Subject: [PATCH 9/9] fix(resolution): preserve cross-file exact method ownership --- __tests__/resolution.test.ts | 87 ++++++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 10 +++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 29ef9a385..2d1b4b1a9 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3761,6 +3761,93 @@ export function use(): void { fs.rmSync(tmpDir, { recursive: true, force: true }); } }, 30000); + + it('C++ out-of-line inherited method resolves through conformance postpass (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-cpp-inherit-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'base.hpp'), + `#pragma once +class Base { +public: + void run(); +}; +class Derived : public Base {}; +` + ); + fs.writeFileSync( + path.join(tmpDir, 'base.cpp'), + `#include "base.hpp" +void Base::run() {} +` + ); + fs.writeFileSync( + path.join(tmpDir, 'use.cpp'), + `#include "base.hpp" +void use() { + Derived d; + d.run(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('base.cpp'); + const baseRun = allNodes.find((n) => n.kind === 'method' && n.name === 'run' && n.qualifiedName === 'Base::run'); + expect(baseRun).toBeDefined(); + + const callers = await cg.getCallers(baseRun!.id); + expect(callers.map((c) => c.node.name)).toContain('use'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + + it('multiple same-depth inherited targets decline resolution due to ambiguity (#1566)', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1566-depth-ambiguity-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync( + path.join(tmpDir, 'test.ts'), + `export class SuperA { + run(): void {} +} +export class SuperB { + run(): void {} +} +export class Derived implements SuperA, SuperB { + // no run of its own +} +export function use(d: Derived): void { + d.run(); +} +` + ); + + cg = await CodeGraph.init(tmpDir, { index: true }); + + const allNodes = cg.getNodesInFile('test.ts'); + const runA = allNodes.find((n) => n.kind === 'method' && n.name === 'run' && n.qualifiedName.startsWith('SuperA')); + const runB = allNodes.find((n) => n.kind === 'method' && n.name === 'run' && n.qualifiedName.startsWith('SuperB')); + expect(runA).toBeDefined(); + expect(runB).toBeDefined(); + + const callersA = await cg.getCallers(runA!.id); + const callersB = await cg.getCallers(runB!.id); + expect(callersA.map((c) => c.node.name)).not.toContain('use'); + expect(callersB.map((c) => c.node.name)).not.toContain('use'); + } finally { + if (cg) { + cg.close(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); }); describe('Object-literal namespace members (#1573)', () => { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index edd94acca..7ce236eaf 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1479,15 +1479,21 @@ export function getDirectCallableCandidatesOnTypeNode( `${typeNode.qualifiedName}.${methodName}`, ]); - return context + const exactCandidates = context .getNodesByName(methodName) .filter( (m) => (m.kind === 'method' || m.kind === 'function') && - m.filePath === typeNode.filePath && (m.language === typeNode.language || sameLanguageFamily(m.language, typeNode.language)) && acceptedQualifiedNames.has(m.qualifiedName) ); + + const sameFileCandidates = exactCandidates.filter((m) => m.filePath === typeNode.filePath); + if (sameFileCandidates.length > 0) { + return sameFileCandidates; + } + + return exactCandidates; } /**