mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 05:41:18 +08:00
fix(scala): a bare call reached by name alone must be in reach (#2160)
Read at the site:
- a later link of a chain (the extractor keeps one receiver level; the line
shows the dot) reaches a member whose owner the receiver chain is named
after (call / type arguments dropped: `proc("x").call()` → proc,
`checker.value.onWrite` → checker), or a same-file member; never a package
object's function unless it is a Scala 3 `extension` method;
- a name the enclosing def binds (parameter, val/var/def, lambda / for param)
is only a def inside that function;
- otherwise a member of the types around it, an anonymous subclass's bases
(`new OptionParser[C]("x") { head(…) }`), their extends/with supertypes
(source-read heads), same file, or an imported object (`import Foo._`,
`import Foo.{bar}`); a value import (`import builder._`) leaves the file
unjudged.
A/B (edges removed / added): cats -1693/+678, sttp -887/+271, munit
-265/+175, scopt -245/+78, os-lib -115/+43.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- In Scala, a call reached by its name alone now stays in reach. A name that the enclosing function binds as a parameter or local (`f(true)` for `f: A => B`) is that local. A call later in a chain, like `fa.iterator.map(…)`, reaches only a member of what its receiver is named after. A plain call reaches only the enclosing class and its supertypes (an anonymous subclass's base included), a companion, or an imported object. On cats, parameter calls like `f(a)` used to land on a case class's field `f` over 400 times, and chained `.map(…)` on one ops class's `map`.
|
||||
- A link made by a framework or bridge resolver now records which one made it (`metadata.framework`, such as `swift-objc-bridge` or `react-native-bridge`), as the README describes. Before, only synthesized event and view channels were named.
|
||||
- ASP.NET apps built with FastEndpoints now have their routes: each endpoint class's `Get(…)` / `Post(…)` in `Configure()` becomes a route linked to its own `HandleAsync` / `ExecuteAsync`, including paths kept in a request class's `Route` constant. ardalis/CleanArchitecture went from no routes to 24. Minimal API routes written without a leading slash (`app.MapGet("api/todos", …)`) are now named `/api/todos`.
|
||||
- Vapor routes whose handler is a trailing closure (`app.get("hello") { req in … }`), WebSocket routes (`app.webSocket("chat") { … }`) and `routes.on(.POST, "x", use: handler)` registrations are now found. Only routes with a `use:` handler were read before: the Swift Package Index server went from 13 routes to 34, and a WebSocket-only chat server from none to one.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* A bare Scala name reached by name alone must be in reach:
|
||||
* - a parameter or local binds it: `f(true)` with `f: A => B` is not a case
|
||||
* class's field `f` (cats: 438 times);
|
||||
* - a later link of a chain (`fa.iterator.map(f)`) reaches a member of what
|
||||
* the receiver is named after (`basicRequest.send` → Request), never a
|
||||
* package object's function;
|
||||
* - otherwise a member of the types around it — an anonymous subclass's base
|
||||
* included (`new OptionParser[C]("x") { head("x") }`) — of their supertypes,
|
||||
* or of an imported object.
|
||||
*/
|
||||
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-scope-'));
|
||||
const files: Record<string, string> = {
|
||||
'src/main/scala/app/FreeT.scala': `package app
|
||||
|
||||
final case class FlatMapped[A](f: A => A)
|
||||
`,
|
||||
'src/main/scala/app/LazyOps.scala': `package app
|
||||
|
||||
class LazyOps[A](xs: List[A]) {
|
||||
def map[B](g: A => B): List[B] = xs.map(g)
|
||||
}
|
||||
`,
|
||||
'src/main/scala/app/OptionParser.scala': `package app
|
||||
|
||||
abstract class OptionParser[C](name: String) {
|
||||
def head(xs: String*): Unit = ()
|
||||
}
|
||||
`,
|
||||
'src/main/scala/app/Base.scala': `package app
|
||||
|
||||
trait Base {
|
||||
def helper(): Int = 1
|
||||
}
|
||||
`,
|
||||
'src/main/scala/app/Request.scala': `package app
|
||||
|
||||
class Request {
|
||||
def send(backend: String): String = backend
|
||||
}
|
||||
`,
|
||||
'src/main/scala/app/Use.scala': `package app
|
||||
|
||||
object Use extends Base {
|
||||
def tabulate[A](f: Boolean => A): (A, A) = (f(true), f(false))
|
||||
def twice(fa: List[Int]): List[Int] = fa.iterator.map(_ + 1).toList
|
||||
def run(basicRequest: Request): String = basicRequest.send("b")
|
||||
def local(): Int = helper()
|
||||
val parser = new OptionParser[Int]("scopt") {
|
||||
head("scopt", "3.x")
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
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 });
|
||||
});
|
||||
|
||||
describe('bare Scala calls', () => {
|
||||
it('stay with locals, the receiver’s type, the class hierarchy and anonymous bases', () => {
|
||||
const ids = cg.getNodesInFile('src/main/scala/app/Use.scala').map((n) => n.id);
|
||||
const targets = cg
|
||||
.getOutgoingEdgesFrom(ids)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => cg.getNode(e.target)!.qualifiedName);
|
||||
expect(targets.some((t) => t.endsWith('FlatMapped::f'))).toBe(false);
|
||||
expect(targets.some((t) => t.endsWith('LazyOps::map'))).toBe(false);
|
||||
expect(targets.some((t) => t.endsWith('Request::send'))).toBe(true);
|
||||
expect(targets.some((t) => t.endsWith('Base::helper'))).toBe(true);
|
||||
expect(targets.some((t) => t.endsWith('OptionParser::head'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1432,6 +1432,191 @@ function dartHeadOf(decl: Node, context: ResolutionContext): { supers: string[];
|
||||
};
|
||||
}
|
||||
|
||||
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> }>>();
|
||||
|
||||
/**
|
||||
* Whether a bare Scala name can mean the member `n`, read at its site. Three
|
||||
* shapes:
|
||||
* - a later link of a chain (`fa.iterator.map(f)` — the extractor keeps one
|
||||
* receiver level, the line still shows the dot): the receiver must be named
|
||||
* after `n`'s owner (`Foo.bar` on object Foo). cats's chained `.map(…)` went
|
||||
* to a lazy-list ops class's `map` 186 times;
|
||||
* - a name the enclosing definition binds — a parameter `f: A => B`, a
|
||||
* `val` — is that local: `f(true)` is not a case class's field `f` (396);
|
||||
* - otherwise a member of the types around it or their `extends` / `with`
|
||||
* supertypes, of a companion, of the same file, or of an object the file
|
||||
* imports (`import Foo._`, `import Foo.{bar}`).
|
||||
*/
|
||||
function isScalaMemberInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
|
||||
if (line === undefined) return true;
|
||||
const name = ref.referenceName;
|
||||
const at = new RegExp(`(?<![\\w$])${name.replace(/[$]/g, '\\$')}\\b`).exec(line);
|
||||
if (!at) return true;
|
||||
const before = line.slice(0, at.index);
|
||||
if (/\.\s*$/.test(before)) {
|
||||
// A later link of a chain: a member of what the receiver is named after,
|
||||
// never a package object's function — unless it is an `extension` method.
|
||||
if (n.filePath === ref.filePath) return true;
|
||||
if (!SCALA_MEMBER_KINDS.has(n.kind)) return n.kind !== 'function' || isScalaExtensionMethod(n, context);
|
||||
const receiver = scalaReceiverName(before);
|
||||
return receiver !== '' && sharesReceiverWord(receiver, n);
|
||||
}
|
||||
const local = scalaLocalBinder(name, ref, context);
|
||||
if (local) return n.filePath === ref.filePath && n.startLine >= local.startLine && n.endLine <= local.endLine;
|
||||
if (!SCALA_MEMBER_KINDS.has(n.kind)) return true;
|
||||
const cut = n.qualifiedName.lastIndexOf('::');
|
||||
if (cut < 0 || n.filePath === ref.filePath) return true;
|
||||
const owner = n.qualifiedName.slice(0, cut).split('::').pop()!;
|
||||
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;
|
||||
const around = context
|
||||
.getNodesInFile(ref.filePath)
|
||||
.filter((t) => SCALA_TYPE_KINDS.has(t.kind) && t.startLine <= ref.line && t.endLine >= ref.line);
|
||||
if (around.length === 0) return true;
|
||||
const seen = new Set<string>();
|
||||
const queue = [...around.map((t) => t.name), ...scalaAnonymousBases(ref, context)];
|
||||
while (queue.length > 0 && seen.size < 60) {
|
||||
const typeName = queue.shift()!;
|
||||
if (seen.has(typeName)) continue;
|
||||
seen.add(typeName);
|
||||
if (typeName === owner) return true;
|
||||
queue.push(...scalaSupertypesOf(typeName, context));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The types an anonymous class around a Scala site instantiates — `new
|
||||
* scopt.OptionParser[Config]("scopt") { head("scopt") }` puts OptionParser's
|
||||
* members in scope. Read backwards over the open braces above the site.
|
||||
*/
|
||||
function scalaAnonymousBases(ref: UnresolvedRef, context: ResolutionContext): string[] {
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split('\n') ?? [];
|
||||
const bases: string[] = [];
|
||||
let depth = 0;
|
||||
for (let i = ref.line - 1; i >= 0 && i >= ref.line - 400; i--) {
|
||||
const text = lines[i]!;
|
||||
for (let c = text.length - 1; c >= 0; c--) {
|
||||
if (text[c] === '}') depth++;
|
||||
else if (text[c] === '{') {
|
||||
if (depth > 0) { depth--; continue; }
|
||||
const head = /\bnew\s+([\w.]+(?:\s*\[[^\]]*\])?(?:\s*\([^)]*\))?(?:\s+with\s+[\w.]+(?:\s*\[[^\]]*\])?)*)\s*$/.exec(text.slice(0, c));
|
||||
if (head) for (const m of head[1]!.replace(/\[[^\]]*\]|\([^)]*\)/g, '').split(/\s+with\s+/)) bases.push(m.trim().split('.').pop()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return bases;
|
||||
}
|
||||
|
||||
/**
|
||||
* The receiver a Scala `….name` is written on, as its dotted identifiers with
|
||||
* call and type arguments dropped: `proc("bash").call()` → `proc`,
|
||||
* `Alternative[List].unite` → `Alternative`, `checker.value.onWrite` →
|
||||
* `checker.value`. Read backwards to the expression's start.
|
||||
*/
|
||||
function scalaReceiverName(before: string): string {
|
||||
const text = before.replace(/\s*\.\s*$/, '');
|
||||
let out = '';
|
||||
let i = text.length - 1;
|
||||
while (i >= 0) {
|
||||
const ch = text[i]!;
|
||||
if (ch === ')' || ch === ']') {
|
||||
const open = ch === ')' ? '(' : '[';
|
||||
let depth = 0;
|
||||
for (; i >= 0; i--) {
|
||||
if (text[i] === ch) depth++;
|
||||
else if (text[i] === open && --depth === 0) break;
|
||||
}
|
||||
if (i < 0) return '';
|
||||
i--;
|
||||
} else if (/[\w$.]/.test(ch)) {
|
||||
out = ch + out;
|
||||
i--;
|
||||
} else break;
|
||||
}
|
||||
return out.replace(/^\.+|\.+$/g, '');
|
||||
}
|
||||
|
||||
/** Whether a Scala function is declared in an `extension (…)` block. */
|
||||
function isScalaExtensionMethod(n: Node, context: ResolutionContext): boolean {
|
||||
const lines = context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split('\n') ?? [];
|
||||
return lines.slice(Math.max(0, n.startLine - 4), n.startLine).some((l) => /^\s*extension\b/.test(l));
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition around a Scala site that binds `name` itself — a parameter, a
|
||||
* `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)
|
||||
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
|
||||
if (!fn) 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 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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!memo) SCALA_SUPERS.set(context, (memo = new Map()));
|
||||
const hit = memo.get(typeName);
|
||||
if (hit) return hit;
|
||||
const names: string[] = [typeName];
|
||||
for (const decl of context.getNodesByName(typeName)) {
|
||||
if (decl.language !== 'scala' || !SCALA_TYPE_KINDS.has(decl.kind)) continue;
|
||||
const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? [];
|
||||
const text = lines.slice(decl.startLine - 1, decl.startLine + 12).join(' ');
|
||||
let depth = 0;
|
||||
let flat = '';
|
||||
for (const ch of text) {
|
||||
if (ch === '[' || ch === '(') depth++;
|
||||
else if (ch === ']' || ch === ')') depth = Math.max(0, depth - 1);
|
||||
else if (depth === 0) {
|
||||
if (ch === '{' || ch === '=') break;
|
||||
flat += ch;
|
||||
}
|
||||
}
|
||||
const clause = /\bextends\b(.*)$/.exec(flat)?.[1] ?? '';
|
||||
for (const m of clause.matchAll(/([A-Za-z_][\w.]*)/g)) {
|
||||
const simple = m[1]!.split('.').pop()!;
|
||||
if (simple !== 'with' && simple !== 'derives' && simple !== typeName) names.push(simple);
|
||||
}
|
||||
}
|
||||
memo.set(typeName, names.slice(1));
|
||||
return names.slice(1);
|
||||
}
|
||||
|
||||
/** 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> } {
|
||||
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 owner = m[1]!.split('.').pop()!;
|
||||
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]!;
|
||||
if (id === '_' || id === '*') found.owners.add(owner);
|
||||
else if (id) found.members.add(`${owner}.${id}`);
|
||||
}
|
||||
}
|
||||
memo.set(file, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
const CSHARP_TYPE_KINDS: ReadonlySet<string> = new Set(['class', 'interface', 'enum', 'struct', 'record']);
|
||||
const CSHARP_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'property', 'field', 'enum_member', 'constant', 'event']);
|
||||
const CSHARP_SUPERS = new WeakMap<ResolutionContext, Map<string, string[]>>();
|
||||
@@ -2391,8 +2576,10 @@ export function matchByExactName(
|
||||
const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName)
|
||||
? objcCallShape(ref, context) : null;
|
||||
const csharpBare = ref.language === 'csharp' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||||
const scalaBare = ref.language === 'scala' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName);
|
||||
const phpSelf = phpSelfReceiver(ref, context);
|
||||
const filtered = sameName.filter((n) =>
|
||||
!(scalaBare && !isScalaMemberInScope(n, ref, context)) &&
|
||||
!(csharpBare && !isCsharpMemberInScope(n, ref, context)) &&
|
||||
!(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) &&
|
||||
!(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) &&
|
||||
@@ -3520,6 +3707,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
OBJC_SUPERS.delete(context);
|
||||
CSHARP_SUPERS.delete(context);
|
||||
CSHARP_STATIC_USINGS.delete(context);
|
||||
SCALA_SUPERS.delete(context);
|
||||
SCALA_IMPORTS.delete(context);
|
||||
LUA_LOCALS.delete(context);
|
||||
PHP_FILE_SCOPES.delete(context);
|
||||
JAVA_STATIC_IMPORTS.delete(context);
|
||||
@@ -5552,6 +5741,7 @@ export function matchFuzzy(
|
||||
const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName)
|
||||
? objcCallShape(ref, context) : null;
|
||||
const csharpBare = ref.language === 'csharp' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||||
const scalaBare = ref.language === 'scala' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName);
|
||||
const phpSelf = phpSelfReceiver(ref, context);
|
||||
// Names are case-sensitive in every language but a handful: Rust's
|
||||
// `Bytes` is not the method `bytes`, Python's builtin `dir(…)` not a class
|
||||
@@ -5574,6 +5764,7 @@ export function matchFuzzy(
|
||||
!(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) &&
|
||||
!(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) &&
|
||||
!(csharpBare && !isCsharpMemberInScope(n, ref, context)) &&
|
||||
!(scalaBare && !isScalaMemberInScope(n, ref, context)) &&
|
||||
!(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))))
|
||||
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
|
||||
sameLanguageFamily(n.language, ref.language));
|
||||
|
||||
Reference in New Issue
Block a user