Skip to content
Merged
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 @@ -47,6 +47,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Dart, a call further down a chain, like Riverpod's `FutureProvider.autoDispose.family(…)` or `events.map(mapper).transform(…)` on a stream, now links to the method of the type the chain before it produces, worked out from what each step is declared to return, and to nothing when that type can't be worked out. Before, it linked to whichever project method shared its name, so `X.autoDispose.family(…)` reached the same provider builder whatever `X` was, and a stream's `transform` reached an unrelated `transform` method. `super.` calls now reach the superclass's method, `context.read<CounterBloc>().add(…)` reaches the bloc's own `add`, and the members of an extension marked `@internal` are found too. Re-index Dart projects after upgrading. (#750)
- In VB.NET, reading or setting a field or property through a variable, parameter, field, `Me` or `MyBase`, like `x.Normal = 3`, `Me._h.Title`, `.Value` inside a `With` block or `.Switch` in `New BoolParam With {…}`, now counts as a use of that member, and so do calls inside a `With` block, functions called without parentheses and reads through a class whose name starts in lower case. Before, `codegraph callers` on an instance field or property missed most of its uses. Only a member that the value's declared type has or inherits is linked, nothing when that type comes from outside your project, and a value named after its class, like `theme As Theme`, counts as a use of the class only when it reads a `Shared` member. Re-index VB.NET projects after upgrading.
- In JavaScript and TypeScript, an import whose name contains a `$`, like `import items$ from './store'`, `import * as api$ from './api'` or `import { load as load$ } from './data'`, now links the calls made through it, such as `items$.getState().inc()`, to the code it imports, and so does a `$` name in a `require(…)` destructuring or an `export { … } from` barrel. Before, such an import was skipped or bound under a cut-off name (`load` for `load$`), so those calls reached nothing and the functions they call could look unused. A local variable or parameter that reuses an imported `$` name, like `const items$ = …` inside a function, is no longer mistaken for the import. Re-index JavaScript and TypeScript projects after upgrading.
- In Dart, calling a parameter or a local variable, like `create(ref, arg)` inside `overrideWith(… create)`, a closure's parameter, a loop variable or a test's mock `listener(0, 1)`, no longer links the call to an unrelated function or constant of the same name in another file, or to a method the class inherits: a Bloc event handler's `emit(…)` is the handler's own `emit` parameter, not `Bloc.emit`. So `codegraph callers` and impact no longer list code that never calls them, while calls the parameter or variable doesn't cover, and calls to a local function, link as before. Re-index Dart projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
248 changes: 248 additions & 0 deletions __tests__/dart-local-call-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* A bare Dart call to a name a parameter or local around it binds calls that
* parameter or local — never a same-named symbol elsewhere.
*
* riverpod's `overrideWith(CreatedT Function(Ref ref, ArgT arg) create)` calls
* its parameter (`create(ref, arg)`), as do its tests' provider factories and
* an analyzer cache's `upsert(key, create)`. All of them were linked to a docs
* example's top-level `final create = Mutation<…>()` that none of them import.
* Tests' mocks (`final listener = Listener<int>(); listener(0, 1)`) went to
* other test files' `listener` functions, and bloc's event handlers' `emit`
* parameter (`on<E>((event, emit) => emit(…))`) to `Bloc.emit`, which a
* handler never calls. None of those bindings is a node, so such a call links
* nothing. A call the binding does not cover still links.
*
* Runs against the native kernel (when built) and the wasm extractor, which
* must agree.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import type { Node } from '../src/types';

const FILES: Record<string, string> = {
'pubspec.yaml': 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n',
// The docs example nobody imports: the only top-level `create`.
'doc/mutation.dart': `class Mutation<T> {
Object call(String key) => key;
}

final create = Mutation<Object>();
final createTodo = create('create_todo');
`,
'lib/family.dart': `class Ref {}

class Override {
Override(Object Function(Ref ref) build);
}

mixin FunctionalFamilyOverride<CreatedT, ArgT> {
ArgT get argument => throw 0;

Override overrideWith(CreatedT Function(Ref ref, ArgT arg) create) {
return Override((ref) => create(ref, argument) as Object);
}
}

class Cache<T> {
final _values = <Object, T>{};

T upsert(Object key, T Function() create) {
final existing = _values[key];
if (existing != null) return existing;
final created = create();
_values[key] = created;
return created;
}
}

class Holder {
void create() {}

void run(void Function() create) {
this.create();
create();
}
}

enum Kind { set, list }

R pick<R>({required R Function() set, required R Function() list}) => set();

class Box {}

Box? find() => null;

void check() {
final Box? box = find();
print(box);
Box();
}
`,
'test/matrix.dart': `typedef Factory = Object Function(Object Function(Object ref, Object? arg) create, {String? name});

final factories = <Factory>[
(create, {name}) => create(Object(), name),
];

final generic = <T>(T Function() create) => create();
`,
// Another test's function of the same name as the mock below.
'test/other_test.dart': `void listener() {}

void fn() {}
`,
'test/listener_test.dart': `class Listener<T> {
void call(T? previous, T next) {}
}

void main() {
final listener = Listener<int>();
listener(0, 1);

void Function() build = () {};
build();

for (final fn in <void Function()>[]) {
fn();
}

final (create, _) = (() => 0, 1);
create();
}
`,
'lib/bloc.dart': `abstract class Emitter<S> {
void call(S state);
}

class Bloc<S> {
void emit(S state) {}

void on<E>(void Function(E event, Emitter<S> emit) handler) {}
}

class CounterBloc extends Bloc<int> {
CounterBloc() {
on<String>((event, emit) => emit(1));
}

void onReset(String event, Emitter<int> emit) {
emit(0);
}

void bump() => emit(2);
}
`,
'lib/util.dart': `void notify(int value) {}
`,
// Calls a closure parameter or a loop variable does not cover, and a local
// function nearer than the parameter it shadows.
'lib/wire.dart': `import 'util.dart';

void wire(List<int> values) {
values.forEach((notify) => print(notify));
notify(1);
for (final notify in values) {
print(notify);
}
notify(2);
}

int outer(int Function() make) {
int helper() {
int make() => 2;
return make();
}

return helper() + make();
}
`,
};

