Files
codegraph/__tests__/kotlin-top-level-visibility.test.ts
Colby Mchenry 2e2b98c94e fix(kotlin): a bare call reaches only a member in reach (#2172)
A receiver-less Kotlin call exact-matched any class's same-named method:
koin's module { } in one test went to another test class's private
module 268 times, error("…") to a Logger's error, get() inside a
definition lambda to Koin's get rather than the Scope it runs on.

A bare call now reaches a method of a class, anonymous object or extension
receiver around it (read from nodes and from the source's braces, so a
class the parser lost still counts) and what those inherit (with Android
framework ancestry), of a type the project's function types take as a
lambda receiver (plus outside types it writes extensions on), or of an
object the file imports. with/apply/run blocks and .kts scripts are left
unjudged; Kotlin's require/check/assert preconditions are the stdlib's.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:20:22 +00:00

154 lines
5.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A top-level Kotlin function — an extension function included — is visible
* from its own package, and from a file that imports it by name or imports
* its package with `*`. Nowhere else.
*
* Exposed keeps a JDBC and an R2DBC test suite, each with a
* `fun <T> …Transaction.assertEquals(…)` helper. Tests that imported
* kotlin.test's or JUnit's `assertEquals`, or the JDBC helper, all landed on
* the R2DBC one: 3,075 calls. okhttp's tests constructed the deprecated
* `okhttp3.mockwebserver.MockResponse` although every one imports
* `mockwebserver3.MockResponse`.
*
* Also: a function type with a qualified receiver
* (`(DatabaseConfig.Builder.() -> Unit)?`) no longer breaks the parse of the
* class around it. Exposed's `DatabaseTestsBase` came out as loose functions.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { blankKotlinQualifiedReceivers } from '../src/extraction/languages/kotlin';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-visibility-'));
const files: Record<string, string> = {
'jdbc/src/main/kotlin/app/jdbc/tests/Assert.kt': `package app.jdbc.tests
class JdbcTransaction
fun withTables(statement: JdbcTransaction.() -> Unit) {}
fun <T> JdbcTransaction.assertEquals(exp: T, act: T) {}
`,
'r2dbc/src/main/kotlin/app/r2dbc/tests/Assert.kt': `package app.r2dbc.tests
class R2dbcTransaction
fun withTables(statement: R2dbcTransaction.() -> Unit) {}
fun <T> R2dbcTransaction.assertEquals(exp: T, act: T) {}
`,
'jdbc/src/test/kotlin/app/jdbc/JdbcTest.kt': `package app.jdbc
import app.jdbc.tests.assertEquals
import app.jdbc.tests.withTables
class JdbcTest {
fun works() {
withTables { assertEquals(1, 1) }
}
}
`,
'r2dbc/src/test/kotlin/app/r2dbc/R2dbcTest.kt': `package app.r2dbc
import app.r2dbc.tests.*
class R2dbcTest {
fun works() {
withTables { assertEquals(1, 1) }
}
}
`,
'core/src/test/kotlin/app/core/PlainTest.kt': `package app.core
import kotlin.test.assertEquals
class PlainTest {
fun works() {
assertEquals(1, 1)
}
}
`,
'jdbc/src/main/kotlin/app/jdbc/tests/Local.kt': `package app.jdbc.tests
fun check() {
withTables { assertEquals(1, 1) }
}
`,
'jdbc/src/main/kotlin/app/jdbc/tests/DatabaseTestsBase.kt': `package app.jdbc.tests
class DatabaseConfig {
class Builder
}
@MethodSource("data")
abstract class DatabaseTestsBase {
fun withTables(configure: (DatabaseConfig.Builder.() -> Unit)? = null, statement: () -> Unit) {
statement()
}
}
`,
};
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);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** The file each `assertEquals` call in `file` resolved to. */
function assertTargets(file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)!)
.filter((n) => n.name === 'assertEquals')
.map((n) => n.filePath);
}
describe('Kotlin top-level functions are visible where imported', () => {
it('an explicit import picks that package’s extension', () => {
expect(assertTargets('jdbc/src/test/kotlin/app/jdbc/JdbcTest.kt')).toEqual(['jdbc/src/main/kotlin/app/jdbc/tests/Assert.kt']);
});
it('a star import of the package does too', () => {
expect(assertTargets('r2dbc/src/test/kotlin/app/r2dbc/R2dbcTest.kt')).toEqual(['r2dbc/src/main/kotlin/app/r2dbc/tests/Assert.kt']);
});
it('a file importing kotlin.test’s assertEquals reaches neither project helper', () => {
expect(assertTargets('core/src/test/kotlin/app/core/PlainTest.kt')).toEqual([]);
});
it('the same package needs no import', () => {
expect(assertTargets('jdbc/src/main/kotlin/app/jdbc/tests/Local.kt')).toEqual(['jdbc/src/main/kotlin/app/jdbc/tests/Assert.kt']);
});
});
describe('a function type with a qualified receiver', () => {
it('keeps the class around it', () => {
const nodes = cg.getNodesInFile('jdbc/src/main/kotlin/app/jdbc/tests/DatabaseTestsBase.kt');
const withTables = nodes.find((n) => n.name === 'withTables')!;
expect(withTables.kind).toBe('method');
expect(withTables.qualifiedName).toBe('app.jdbc.tests::DatabaseTestsBase::withTables');
});
it('is blanked to its simple name, offsets intact', () => {
const src = 'fun f(c: (DatabaseConfig.Builder.() -> Unit)?, d: a.b.Response.Builder<T>.(Int) -> Unit, e: Builder.() -> Unit) {}';
const out = blankKotlinQualifiedReceivers(src);
expect(out.length).toBe(src.length);
const blank = (s: string) => ' '.repeat(s.length);
expect(out).toBe(src.replace('DatabaseConfig.', blank('DatabaseConfig.')).replace('a.b.Response.', blank('a.b.Response.')));
});
});