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 @@ -154,6 +154,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
- In Java, a field read inside an anonymous or inner class, or written as `Outer.this.field`, now resolves on the field's declared type, and a library type reached through a chain of fields (`owner.ctx.getResources()`) no longer links to an unrelated project method. A type imported from a library (`import java.util.Iterator`) or imported as a nested class (`import p.Http.Cookie`) is no longer mistaken for a same-named type elsewhere in the project, and a Java type now wins over a same-named Scala one. Re-index Java projects after upgrading. (#1942)

## [1.6.2] - 2026-10-03

Expand Down
278 changes: 278 additions & 0 deletions __tests__/java-receiver-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/**
* A Java call through a field or a local resolves on the receiver's declared
* type, and a receiver of a library type gets no edge.
*
* The field lookup only read the tightest enclosing class and only a plain
* name, so `this.mRepo.save()`, `Outer.this.mRepo.save()`, a field read from
* inside an anonymous class and a field chain `mOwner.repo.save()` were all
* left to name-only guessing. And when the declared type WAS known but was a
* library class (`Parcel`, `Handler`, `List`), the call fell through to the
* same guessing, which bound it to whichever project class declares a method
* of that name (`parcel.readInt()` → a project `VersionedParcel.readInt`).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';

describe('Java receivers resolve on the declared type', () => {
let dir: string;
let cg: CodeGraph;

beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-java-recv-'));
const src = path.join(dir, 'src', 'p');
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, 'Repo.java'), `package p;
public class Repo {
public void save() {}
public void close() {}
}
`);
fs.writeFileSync(path.join(src, 'Owner.java'), `package p;
public class Owner {
public Repo repo = new Repo();
public android.content.Context ctx;
}
`);
fs.writeFileSync(path.join(src, 'Hook.java'), `package p;
public abstract class Hook {
public abstract void fire();
}
`);
fs.writeFileSync(path.join(src, 'Box.java'), `package p;
public class Box<T> {
public void seal() {}
}
`);
// Same-named methods on unrelated project classes: what a name-only
// guess lands on. The decoy sits in the callers' own file below too.
fs.writeFileSync(path.join(src, 'VersionedParcel.java'), `package p;
public class VersionedParcel {
public int readInt() { return 0; }
public void writeInt(int v) {}
}
`);
// Project types nested in other packages that share a simple name with
// a JDK type the callers use.
fs.mkdirSync(path.join(dir, 'src', 'q'));
fs.writeFileSync(path.join(dir, 'src', 'q', 'Bytes.java'), `package q;
public class Bytes {
public static class Iterator { public boolean hasNext() { return false; } }
}
`);
fs.writeFileSync(path.join(dir, 'src', 'q', 'Table.java'), `package q;
public class Table {
public static class Entry { public Object getKey() { return null; } }
}
`);
// A Scala type of the same simple name as a Java one, reached from a
// Java file through a wildcard import.
fs.writeFileSync(path.join(src, 'Form.java'), `package p;
public class Form<T> {
public T get() { return null; }
}
`);
fs.mkdirSync(path.join(dir, 'src', 'a'));
fs.writeFileSync(path.join(dir, 'src', 'a', 'Form.scala'), `package api
class Form(value: Int) {
def get(): Int = value
}
`);
// A Scala trait in a file not named after it, imported explicitly.
fs.mkdirSync(path.join(dir, 'src', 'api', 'mvc'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src', 'api', 'mvc', 'BodyParsers.scala'), `package api.mvc
trait PlayBodyParsers {
def raw(): Int
}
`);
// Same simple names in another package, indexed first: what an unpinned
// lookup would land on.
fs.mkdirSync(path.join(dir, 'src', 'b'));
fs.writeFileSync(path.join(dir, 'src', 'b', 'Cookie.java'), `package b;
public class Cookie { public String value() { return null; } }
`);
fs.writeFileSync(path.join(dir, 'src', 'b', 'PlayBodyParsers.java'), `package b;
public class PlayBodyParsers { public int raw() { return 0; } }
`);
fs.writeFileSync(path.join(src, 'Http.java'), `package p;
public class Http {
public static class Cookie { public String value() { return null; } }
}
`);
fs.mkdirSync(path.join(dir, 'src', 'r'));
fs.writeFileSync(path.join(dir, 'src', 'r', 'Controller.java'), `package r;
import api.mvc.PlayBodyParsers;
import p.*;
import p.Http.Cookie;
public class Controller {
void submit(Form<String> form) { form.get(); }
void parse(PlayBodyParsers parsers) { parsers.raw(); }
void written(api.mvc.PlayBodyParsers other) { other.raw(); }
void writtenLocal() {
api.mvc.PlayBodyParsers local = null;
local.raw();
}
void cookie(Cookie crumb) { crumb.value(); }
}
`);
fs.writeFileSync(path.join(src, 'Screen.java'), `package p;

import android.os.Handler;
import android.os.Parcel;
import android.util.Log;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import q.Bytes;

class Decoy {
void save() {}
void post(Runnable r) {}
void add(Object o) {}
void put(Object k, Object v) {}
void d(String t, String m) {}
void getResources() {}
void seal() {}
}
// Decoys whose names share words with the receivers, so a receiver-name
// guess prefers them over the declared type.
class StoreCache { void save() {} void close() {} }
class SharedCache { void close() {} }
class OwnerRepoCache { void save() {} }
class BoxCache { void seal() {} }

public class Screen {
private Repo mStore;
private Handler mHandler;
private Owner mOwner;
private static final Repo sShared = new Repo();

void viaField() { mStore.save(); }
void viaThis() { this.mStore.save(); }
void viaConstant() { sShared.close(); }
void viaChain() { mOwner.repo.save(); }
void viaThisChain() { this.mOwner.repo.save(); }
void viaLocalChain(Owner owner) { owner.repo.save(); }
void libraryField() { mHandler.post(null); }
void libraryChain() { mOwner.ctx.getResources(); }
void libraryParam(Parcel parcel) { parcel.readInt(); parcel.writeInt(1); }
void libraryGenericLocal() {
List<String> items = null;
items.add("x");
Map<String, List<Repo>> byName = null;
byName.put("k", null);
}
void libraryForEach(List<Parcel> parcels) {
for (Parcel p : parcels) { p.readInt(); }
}
void projectGenericLocal() {
Box<Repo> crate = make();
crate.seal();
}
void iterate(List<Repo> list) {
Iterator<Repo> it = list.iterator();
it.hasNext();
}
void entries(Map<String, String> map) {
for (Map.Entry<String, String> e : map.entrySet()) { e.getKey(); }
}
void nested(Bytes.Iterator bytes) { bytes.hasNext(); }
static Box<Repo> make() { return null; }
void staticLibrary() { Log.d("t", "m"); }

Runnable anonymous() {
return new Runnable() {
public void run() { mStore.save(); }
};
}

class Inner {
private Box<Repo> mStore;
void viaOuter() { Screen.this.mStore.close(); }
}
}

class Holder<T extends Hook> {
T hook;
void go() { hook.fire(); }
}
`);
cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
});

afterAll(() => {
cg?.destroy();
fs.rmSync(dir, { recursive: true, force: true });
});

/** Qualified names of what `owner.method` calls (`owner` may name an anonymous class's supertype). */
function callees(owner: string, method: string): string[] {
const node = cg.searchNodes(method).map((r) => r.node)
.find((n) => n.qualifiedName.endsWith(`${owner}::${method}`) || (n.qualifiedName.includes(`${owner}$anon`) && n.qualifiedName.endsWith(`::${method}`)));
expect(node, `${owner}::${method}`).toBeDefined();
return cg.getCallees(node!.id)
.filter((c) => c.edge.kind === 'calls')
.map((c) => c.node.qualifiedName)
.sort();
}

