diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b287422..8c40c77df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript and JavaScript, a class in a `.tsx` or `.jsx` file now links its base class's and interfaces' methods to its overrides, as a class in a `.ts` or `.js` file already did. `codegraph_explore`'s call path, callers and impact now follow a call through the base into the override, like outline's `BaseEmail.send()` reaching each email's `render()`. This also works across file types, such as an interface in a `.ts` file implemented in a `.tsx` one. Before, these classes had no override links at all. Re-index projects with `.tsx` or `.jsx` files after upgrading. - In Ruby, a class method, `def self.build` or a `def` inside `class << self`, is now told apart from an instance method of the same name. A call on the class, like `Widget.build`, links to the class method; a call on an instance, like `widget.build` after `widget = Widget.new`, links to the instance method; and a call without a receiver links to the one that matches the method it is written in. Before, both were stored as instance methods under one name, so a call linked to whichever the class declared first: in a service object with `def self.call` and `def call`, `card.call` on an instance linked to the class method. Re-index Ruby projects after upgrading. - In Java, Kotlin, C# and Scala, each overload of an interface or base-class method now links only to the overrides with the same parameters. Before, every overload linked to every method of that name in the implementing class, so `ExampleService.putTairValue(String)` also led to `putTairValue(List, double)`, and `codegraph_explore`'s call path, callers and impact went into overloads that call never runs. Where a parameter is a type parameter, like `save(T entity)` implemented as `save(String entity)`, or the class is written in another language than its interface, the number of parameters decides; a method declared once on each side links as before. Re-index Java, Kotlin, C# and Scala projects after upgrading. Thanks @hxj1225 for the report and @danusha2345 for the pull request. (#2663) +- In Scala, a call like `RemoteNode.Obfuscated("x")` or `Uci.Move(from, to)` now links to the case class or nested object it builds. Before, it linked to nothing, or, under a return type like `: WebSocket`, to an unrelated Java constructor whose name only shared words with it. Re-index Scala projects after upgrading. Thanks @danusha2345. - In Java and TypeScript, a class that implements or extends a parameterized type, like `implements Store`, `extends Base`, an interface's `extends Store` or TypeScript's `implements Store`, is now linked to that type, so its methods get their interface-to-implementation links and `codegraph_explore` follows a call on the interface into the class. Before, such a supertype was read with its type arguments and matched nothing. Re-index Java and TypeScript projects after upgrading. Thanks @danusha2345, and @paiyahooJay for the earlier pull request. (#2705) - In Java and C#, a method called on a newly constructed object, like `new SqlSessionFactoryBuilder().build(reader)` or `new JsonSerializer().Serialize(writer, value)`, now links to that class's own or inherited method. When several packages or namespaces declare a class of that name, it links to the one the file imports, and to none when the file could mean either. Re-index Java and C# projects after upgrading. - In Java, a `record` is now indexed as a class, with its methods, its constructors, a field for each component and the accessor each component gets, like `info.remoteAddress()`. Before, a record had no symbol of its own and its methods were filed under the package or the class around it, so a call on a record-typed value linked to nothing or to the wrong method, and records were missing from callers, impact and implementations. A call like `Circle.unit()` on a type that a sealed interface's `permits` list names first now links too. Re-index Java projects after upgrading. Thanks @danusha2345. diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 738f13af9..631132d56 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -5225,6 +5225,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 7a43a5e32..837eacd06 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -20103,7 +20103,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 @@ -20473,6 +20477,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. @@ -24136,6 +24158,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',