From 385001398b19319ec93dd7c65e8f609ff4ce4f58 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 2 Jul 2026 12:07:25 -0500 Subject: [PATCH] fix(resolution): infer typed-parameter receivers in TS/JS (#1125) (#1129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local-variable receiver-type inference from #1108/#1110 covered typed parameters for every language except TypeScript/JavaScript (+ TSX/JSX). The TS/JS `:`-annotation pattern required a leading `const|let|var`, so it only matched a local's own annotation (`const lg: Logger`) and never a bare parameter (`function use(lg: Logger)` / `(lg: Logger) =>`). With a second class sharing the method name — the case where a same-name fallback can't paper over it — `lg.log()` resolved to no edge, dropping it from callers and impact/blast-radius. TS/JS is the most common language pair in the userbase, so this was a real precision gap. Replace the keyword-anchored pattern with the keyword-free `\b${r}\b\s*:\s*([A-Z][\w.$]*)`, mirroring Kotlin/Swift/Scala. It's a strict superset (still matches `const lg: Logger`) plus the typed-parameter case, and the capture stops at `<` so a generic-typed param (`repo: Repository`) still yields `Repository`. resolveMethodOnType already validates the inferred type declares the method, so the looser match produces no edge on a mis-inference — the same safety net the other languages rely on; Swift already ships this identical bare-colon pattern with the same theoretical ternary/dict-literal exposure. Adds a regression test using two ambiguous classes + typed params, asserting each call routes to its OWN class's method (verified to fail without the fix and pass with it — a single-class version would pass either way via the same-name fallback, which is why the collision is load-bearing). Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + __tests__/resolution.test.ts | 41 ++++++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 9 +++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e667a..eea7d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Graph traversal and blast-radius results no longer drop or miscount relationships in a handful of edge cases. When a symbol could be reached by more than one path, an impact/blast-radius query could leave out a direct dependency between two symbols that were already linked another way; separately, the lower-level graph traversal used by the library API could keep only one of several relationships between the same pair of symbols (for example a symbol that both calls and references another), count a caller reached through two different call sites twice, or return slightly more results than the requested size limit on a very highly-connected symbol. These were long-standing and mostly masked by later de-duplication, so day-to-day query results were largely unaffected, but the traversal now returns the complete, correctly-bounded set. Thanks @inth3shadows for the precise, individually-traced reports. (#1086, #1087, #1088, #1089, #1090) - Method calls to same-named classes in different files now resolve to the right definition. If two files each declared, say, a `Logger` class with its own `log()` method, a call could be linked to whichever definition happened to be indexed first — so a call in one file wrongly pointed at the class in another, mixing up that method's callers and blast radius. This affected calls written as `obj.log()`, `Logger.log()`, and `Logger::log()` across many languages, including C++, Python, TypeScript, Java, C#, and Rust. When a method name is ambiguous, CodeGraph now prefers the definition in the calling file itself — the correct target in the common case — while Java/Kotlin calls that an `import` already pins to another file are unaffected. Thanks @inth3shadows for the minimal repro and root-cause analysis. (#1079) - Live auto-sync now gives up cleanly if indexing keeps failing, instead of retrying forever in the background. If a repeatable indexing error kept every sync from completing — for example a file that reliably crashes one language's parser, a full disk, or a corrupt database — a long-running server or daemon would retry every couple of seconds indefinitely, quietly wasting work and filling the logs while the graph silently stopped updating. Auto-sync now backs off after repeated failures and, if they persist, disables itself with an actionable message (naming the underlying error and pointing you to run `codegraph sync`), so the stalled index is surfaced rather than hidden — matching how prolonged file-lock contention and watch-limit exhaustion already degrade. A single successful sync resets everything, so an occasional transient hiccup is unaffected. Thanks @inth3shadows for the precise, code-traced report. (#1127) +- TypeScript/JavaScript method calls made through a typed function parameter now resolve to the right method. A call like `function use(lg: Logger) { lg.log(); }` wasn't linking to `Logger`'s `log()` — CodeGraph inferred a receiver's type from a local declaration (`const lg: Logger`) but not from a bare typed parameter, so when another class defined a method of the same name the call was dropped from callers and impact/blast-radius. Typed parameters are now recognized the same way, including generic types (`repo: Repository`), matching how Java, C#, Kotlin, Swift, and Scala already handled it. Thanks @inth3shadows for the isolated repro and root-cause analysis. (#1125) ## [1.1.6] - 2026-06-30 diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 604a7f8..8c4e28d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1734,6 +1734,47 @@ func main() { // Logger.new is still an instantiation of the class. expect(out.some((e) => e.kind === 'instantiates' && e.target === logger.id)).toBe(true); }); + + it('TypeScript: infers a typed-parameter receiver, disambiguating same-named methods (#1125)', async () => { + // A typed function parameter used as a receiver — `function use(lg: Logger)` + // — never matched the old TS/JS pattern (it required a const|let|var + // prefix), so `lg.log()` fell through to no edge once a second class shared + // the method name. Two ambiguous classes are load-bearing here: a + // single-class version resolves via a same-name fallback even without + // inference, so only the collision proves type inference actually fired. + fs.writeFileSync( + path.join(tempDir, 'svc.ts'), + `class Logger { log() { return 1; } }\n` + + `class Other { log() { return 2; } }\n` + + `export function use(lg: Logger) { return lg.log(); }\n` + + `export function useOther(o: Other) { return o.log(); }\n`, + ); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const classes = cg.getNodesByKind('class'); + const logger = classes.find((n) => n.name === 'Logger')!; + const other = classes.find((n) => n.name === 'Other')!; + const logs = cg.getNodesByKind('method').filter((n) => n.name === 'log'); + expect(logs.length, 'both log methods should be indexed').toBe(2); + + // Associate each same-named `log` with its class by line containment. + const inClass = (m: (typeof logs)[number], c: typeof logger) => + m.startLine >= c.startLine && m.startLine <= (c.endLine ?? c.startLine); + const loggerLog = logs.find((m) => inClass(m, logger))!; + const otherLog = logs.find((m) => inClass(m, other))!; + expect(loggerLog, "Logger's log").toBeDefined(); + expect(otherLog, "Other's log").toBeDefined(); + + const loggerCallers = cg.getCallers(loggerLog.id).map((x) => x.node.name); + const otherCallers = cg.getCallers(otherLog.id).map((x) => x.node.name); + + // Each typed-param call routes to its OWN class's method, not the other's. + expect(loggerCallers).toContain('use'); + expect(loggerCallers).not.toContain('useOther'); + expect(otherCallers).toContain('useOther'); + expect(otherCallers).not.toContain('use'); + }); }); describe('Name Matcher: kind bias for new ref kinds', () => { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 228ac9f..70d7e14 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1065,7 +1065,14 @@ function localReceiverTypePatterns(language: Language, r: string): RegExp[] { case 'jsx': return [ new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger() - new RegExp(`\\b(?:const|let|var)\\s+${r}\\s*:\\s*([A-Z][\\w.$]*)`), // lg: Logger + // No keyword requirement, so this matches BOTH a local annotation + // (`const lg: Logger`) and a typed parameter (`function use(lg: Logger)` + // / `(lg: Logger) =>`) — the parameter case the old `const|let|var` + // prefix excluded (#1125). Mirrors Kotlin/Swift/Scala; the capture stops + // at `<` so a generic-typed param (`repo: Repository`) still yields + // `Repository`. resolveMethodOnType validates the type actually declares + // the method, so the looser match produces no edge on a mis-inference. + new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*)`), // lg: Logger (annotation or typed param) ]; case 'python': return [