it('a field, `this.field` and a static final field resolve on the declared type', () => {
expect(callees('Screen', 'viaField')).toEqual(['p::Repo::save']);
expect(callees('Screen', 'viaThis')).toEqual(['p::Repo::save']);
expect(callees('Screen', 'viaConstant')).toEqual(['p::Repo::close']);
});

it('an anonymous class reads its outer class field, and `Outer.this.field` names the class', () => {
expect(callees('Runnable', 'run')).toEqual(['p::Repo::save']);
expect(callees('Inner', 'viaOuter')).toEqual(['p::Repo::close']);
});

it('a chain of fields resolves through each declared type', () => {
expect(callees('Screen', 'viaChain')).toEqual(['p::Repo::save']);
expect(callees('Screen', 'viaThisChain')).toEqual(['p::Repo::save']);
expect(callees('Screen', 'viaLocalChain')).toEqual(['p::Repo::save']);
});

it('a field, a chain hop, a parameter or a local of a library type gets no edge', () => {
expect(callees('Screen', 'libraryField')).toEqual([]);
expect(callees('Screen', 'libraryChain')).toEqual([]);
expect(callees('Screen', 'libraryParam')).toEqual([]);
expect(callees('Screen', 'libraryGenericLocal')).toEqual([]);
expect(callees('Screen', 'libraryForEach')).toEqual([]);
});

it('a JDK type is not read as a same-named type nested in another package', () => {
// An explicit `import java.util.Iterator`, and a qualified `Map.Entry`.
expect(callees('Screen', 'iterate')).toEqual([]);
expect(callees('Screen', 'entries')).toEqual([]);
});

it('a nested project type written with its outer class resolves on it', () => {
expect(callees('Screen', 'nested')).toEqual(['q::Bytes::Iterator::hasNext']);
});

it('a generic local of a project type resolves on that type', () => {
expect(callees('Screen', 'projectGenericLocal')).toEqual(['p::Box::seal', 'p::Screen::make']);
});

it('a Java receiver type prefers the Java declaration over a same-named Scala one', () => {
expect(callees('Controller', 'submit')).toEqual(['p::Form::get']);
});

it('an explicitly imported Scala type is a project type wherever its file is named', () => {
expect(callees('Controller', 'parse')).toEqual(['PlayBodyParsers::raw']);
expect(callees('Controller', 'written')).toEqual(['PlayBodyParsers::raw']);
expect(callees('Controller', 'writtenLocal')).toEqual(['PlayBodyParsers::raw']);
});

it('an imported nested type is the one the import names', () => {
expect(callees('Controller', 'cookie')).toEqual(['p::Http::Cookie::value']);
});

it('a type-parameter receiver is not treated as a library type', () => {
expect(callees('Holder', 'go')).toEqual(['p::Hook::fire']);
});
});
Loading