Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading.
- In JavaScript and TypeScript, a subclass's `static` method no longer counts as overriding an instance method of the same name in its base class, or the reverse, since calling one never runs the other. In webpack, `codegraph_explore`'s call path and impact went on from `Module`'s `deserialize(context)` into the `static deserialize(context)` of `NormalModule` and other modules. A static method still overrides its base's static method and an instance method its base's instance method, and JavaScript class members are now marked static, as TypeScript ones already were. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and @yang0228 for the pull request. (#2761)
- 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<String>, 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 Kotlin, a call on what one of your functions returns, like `repository().pending(1)` or `val repo = repository(); repo.pending(1)` with a `private fun repository(): RealRepo` in a test class, now links to the method of the type that function declares, including one the type inherits or an extension on it. Before, the chained call went to whichever `pending` the name matched first, like a test double's in another package, and the local was guessed from its name, which reached the interface. The function is found in the calling class, a class it inherits from or one around it, or at the top level, and a nested type like `Request.Builder` is that type, not another class's `Builder`. When the declared type has no such method, the call now links to nothing rather than a method of the same name elsewhere; a type from outside your project is matched by name as before. Re-index Kotlin projects after upgrading. Thanks @mindcipherrr for the report. (#2805)
- In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497)
- In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading.
- In Go, a type defined from other types, like etcd's `type WatchChan <-chan WatchResponse` or gin's `type HandlerFunc func(*Context)` and `type HandlersChain []HandlerFunc`, now links to the types it is built from, so impact on `WatchResponse` or gin's `Context` reaches these types and the code that uses them. Before, such a type linked to nothing. One written through another package, like etcd's `type PutResponse pb.PutResponse`, links to that package's type, and a recursive one like `type stateFn func(*Lexer) stateFn` doesn't link to itself. Re-index Go projects after upgrading.
Expand Down
365 changes: 365 additions & 0 deletions __tests__/kotlin-call-result-receiver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
/**
* A Kotlin call on what an unqualified call returns is a call on the type
* that function declares (#2805). `repository().pending(1)`, with a private
* `fun repository(): RealRepo` in the calling class, went to whichever
* `pending` the name matched first — a test double's `FakeRepo.pending` in
* another package — and `val repo = repository(); repo.pending(1)` went to
* the interface `Repo.pending`, guessed from the local's name. Both now link
* `RealRepo.pending`, through the helper the class declares, inherits or the
* package declares at the top level; a type that has no such method links
* nothing rather than a namesake elsewhere.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';

const FILES: Record<string, string> = {
// The issue's four files, verbatim.
'src/main/kotlin/demo/domain/Repo.kt': `package demo.domain

interface Repo {
fun pending(id: Long): Int
}
`,
'src/main/kotlin/demo/data/RealRepo.kt': `package demo.data

import demo.domain.Repo

class RealRepo : Repo {
override fun pending(id: Long): Int = 1
}
`,
'src/test/kotlin/demo/testutil/FakeRepo.kt': `package demo.testutil

import demo.domain.Repo

class FakeRepo : Repo {
override fun pending(id: Long): Int = 2
}
`,
'src/test/kotlin/demo/data/RealRepoTest.kt': `package demo.data

class RealRepoTest {
private fun repository(): RealRepo = RealRepo()

fun viaConstructor() {
val repo = RealRepo()
repo.pending(1)
}

fun viaHelperLocal() {
val repo = repository()
repo.pending(1)
}

fun viaHelperChain() {
repository().pending(1)
}
}
`,
// More helpers and call shapes.
'src/test/kotlin/demo/data/MoreHelperTest.kt': `package demo.data

import demo.domain.Repo

abstract class BaseRepoTest {
protected fun baseRepository(): RealRepo = RealRepo()
}

class MoreHelperTest : BaseRepoTest() {
private fun repository(): RealRepo = RealRepo()
private fun asInterface(): Repo = RealRepo()
private fun nullable(): RealRepo? = null

private fun runTest(block: () -> Unit) = block()

fun viaBaseHelper() {
baseRepository().pending(1)
}

fun viaInterfaceHelper() {
asInterface().pending(1)
}

fun viaThis() {
this.repository().pending(1)
}

fun viaSafeCall() {
nullable()?.pending(1)
}

fun viaTopLevel() {
freshRepo().pending(1)
}

fun viaLambdaLocal() {
runTest {
var repo = repository()
repo.pending(1)
}
}

fun viaMissing() {
repository().missing()
}
}
`,
'src/test/kotlin/demo/data/Fixtures.kt': `package demo.data

fun freshRepo(): RealRepo = RealRepo()

fun RealRepo.describe(): String = "real"

class Codec {
class Reader {
fun readInt(): Int = 0
}
}

// A nested type sharing its name with the one the helper names (in Request.kt); an extension on a nested type.
class MockRequest {
class Builder {
fun post(): Builder = this
}
}

class Mall {
class Builder {
fun open(): Mall = Mall()
}
}

// Shop.newBuilder() declares a plain Builder, which is Shop.Builder.
fun Shop.reopen(): Shop = newBuilder().open()

class Dns {
class Call
}

fun Dns.Call.toEvents(): Int = 1

class Client {
private fun request(): Request.Builder = Request.Builder()
private fun newCall(): Dns.Call = Dns.Call()

fun send() {
request().post()
}

fun events() {
newCall().toEvents()
}
}

class InheritedHelperTest {
private fun audited(): AuditedRepo = AuditedRepo()
private fun repository(): RealRepo = RealRepo()
private fun newReader(): Codec.Reader = Codec.Reader()
private fun text(): StringBuilder = StringBuilder()

// A nested type the helper names through its outer class.
fun viaNested() {
newReader().readInt()
}

// A type from outside the project: linked by the method's name, as before.
fun viaOutside() {
text().reverse()
}

// A method the helper's type inherits; an extension function on it.
fun viaInherited() {
audited().audit()
}

fun viaExtension() {
repository().describe()
}

fun viaVisibleExtension() {
repository().label()
}
}
`,
'src/main/kotlin/demo/data/Shop.kt': `package demo.data

class Shop {
class Builder {
fun open(): Shop = Shop()
}

fun newBuilder(): Builder = Builder()
}
`,
'src/main/kotlin/demo/data/Request.kt': `package demo.data

class Request {
class Builder {
fun post(): Builder = this
}
}
`,
// The helper's type and its base, in a file indexed after the caller's.
'src/test/kotlin/demo/data/zz/Audit.kt': `package demo.data

open class BaseAudit {
fun audit() {}
}

class AuditedRepo : BaseAudit()
`,
// Two extensions of one name: the caller's package declares one, another package the other.
'src/test/kotlin/demo/aa/OtherLabels.kt': `package demo.aa

import demo.data.RealRepo

fun RealRepo.label(): String = "other"
`,
'src/test/kotlin/demo/data/zz/Labels.kt': `package demo.data

fun RealRepo.label(): String = "own"
`,
// A namesake of both methods in an unrelated package.
'src/test/kotlin/demo/other/Decoy.kt': `package demo.other

class Decoy {
fun missing() {}
fun pending(id: Long): Int = 3
fun audit() {}
fun describe(): String = "decoy"
fun readInt(): Int = 3
fun reverse() {}
}
`,
};

let root = '';
let graph: CodeGraph;

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-call-result-'));
for (const [rel, content] of Object.entries(FILES)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
graph = await CodeGraph.init(root, { index: true });
}, 120_000);

afterAll(() => {
graph?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

/** The qualified names `owner::fn` calls, sorted. */
function callees(owner: string, fn: string): string[] {
const caller = graph.getNodesByKind('method').find((m) =>
m.qualifiedName.endsWith(`::${owner}::${fn}`) || m.qualifiedName === `${owner}::${fn}`);
expect(caller, `${owner}::${fn}`).toBeDefined();
return graph
.getOutgoingEdges(caller!.id)
.filter((e) => e.kind === 'calls')
.map((e) => graph.getNode(e.target)!.qualifiedName)
.sort();
}

