diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aaca65a56..940fe35ea9 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 Kotlin `private val` / `private var` no longer captures calls from other files.** Properties were indexed without their visibility modifier, so the file-local rule that already declines a `private fun` read every property as public: an Android app's `token(...)` validator resolved onto another class's `@Volatile private var token`, and JavaScript, Go and Python callers landed on Kotlin test fixtures' private fields. Both the WebAssembly and the native path now record `private` / `internal` / `protected` on properties. Re-index after upgrading. (#1731) - 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 JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - 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) diff --git a/__tests__/cross-file-visibility.test.ts b/__tests__/cross-file-visibility.test.ts index 1cb0007c94..7dfe5e4706 100644 --- a/__tests__/cross-file-visibility.test.ts +++ b/__tests__/cross-file-visibility.test.ts @@ -96,6 +96,31 @@ describe('Kotlin: a private function is class- or file-local', () => { }); }); +describe('Kotlin: a private property is class- or file-local too', () => { + it('records the modifier on the property and declines a call onto it from another file', async () => { + project({ + 'WebRtcTransport.kt': 'class WebRtcTransport {\n @Volatile private var token = ""\n internal val shared = 1\n}\n', + 'CommandValidator.kt': 'class CommandValidator {\n fun validate(command: Map): String? {\n if (!token(command["sessionId"], 128)) return "bad"\n return null\n }\n}\n', + }); + expect(await calleesOf('validate')).not.toContain('WebRtcTransport.kt:token'); + const fields = cg!.getNodesByKind('field').filter((n) => n.filePath.endsWith('WebRtcTransport.kt')); + expect(fields.find((n) => n.name === 'token')?.visibility).toBe('private'); + expect(fields.find((n) => n.name === 'shared')?.visibility).toBe('internal'); + }); +}); + +describe('Kotlin: a member of a private class is not visible either', () => { + it('declines a call onto a public fun of another file\'s private class', async () => { + // `clock`'s type is unknown here, so only the method name picks a target. + // Not a test file: production code never reaches a test suite's symbol anyway. + project({ + 'Coordinator.kt': 'class Coordinator {\n private class Clock(var value: Long) { fun now() = value }\n}\n', + 'Gimbal.kt': 'class Gimbal {\n fun dispatchSpeed() {\n val clock = provide()\n val timestamp = clock.now()\n }\n}\n', + }); + expect(await calleesOf('dispatchSpeed')).not.toContain('Coordinator.kt:now'); + }); +}); + describe('Go: an unexported identifier is package-local', () => { it('does not resolve a call onto an unexported func in another package', async () => { project({ diff --git a/__tests__/fixtures/kernel-parity/torture.kt b/__tests__/fixtures/kernel-parity/torture.kt index 3e85d3de54..d2f1878f55 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -26,6 +26,9 @@ fun com.example.Qualified.qext() {} suspend fun suspender(): Unit { helper() } private internal fun visFn() {} +private val hiddenTop = 1 +internal var sharedTop = 2 + fun inferred() = helper() diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 83170723ff..6b1901704b 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -784,9 +784,11 @@ impl<'t> Walker<'t> { return Some(Vec::new()); }; // The `type`-field signature read is dead (zero fields) → signature - // undefined; NO docstring/visibility/isStatic — the modifiers merge in - // create_node still decorates expect/actual properties. - let row = self.create_node(kind, &name, node, Extra::default()); + // undefined; NO docstring/isStatic — the modifiers merge in + // create_node still decorates expect/actual properties. Visibility IS + // read: a `private val` is file-local to the resolver (#1731). + let extra = Extra { visibility: Some(self.visibility_of(node)), ..Extra::default() }; + let row = self.create_node(kind, &name, node, extra); // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go // fix, ported): without this the subtree is only fn-ref-scanned, so a // lambda / SAM / object initializer (`val cb = Runnable { target() }` — diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index c849830940..db42a560fa 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -2,6 +2,21 @@ import type { Node as SyntaxNode } from 'web-tree-sitter'; import { getNodeText } from '../tree-sitter-helpers'; import type { LanguageExtractor } from '../tree-sitter-types'; +/** The visibility a Kotlin declaration's `modifiers` child spells out; public by default. */ +function kotlinVisibility(node: { childCount: number; child(i: number): { type: string; text: string } | null }): 'public' | 'private' | 'protected' | 'internal' { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.type === 'modifiers') { + const text = child.text; + if (text.includes('public')) return 'public'; + if (text.includes('private')) return 'private'; + if (text.includes('protected')) return 'protected'; + if (text.includes('internal')) return 'internal'; + } + } + return 'public'; +} + /** Kotlin return types that can't be a chained-call receiver (no class to chain on). */ const KOTLIN_NON_CLASS_RETURN = new Set(['Unit', 'Nothing']); @@ -282,7 +297,10 @@ export const kotlinExtractor: LanguageExtractor = { const sig = typeNode ? `${isVal ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}` : undefined; - const created = ctx.createNode(kind, name, node, { signature: sig }); + // `private val` / `internal var` at class or object scope: the modifier + // decides who can name it from another file (#1731's Kotlin rule reads + // `visibility`, and a property without one read as public). + const created = ctx.createNode(kind, name, node, { signature: sig, visibility: kotlinVisibility(node) }); // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go // fix, ported to Kotlin): the hook consumes this subtree, so without an // explicit walk a lambda / SAM / object initializer @@ -486,20 +504,7 @@ export const kotlinExtractor: LanguageExtractor = { } return sig; }, - getVisibility: (node) => { - // Check for visibility modifiers in Kotlin - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (child?.type === 'modifiers') { - const text = child.text; - if (text.includes('public')) return 'public'; - if (text.includes('private')) return 'private'; - if (text.includes('protected')) return 'protected'; - if (text.includes('internal')) return 'internal'; - } - } - return 'public'; // Kotlin defaults to public - }, + getVisibility: (node) => kotlinVisibility(node), isStatic: (_node) => { // Kotlin doesn't have static, uses companion objects return false; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 122dece68b..9173cb93e4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -3366,6 +3366,38 @@ function isSfcPrivate(n: Node, context: ResolutionContext): boolean { return true; } +/** Per-context memo: node id → "this member sits inside a `private` class/object". */ +const PRIVATE_OWNER_MEMO = new WeakMap>(); + +/** Kinds that own members and carry a visibility of their own. */ +const OWNER_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'namespace']); + +/** + * Whether `candidate` is a member of a `private` type in its file — a Kotlin + * test's `private class Clock { fun now() = value }`: the method is public, + * the class is not, so nothing outside the file can name `now`. Found by + * line range among the file's own nodes, memoised per node. + */ +function isInsidePrivateType(candidate: Node, context: ResolutionContext): boolean { + let memo = PRIVATE_OWNER_MEMO.get(context); + if (!memo) { + memo = new Map(); + PRIVATE_OWNER_MEMO.set(context, memo); + } + const hit = memo.get(candidate.id); + if (hit !== undefined) return hit; + const inside = context.getNodesInFile(candidate.filePath).some( + (n) => + n.id !== candidate.id && + OWNER_KINDS.has(n.kind) && + n.visibility === 'private' && + n.startLine <= candidate.startLine && + n.endLine >= candidate.endLine + ); + memo.set(candidate.id, inside); + return inside; +} + /** * Whether Go lets `ref`'s file name the Go declaration `candidate`: an * exported name from anywhere, an unexported one only inside its own package, @@ -3507,7 +3539,9 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex if (lang === 'dart') { return candidate.visibility !== 'private' || (ref.language === 'dart' && dartPrivateVisible(ref.filePath, candidate.filePath, context)); } - if (PRIVATE_IS_FILE_LOCAL.has(lang)) return candidate.visibility !== 'private'; + if (PRIVATE_IS_FILE_LOCAL.has(lang)) { + return candidate.visibility !== 'private' && !isInsidePrivateType(candidate, context); + } // An R test file runs in an environment of its own (testthat): its top-level // `c <- ggplot(…)` is not what the package's 2,843 `c(…)` calls mean. The // `helper-*.R` / `setup-*.R` files are sourced for every test, so theirs are shared. @@ -18170,6 +18204,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { JAVA_STATIC_IMPORTS.delete(context); PY_IMPORTS.delete(context); PY_MODULE_LOCAL.delete(context); + PRIVATE_OWNER_MEMO.delete(context); SEALED_MODULES.delete(context); LOCAL_BINDING_MEMO.delete(context); LOCAL_BINDING_SITES.delete(context);