From 2ec877b08cc38a2354e1c3f012280c15367a0b41 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 16 Jul 2026 15:36:33 -0500 Subject: [PATCH] fix(resolution): calls through an imported singleton resolve to the method (#1315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reproStore.notifyJoinGuildStatus() after `import { reproStore }` resolved its calls edge to the exported CONSTANT (resolvedBy:'import'), while the identical same-file call resolved to the method via local-variable receiver inference (#1108) — so `callers ` missed every cross-file use and a widely-used method could look unused (#1292). resolveViaImport's member-descend now handles imported VALUES alongside the #825 static-member case: when the base resolves to a constant/variable, the value's type is inferred from ITS OWN declaration lines in the exporting file (the shared #1108 pattern table: `= new T(...)` initializers and type annotations) and the member is resolved AND VALIDATED on that type via resolveMethodOnType. A failed inference or validation keeps the existing constant edge — never a fabricated one. Calls only; plain member reads still reference the value. excalidraw control: byte-identical graph (10,653 nodes / 19,483 calls edges before and after). Fixes #1292 Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + __tests__/resolution.test.ts | 55 ++++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 59 +++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 4 +-- 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cecc9ba..44dcf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- TypeScript/JavaScript method calls through an imported singleton (`import { store } from './store'; store.notify()`) now resolve to the class method instead of the exported constant, so `codegraph callers` sees cross-file callers of the method — previously only same-file calls were attributed and a method used everywhere could look unused. The same declaration-based type inference applies across the languages that share it (Python, Java, Kotlin, Go, and more), and a failed inference keeps the old edge rather than guessing. (#1292) - `codegraph node -f ` now prints the symbol's source body. Pinning an ambiguous name to a specific file (the whole point of `-f` when many files define the same function) returned only the location and caller trail with no code. (#1284) - Deleting a whole directory is now picked up by watch mode: the files inside it are removed from the index on the next auto-sync instead of lingering as stale records until an unrelated edit happened to trigger one. Operating systems often report a directory deletion as a single event on the directory itself (with no per-file events for its contents), which the watcher previously discarded. (#1285) - `codegraph sync` now gets the same slow-disk fix that made full indexing fast in 1.4.0: database checkpointing is deferred for the whole incremental run instead of firing every few megabytes of writes. On mechanical drives and other high-latency storage, a small sync on a large index no longer stalls for minutes at near-zero CPU — the cost of a sync scales with what changed, not with the size of the existing index. The same `CODEGRAPH_NO_WAL_DEFER=1` switch turns it off. (#1248) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 3749388..da5a4d1 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -2541,6 +2541,61 @@ func main() { }); }); + describe('Imported singleton instance-method calls (#1292)', () => { + // `reproStore.notifyJoinGuildStatus()` after `import { reproStore }` used + // to emit its calls edge to the CONSTANT (resolvedBy:'import'), while the + // identical call in the defining file resolved to the method — so callers + // of the method missed every cross-file use. The import path now infers + // the value's type from its own declaration and resolves the member on it. + it('cross-file call through an imported singleton resolves to the class method', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1292-')); + try { + fs.mkdirSync(path.join(tmpDir, 'src')); + fs.writeFileSync( + path.join(tmpDir, 'src', 'store.ts'), + `export class ReproStore { + notifyJoinGuildStatus(): void { + console.log('notified'); + } +} + +export const reproStore = new ReproStore(); + +export function callInDefinitionFile(): void { + reproStore.notifyJoinGuildStatus(); +} +` + ); + fs.writeFileSync( + path.join(tmpDir, 'src', 'caller.ts'), + `import { reproStore } from './store'; + +export function callFromImportedFile(): void { + reproStore.notifyJoinGuildStatus(); +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const method = (await cg.searchNodes('notifyJoinGuildStatus', { limit: 5 })).find( + (r) => r.node.kind === 'method' + ); + expect(method).toBeDefined(); + + // BOTH functions call the method — the cross-file one included. + const callers = await cg.getCallers(method!.node.id); + const callerNames = callers.map((c) => c.node.name).sort(); + expect(callerNames).toContain('callInDefinitionFile'); + expect(callerNames).toContain('callFromImportedFile'); + cg.close(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + }); + describe('C++ namespace-qualified static method calls to out-of-line definitions (#1291)', () => { // The issue's exact shape: nested types + out-of-line static method // definition inside `namespace simulator { }` in the .cpp, called via the diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index d11078c..75056d3 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -10,6 +10,11 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types'; import { applyAliases } from './path-aliases'; import { resolveWorkspaceImport } from './workspace-packages'; +import { + resolveMethodOnType, + localReceiverTypePatterns, + normalizeInferredTypeName, +} from './name-matcher'; /** * Extension resolution order by language @@ -1512,6 +1517,18 @@ export function resolveViaImport( resolvedBy: 'import', }; } + // An imported VALUE (singleton constant / shared instance) called + // through a member: `reproStore.notifyJoinGuildStatus()` after + // `import { reproStore } from './store'`. findExportedSymbol + // resolved the CONSTANT itself; linking the CALL there hides the + // real callee — callers of the method miss every cross-file use + // and the method can look unused (#1292). Infer the value's type + // from its own declaration in the exporting file and resolve the + // member on that type. resolveMethodOnType VALIDATES the type + // declares the method, so a mis-inference falls through to the + // constant edge below rather than fabricating a wrong one. + const instanceMember = resolveImportedInstanceMember(targetNode, ref, imp.localName, context); + if (instanceMember) return instanceMember; } return { @@ -2158,6 +2175,48 @@ const STATIC_MEMBER_CONTAINERS = new Set([ * languages whose members aren't `::`-qualified, and genuine class references, * are unaffected. See #825. */ +/** + * Resolve a CALL through an imported value to the method on the value's own + * type: `reproStore.notifyJoinGuildStatus()` where `reproStore` is + * `export const reproStore = new ReproStore()` in the imported file (#1292). + * The same-file form of this call already resolves via local-variable + * receiver inference (#1108); this is the cross-file/import half. The type is + * recovered from the VALUE'S OWN declaration lines in the exporting file + * (initializer `= new T(...)` or a type annotation, per the shared #1108 + * pattern table), then the member is resolved AND VALIDATED on that type by + * resolveMethodOnType — a failed inference or validation returns null so the + * caller keeps its existing constant-edge behavior. + */ +function resolveImportedInstanceMember( + value: Node, + ref: UnresolvedRef, + localName: string, + context: ResolutionContext +): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; + if (value.kind !== 'constant' && value.kind !== 'variable') return null; + const member = ref.referenceName.slice(localName.length + 1).split('.')[0]; + if (!member) return null; + + const source = context.readFile(value.filePath); + if (!source) return null; + // Only the value's own declaration lines — never the whole file, so a + // same-named identifier elsewhere can't donate a type. + const lines = source.split('\n'); + const declSlice = lines.slice(Math.max(0, value.startLine - 1), value.endLine).join('\n'); + + const receiver = value.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + for (const pattern of localReceiverTypePatterns(value.language as Language, receiver)) { + const m = declSlice.match(pattern); + if (!m || !m[1]) continue; + const typeName = normalizeInferredTypeName(m[1]); + if (!typeName) continue; + const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method'); + if (resolved) return resolved; + } + return null; +} + function resolveStaticMember( container: Node, ref: UnresolvedRef, diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9e77d63..b4d1e31 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1075,7 +1075,7 @@ const NON_TYPE_RECEIVER_TOKENS = new Set([ * args and pointer/ref markers, take the last `.`/`::`-qualified segment, and * reject obvious non-types. */ -function normalizeInferredTypeName(raw: string): string | null { +export function normalizeInferredTypeName(raw: string): string | null { const cleaned = raw.replace(/<[^>]*>/g, '').replace(/[&*]/g, '').trim(); const seg = cleaned.split(/[.:]+/).filter(Boolean).pop(); if (!seg) return null; @@ -1090,7 +1090,7 @@ function normalizeInferredTypeName(raw: string): string | null { * PascalCase is required in the capture where the language convention allows, * as a cheap false-positive guard on top of resolveMethodOnType's validation. */ -function localReceiverTypePatterns(language: Language, r: string): RegExp[] { +export function localReceiverTypePatterns(language: Language, r: string): RegExp[] { switch (language) { case 'typescript': case 'javascript':