describe('a Kotlin call on what an unqualified call returns (#2805)', () => {
it("the issue's local and chained forms reach the helper's declared type", () => {
expect(callees('RealRepoTest', 'viaConstructor')).toEqual(['demo.data::RealRepo::pending']);
expect(callees('RealRepoTest', 'viaHelperLocal')).toEqual([
'demo.data::RealRepo::pending',
'demo.data::RealRepoTest::repository',
]);
expect(callees('RealRepoTest', 'viaHelperChain')).toEqual([
'demo.data::RealRepo::pending',
'demo.data::RealRepoTest::repository',
]);
});

it('a helper the class inherits, one through `this.`, a safe call and a top-level function', () => {
expect(callees('MoreHelperTest', 'viaBaseHelper')).toEqual([
'demo.data::BaseRepoTest::baseRepository',
'demo.data::RealRepo::pending',
]);
expect(callees('MoreHelperTest', 'viaThis')).toEqual([
'demo.data::MoreHelperTest::repository',
'demo.data::RealRepo::pending',
]);
expect(callees('MoreHelperTest', 'viaSafeCall')).toEqual([
'demo.data::MoreHelperTest::nullable',
'demo.data::RealRepo::pending',
]);
expect(callees('MoreHelperTest', 'viaTopLevel')).toEqual([
'demo.data::RealRepo::pending',
'demo.data::freshRepo',
]);
});

it('a helper declared to return the interface reaches the interface method', () => {
expect(callees('MoreHelperTest', 'viaInterfaceHelper')).toEqual([
'demo.data::MoreHelperTest::asInterface',
'demo.domain::Repo::pending',
]);
});

it('a local set from a helper inside a lambda', () => {
expect(callees('MoreHelperTest', 'viaLambdaLocal')).toEqual([
'demo.data::MoreHelperTest::repository',
'demo.data::MoreHelperTest::runTest',
'demo.data::RealRepo::pending',
]);
});

it("a method the helper's type inherits, and an extension function on it", () => {
expect(callees('InheritedHelperTest', 'viaInherited')).toEqual([
'demo.data::BaseAudit::audit',
'demo.data::InheritedHelperTest::audited',
]);
expect(callees('InheritedHelperTest', 'viaExtension')).toEqual([
'RealRepo::describe',
'demo.data::InheritedHelperTest::repository',
]);
});

it('a nested type named through its outer class; a type from outside the project as before', () => {
expect(callees('InheritedHelperTest', 'viaNested')).toEqual([
'demo.data::Codec::Reader::readInt',
'demo.data::InheritedHelperTest::newReader',
]);
// `StringBuilder.reverse()` is the JDK's: the project's namesake is the
// guess it was on main, left to the strategies this change doesn't touch.
expect(callees('InheritedHelperTest', 'viaOutside')).toEqual([
'demo.data::InheritedHelperTest::text',
'demo.other::Decoy::reverse',
]);
});

it('a nested type of a shared name is the one the helper names', () => {
expect(callees('Client', 'send')).toEqual([
'demo.data::Client::request',
'demo.data::Request::Builder::post',
]);
expect(callees('Shop', 'reopen')).toEqual([
'demo.data::Shop::Builder::open',
'demo.data::Shop::newBuilder',
]);
// An extension on a nested type is kept under the outer type's name: found by name, as before.
expect(callees('Client', 'events')).toEqual([
'Dns::toEvents',
'demo.data::Client::newCall',
]);
});

it("an extension the calling file can name, of two alike", () => {
const caller = graph.getNodesByKind('method').find((m) => m.qualifiedName.endsWith('::InheritedHelperTest::viaVisibleExtension'))!;
const targets = graph.getOutgoingEdges(caller.id).filter((e) => e.kind === 'calls').map((e) => graph.getNode(e.target)!)
.filter((n) => n.name === 'label').map((n) => n.filePath);
expect(targets).toEqual(['src/test/kotlin/demo/data/zz/Labels.kt']);
});

it('a method the declared type lacks links nothing, not a namesake elsewhere', () => {
expect(callees('MoreHelperTest', 'viaMissing')).toEqual(['demo.data::MoreHelperTest::repository']);
});
});
Loading