diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611ba779..cc06fa2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A Scala `Outer.Inner(...)` call now reaches the Scala case class it builds, not a Java constructor. A companion `apply` such as `RemoteNode.Obfuscated("x")` was linked to an unrelated Java class's constructor that merely shared the words in its name; it now links to the Scala type of that name, and a Java constructor is never guessed that way. - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 14b5198f4..02b33e88c 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -5194,6 +5194,134 @@ object Main { // Bar has no onlyOther() — must not mis-attach to the same-named Other::onlyOther. expect(callerNamesOf('Other::onlyOther')).toEqual([]); }); + + describe('Scala Outer.Inner(args) apply vs a Java constructor', () => { + function write(rel: string, body: string): void { + fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true }); + fs.writeFileSync(path.join(tempDir, rel), body); + } + function edgesFrom(name: string): { kind: string; qn: string; lang: string }[] { + const from = cg.getNodesByKind('method').find((n) => n.name === name); + if (!from) return []; + return cg + .getOutgoingEdges(from.id) + .filter((e) => e.kind !== 'contains' && e.kind !== 'references') + .map((e) => { + const t = cg.getNode(e.target)!; + return { kind: e.kind, qn: t.qualifiedName, lang: t.language }; + }); + } + + beforeEach(() => { + write('java/com/acme/http/WebSocket.java', `package com.acme.http; +public abstract class WebSocket { + public static WebSocket fromString(String s) { return null; } + public static class Accepted { + public Accepted(String flow) {} + } +} +`); + // A second Java class sharing the method names, so the receiver-word + // strategy (not a unique-name shortcut) is what decides. + write('java/com/acme/http/Other.java', `package com.acme.http; +public class Other { + public static Other fromString(String s) { return null; } + public static class Accepted { + public Accepted(String flow) {} + } +} +`); + }); + + it('resolves WebSocket.Accepted(...) to the Scala case class, never the Java constructor', async () => { + write('scala/app/WebSocket.scala', `package app +trait WebSocket +object WebSocket { + final case class Accepted[In, Out](flow: String, sub: Option[String]) +} +`); + write('scala/app/Use.scala', `package app +class Use { + def accept(): Unit = { WebSocket.Accepted("f", None) } +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + const out = edgesFrom('accept'); + expect(out.filter((e) => e.lang === 'java')).toEqual([]); + expect(out).toContainEqual({ kind: 'instantiates', qn: 'WebSocket::Accepted', lang: 'scala' }); + }); + + it('does not take a Java constructor from a return-type annotation read as the receiver type', async () => { + // `def f(): WebSocket = WebSocket.Accepted(...)` — the local-declaration + // pattern reads `WebSocket = WebSocket.Accepted` as the receiver's type. + write('scala/app/WebSocket.scala', `package app +trait WebSocket +object WebSocket { + final case class Accepted(flow: String) extends WebSocket +} +`); + write('scala/app/Use.scala', `package app +class Use { + def accept(flow: String): WebSocket = WebSocket.Accepted(flow) +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + const out = edgesFrom('accept'); + expect(out.filter((e) => e.lang === 'java')).toEqual([]); + expect(out).toContainEqual({ kind: 'instantiates', qn: 'WebSocket::Accepted', lang: 'scala' }); + }); + + it('still resolves a Scala call to a real Java static method', async () => { + write('scala/app/Use.scala', `package app +class Use { + def load(): Unit = { WebSocket.fromString("y") } +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(edgesFrom('load')).toContainEqual({ kind: 'calls', qn: 'com.acme.http::WebSocket::fromString', lang: 'java' }); + }); + + it('resolves Outer.Inner(...) to a nested Scala object that defines apply', async () => { + // A Scala `object` is indexed as a `module`, not a class. + write('scala/app/WebSocket.scala', `package app +object WebSocket { + object Accepted { + def apply(flow: String): String = flow + } +} +`); + write('scala/app/Use.scala', `package app +class Use { + def accept(): Unit = { WebSocket.Accepted("f") } +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + const out = edgesFrom('accept'); + expect(out.filter((e) => e.lang === 'java')).toEqual([]); + expect(out).toContainEqual({ kind: 'calls', qn: 'WebSocket::Accepted', lang: 'scala' }); + }); + + it('prefers the case class over its companion object declared first', async () => { + write('scala/app/WebSocket.scala', `package app +object WebSocket { + object Accepted { + def apply(): Accepted = Accepted("default") + } + final case class Accepted(flow: String) +} +`); + write('scala/app/Use.scala', `package app +class Use { + def accept(): Unit = { WebSocket.Accepted("f") } +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + const out = edgesFrom('accept'); + expect(out.filter((e) => e.lang === 'java')).toEqual([]); + expect(out).toContainEqual({ kind: 'instantiates', qn: 'WebSocket::Accepted', lang: 'scala' }); + expect(out).not.toContainEqual({ kind: 'calls', qn: 'WebSocket::Accepted', lang: 'scala' }); + }); + }); }); describe('Scala companion object vs extends resolution', () => { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0862d9814..df6984a7f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -19281,7 +19281,11 @@ export function matchMethodCall( if (!target || (target.qualifiedName.startsWith(`${inferredType}::`) && target.filePath !== awaited.filePath)) return null; return { ...typedMatch, original: ref }; } - return typedMatch; + // A Scala call never lands on another language's constructor here: a + // `def f(): T = T.Inner(...)` return annotation reads as `T`'s type and + // hands `Inner::Inner` over. Fall through to the Scala type lookup. + const target = ref.language === 'scala' ? context.getNodeById?.(typedMatch.targetNodeId) : null; + if (!target || !isOtherLanguageConstructor(target, ref.language)) return typedMatch; } if (awaited) return null; // A known JS/TS builtin receiver is external when it has no project @@ -19643,6 +19647,24 @@ export function matchMethodCall( if (strat2) return strat2; } + // Scala `Outer.Inner(args)`: a case-class / companion `apply` on the nested + // Scala type `Outer::Inner` (Scala qualified names carry no package). Taken + // only when every match sits in one file (a class and its companion share + // the name), or else from the call site's own file. A Scala `object` is a + // `module`; a class / trait of the same name is preferred over it. + if (ref.language === 'scala' && dotMatch && /^[A-Z]/.test(methodName!)) { + const typeQn = `${objectOrClass!.replace(/\./g, '::')}::${methodName}`; + const types = context + .getNodesByQualifiedName(typeQn) + .filter((n) => n.language === 'scala' && (n.kind === 'class' || n.kind === 'trait' || n.kind === 'module')) + .sort((a, b) => Number(a.kind === 'module') - Number(b.kind === 'module')); + const oneFile = types.length > 0 && types.every((n) => n.filePath === types[0]!.filePath); + const chosen = oneFile ? types[0] : types.find((n) => n.filePath === ref.filePath); + if (chosen) { + return { original: ref, targetNodeId: chosen.id, confidence: 0.85, resolvedBy: 'qualified-name' }; + } + } + // Strategy 3: Find methods by name across the codebase, match by receiver // name similarity with the containing class. Handles abbreviated variable // names like permissionEngine → PermissionRuleEngine. @@ -23017,6 +23039,17 @@ export function pythonAttrRefResolves(ref: UnresolvedRef, context: ResolutionCon return !!matchPythonAttrCall(receiver, ref.referenceName.slice(dot + 1), ref, context); } +/** + * A constructor node of another language: a method named like its owner + * (`pkg::Outer::Inner::Inner`), which is how the Java/Kotlin extractors record + * an explicit constructor. + */ +function isOtherLanguageConstructor(node: Node, language: string): boolean { + if (node.language === language) return false; + const segs = node.qualifiedName.split('::'); + return segs.length >= 2 && segs[segs.length - 1] === segs[segs.length - 2]; +} + /** Go builtin/primitive field types that can never carry a project method. */ const GO_BUILTIN_FIELD_TYPES = new Set([ 'string', 'bool', 'byte', 'rune', 'error', 'any',