Files
codegraph/__tests__/dart-chain-links.test.ts
Colby Mchenry b7e05f3ccd fix(dart): link a chained call only through its receiver's declared types (#750) (#2376)
The Dart extractor keeps one receiver level, so the later links of a call
chain reach the resolver by their bare name (`Provider.autoDispose.family(…)`
→ `family`, `events.map(mapper).transform(…)` → `transform`). They then fell
to name matching and bound to whichever project method shared the name: on
felangel/bloc a Stream's `transform` went to angular_bloc's
`BlocPipe::transform`, and on rrousselGit/riverpod every
`X.autoDispose.family(…)` went to one builder's `family` whatever X was.

A chain link now reaches only the member of what the chain before it
evaluates to, typed from evidence: the head (a type, a constructor call, a
string, `this` / `super`, an import prefix, a local, parameter, field or
getter with a declared type) and each link's declared type (a method's or
getter's return type with the method's own type parameters substituted, a
field's or abstract getter's type, a static constant's initializer). A lookup
nothing declared answers for hands back its one type argument
(`context.read<LoginCubit>()`). Without evidence it links nothing.

Along the way: an annotated extension (`@internal extension X on Y`) is
recognized as one, so its members reach member reads and chains; `super.m()`
reaches the superclass's method instead of the caller itself; an extension
on a type parameter of its own applies to the project's types; a spread's
`...foo()` is a receiver-less call.

Validated before/after with the native kernel: bloc 337 name-only chain
edges on main → 123 kept (now typed), 106 retargeted, 106 removed (68 on a
typed receiver lacking the member), +75 `super.` calls; riverpod 1,254 →
171 kept, 383 retargeted, 696 removed (137 provably wrong), +263. Kernel and
wasm graphs are identical with the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:36:56 +00:00