describe('Dart calls to a parameter or local', () => {
let root = '';
let cg: CodeGraph | undefined;
let kernel: string | undefined;

beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-local-calls-'));
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);
}
kernel = process.env.CODEGRAPH_KERNEL;
});

afterEach(() => {
cg?.close();
cg = undefined;
fs.rmSync(root, { recursive: true, force: true });
if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL;
else process.env.CODEGRAPH_KERNEL = kernel;
});

it.each(['default', 'wasm'])('link nothing, and leave the calls they do not cover alone (%s)', async (backend) => {
if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
else delete process.env.CODEGRAPH_KERNEL;
cg = await CodeGraph.init(root, { index: true });
const graph = cg;
const node = (file: string, qualifiedName: string): Node => {
const found = graph.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName);
expect(found, `${file} ${qualifiedName}`).toBeDefined();
return found!;
};
/** `qualifiedName:line` of each call (or construction) out of a node. */
const calls = (from: Node): string[] =>
graph
.getOutgoingEdgesFrom([from.id])
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => `${graph.getNode(e.target)!.qualifiedName}:${e.line}`)
.sort();
/** The line of `file` that holds `text`. */
const lineOf = (file: string, text: string): number => FILES[file]!.split('\n').findIndex((l) => l.includes(text)) + 1;

// Only the docs example's own file calls into it (a call through its
// `create` constant constructs the `Mutation` the constant holds).
const docs = graph.getNodesInFile('doc/mutation.dart').map((n) => n.id);
expect([...new Set(graph.getIncomingEdgesTo(docs, ['calls', 'instantiates']).map((e) => graph.getNode(e.source)!.filePath))])
.toEqual(['doc/mutation.dart']);

// A parameter: a method's, a function-typed one inside a closure, closures' in initializers.
expect(calls(node('lib/family.dart', 'FunctionalFamilyOverride::overrideWith')))
.toEqual([`Override:${lineOf('lib/family.dart', 'create(ref, argument)')}`]);
expect(calls(node('lib/family.dart', 'Cache::upsert'))).toEqual([]);
// (a parameter may be named `set`, which is no enum's member here)
expect(calls(node('lib/family.dart', 'pick'))).toEqual([]);
expect(calls(node('test/matrix.dart', 'factories'))).toEqual([]);
expect(calls(node('test/matrix.dart', 'generic'))).toEqual([]);

// A parameter shadows a method of the class, which `this.` still reaches.
expect(calls(node('lib/family.dart', 'Holder::run')))
.toEqual([`Holder::create:${lineOf('lib/family.dart', 'this.create();')}`]);

// A local: a mock, a function-typed variable, a loop variable, a destructured one.
expect(calls(node('test/listener_test.dart', 'main')))
.toEqual([`Listener:${lineOf('test/listener_test.dart', 'Listener<int>()')}`]);
const otherTest = graph.getNodesInFile('test/other_test.dart').map((n) => n.id);
expect(graph.getIncomingEdgesTo(otherTest, ['calls'])).toEqual([]);

// A handler's `Emitter` parameter is not `Bloc.emit`; a bare call of the inherited method is.
expect(calls(node('lib/bloc.dart', 'CounterBloc'))).toEqual([`Bloc::on:${lineOf('lib/bloc.dart', 'on<String>')}`]);
expect(calls(node('lib/bloc.dart', 'CounterBloc::onReset'))).toEqual([]);
expect(calls(node('lib/bloc.dart', 'CounterBloc::bump'))).toEqual([`Bloc::emit:${lineOf('lib/bloc.dart', 'emit(2)')}`]);

// A sibling closure's parameter and a finished loop's variable cover nothing after them.
expect(calls(node('lib/wire.dart', 'wire')))
.toEqual([`notify:${lineOf('lib/wire.dart', 'notify(1)')}`, `notify:${lineOf('lib/wire.dart', 'notify(2)')}`]);

// A local function is nearer than the parameter it shadows, inside its block only.
expect(calls(node('lib/wire.dart', 'helper'))).toEqual([`make:${lineOf('lib/wire.dart', 'return make()')}`]);
expect(calls(node('lib/wire.dart', 'outer'))).not.toContain(`make:${lineOf('lib/wire.dart', 'helper() + make()')}`);

// A nullable typed local's type is not a binding: `Box()` still constructs `Box`.
expect(calls(node('lib/family.dart', 'check')))
.toEqual([`Box:${lineOf('lib/family.dart', ' Box();')}`, `find:${lineOf('lib/family.dart', '= find()')}`]);
});
});
Loading