Skip to content
Closed
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

- Play projects no longer send `Class.method` calls to another class's method in the same file. In a Play app, a route handler or a call like `MediaType.parse(...)` went to the first method with that name anywhere in the class's file — often a nested or sibling class's — and now goes only to a method of the class it names.
- 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 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)
- In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600)
Expand Down
85 changes: 85 additions & 0 deletions __tests__/frameworks-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1444,3 +1444,88 @@ describe('Terraform follow-ups: remote-state bridge, provider alias, moved block
}
});
});

describe('Play end-to-end — Class.method resolves to a member of that class', () => {
let tmpDir: string;
afterEach(() => {
if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
});

const targetOf = (cg: CodeGraph, fromName: string, calleeName: string): string | undefined => {
const from = cg.getNodesByKind('method').find((n) => n.name === fromName);
expect(from).toBeDefined();
const edge = cg
.getOutgoingEdges(from!.id)
.filter((e) => e.kind === 'calls' || e.kind === 'references')
.map((e) => cg.getNode(e.target))
.find((t) => t?.name === calleeName);
return edge?.qualifiedName;
};

it('picks the named nested class member, not the first same-named method in its file', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-play-member-'));
fs.mkdirSync(path.join(tmpDir, 'conf'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'app/controllers'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'app/util'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'conf/routes'), 'GET / controllers.Application.index\n');
fs.writeFileSync(path.join(tmpDir, 'app/controllers/Application.java'), [
'package controllers;',
'public class Application {',
' public static class Helper {',
' public static String index() { return "helper"; }',
' }',
' public static String index() { return "app"; }',
'}',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, 'app/util/Http.java'), [
'package util;',
'public class Http {',
' public static class Cookie {',
' public static String parse(String s) { return s; }',
' }',
' public static class MediaType {',
' public static String parse(String s) { return s; }',
' }',
'}',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, 'app/util/Scheme.scala'), [
'package util',
'object Scheme {',
' def parse(s: String): String = s',
'}',
'object AuthorityPort {',
' def parse(s: String): String = s',
'}',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, 'app/util/Caller.java'), [
'package util;',
'public class Caller {',
' public String javaCall() { return MediaType.parse("x"); }',
' public String javaCookie() { return Cookie.parse("x"); }',
'}',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, 'app/util/ScalaCaller.scala'), [
'package util',
'class ScalaCaller {',
' def scalaCall(): String = AuthorityPort.parse("x")',
' def scalaScheme(): String = Scheme.parse("x")',
'}',
].join('\n'));

const cg = await CodeGraph.init(tmpDir, { index: true });
try {
expect(targetOf(cg, 'javaCall', 'parse')).toBe('util::Http::MediaType::parse');
expect(targetOf(cg, 'javaCookie', 'parse')).toBe('util::Http::Cookie::parse');
expect(targetOf(cg, 'scalaCall', 'parse')).toBe('AuthorityPort::parse');
expect(targetOf(cg, 'scalaScheme', 'parse')).toBe('Scheme::parse');

const route = cg.getNodesByKind('route').find((n) => n.name === 'GET /');
expect(route).toBeDefined();
const handlers = cg.getOutgoingEdges(route!.id).map((e) => cg.getNode(e.target)?.qualifiedName);
expect(handlers).toContain('controllers::Application::index');
expect(handlers).not.toContain('controllers::Application::Helper::index');
} finally {
cg.close();
}
});
});
8 changes: 7 additions & 1 deletion src/resolution/frameworks/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ export const playResolver: FrameworkResolver = {
if (!m) return null;
const [, className, methodName] = m;
const classNodes = context.getNodesByName(className!).filter((n) => CLASS_KINDS.has(n.kind));
// Only a member of THAT class counts: a file can hold many classes (Java
// nested classes, a Scala class + its companion + siblings), and the first
// same-named method in the file is usually another class's. A Scala
// companion shares its class's qualifiedName, so it is covered too. An
// inherited member isn't looked up here — decline to the generic resolver.
for (const cls of classNodes) {
const memberQn = `${cls.qualifiedName}::${methodName}`;
const method = context
.getNodesInFile(cls.filePath)
.find((n) => METHOD_KINDS.has(n.kind) && n.name === methodName);
.find((n) => METHOD_KINDS.has(n.kind) && n.name === methodName && n.qualifiedName === memberQn);
if (method) {
return { original: ref, targetNodeId: method.id, confidence: 0.9, resolvedBy: 'framework' };
}
Expand Down