fix(scala): block-local vals, package imports and package objects (#2193)

- A `val` the graph files under a class but that is written inside a block
  of its body (cats' `test("…") { val f = … }`) is that block's alone. It
  took `f(…)` calls from lambda parameters in other suites and from cats'
  core library (`FreeT::FlatMapped::f` from option.scala, Invariant.scala…).
  A `val`/`const` declared inside a function's lines is likewise local
  (outside C/C++, whose nesting is not trusted; a Lua global stays global).
- In a class body with no enclosing method, a binder (a lambda parameter, a
  `val`) counts as local only inside a block still open at the site.
- Only an import rooted at a value (`import builder._`) lets any type's
  members in; a lowercase package path (`import cats.syntax.all._`) did too.
- An imported object's supertypes are in scope, and `package object x
  extends Api` (no node in the graph) is read from source - imported, or the
  file's own package: sttp's `multipart(...)`, `asJson(...)`, `asEither(...)`.

cats -478 +63, sttp -153 +81 (sampled losses: other tests' locals, scalatest
`test`/`eventually`, libc `free`, `sttp.model.Header`). fs2, http4s, okio,
koin, Exposed, lodash, excalidraw, flask, kong, jekyll, halo unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 19:15:47 +00:00
committed by GitHub
parent 7c1c9a3691
commit 8410db42a1
3 changed files with 274 additions and 12 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Scala calls no longer land on another test's local value. A `val f` written inside one `test("…") { }` block was being picked for a lambda parameter `f` in another suite, and even for `f(…)` in cats' core library. A package import like `import cats.syntax.all._` no longer puts every class's members in scope. Members that really are in scope now resolve: those of an imported package object's parent types (`import sttp.client4._` gives `SttpApi`'s `multipart`), and of the file's own package object.
- A barrel's `export * as core from './core'` now exports only `core`, not everything in `./core`. In zod, `z.string()` and `z.number()` had been landing on same-named regex constants deep in its core module instead of the schema functions. Calls through `import * as ns` also find members a barrel forwards by name (`export { safeParse } from '../core'`). They follow a namespace path like `z.coerce.number()` too.
- JavaScript calls through CommonJS `require` bindings now reach the right function. `var express = require('../')` follows `module.exports` through an `index.js` to the function it exports, and every binding in a `var a = require('x'), b = require('y')` list is read. `require('./utils').setCharset` means that module's `setCharset` rather than its first export, and a call to a package from outside the project (`supertest`, `node:assert`) is no longer an edge to the local variable. On express, over 700 `express()` calls now reach `createApplication`.
- Lua calls made through a `local` alias now reach the function it names. That covers `local splitn = require("kong.tools.string").splitn`, a module member such as `utils.select_listener`, and a function a module exports under another name (`return { check = check_phase }`). Aliases of Lua's own functions, such as `local type = type` or `local fmt = string.format`, no longer show up as calls into your project. On Kong that is over 1,000 calls that now reach their real function, and about 5,000 fewer dead-end edges.
+118
View File
@@ -0,0 +1,118 @@
/**
* A Scala `val` written inside a block of a class body — cats' `test("…") {
* val f = … }` — is that block's, not a member of the class: a lambda
* parameter `f` in another suite (`forAll { (e: Int, f: Int => Int) => f(e) }`)
* and a sibling test's `f(2)` are not calls to it. A lowercase package import
* (`import cats.syntax.all._`) no longer lets every class's members in; an
* imported package object's supertypes do (`package object api extends Api`),
* as does the package object of the file's own package.
*/
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-scala-block-'));
const files: Record<string, string> = {
'src/test/scala/app/ASuite.scala': `package app
class ASuite {
def test(name: String)(body: => Unit): Unit = ()
test("a") {
val f = (i: Int) => i
f(1)
}
test("c") {
f(2)
}
}
`,
'src/test/scala/app/BSuite.scala': `package app
import app.syntax.all._
class BSuite {
def test(name: String)(body: => Unit): Unit = ()
def forAll(p: (Int, Int => Int) => Boolean): Unit = ()
test("b") {
forAll { (e: Int, f: Int => Int) =>
f(e) == 1
}
}
}
`,
'src/main/scala/app/syntax/all.scala': `package app.syntax
object all
`,
'src/main/scala/app/Api.scala': `package app
trait Api {
def multipart(s: String): String = s
}
`,
'src/main/scala/app/api/package.scala': `package app
package object api extends Api
`,
'src/main/scala/app/client/Client.scala': `package app.client
import app.api._
class Client {
def send(): String = multipart("x")
}
`,
'src/main/scala/app/api/Inner.scala': `package app.api
class Inner {
def send(): String = multipart("y")
}
`,
};
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 });
});
/** `qualified name:line` of every call edge out of a file. */
function callsFrom(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)!.qualifiedName}:${e.line}`)
.sort();
}
describe('Scala block-local vals', () => {
it('are called from their own block only', () => {
expect(callsFrom('src/test/scala/app/ASuite.scala').filter((c) => c.startsWith('ASuite::f'))).toEqual(['ASuite::f:8']);
});
it('are not another suite’s lambda parameter, whatever the file imports', () => {
expect(callsFrom('src/test/scala/app/BSuite.scala').filter((c) => c.includes('::f:'))).toEqual([]);
});
});
describe('Scala package objects', () => {
it('bring their supertypes’ members in when imported, and into their own package', () => {
expect(callsFrom('src/main/scala/app/client/Client.scala')).toEqual(['Api::multipart:6']);
expect(callsFrom('src/main/scala/app/api/Inner.scala')).toEqual(['Api::multipart:4']);
});
});
+155 -12
View File
@@ -618,6 +618,13 @@ function isLexicallyReachable(
ref: UnresolvedRef,
context: ResolutionContext
): boolean {
// A `val` / `const` declared in a function body is that body's alone —
// okio's `(source as Source).buffer()` bound to a `val buffer = Buffer()`
// inside another test file's `pipe()`.
if (candidate.kind === 'variable' || candidate.kind === 'constant' || (candidate.kind === 'field' && candidate.language === 'scala')) {
const scope = localDeclarationScope(candidate, context);
return scope === null || (ref.filePath === candidate.filePath && ref.line >= scope.start && ref.line <= scope.end);
}
// A function — or a type (`case class B()` in a test method), or a method
// of such a type — declared inside a function is only in scope in there.
if (candidate.kind !== 'function' && candidate.kind !== 'method' && !LOCAL_TYPE_KINDS.has(candidate.kind)) return true;
@@ -633,6 +640,66 @@ function isLexicallyReachable(
return scope === null || (ref.filePath === candidate.filePath && ref.line >= scope.start && ref.line <= scope.end);
}
/** Per context: node id → the function body (or Scala block) a declaration is local to. */
const LOCAL_DECL_MEMO = new WeakMap<ResolutionContext, Map<string, { start: number; end: number } | null>>();
/**
* The lines a variable declaration is in scope for when it is local: the
* innermost function or method of its file whose lines hold it — or, for a
* Scala `val` the graph files under its class but written inside a block of
* the class body (cats' `test("…") { val f = … }`), that block. Null for a
* declaration at file, class or object level. A Lua global assigned inside a
* function is still global; C and C++ nesting is not trusted (see below).
*/
function localDeclarationScope(candidate: Node, context: ResolutionContext): { start: number; end: number } | null {
if (NO_NESTED_FUNCTIONS.has(candidate.language)) return null;
let memo = LOCAL_DECL_MEMO.get(context);
if (!memo) LOCAL_DECL_MEMO.set(context, (memo = new Map()));
const hit = memo.get(candidate.id);
if (hit !== undefined) return hit;
let scope: { start: number; end: number } | null = null;
if (!((candidate.language === 'lua' || candidate.language === 'luau') && !isLuaLocal(candidate, context))) {
for (const n of context.getNodesInFile(candidate.filePath)) {
if ((n.kind !== 'function' && n.kind !== 'method') || n.id === candidate.id) continue;
if (n.startLine > candidate.startLine || n.endLine < candidate.startLine || n.startLine === n.endLine) continue;
if (n.startLine === candidate.startLine && (n.startColumn ?? 0) >= (candidate.startColumn ?? 0)) continue;
if (!scope || n.endLine - n.startLine < scope.end - scope.start) scope = { start: n.startLine, end: n.endLine };
}
if (!scope && candidate.kind === 'field' && candidate.language === 'scala') scope = scalaBlockOf(candidate, context);
}
memo.set(candidate.id, scope);
return scope;
}
/** The `{ … }` block, deeper than its class body, that a Scala `val` is written in; null for a member. */
function scalaBlockOf(candidate: Node, context: ResolutionContext): { start: number; end: number } | null {
const owner = candidate.qualifiedName.includes('::') ? candidate.qualifiedName.slice(0, candidate.qualifiedName.lastIndexOf('::')) : '';
const cls = context.getNodesInFile(candidate.filePath).find((n) =>
n.qualifiedName === owner && (n.kind === 'class' || n.kind === 'trait' || n.kind === 'struct' || n.kind === 'module'));
if (!cls || cls.startLine >= candidate.startLine) return null;
const lines = context.getFileLines?.(candidate.filePath) ?? context.readFile(candidate.filePath)?.split(/\r?\n/) ?? [];
const clean = (l: string) => l.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)'/g, '""').replace(/\/\/.*$/, '');
// Depth at the start of each line, counted from the class header; the body is depth 1.
const opens: number[] = [];
let depth = 0;
for (let line = cls.startLine; line < candidate.startLine; line++) {
for (const ch of clean(lines[line - 1] ?? '')) {
if (ch === '{') { depth++; opens.push(line); }
else if (ch === '}') { depth = Math.max(0, depth - 1); opens.pop(); }
}
}
if (depth <= 1) return null;
const start = opens[opens.length - 1]!;
let d = depth;
for (let line = candidate.startLine; line <= cls.endLine; line++) {
for (const ch of clean(lines[line - 1] ?? '')) {
if (ch === '{') d++;
else if (ch === '}' && --d < depth) return { start, end: line };
}
}
return { start, end: cls.endLine };
}
/** Per context: a candidate's scoping function body, or null when nothing scopes it. */
const LEXICAL_SCOPE_MEMO = new WeakMap<ResolutionContext, Map<string, { start: number; end: number } | null>>();
@@ -2445,7 +2512,7 @@ const GO_STD_METHODS: ReadonlySet<string> = new Set([
const SCALA_TYPE_KINDS: ReadonlySet<string> = new Set(['class', 'trait', 'interface', 'enum', 'struct', 'module', 'namespace']);
const SCALA_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'field', 'property', 'variable', 'constant']);
const SCALA_SUPERS = new WeakMap<ResolutionContext, Map<string, string[]>>();
const SCALA_IMPORTS = new WeakMap<ResolutionContext, Map<string, { owners: Set<string>; members: Set<string> }>>();
const SCALA_IMPORTS = new WeakMap<ResolutionContext, Map<string, { owners: Set<string>; members: Set<string>; values: Set<string> }>>();
/**
* Whether a bare Scala name can mean the member `n`, read at its site. Three
@@ -2484,7 +2551,10 @@ function isScalaMemberInScope(n: Node, ref: UnresolvedRef, context: ResolutionCo
const imports = scalaImportsOf(ref.filePath, context);
if (imports.owners.has(owner) || imports.members.has(`${owner}.${name}`)) return true;
// `import builder._` brings in a VALUE's members, of a type the file doesn't say.
if ([...imports.owners].some((o) => /^[a-z]/.test(o))) return true;
if (imports.values.size > 0) return true;
// An imported object's inherited members: `import sttp.client4._` is
// `package object client4 extends SttpApi`, so `multipart(…)` is SttpApi's.
if (scalaImportedSupertypes(ref.filePath, imports, context).has(owner)) return true;
const around = context
.getNodesInFile(ref.filePath)
.filter((t) => SCALA_TYPE_KINDS.has(t.kind) && t.startLine <= ref.line && t.endLine >= ref.line);
@@ -2564,18 +2634,41 @@ function isScalaExtensionMethod(n: Node, context: ResolutionContext): boolean {
* `val` / `var` / `def`, a lambda or `for` parameter — or null.
*/
function scalaLocalBinder(name: string, ref: UnresolvedRef, context: ResolutionContext): Node | null {
const fn = context
.getNodesInFile(ref.filePath)
.filter((f) => (f.kind === 'method' || f.kind === 'function') && f.startLine <= ref.line && f.endLine >= ref.line)
const nodes = context.getNodesInFile(ref.filePath);
const innermost = (kinds: ReadonlySet<string>) => nodes
.filter((f) => kinds.has(f.kind) && f.startLine <= ref.line && f.endLine >= ref.line)
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
if (!fn) return null;
const fn = innermost(SCALA_FUNCTION_KINDS);
// A test suite's body runs in its class: `test("…") { forAll { (e: E, f: A => B) => f(1) } }`.
const scope = fn ?? innermost(SCALA_TYPE_KINDS);
if (!scope) return null;
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split('\n') ?? [];
const text = lines.slice(fn.startLine - 1, ref.line).join('\n');
const text = lines.slice(scope.startLine - 1, ref.line).join('\n');
const n = name.replace(/[$]/g, '\\$');
const binds = new RegExp(`(?:[(,\\[]\\s*(?:implicit\\s+|using\\s+)?${n}\\s*:)|(?:\\b(?:val|var|def|lazy\\s+val)\\s+${n}\\b)|(?:(?<![\\w$.])${n}\\s*(?:=>|<-))|(?:\\(\\s*${n}\\s*(?:,[^)]*)?\\)\\s*=>)`).test(text);
return binds ? fn : null;
const binder = new RegExp(`(?:[(,\\[]\\s*(?:implicit\\s+|using\\s+)?${n}\\s*:)|(?:\\b(?:val|var|def|lazy\\s+val)\\s+${n}\\b)|(?:(?<![\\w$.])${n}\\s*(?:=>|<-))|(?:\\(\\s*${n}\\s*(?:,[^)]*)?\\)\\s*=>)`, 'g');
if (fn) return binder.test(text) ? fn : null;
// In a class body, a binder counts only inside a block still open at the site —
// not a sibling test's `val f`, not the class's own members at its body's depth.
const blockAt: number[] = new Array(text.length);
const open: number[] = [];
let next = 0;
let depth = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '{') { open.push(++next); depth++; }
else if (ch === '}') { open.pop(); depth--; }
blockAt[i] = open.length > 1 ? open[open.length - 1]! : 0;
}
const live = new Set(open.slice(1));
for (const m of text.matchAll(binder)) {
const block = blockAt[m.index!] ?? 0;
if (block !== 0 && live.has(block)) return scope;
}
return null;
}
const SCALA_FUNCTION_KINDS: ReadonlySet<string> = new Set(['method', 'function']);
/** The simple names a Scala type's declarations extend or mix in. */
function scalaSupertypesOf(typeName: string, context: ResolutionContext): string[] {
let memo = SCALA_SUPERS.get(context);
@@ -2607,15 +2700,62 @@ function scalaSupertypesOf(typeName: string, context: ResolutionContext): string
return names.slice(1);
}
const SCALA_IMPORTED_SUPERS = new WeakMap<ResolutionContext, Map<string, Set<string>>>();
const SCALA_PACKAGE_OBJECTS = new WeakMap<ResolutionContext, Map<string, string[]>>();
/** `package object client4 extends SttpApi with …` — the graph holds no node for one. */
function scalaPackageObjects(context: ResolutionContext): Map<string, string[]> {
const hit = SCALA_PACKAGE_OBJECTS.get(context);
if (hit) return hit;
const out = new Map<string, string[]>();
for (const file of context.getAllFiles()) {
if (!file.endsWith('.scala') || (context.fileContains && !context.fileContains(file, 'package object'))) continue;
const source = context.readFile(file) ?? '';
for (const m of source.matchAll(/\bpackage\s+object\s+([\w$]+)\s+extends\s+([^{\n]+)/g)) {
const names = m[2]!.replace(/\[[^\]]*\]/g, '').split(/\bwith\b/).map((t) => t.trim().split('.').pop()!.replace(/\(.*$/, '').trim()).filter(Boolean);
out.set(m[1]!, [...(out.get(m[1]!) ?? []), ...names]);
}
}
SCALA_PACKAGE_OBJECTS.set(context, out);
return out;
}
/** Every supertype of the objects a Scala file imports wholesale (`import Obj._`). */
function scalaImportedSupertypes(file: string, imports: { owners: Set<string> }, context: ResolutionContext): Set<string> {
let memo = SCALA_IMPORTED_SUPERS.get(context);
if (!memo) SCALA_IMPORTED_SUPERS.set(context, (memo = new Map()));
const hit = memo.get(file);
if (hit) return hit;
const seen = new Set<string>();
// Code in `package sttp.client4` (or under it) sees `package object client4`'s members unimported.
const packages = [...(context.readFile(file) ?? '').matchAll(/^\s*package\s+([\w.]+)\s*$/gm)].flatMap((m) => m[1]!.split('.'));
const queue = [...imports.owners, ...packages];
const packageObjects = scalaPackageObjects(context);
while (queue.length > 0 && seen.size < 120) {
const typeName = queue.shift()!;
for (const sup of [...scalaSupertypesOf(typeName, context), ...(packageObjects.get(typeName) ?? [])]) {
if (!seen.has(sup)) { seen.add(sup); queue.push(sup); }
}
}
memo.set(file, seen);
return seen;
}
/** A Scala file's `import a.b.Obj._` / `import a.b.Obj.*` owners and `import a.b.Obj.{x, y}` / `Obj.x` members. */
function scalaImportsOf(file: string, context: ResolutionContext): { owners: Set<string>; members: Set<string> } {
function scalaImportsOf(file: string, context: ResolutionContext): { owners: Set<string>; members: Set<string>; values: Set<string> } {
let memo = SCALA_IMPORTS.get(context);
if (!memo) SCALA_IMPORTS.set(context, (memo = new Map()));
const hit = memo.get(file);
if (hit) return hit;
const found = { owners: new Set<string>(), members: new Set<string>() };
for (const m of (context.readFile(file) ?? '').matchAll(/^\s*import\s+([\w.]+?)\.(?:(_|\*)|\{([^}]*)\}|([\w$]+))\s*$/gm)) {
const found = { owners: new Set<string>(), members: new Set<string>(), values: new Set<string>() };
const source = context.readFile(file) ?? '';
for (const m of source.matchAll(/^\s*import\s+([\w.]+?)\.(?:(_|\*)|\{([^}]*)\}|([\w$]+))\s*$/gm)) {
const owner = m[1]!.split('.').pop()!;
// `import builder._` — rooted at a value the file declares, whose type it doesn't say.
const root = m[1]!.split('.')[0]!;
if (/^[a-z]/.test(root) && (root === m[1] || new RegExp(`\\b(?:val|var|lazy\\s+val)\\s+${root}\\b|[(,]\\s*${root}\\s*:`).test(source))) {
found.values.add(owner);
}
if (m[2]) found.owners.add(owner);
else for (const member of (m[3] ?? m[4] ?? '').split(',')) {
const id = member.trim().split(/\s*=>\s*/)[0]!;
@@ -4750,6 +4890,9 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
RUST_TRAIT_IMPL_MEMO.delete(context);
RUST_USES.delete(context);
LEXICAL_SCOPE_MEMO.delete(context);
SCALA_IMPORTED_SUPERS.delete(context);
SCALA_PACKAGE_OBJECTS.delete(context);
LOCAL_DECL_MEMO.delete(context);
JAVA_SUPERS.delete(context);
PHP_SUPERS.delete(context);
DART_SUPERS.delete(context);