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 @@ -43,6 +43,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript files with Windows (CRLF) line endings, as a Windows checkout usually has them, a store exported on a later line, like `const useStore = create(…)` followed by `export default useStore;`, now has its actions indexed, as it already did with Unix line endings. Before, those actions were missing from the graph on Windows, so their callers and impact came back empty. Re-index JavaScript and TypeScript projects after upgrading.
- In VB.NET, a name that begins with a keyword, like `SharedCache`, `Dimension`, `PublicKey` or `FriendlyName`, is now indexed whole, and a method like `Sub NewItem()` is no longer taken for a constructor. Before, the keyword was cut off (`Cache`, `ension`, `Key`) or the declaration was lost, and a line in a method that starts with such a name, like `Constants.Reload()` or `ConstVBV.Value = False`, was misread as a declaration, so reads of and calls to these names didn't link. A `Private Protected` member now counts as private instead of public. Re-index VB.NET projects after upgrading.
- In JavaScript and TypeScript, a store whose name contains a `$`, like `const items$ = create(…)` exported on a later line with `export default items$;`, now has its actions indexed. Before, they could be missing from the graph, for example in a file that also had a syntax error. Re-index JavaScript and TypeScript projects after upgrading.
- In Dart, what a top-level variable's or a field's initializer calls, creates and reads is now linked. In Riverpod apps, where most wiring lives in provider closures like `final repoProvider = Provider((ref) => Repository(ref.watch(dioProvider)));`, `callers`, impact and `codegraph_explore` now follow a provider to the code it builds instead of stopping at the provider. A `final` or `const` initializer belongs to its constant, and a `var`'s or a field's to its file or class. A call through a type's static constant, like `FutureProvider.autoDispose(…)`, now links to that constant instead of an unrelated method that shares its name. Re-index Dart projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
245 changes: 245 additions & 0 deletions __tests__/dart-initializer-calls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/**
* Dart initializers are code: calls written in a top-level variable's or a
* field's initializer belong to the declaration.
*
* Both extractors skipped them. A `final`/`const` declaration minted its
* constant and stopped, and a field's or a `var`'s initializer only had its
* types recorded (#2327). So in a Riverpod app, where most wiring is written
* in provider closures — `final repoProvider = Provider((ref) =>
* Repository(ref.watch(dioProvider)));` — `Repository` and every function a
* provider builds with had no callers, and impact and flows stopped at the
* provider.
*
* A `final`/`const` initializer belongs to its constant. A field's or a
* `var`'s initializer belongs to its class or to the file, because Dart mints
* no node for those declarations.
*
* A call through a type's static constant — `Provider.autoDispose(…)` on
* `static const autoDispose = AutoDisposeProviderBuilder();` — calls that
* constant, not a same-named method another type declares.
*
* 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',
'lib/riverpod.dart': `class Ref {
T watch<T>(Object provider) => throw 0;
T read<T>(Object provider) => throw 0;
}

class Provider<T> {
Provider(T Function(Ref ref) create);

static const autoDispose = AutoDisposeProviderBuilder();
}

class AutoDisposeProviderBuilder {
const AutoDisposeProviderBuilder();

Provider<T> call<T>(T Function(Ref ref) create) => Provider(create);
}

class ProviderFamilyBuilder {
Object autoDispose() => this;
}

class StateNotifierProvider<N, S> {
StateNotifierProvider(N Function(Ref ref) create);
}

class FutureProvider<T> {
FutureProvider(Future<T> Function(Ref ref) create);

static const family = FutureProviderFamilyBuilder();
}

class FutureProviderFamilyBuilder {
const FutureProviderFamilyBuilder();

FutureProvider<T> call<T, A>(Future<T> Function(Ref ref, A arg) create) => throw 0;
}
`,
// A method named like `FutureProvider.family`, on another type in another file.
'lib/builders.dart': `class AutoDisposeFutureProviderBuilder {
Object family() => this;
}
`,
'lib/data.dart': `class Dio {}

class Api {
Api(Dio dio);
}

class Repository {
Repository(Dio dio);
}

class Counter {
Counter(Api api);
}

Dio createDio() => Dio();
void register(void Function() cb) {}
int start() => 0;
`,
// The usual Riverpod provider shapes, and the providers they read.
'lib/providers.dart': `import 'riverpod.dart';
import 'data.dart';

final dioProvider = Provider((ref) => createDio());
final apiProvider = Provider((ref) => Api(ref.watch(dioProvider)));
final repoProvider = Provider((ref) => Repository(ref.watch(dioProvider)));
final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref.read(apiProvider)));
`,
'lib/more.dart': `import 'riverpod.dart';
import 'data.dart';
import 'providers.dart';

final typedProvider = Provider((Ref ref) => ref.read(dioProvider));
final blockProvider = Provider((ref) {
final dio = createDio();
return Repository(dio);
});
void onTap() {}
final wired = register(onTap);
var cache = createDio();
late final lazyRepo = Repository(createDio());
final autoProvider = Provider.autoDispose((ref) => createDio());
final userProvider = FutureProvider.family((ref, int id) async => Api(createDio()));

Provider<Dio> makeAuto() => Provider.autoDispose((ref) => Dio());

class Service {
static final instance = Service._(createDio());
static var shared = Api(createDio());
final repo = Repository(Dio());
final Dio dio;
Service._(this.dio);

int run() {
var total = 0;
for (var i = 0, j = start(); i < j; i++) {
total += i;
}
return total;
}
}
`,
};

describe('Dart initializer calls', () => {
let root = '';
let cg: CodeGraph | undefined;
let kernel: string | undefined;

beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-initializers-'));
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'])('belong to the constant, class or file they initialize (%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!;
};
const targets = (from: Node, kind: string): string[] =>
[...new Set(graph
.getOutgoingEdgesFrom([from.id])
.filter((e) => e.kind === kind)
.map((e) => graph.getNode(e.target)!.qualifiedName))]
.sort();
const sources = (to: Node, kind: string): string[] =>
[...new Set(graph
.getIncomingEdgesTo([to.id], [kind])
.map((e) => graph.getNode(e.source)!)
.map((n) => `${n.kind} ${n.qualifiedName}`))]
.sort();

// The issue's providers construct what they build and call what they build it with.
expect(targets(node('lib/providers.dart', 'dioProvider'), 'calls')).toEqual(['createDio']);
expect(targets(node('lib/providers.dart', 'apiProvider'), 'instantiates')).toEqual(['Api', 'Provider']);
expect(targets(node('lib/providers.dart', 'repoProvider'), 'instantiates')).toEqual(['Provider', 'Repository']);
expect(targets(node('lib/providers.dart', 'counterProvider'), 'instantiates')).toEqual(['Counter', 'StateNotifierProvider']);

// A typed closure parameter types its receiver; a block-bodied closure is walked whole.
expect(targets(node('lib/more.dart', 'typedProvider'), 'calls')).toEqual(['Ref::read']);
expect(targets(node('lib/more.dart', 'blockProvider'), 'calls')).toEqual(['createDio']);
expect(targets(node('lib/more.dart', 'blockProvider'), 'instantiates')).toEqual(['Provider', 'Repository']);

// A function passed as a value is the constant's reference, captured once.
expect(targets(node('lib/more.dart', 'wired'), 'calls')).toEqual(['register']);
expect(sources(node('lib/more.dart', 'onTap'), 'references')).toEqual(['constant wired']);

// `var` and `late final` mint no node: the file runs them.
const file = node('lib/more.dart', 'lib/more.dart');
expect(targets(file, 'calls')).toEqual(['createDio']);
expect(targets(file, 'instantiates')).toEqual(['Repository']);

// A `static final` is the class's constant; a `static var` and an instance
// field mint no node, so they are the class's.
expect(targets(node('lib/more.dart', 'Service::instance'), 'calls')).toEqual(['Service::_', 'createDio']);
const service = node('lib/more.dart', 'Service');
expect(targets(service, 'calls')).toEqual(['createDio']);
expect(targets(service, 'instantiates')).toEqual(['Api', 'Dio', 'Repository']);

// `Provider.autoDispose(…)` calls the static constant, whose own
// initializer constructs the builder — from a provider and from a body
// alike — and not a same-named method of another type, in its file or not.
const autoDispose = node('lib/riverpod.dart', 'Provider::autoDispose');
expect(autoDispose.kind).toBe('constant');
expect(targets(node('lib/more.dart', 'autoProvider'), 'calls')).toEqual(['Provider::autoDispose', 'createDio']);
expect(targets(node('lib/more.dart', 'makeAuto'), 'calls')).toEqual(['Provider::autoDispose']);
expect(targets(autoDispose, 'instantiates')).toEqual(['AutoDisposeProviderBuilder']);
expect(sources(node('lib/riverpod.dart', 'ProviderFamilyBuilder::autoDispose'), 'calls')).toEqual([]);
expect(targets(node('lib/more.dart', 'userProvider'), 'calls')).toEqual(['FutureProvider::family', 'createDio']);
expect(sources(node('lib/builders.dart', 'AutoDisposeFutureProviderBuilder::family'), 'calls')).toEqual([]);

// Each provider and declaration above is a caller of what it builds.
expect(sources(node('lib/data.dart', 'createDio'), 'calls')).toEqual([
'class Service',
'constant Service::instance',
'constant autoProvider',
'constant blockProvider',
'constant dioProvider',
'constant userProvider',
'file lib/more.dart',
]);
expect(sources(node('lib/data.dart', 'Repository'), 'instantiates')).toEqual([
'class Service',
'constant blockProvider',
'constant repoProvider',
'file lib/more.dart',
]);

// A local declaration's second variable is its function's, once.
const run = node('lib/more.dart', 'Service::run');
expect(targets(run, 'calls')).toEqual(['start']);
expect(graph.getOutgoingEdgesFrom([run.id]).filter((e) => e.kind === 'calls')).toHaveLength(1);
expect(sources(node('lib/data.dart', 'start'), 'calls')).toEqual(['method Service::run']);
});
});
3 changes: 2 additions & 1 deletion __tests__/dart-type-positions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ describe('Dart type positions (#2327)', () => {
const body = cg.getNodesByQualifiedName('body')[0]!;
const refs = cg.getOutgoingEdgesFrom([body.id], ['references']).filter((e) => cg.getNode(e.target)!.name === 'Score');
expect([...new Set(refs.map((e) => e.line))].sort()).toEqual([26, 27]);
expect(referrers('Score', 'instantiates')).toEqual(['function body']);
// `static const Score zero = Score(0);` constructs one too, from its constant.
expect(referrers('Score', 'instantiates')).toEqual(['constant Board::zero', 'function body']);
const prefixRefs = cg.getUnresolvedReferencesInFile('lib/more.dart').filter((r) => r.referenceName === 'm');
expect(prefixRefs).toEqual([]);
});
Expand Down
89 changes: 89 additions & 0 deletions __tests__/fixtures/kernel-parity/TortureInitializers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Calls, instantiations, reads and function values written in initializers:
// top-level variables and constants, and fields of every member body kind.
import 'package:app/model.dart';

int compute(int n) => n;
void onTap() {}
void register(Object o) {}
Widget build() => Widget(0);

// final / const: the constant owns its initializer.
final repoProvider = Provider((ref) => Repository(ref.watch(dioProvider)));
final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref.read(apiProvider)));
final blockProvider = Provider((ref) {
final dio = Dio.create(compute(1));
void local(int x) {
register(x);
}
local(2);
return Repository(dio)..init()..start();
});
const shared = Widget.named(3);
const made = const Pair<int, Widget>(1, Widget(2));
final created = new Widget(compute(4));
final chained = Widget.factory().configure().run();
final reads = Config.instance.name;
final label = 'total ${compute(5)} of $shared';
final cb = onTap;
final wired = register(onTap);
final table = [onTap, build];
final byName = {'tap': onTap, 'build': build};
final first = compute(6), second = Widget(7);
final Provider<Widget> typed = Provider((ref) => build());
final asyncProvider = FutureProvider((ref) async => await load(ref));
final viaTypedParam = Provider((Ref ref) => ref.watch(dioProvider).value);

// var / typed / late: the file owns the initializer.
var counter = compute(8);
Widget top = build();
late final lazy = Widget(compute(9));
var list = <Widget>[Widget(10), build()], other = compute(11);
int plain = 0;

class Holder {
static final instance = Holder._(compute(12));
static const empty = Holder._(0);
static var cache = Widget(compute(13));
static Widget typedStatic = build();
final made = Widget(14);
late final lazyField = compute(size);
var callbacks = [onTap];
final Widget other = Widget.named(15), more = build();
final int size;
Holder._(this.size);

int run() {
var total = 0;
for (var i = 0, j = compute(16); i < j; i++) {
total += i;
}
final local = Widget(17), again = compute(18);
return total;
}
}

mixin Tracking {
static var tracker = Tracker.start();
final events = <Event>[Event('init')];
}

extension Formatting on String {
static final formatter = Formatter(compute(19));
static var counter = compute(20);
}

enum Mode {
fast,
slow;

static final byName = {for (final m in Mode.values) m.name: m};
static const fallback = Mode.fast;
final weight = 1;
}

extension type Meters(int value) {
static var unit = Unit.create('m');
}

// Positions after multi-byte text are UTF-16 columns on both arms.
final emoji = 'é😀'; final afterEmoji = compute(21); var alsoAfter = Widget(22);
10 changes: 8 additions & 2 deletions __tests__/kernel-dart-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
* twins; TortureMini/TortureSigs/TortureCtors/TortureVrefDart: signatures
* verbatim, prefixed-return-type prefix bug, const factories invisible,
* value-ref matrix with `$X` vs `${X}` asymmetry; TortureReadsTypes.dart:
* getter-read refs and the type positions outside signatures — #2338/#2327)
* and their CRLF variants
* getter-read refs and the type positions outside signatures — #2338/#2327;
* TortureInitializers.dart: initializers walked as the code their constant,
* class or file runs, every member-body kind, and a local declaration's
* second variable left to its function) and their CRLF variants
* (derived in-memory — #1329), plus defer and generated-file pins.
*
* The full-repo sweeps live in scripts/kernel-parity.mjs (shelf/bloc/flutter
Expand Down Expand Up @@ -116,6 +118,10 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => {
// extension `on` types, field / enum-field / top-level types, constant and
// field initializers, body types, and the read shapes that are not reads.
['TortureReadsTypes.dart', 20],
// Initializers (top-level, static and instance) walked as code: calls,
// instantiations, reads, closures with block bodies and local functions,
// and function values captured once, for the declaration.
['TortureInitializers.dart', 30],
] as const;

for (const [file, minNodes] of FIXTURES) {
Expand Down
Loading