243 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A later link of a Dart call chain — `Provider.autoDispose.family(…)`,
* `events.map(mapper).transform(…)` — reaches the resolver by its bare name:
* the extractor keeps one receiver level. It calls a member of what the chain
* before it evaluates to, typed from the chain's head and each link's
* declared type, or nothing — never a member found by its name alone.
*
* Without that, bloc's `events.map(mapper).transform(…)` on a Stream went to
* angular_bloc's `BlocPipe.transform`, riverpod's `X.autoDispose.family(…)`
* went to one builder's `family` whatever X was, and a sign-up test's
* `SignUpState().withEmail(e).withPassword(p)` to the login form's state.
*/
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-dart-chain-'));
const files: Record<string, string> = {
'pubspec.yaml': 'name: app\n',
// angular_bloc's pipe: the one project `transform` a by-name guess finds.
'lib/pipe.dart': `class BlocPipe {
Object? transform(Object? value) => value;
}
`,
'lib/bloc.dart': `import 'dart:async';
class Bloc<E> {
Stream<dynamic> transformEvents(Stream<E> events, Stream<dynamic> Function(E) mapper) {
return events
.map(mapper)
.transform<dynamic>(const _FlatMap<dynamic>());
}
}
class _FlatMap<T> {
const _FlatMap();
}
`,
'lib/builders.dart': `class FutureProviderBuilder {
const FutureProviderBuilder();
FutureProviderFamilyBuilder get family => const FutureProviderFamilyBuilder();
}
class FutureProviderFamilyBuilder {
const FutureProviderFamilyBuilder();
Object call(Object create) => create;
}
class StateProviderBuilder {
const StateProviderBuilder();
StateProviderFamilyBuilder get family => const StateProviderFamilyBuilder();
}
class StateProviderFamilyBuilder {
const StateProviderFamilyBuilder();
Object call(Object create) => create;
}
`,
'lib/providers.dart': `import 'builders.dart';
class FutureProvider {
static const autoDispose = FutureProviderBuilder();
}
class StateProvider {
static const autoDispose = StateProviderBuilder();
}
`,
'lib/app.dart': `import 'providers.dart';
final users = FutureProvider.autoDispose.family<int, String>((ref, id) => 0);
final counts = StateProvider.autoDispose
.family<int, String>((ref, id) => 0);
`,
'lib/sign_up/sign_up_state.dart': `class SignUpState {
const SignUpState();
SignUpState withEmail(String email) => this;
SignUpState withPassword(String password) => this;
}
`,
'test/sign_up/login_state.dart': `class LoginState {
const LoginState();
LoginState withEmail(String email) => this;
LoginState withPassword(String password) => this;
}
`,
'test/sign_up/sign_up_state_test.dart': `import 'package:app/sign_up/sign_up_state.dart';
void main() {
final constructed = SignUpState().withEmail('a').withPassword('b');
final constant = const SignUpState().withEmail('a');
}
`,
'lib/counter.dart': `abstract class BlocEventSink<E> {
void add(E event);
}
abstract class BlocBase<S> {
Future<void> close() async {}
}
abstract class Bloc<E, S> extends BlocBase<S> implements BlocEventSink<E> {
@override
void add(E event) {}
@override
Future<void> close() async {
await super.close();
}
}
class CounterBloc extends Bloc<int, int> {
void increment() {}
}
`,
'lib/counter_view.dart': `import 'package:flutter/widgets.dart';
import 'counter.dart';
class CounterView {
void onTap(BuildContext context) {
context.read<CounterBloc>().add(1);
}
}
`,
'lib/todo.dart': `class Todo {
const Todo();
Todo copyWith({String? title}) => this;
}
`,
'lib/edit_todo.dart': `import 'todo.dart';
class EditTodoState {
const EditTodoState({this.initialTodo});
final Todo? initialTodo;
EditTodoState copyWith({Todo? initialTodo}) => this;
}
class EditTodoBloc {
EditTodoState state = const EditTodoState();
void submit() {
final todo = (state.initialTodo ?? const Todo()).copyWith(title: 'x');
}
}
`,
'lib/container.dart': `import 'package:meta/meta.dart';
class ProviderPointerManager {
void readPointer(Object provider) {}
}
class ProviderContainer {
final ProviderPointerManager _pointerManager = ProviderPointerManager();
}
@internal
extension ProviderContainerTest on ProviderContainer {
ProviderPointerManager get pointerManager => _pointerManager;
}
void probe(ProviderContainer container) {
container.pointerManager.readPointer(1);
}
`,
'lib/other_manager.dart': `class OtherManager {
void readPointer(Object provider) {}
}
`,
};
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 });
});
/** `from -> Owner::name` for every call edge out of a file. */
function callsFrom(file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls')
.map((e) => `${cg.getNode(e.source)!.name} -> ${cg.getNode(e.target)!.qualifiedName}`)
.sort();
}
describe('Dart call chains link through their receiver’s declared types', () => {
it('a Stream’s `transform` is not a project method of that name', () => {
expect(callsFrom('lib/bloc.dart')).toEqual([]);
});
it('`X.autoDispose.family(…)` is the family getter of the builder X’s constant holds', () => {
expect(callsFrom('lib/app.dart')).toEqual([
'counts -> StateProviderBuilder::family',
'users -> FutureProviderBuilder::family',
]);
});
it('every link of a fluent chain stays on the type each link returns', () => {
expect(callsFrom('test/sign_up/sign_up_state_test.dart')).toEqual([
'main -> SignUpState::withEmail',
'main -> SignUpState::withEmail',
'main -> SignUpState::withPassword',
]);
});
it('`super.close()` is the superclass’s, and `context.read<T>()` hands back a T', () => {
expect(callsFrom('lib/counter.dart')).toEqual(['close -> BlocBase::close']);
expect(callsFrom('lib/counter_view.dart')).toEqual(['onTap -> Bloc::add']);
});
it('a parenthesized receiver is what its last operand is', () => {
expect(callsFrom('lib/edit_todo.dart')).toEqual(['submit -> Todo::copyWith']);
});
it('a getter an annotated extension adds is in the chain, and is called', () => {
expect(callsFrom('lib/container.dart')).toEqual([
'probe -> ProviderContainerTest::pointerManager',
'probe -> ProviderPointerManager::readPointer',
]);
});
});