fix(dart): link a call chained on a generic lookup like BlocProvider.of<T>(…) (#750) (#2383)

When a generic call parses as a call (a named argument after a positional
one, as in `(context, listen: false)`, or a type argument like `<A?>`,
`<A, B>` or `<List<A>>`), the Dart extractor records a chain on it as
`BlocProvider.of().increment`. The resolver typed that receiver by what
`BlocProvider.of` is declared to return: `T` for `static T of<T>(…)`,
which names no type, and nothing at all when the lookup comes from a
package, as flutter_bloc and provider do in an app. So
`BlocProvider.of<CounterCubit>(context, listen: false).increment()` and
provider's `Provider.of<Cart>(context, listen: false).add(item)` linked
nothing.

The declared return type is still tried first, so `Foo.create().bar()`
with a concrete return type resolves as before. When it gives no edge,
the chain is read at the call site the way #2376 reads a later link of a
chain: the lookup's own type parameter is the type argument the call
gives, and an outside `of<T>(…)` lookup hands back its T. The edge stays
on the encoded ref, as in Go's fallback (the gin runaway).

The extractors are unchanged. A chain on a call parsed as two
comparisons keeps its bare name (#2375), and now reaches the same
method; the comments that gave the old reason are corrected.

Validated before/after with the native kernel at 3f96f80f, nothing
removed anywhere: felangel/bloc @b9be1e2 0 (its four such sites parse as
comparisons and already linked), rrousselGit/riverpod @4ba1be2 +6
(`X.family<…>(…).call(42)` to `ClassFamily::call`), flutter/samples
@63411527 +2 and brianegan/flutter_architecture_samples @d898d13 +6,
all `Provider.of<T>(context, listen: false).m()`. A bare-name fallback
(as Go does) added nothing on those repos, since the lookups are outside
the project, and linked 2 of the test's 8 chains wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-06 15:29:47 +00:00
committed by GitHub
parent 1b752de2c0
commit d500e58a52
6 changed files with 257 additions and 10 deletions
+1
View File
@@ -51,6 +51,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Dart, `const` constructors and redirecting factories, like flutter_bloc's `const BlocProvider.value(…)` or freezed's `const factory LoginState.loading() = _Loading;`, are now indexed, so a call such as `BlocProvider.value(…)` or `BlocProvider<CounterCubit>.value(…)` links to that constructor instead of another class's member with the same name. A call through a class name now reaches only that class's own constructors and static members, never a look-alike class, a parent class, an instance getter of the same name, or a project method that merely shares its name with an SDK call like `Uri.parse(…)`, and a constructor is no longer linked from a bare name, the end of a call chain or an annotation like `@internal`. Re-index Dart projects after upgrading.
- In Dart, a call that passes a type argument and arguments, like `ref.read<Repo>(repoProvider)`, `ref.watch<int>(counterProvider)`, `Provider<User>((ref) => …)`, `BlocProvider.of<CounterCubit>(context)` or `RepositoryProvider<Repo>(create: …)`, now links to the method or class it calls, and the type it passes counts as used there. Depending on the code around it, the parser often read such a call as two comparisons, so it linked nothing, and Riverpod providers, repository reads and bloc lookups were missing from `callers`, impact and `codegraph_explore`. A real comparison like `a < b` is still left alone. Re-index Dart projects after upgrading.
- In Dart, a constructor or member written without a body, like flutter_bloc's `const BlocProvider.value(…)`, `Foo._();`, `Point.origin() : x = 0, y = 0;` or an abstract or `external` method, now keeps the `///` documentation and the annotations written above it, the way one with a body already did. Before, that documentation was lost, so searching for words in it didn't find the symbol, and an annotation like `@visibleForTesting` or `@protected` on it wasn't recorded. Re-index Dart projects after upgrading.
- In Dart, a call on what a generic lookup returns, like flutter_bloc's `BlocProvider.of<CounterCubit>(context, listen: false).increment()` or Provider's `Provider.of<CartModel>(context, listen: false).add(item)`, now links to that method of the type in the angle brackets, whether the lookup is declared in your project or comes from a package. Before, such a call often linked nothing — for example whenever the lookup passed `listen: false` — so `callers` and impact missed those methods. A lookup that names no type, like `BlocProvider.of(context)`, still links nothing. Re-index Dart projects after upgrading. (#750)
## [1.6.2] - 2026-10-03
@@ -0,0 +1,228 @@
/**
* A Dart call chained on a generic static factory — bloc's
* `BlocProvider.of<CounterCubit>(context, listen: false).increment()`,
* `RepositoryProvider.of<TodosRepository>(context).saveTodo(t)` — calls a
* member of the type the factory's type argument names.
*
* When the generic call parses as a call (tree-sitter-dart reads about half
* of them as two comparisons, but a named argument or a type argument like
* `<A?>` makes it a call), the extractor records the chain as
* `BlocProvider.of().increment`, and the resolver typed it by what
* `BlocProvider.of` is declared to return. That is `T` (`static T of<T>(…)`),
* which names no type, so the call linked nothing. In an app, where
* flutter_bloc is a dependency and not in the project, nothing typed it
* either. The chain read back from the call site is typed the way a later
* link of a chain is (#750): the factory's own type parameter is the type
* argument the call gives, and an outside lookup such as `of<T>(…)` hands
* back its T. A chain on a call read as two comparisons keeps its bare name
* (`increment`), and reaches the same method.
*
* It never falls back to a member found by its name: `StepCounter.increment`,
* next to the call site, is not what `of<CounterCubit>` returns, and a call
* with no type argument (`BlocProvider.of(context)`) links nothing.
*
* 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';
const PUBSPEC = 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n';
// A method named like each chained call, nearer the call site than the type
// that declares it: the one a guess by name would pick.
const DECOY = `class StepCounter {
void increment() {}
void decrement() {}
void saveTodo(Object todo) {}
}
`;
/** The bloc repo itself: BlocProvider is declared in the project. */
const LIBRARY: Record<string, string> = {
'pubspec.yaml': PUBSPEC,
'lib/bloc_provider.dart': `class BlocProvider<T> {
const BlocProvider();
static T of<T>(Object context, {bool listen = false}) => throw 0;
static T? maybeOf<T>(Object context, {bool listen = false}) => null;
}
`,
'lib/bloc.dart': `abstract class Bloc<E, S> {
void add(E event) {}
}
`,
'lib/counter/counter_cubit.dart': `class CounterCubit {
void increment() {}
}
`,
'lib/counter/counter_bloc.dart': `import '../bloc.dart';
class CounterBloc extends Bloc<int, int> {}
`,
'lib/counter/step_counter.dart': DECOY,
'lib/counter/counter_page.dart': `import '../bloc_provider.dart';
import 'counter_cubit.dart';
import 'counter_cubit.dart' as cubits;
import 'counter_bloc.dart';
class Button {
const Button({Object? onPressed});
}
class CounterPage {
void tap(Object context) {
BlocProvider.of<CounterCubit>(context, listen: false).increment();
}
void tapWrapped(Object context) {
BlocProvider.of<CounterCubit>(context, listen: false)
.increment();
}
void tapPrefixed(Object context) {
BlocProvider.of<cubits.CounterCubit>(context, listen: false).increment();
}
void tapMaybe(Object context) {
BlocProvider.maybeOf<CounterCubit>(context, listen: false)?.increment();
}
void tapBloc(Object context) {
BlocProvider.of<CounterBloc>(context, listen: false).add(1);
}
void tapMissing(Object context) {
BlocProvider.of<CounterCubit>(context, listen: false).decrement();
}
void tapInferred(Object context) {
BlocProvider.of(context, listen: false).increment();
}
// Parsed as two comparisons, so increment arrives by its bare name.
void tapRecovered(Object context) {
BlocProvider.of<CounterCubit>(context).increment();
}
Object build(Object context) => Button(
onPressed: () => BlocProvider.of<CounterCubit>(context, listen: false).increment(),
);
}
`,
};
/** An app: flutter_bloc is a dependency, outside the project. */
const APP: Record<string, string> = {
'pubspec.yaml': PUBSPEC,
'lib/counter/counter_cubit.dart': `class CounterCubit {
void increment() {}
}
`,
'lib/counter/step_counter.dart': DECOY,
'lib/todos/todos_repository.dart': `class TodosRepository {
void saveTodo(Object todo) {}
}
`,
'lib/counter/counter_page.dart': `import 'package:flutter_bloc/flutter_bloc.dart';
import '../todos/todos_repository.dart';
import 'counter_cubit.dart';
class CounterPage {
void tap(Object context) {
BlocProvider.of<CounterCubit>(context, listen: false).increment();
}
void save(Object context, Object todo) {
RepositoryProvider.of<TodosRepository>(context, listen: false).saveTodo(todo);
}
void tapInferred(Object context) {
BlocProvider.of(context, listen: false).increment();
}
// Parsed as two comparisons, so increment arrives by its bare name.
void tapRecovered(Object context) {
BlocProvider.of<CounterCubit>(context).increment();
}
}
`,
};
describe('Dart chains on a generic static factory', () => {
let root = '';
let cg: CodeGraph | undefined;
let kernel: string | undefined;
const index = async (files: Record<string, string>, backend: string): Promise<CodeGraph> => {
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);
}
if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
else delete process.env.CODEGRAPH_KERNEL;
cg = await CodeGraph.init(root, { index: true });
return cg;
};
/** `caller -> Owner::name` for every call edge out of a file. */
const callsFrom = (graph: CodeGraph, file: string): string[] => {
const ids = graph.getNodesInFile(file).map((n) => n.id);
return graph
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls')
.map((e) => `${graph.getNode(e.source)!.name} -> ${graph.getNode(e.target)!.qualifiedName}`)
.sort();
};
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-generic-factory-'));
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'])('call a member of the type argument a project factory is given (%s)', async (backend) => {
const graph = await index(LIBRARY, backend);
expect(callsFrom(graph, 'lib/counter/counter_page.dart')).toEqual([
'build -> BlocProvider::of',
'build -> CounterCubit::increment',
'tap -> BlocProvider::of',
'tap -> CounterCubit::increment',
// What CounterBloc inherits.
'tapBloc -> Bloc::add',
'tapBloc -> BlocProvider::of',
// No type argument says what `of` returns.
'tapInferred -> BlocProvider::of',
'tapMaybe -> BlocProvider::maybeOf',
'tapMaybe -> CounterCubit::increment',
// CounterCubit has no `decrement`; StepCounter's is not this one.
'tapMissing -> BlocProvider::of',
'tapPrefixed -> BlocProvider::of',
'tapPrefixed -> CounterCubit::increment',
'tapRecovered -> BlocProvider::of',
'tapRecovered -> CounterCubit::increment',
'tapWrapped -> BlocProvider::of',
'tapWrapped -> CounterCubit::increment',
]);
});
it.each(['default', 'wasm'])('call a member of the type argument an outside lookup is given (%s)', async (backend) => {
const graph = await index(APP, backend);
expect(callsFrom(graph, 'lib/counter/counter_page.dart')).toEqual([
'save -> TodosRepository::saveTodo',
'tap -> CounterCubit::increment',
'tapRecovered -> CounterCubit::increment',
]);
});
});
+2 -2
View File
@@ -1219,8 +1219,8 @@ impl<'t> Walker<'t> {
/// operator) and the identifier naming its type argument. Recovered when
/// the type argument names a type and the code is laid out as a call —
/// `<` against the callee, `(` against the `>`. A call chained on it keeps
/// its bare name (the `X().m` encoding resolves through a generic
/// factory's type parameter and finds nothing).
/// its bare name, which the resolver types from the chain written before
/// it, as it types a parsed chain's `X().m` (#750).
fn misparsed_generic_call(&self, lt: Node<'t>) -> Option<(Node<'t>, Node<'t>)> {
if lt.kind() != "relational_operator" || lt.child(0)?.kind() != "<" {
return None;
+6 -4
View File
@@ -754,9 +754,11 @@ recovered form emits what the parsed call does:
A call chained on a recovered one keeps its bare name (`increment` in
`BlocProvider.of<CounterCubit>(context).increment()`), unlike the parsed
chain's `BlocProvider.of().increment`: that encoding resolves through `of`'s
return type, which for generic factories is the type parameter, so it would
lose the edge the bare name finds.
chain's `BlocProvider.of().increment`. The resolver gives both the same
method: a bare link is typed from the chain written before it, and so is an
encoded chain whose inner call declares no type of its own to return — a
generic factory's type parameter (`static T of<T>`) or a factory outside the
project (#750). Nothing is gained by re-encoding the recovered chain.
The grammar can also end an arrow function in front of a generic call:
`(ref) => ref.watch<int>(p)` → `((ref) => ref).watch<int>(p)`, in the
@@ -765,7 +767,7 @@ receiver in that place from the end of the arrow's body (Dart writes a member
access on an arrow itself in parentheses), for callee names and static-member
refs: `ref.watch`, `BlocProvider.of`. dartCalleeOfArgPart / callee_of_arg_part
do not, so `() => BlocProvider.of<CounterCubit>(context).increment()` keeps
`increment` bare, for the reason above.
`increment` bare, which resolves as above.
### Static-member / value-read refs — dart branch (:4759-4767), STATIC_MEMBER_LANGS:346
+5 -4
View File
@@ -105,10 +105,11 @@ interface DartMisparsedCall {
* the callee and `(` against the `>`. A comparison is written `a < b`.
*
* A call chained on it keeps the bare name it had (`increment` in
* `BlocProvider.of<CounterCubit>(context).increment()`) rather than taking
* the `BlocProvider.of().increment` a parsed chain gets: that encoding
* resolves through what `of` returns, and a generic factory like this one
* returns its type parameter, so it would drop the edge the bare name finds.
* `BlocProvider.of<CounterCubit>(context).increment()`), where a parsed
* chain gets `BlocProvider.of().increment`. The resolver gives both the same
* method: it types a bare link from the chain written before it, and an
* encoded chain the same way when `of` declares no type of its own to return
* (`static T of<T>(…)`, or a factory outside the project) (#750).
*/
export function dartMisparsedGenericCall(lt: SyntaxNode): DartMisparsedCall | undefined {
if (lt.type !== 'relational_operator' || lt.firstChild?.type !== '<') return undefined;
+15
View File
@@ -8215,6 +8215,21 @@ export function matchDottedCallChain(
const factoryMethod = inner.slice(lastDot + 1);
if (!factoryClass || !factoryMethod) return null;
const ret = lookupCalleeReturnType(`${factoryClass}::${factoryMethod}`, ref, context);
if (ref.language === 'dart') {
const declared = ret
? resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context))
: null;
if (declared) return declared;
// A generic factory returns its type parameter — `static T of<T>(…)` in
// flutter_bloc's BlocProvider — and one outside the project declares
// nothing here, so `BlocProvider.of<CounterCubit>(context).increment()`
// had no type to look `increment` up on. The call site has it: read the
// chain there as a later link of a chain is read (#750), where `of<T>`'s
// T is the type argument the call gives. The edge belongs to this ref, not
// to the bare name it was looked up by (see the gin runaway above).
const link = matchDartChainLink({ ...ref, referenceName: method }, context);
return link ? { ...link, original: ref } : null;
}
if (!ret) {
// Objective-C: a class-message factory — `[X alloc]`, `[X new]`,
// `[X sharedFoo]` — returns an instance of the RECEIVER class `X` by