Skip to content
Open
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 @@ -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<User>("/me")` or `await api.get<T>(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(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
Expand Down
25 changes: 25 additions & 0 deletions __tests__/cross-file-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, Any>): 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({
Expand Down
3 changes: 3 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 5 additions & 3 deletions codegraph-kernel/src/kotlin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() }` —
Expand Down
35 changes: 20 additions & 15 deletions src/extraction/languages/kotlin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 36 additions & 1 deletion src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolutionContext, Map<string, boolean>>();

/** Kinds that own members and carry a visibility of their own. */
const OWNER_KINDS = new Set<string>(['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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down