mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 21:13:11 +08:00
fix(kotlin): a call on an outside type reaches the project's extension on it
A Kotlin import from outside the project owns its name (#2196), so Modifier.fillMaxSize() after import androidx.compose.ui.Modifier never lands on a same-named project method. That rule returns before any other strategy, so a project extension on the imported type, Compose's fun Modifier.pad() (indexed as Modifier::pad), could not be reached by Modifier.pad() either, nor Color.fromHex() on a fun Color.Companion.fromHex(). Inside the rule, a Kotlin Type.member call now resolves to a project extension Type::member that the calling file can see (same file or package, or imported by name or with .*, as for other top-level Kotlin declarations) and whose own file imports the same type. Anything else stays unlinked, as before. Validation: - kotlin-extension-on-imported-type.test.ts: 2 cases, red on main, green here. - A/B against main13f983db(with the Kotlin receiver typing of #1933 and #1761's private rule): mihon +26, all Compose Modifier extensions the project declares (secondaryItemAlpha 15, clickableNoIndication 5, selectedBackground 2, two scrollbars, keepInSection), nowinandroid +2 (Modifier.notificationDot, DeviceConfigurationOverride.WindowInsets), all right; okhttp, SystemUI, jextract, click and gin identical. The same sites as against main4eb8c2a5, before #1933. - Cost (mihon, median of 3 interleaved indexes, on main4eb8c2a5): CPU 25.7 s on main vs 25.6 s, wall 8.2 s vs 8.4 s. Original PR #2258 by @danusha2345. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Colby Mchenry
co-authored by
Claude Opus 5.5
parent
13f983db5a
commit
40f5851787
@@ -256,6 +256,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In Kotlin, a call through a property now links to the method of the type the property is declared with, like `server.enqueue(…)` on a `lateinit var server: MockWebServer` or a call on a constructor property such as `class A(private val repo: Repo)`. So does a call through a chain like `engine.pump.drain()` or `Mode.ON.next()`, through a property one of your classes inherits, through a local set from a call like `val lease = HardwareLock.tryBegin()`, and through a local of the function around an anonymous `object : …`. Before, such a call went to an interface's method or to whichever method had the name: on okhttp, the tests' calls reached the deprecated `MockWebServer` instead of the one each file imports. A call on a type from outside your project, like `Regex` or a JDK class, no longer links to a same-named method of your own, unless your project extends that type with it. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
|
||||
- In Kotlin, a `fun interface` is now indexed as an interface with its methods, like okhttp's `HttpLoggingInterceptor.Logger`. Before, it was read as a broken function, and when a comment followed it, the declaration after it went missing too, its members turning into loose functions. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
|
||||
- In Kotlin, a `private val` or `private var` property, and the members of a `private` class or object, no longer take calls from other files. Properties were indexed without their visibility, so a call like `token(...)` in one file could link to another class's `private var token`, and JavaScript, Go or Python callers could land on a Kotlin test's private property; a call to `now()` could also land on a method of a test's `private class Clock`. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
|
||||
- In Kotlin, a call on a library type you import, like Jetpack Compose's `Modifier.pad()`, now links to the extension your project declares on that type, `fun Modifier.pad()`, when the calling file can see it: in the same package, or imported by name or with `.*`. The library type's own methods, like `Modifier.fillMaxSize()`, still never link to a project method that shares the name. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
|
||||
- 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 JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499)
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* A Kotlin import from outside the project owns its name (#2196): after
|
||||
* `import androidx.compose.ui.Modifier`, `Modifier.fillMaxSize()` is
|
||||
* Compose's, never a project method that happens to be called `fillMaxSize`.
|
||||
* But Kotlin lets the project extend that type — `fun Modifier.pad()`,
|
||||
* indexed as `Modifier::pad` — and `Modifier.pad()` is that extension when
|
||||
* the call site can see it: in its own package, or imported by name or `.*`.
|
||||
* `fun Color.Companion.fromHex()` is called the same way, `Color.fromHex()`.
|
||||
*/
|
||||
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';
|
||||
|
||||
let root = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-ext-imported-'));
|
||||
const files: Record<string, string> = {
|
||||
'src/main/kotlin/app/ui/Ext.kt': `package app.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
fun Modifier.pad(): Modifier = this
|
||||
|
||||
fun Modifier.hidden(): Modifier = this
|
||||
|
||||
fun Color.Companion.fromHex(hex: String): Color = Color.Red
|
||||
`,
|
||||
'src/main/kotlin/app/other/Box.kt': `package app.other
|
||||
|
||||
class Box {
|
||||
fun fillMaxSize(): Box = this
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/app/gen/Gen.kt': `package app.gen
|
||||
|
||||
import javax.lang.model.element.Modifier
|
||||
|
||||
fun Modifier.keyword(): String = name
|
||||
`,
|
||||
'src/main/kotlin/app/ui/Screen.kt': `package app.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
fun screen() {
|
||||
Modifier.pad()
|
||||
Modifier.fillMaxSize()
|
||||
Color.fromHex("#fff")
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/app/feature/Feed.kt': `package app.feature
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.gen.keyword
|
||||
import app.ui.pad
|
||||
|
||||
fun feed() {
|
||||
Modifier.pad()
|
||||
Modifier.hidden()
|
||||
Modifier.keyword()
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/app/settings/Settings.kt': `package app.settings
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.ui.*
|
||||
|
||||
fun settings() {
|
||||
Modifier.hidden()
|
||||
}
|
||||
`,
|
||||
};
|
||||
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 });
|
||||
});
|
||||
|
||||
/** `kind qualified-name` of every non-structural edge out of a file. */
|
||||
function edgesFrom(file: string): string[] {
|
||||
const ids = cg.getNodesInFile(file).map((n) => n.id);
|
||||
return cg
|
||||
.getOutgoingEdgesFrom(ids)
|
||||
.filter((e) => e.kind !== 'contains' && e.kind !== 'imports')
|
||||
.map((e) => `${e.kind} ${cg.getNode(e.target)!.qualifiedName}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe('Kotlin: a project extension on a type an outside import names', () => {
|
||||
it('is what a call on that type reaches from the same package; the type’s own members stay outside', () => {
|
||||
expect(edgesFrom('src/main/kotlin/app/ui/Screen.kt')).toEqual([
|
||||
'calls Color::fromHex',
|
||||
'calls Modifier::pad',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is reached from another package only through an import of it, by name or `.*`', () => {
|
||||
expect(edgesFrom('src/main/kotlin/app/feature/Feed.kt')).toEqual(['calls Modifier::pad']);
|
||||
expect(edgesFrom('src/main/kotlin/app/settings/Settings.kt')).toEqual(['calls Modifier::hidden']);
|
||||
});
|
||||
});
|
||||
@@ -1966,6 +1966,29 @@ export function isJavaOutsideImport(name: string, ref: UnresolvedRef, context: R
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Modifier.pad()` after `import androidx.compose.ui.Modifier`: the type is
|
||||
* outside the project, but Kotlin lets the project extend it. The call reaches
|
||||
* a project extension on that type — `fun Modifier.pad()`, indexed as
|
||||
* `Modifier::pad` — that the call site can see (its file, its package, an
|
||||
* import of it by name or `.*`) and whose own file imports the same type.
|
||||
* Compose's own `Modifier.fillMaxSize()` matches none and stays unlinked.
|
||||
*/
|
||||
function kotlinExtensionOnImportedType(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
const call = /^(\w+)\.(\w+)$/.exec(ref.referenceName);
|
||||
if (!call || ref.referenceKind !== 'calls') return null;
|
||||
const binding = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === call[1]);
|
||||
if (!binding) return null;
|
||||
const pkg = binding.source.slice(0, binding.source.lastIndexOf('.'));
|
||||
const extensions = context.getNodesByQualifiedName(`${binding.exportedName}::${call[2]}`).filter((n) => {
|
||||
if (n.kind !== 'method' || n.language !== 'kotlin' || !isKotlinTopLevelVisible(n, ref, context)) return false;
|
||||
const scope = kotlinFileScope(n.filePath, context);
|
||||
return scope.imports.has(binding.source) || scope.stars.has(pkg);
|
||||
});
|
||||
const chosen = extensions.length > 1 ? extensions.filter((n) => n.filePath === ref.filePath) : extensions;
|
||||
return chosen.length === 1 ? { original: ref, targetNodeId: chosen[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' } : null;
|
||||
}
|
||||
|
||||
/** Lua's global functions, and the test runner's: `local type = type` is the standard library's `type`. */
|
||||
const LUA_GLOBAL_FUNCTIONS: ReadonlySet<string> = new Set([
|
||||
'assert', 'error', 'ipairs', 'pairs', 'next', 'type', 'tostring', 'tonumber', 'setmetatable', 'getmetatable',
|
||||
@@ -28885,10 +28908,11 @@ function matchReferenceInner(
|
||||
|
||||
// `import java.lang.reflect.Field;` — the file's `Field` is the JDK's, never
|
||||
// a project class of that name (gson's production code bound it to a test's
|
||||
// nested `ParameterizedTypesTest.Field`).
|
||||
// nested `ParameterizedTypesTest.Field`). Only a Kotlin extension the
|
||||
// project declares on that type is in the graph.
|
||||
if ((ref.language === 'java' || ref.language === 'kotlin') && ref.referenceKind !== 'imports' &&
|
||||
isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) {
|
||||
return null;
|
||||
return ref.language === 'kotlin' ? kotlinExtensionOnImportedType(ref, context) : null;
|
||||
}
|
||||
|
||||
// A symbolic name in a Scala type is a type (`F ~> G`) or a kind-projector
|
||||
|
||||
Reference in New Issue
Block a user