diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..ef05fe291 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 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 ad0ba2374..ea23d1f3f 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 silent', () => { + 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 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('key]'))).toBe(false); + }); + }); }); diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index c16d41ff0..6a1c99901 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -97,7 +97,47 @@ 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('dynamic receiver extraction parity and silence (#1566/#647)', () => { + 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"); +} +export function storeCall(useStore: any) { + useStore.getState().reset(); +} +`; + 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().get'); + expect(names).toContain('factory'); + expect(names).toContain('useStore.getState().reset'); + expect(names).not.toContain('get'); + expect(names).not.toContain('reset'); + expect(names.some((n) => n.includes('key]'))).toBe(false); + } }); it('torture fixture (js): field methods, wrappers, vuex module shape', () => { diff --git a/__tests__/object-literal-methods.test.ts b/__tests__/object-literal-methods.test.ts index 1722ad2d0..acc5c6fbd 100644 --- a/__tests__/object-literal-methods.test.ts +++ b/__tests__/object-literal-methods.test.ts @@ -51,17 +51,17 @@ describe('object-literal method extraction', () => { expect(fnNames).toContain('switchOrganization'); expect(fnNames).toContain('reset'); - // Each action's body was walked: fetchUser references its sibling `reset`, + // 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('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(...)))', () => { @@ -173,4 +173,135 @@ describe('object-literal method resolution (end-to-end)', () => { 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/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index decaadee5..2d1b4b1a9 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3075,6 +3075,781 @@ 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 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"); + return cache.has("answer"); +} + +export function useConstructedProjectCache(): boolean { + const cache = new LRUCache(); + cache.set("answer", "42"); + 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"); + } +} +` + ); + + 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('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); + 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, 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); + + 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); + 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']); + + // 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(); + } + 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); + + 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); + + 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); + + 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)', () => { // `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/__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/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 3e4814736..c4a193d0f 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1064,20 +1064,20 @@ 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 if let Some(call_chain) = get_static_call_result_chain(r, self.src) { + callee_name = format!("{call_chain}.{method_name}"); } else { - callee_name = method_name.to_string(); + // Dynamic/computed receiver has no static receiver identity. + // DO NOT degrade to bare method name (#1566). + return; } } 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 +1335,50 @@ 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, + } +} + +/// 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/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..784d4ef7c 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -388,6 +388,56 @@ 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; +} + +/** + * 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; @@ -4445,7 +4495,37 @@ 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`) + // 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)) { + calleeName = `${chain}.${methodName}`; + } else { + calleeName = methodName; + } + } else { + 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); if (!SKIP_RECEIVERS.has(receiverName)) { calleeName = `${receiverName}.${methodName}`; 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/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/index.ts b/src/resolution/index.ts index 7b4bccc18..633d49479 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -15,9 +15,10 @@ import { ResolutionContext, FrameworkResolver, ImportMapping, + DeferredTypedReceiverRef, } from './types'; -import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; -import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; +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'; import { synthesizeCallbackEdges } from './callback-synthesizer'; @@ -217,6 +218,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: 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. @@ -643,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); @@ -1047,6 +1056,15 @@ export class ReferenceResolver { PHP_PROP_SHAPE.test(ref.referenceName) ) { this.deferredChainRefs.push(ref); + } 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). + const deferral = buildTypedReceiverDeferral(ref, this.context); + if (deferral) { + this.deferredTypedReceiverRefs.push(deferral); + } } return null; } @@ -1450,6 +1468,7 @@ export class ReferenceResolver { unresolved: UnresolvedRef[]; deferredChain: UnresolvedRef[]; deferredThisMember: UnresolvedRef[]; + deferredTypedReceiver: DeferredTypedReceiverRef[]; byMethod: Record; } { this.warmCaches(); @@ -1481,6 +1500,7 @@ export class ReferenceResolver { unresolved, deferredChain: this.deferredChainRefs.splice(0), deferredThisMember: this.deferredThisMemberRefs.splice(0), + deferredTypedReceiver: this.deferredTypedReceiverRefs.splice(0), byMethod, }; } @@ -1496,12 +1516,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: DeferredTypedReceiverRef[] + ): void { this.deferredChainRefs.push(...deferredChain); this.deferredThisMemberRefs.push(...deferredThisMember); + this.deferredTypedReceiverRefs.push(...deferredTypedReceiver); } /** @@ -1661,7 +1686,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 +2442,112 @@ export class ReferenceResolver { return edges.length; } + /** + * 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 { + const deferred = this.deferredTypedReceiverRefs; + this.deferredTypedReceiverRefs = []; + if (deferred.length === 0) return 0; + + this.clearCaches(); + const maybeYield = createYielder(); + const resolved: ResolvedRef[] = []; + + for (const item of deferred) { + await maybeYield(); + 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; + + const perSuperTargets: Node[] = []; + + // Direct member lookup on the exact supertype node: + // 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 ( + m && + m.name === item.methodName && + (m.kind === 'function' || m.kind === 'method') && + (m.language === item.ref.language || sameLanguageFamily(m.language, item.ref.language)) + ) { + perSuperTargets.push(m); + } + } + + // 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); + } + } + + // 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); + 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 c74d8f272..7ce236eaf 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5,7 +5,7 @@ */ import { Language, Node } from '../types'; -import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; +import { UnresolvedRef, ResolvedRef, ResolutionContext, DeferredTypedReceiverRef } from './types'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will @@ -1222,6 +1222,560 @@ 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, verifying direct ownership. + */ +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.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) + ))) + ); + const lines = context.getFileLines ? context.getFileLines(ref.filePath) : null; + if (!field) { + if (lines) { + const constructors = inFile.filter( + (n) => + (n.kind === 'method' || n.kind === 'function') && + n.name === 'constructor' && + (n.qualifiedName === `${enclosing!.qualifiedName}::constructor` || + n.qualifiedName === `${enclosing!.qualifiedName}.constructor`) + ); + + 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]; + } + } + } + } + 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(...)` + 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; +} + +/** + * 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 bindProjectReceiverType( + inferredType: string, + ref: UnresolvedRef, + context: ResolutionContext, +): 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 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)) + ); + 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) { + return null; + } + + // 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 ?? + context.resolveImportPath?.(mapping.source, ref.filePath, ref.language); + 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 sameFileCandidates = inFile.filter( + (n) => + SUPERTYPE_BEARING_KINDS.has(n.kind) && + n.name === inferredType && + (n.language === ref.language || sameLanguageFamily(n.language, ref.language)) + ); + 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 ?? + context.resolveImportPath?.(mapping.source, ref.filePath, ref.language); + 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; +} + +/** + * 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 getDirectCallableCandidatesOnTypeNode( + typeNode: Node, + methodName: string, + _ref: UnresolvedRef, + context: ResolutionContext, +): Node[] { + const acceptedQualifiedNames = new Set([ + `${typeNode.qualifiedName}::${methodName}`, + `${typeNode.qualifiedName}.${methodName}`, + ]); + + const exactCandidates = context + .getNodesByName(methodName) + .filter( + (m) => + (m.kind === 'method' || m.kind === 'function') && + (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; +} + +/** + * 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()]; + + if (unique.length === 1) { + return { + original: ref, + targetNodeId: unique[0]!.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; + + // 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, + }; + } + } + + // 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, + }; + } + } + + // 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, + }; + } + } + + // 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; +} + +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 ?? + context.resolveImportPath?.(mapping.source, ref.filePath, ref.language); + 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). + */ +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) ────────────────────────── // // Instance calls through a local variable (`const lg = new Logger(); lg.log()`) @@ -1729,38 +2283,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 @@ -1788,15 +2310,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 @@ -1806,9 +2335,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 = @@ -1819,16 +2359,19 @@ export function matchMethodCall( : undefined; const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( inferredType, - methodName!, + methodName, ref, context, 0.9, '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; } } @@ -1843,8 +2386,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()`, @@ -1855,8 +2398,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 @@ -1865,7 +2408,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 @@ -1874,19 +2417,51 @@ 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, 'instance-method', importedFqn, )); - if (typedMatch) { - return typedMatch; + return typedMatch; + } + } + + // TypeScript / JavaScript / ArkTS: receiver may be `this.` (#1566/#1496). + // Look up the declared/initialized type of the field on the enclosing class. + if (isWebFamily && dotMatch && objectOrClass.startsWith('this.')) { + const inferredType = inferTsJsFieldReceiverType(objectOrClass, ref, context); + if (inferredType) { + 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; } } + // 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 @@ -2624,6 +3199,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` diff --git a/src/resolution/resolver-pool.ts b/src/resolution/resolver-pool.ts index eac808ba4..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,6 +28,7 @@ export interface ChunkResult { unresolved: UnresolvedRef[]; deferredChain: UnresolvedRef[]; deferredThisMember: UnresolvedRef[]; + deferredTypedReceiver: DeferredTypedReceiverRef[]; 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; diff --git a/src/resolution/types.ts b/src/resolution/types.ts index bc80e2fc7..cb07b1b99 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 */ @@ -132,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