fix(kotlin): a private property, and the members of a private class, stay in their file

A Kotlin property was indexed without its visibility modifier, so the
Kotlin file-local rule (#1745), which declines a private fun in another
file, read every property as public: a call like token(...) could link
to another file's @Volatile private var token, and JavaScript, Go or
Python callers to a Kotlin test fixture's private val. A public member
of a private class or object was reachable from other files too, though
nothing outside its file can name it.

Both extractors (wasm and the native kernel) now read a property's
modifiers the way they read a function's, and a candidate inside a
private Kotlin class or object is declined from another file. That
second rule is Kotlin's alone: a Scala private[pkg] class, or one with
a private constructor, is its package's, and a C# private nested class
serves every file of its partial class. Applied to every language where
private is file-local, as the PR had it, it removed 742 edges on http4s
(right ones among them, like stream.receiveHeaders() on a private[h2]
class H2Stream), 91 on scalachess and 10 on serilog (9 of them right
typed edges).

Validation:
- cross-file-visibility.test.ts: 2 cases, red on main, green here; the
  kernel parity fixture gains a private val and an internal var.
- Kernel parity (kotlin), 0 diffs: okhttp 588/619 files, mihon 891/940,
  nowinandroid 340/350, SystemUI's Kotlin 188/198 byte-identical, the
  same files deferred to wasm as with main's kernel; torture 1/1.
- A/B against main 01e0ac3a (with the Kotlin receiver typing of #1933):
  okhttp -274, mihon -177, SystemUI -94, every one a call from another
  file onto a private Kotlin property or a member of a private class or
  object (okhttp's private inner AndroidQuery.enqueue and
  RealWebSocketTest's private TestStreams, mihon's private IntentFilter
  filter that collections' filter { } reached, SystemUI's private
  MyExecutor.execute); nothing added. nowinandroid, jextract, click and
  gin identical; against main 4eb8c2a5, http4s, scalachess and serilog
  were identical too.
- Cost (mihon, median of 3 interleaved indexes, on main 4eb8c2a5): CPU
  25.7 s on main vs 25.5 s, wall 8.2 s on both.

Original PR #1761 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-10-29 16:07:50 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 01e0ac3aa7
commit 13f983db5a
6 changed files with 93 additions and 19 deletions
+20 -15
View File
@@ -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']);
@@ -300,7 +315,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
@@ -504,20 +522,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;
+39 -1
View File
@@ -3384,6 +3384,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,
@@ -3525,7 +3557,12 @@ 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)) {
// Only Kotlin's private type keeps its members in its file: a Scala
// `private[pkg] class` (or one with a private constructor) is the package's,
// and a C# private nested class serves every file of its partial class.
return candidate.visibility !== 'private' && !(lang === 'kotlin' && 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.
@@ -19425,6 +19462,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);