fix(dart): index const constructors and redirecting factories; a call through a type stays on that type (#2380)

* fix(dart): index const constructors and redirecting factories; a call through a type stays on that type

Both Dart extractors (the TS walker and the native kernel) skipped
`const` constructors and redirecting factories (`const Foo.bar(...)`,
`const factory Foo.bar() = _Bar;`), which parse as signature kinds of
their own. A call such as flutter_bloc's `BlocProvider.value(...)` then
found no method on BlocProvider, and the resolver guessed another
type's `value`: on felangel/bloc, 89 calls linked to
`RepositoryProvider::value`.

Extraction (TS + kernel, at parity):
- Named const constructors and redirecting factories are methods named
  by the constructor, returning their class, with a parameters-only
  signature; the unnamed ones stay unindexed. The name is read only
  from before the parameter list, so a redirect target
  (`= _Impl.named`) never names it.
- A redirecting factory references the class it redirects to, not the
  target constructor's name or an import prefix.
- A constructor called with type arguments keeps its type:
  `BlocProvider<CounterCubit>.value(...)` is `BlocProvider.value`, as a
  statement and as an expression (`constructor_invocation`, which
  produced no call at all before).

Resolver:
- A Dart call through a type's name reaches only that type's own
  constructor, static member or static constant, or nothing: no guess
  by name, no inherited lookup, no same-file look-alike, no instance
  member of the same name. SDK calls like `Uri.parse` no longer land
  on a project `parse` past an `extension on Uri`.
- A named constructor is never reached by a bare name, the last link
  of a chain, a function value or an annotation (riverpod's 210
  `@internal` annotations).

Validated on felangel/bloc @b9be1e2 and rrousselGit/riverpod @4ba1be2
with the kernel loaded: bloc 18,338 -> 18,495 edges, riverpod 85,606
-> 85,854, every removed edge triaged. Afterwards every edge into a
named constructor is written `Owner.ctor`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dart): read a type's static members one way for calls and chains

- A call through a type's name now finds its member with dartStaticMember,
  the lookup a chain's static link uses since #2376, so both pick the same
  member: the call site's own file first, then the nearest copy.
- isDartStaticMember's constructor test is isDartConstructor, anchored at
  the declaration: an instance getter declared just above a same-named
  factory (`Object? get error` over `const factory AsyncValue.error(...)`)
  read the factory from the next lines and counted as static.
- clearNameMatcherMemos drops DART_CONSTRUCTORS with the other
  source-derived Dart memos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-06 14:48:06 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4d6168a701
commit 652c36b72a
9 changed files with 660 additions and 71 deletions
+63 -5
View File
@@ -100,6 +100,16 @@ function dartInnerSignature(node: SyntaxNode): SyntaxNode {
return node;
}
/**
* The constructors that never have a body — `const Foo.bar();` and a
* redirecting factory `const factory Foo.bar() = _Bar;` — each parse as a
* signature kind of its own, inside a `declaration`.
*/
const DART_BODILESS_CTORS: ReadonlySet<string> = new Set([
'constant_constructor_signature',
'redirecting_factory_constructor_signature',
]);
/**
* The factory/named-constructor signature inside a node, if any. A constructor
* parses as `method_signature > {factory_,}constructor_signature` (e.g.
@@ -107,7 +117,10 @@ function dartInnerSignature(node: SyntaxNode): SyntaxNode {
* and — for a named ctor — the constructor-name identifier.
*/
function dartConstructorSignature(node: SyntaxNode): SyntaxNode | undefined {
if (node.type === 'factory_constructor_signature' || node.type === 'constructor_signature') {
if (
node.type === 'factory_constructor_signature' || node.type === 'constructor_signature' ||
DART_BODILESS_CTORS.has(node.type)
) {
return node;
}
if (node.type === 'method_signature') {
@@ -146,7 +159,13 @@ function dartEnclosingTypeName(node: SyntaxNode): string | undefined {
function dartCtorInfo(node: SyntaxNode): { className: string; ctorName: string } | undefined {
const ctor = dartConstructorSignature(node);
if (!ctor) return undefined;
const ids = ctor.namedChildren.filter((c: SyntaxNode) => c.type === 'identifier');
// The names before the parameters: a redirecting factory names its target
// after them (`factory Foo() = _Impl.named;`).
const ids: SyntaxNode[] = [];
for (const c of ctor.namedChildren) {
if (c.type === 'formal_parameter_list') break;
if (c.type === 'identifier') ids.push(c);
}
const className = dartEnclosingTypeName(node);
if (!className || !ids[0]) return undefined;
if (ids[0].text !== className) return undefined; // misparsed method, not a ctor
@@ -176,6 +195,19 @@ function extractDartReturnType(node: SyntaxNode, source: string): string | undef
return last;
}
/**
* The type a call written with type arguments goes through — `BlocProvider`
* in `BlocProvider<CounterCubit>.value(…)`, when `selector` is the `<…>`
* between the type and `.value`. Only a constructor is called that way.
*/
function dartTypeArgumentsReceiver(selector: SyntaxNode | null): string | undefined {
if (selector?.type !== 'selector' || selector.namedChildCount !== 1 || selector.namedChild(0)?.type !== 'type_arguments') {
return undefined;
}
const type = selector.previousNamedSibling;
return type?.type === 'identifier' ? type.text : undefined;
}
/**
* The callee name of the Dart call whose `argument_part` selector is `argPart`
* — mirrors the main extractBareCall accessor logic so a chained receiver
@@ -194,6 +226,8 @@ function dartCalleeOfArgPart(argPart: SyntaxNode): string | undefined {
if (methodId) {
const accessorPrev = prev.previousNamedSibling;
if (accessorPrev?.type === 'identifier') return accessorPrev.text + '.' + methodId.text;
const typeName = dartTypeArgumentsReceiver(accessorPrev);
if (typeName) return typeName + '.' + methodId.text;
return methodId.text;
}
}
@@ -208,7 +242,11 @@ export const dartExtractor: LanguageExtractor = {
// constructor `Foo._()` parses as a bare `constructor_signature`, so include
// it too — resolveName names it by the ctor name and getReturnType gives it
// the class as its return type, so `Foo._().bar()` chains resolve (#750).
methodTypes: ['method_signature', 'constructor_signature'],
// `const` constructors and redirecting factories have signature kinds of
// their own: flutter_bloc's `const BlocProvider.value(…)` is one, and
// without its node a `BlocProvider.value(…)` call went to another class's
// `value`.
methodTypes: ['method_signature', 'constructor_signature', ...DART_BODILESS_CTORS],
interfaceTypes: [],
structTypes: [],
enumTypes: ['enum_declaration'],
@@ -314,7 +352,10 @@ export const dartExtractor: LanguageExtractor = {
// tree-sitter misparsed as a ctor (`@override (T) m()`) is NOT skipped here.
// (isMisparsedFunction skips node creation but still visits the body.)
const ctor = dartCtorInfo(node);
return ctor != null && ctor.ctorName === ctor.className;
// A `const` constructor or redirecting factory is nothing else, so one
// that names no enclosing type is error recovery's, and skipped.
if (!ctor) return DART_BODILESS_CTORS.has(node.type);
return ctor.ctorName === ctor.className;
},
getSignature: (node, source) => {
// For function_signature: extract params + return type
@@ -327,7 +368,9 @@ export const dartExtractor: LanguageExtractor = {
if (inner) sig = inner;
}
const params = sig.namedChildren.find((c: SyntaxNode) => c.type === 'formal_parameter_list');
const retType = sig.namedChildren.find((c: SyntaxNode) =>
// A constructor has no return type: the type a redirecting factory names
// is its target (`= _Impl`).
const retType = DART_BODILESS_CTORS.has(sig.type) ? undefined : sig.namedChildren.find((c: SyntaxNode) =>
c.type === 'type_identifier' || c.type === 'void_type'
);
if (!params && !retType) return undefined;
@@ -467,6 +510,10 @@ export const dartExtractor: LanguageExtractor = {
if (accessorPrev?.type === 'identifier') {
return accessorPrev.text + '.' + methodId.text;
}
// A constructor called with type arguments names its type all the
// same: `BlocProvider<CounterCubit>.value(…)` → `BlocProvider.value`.
const typeName = dartTypeArgumentsReceiver(accessorPrev);
if (typeName) return typeName + '.' + methodId.text;
// Chained static-factory / fluent call: the receiver is itself a call
// (`Foo.create().bar()`), so accessorPrev is that call's argument_part
// selector. Encode `<innerCallee>().<method>` so resolution can infer
@@ -512,6 +559,17 @@ export const dartExtractor: LanguageExtractor = {
return undefined;
}
// `=> BlocProvider<CounterCubit>.value(…)` — a constructor called with type
// arguments, as most expressions parse it. The type is the last
// `type_identifier`: an import prefix (`p.X<T>.named`) is one too.
if (node.type === 'constructor_invocation') {
const typeId = node.namedChildren.filter((c: SyntaxNode) => c.type === 'type_identifier').pop();
const nameId = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (typeId && nameId) return typeId.text + '.' + nameId.text;
if (typeId) return typeId.text;
return undefined;
}
return undefined;
},
extractMemberRead: dartMemberRead,
+12
View File
@@ -7280,6 +7280,18 @@ export class TreeSitterExtractor {
c.type === 'factory_constructor_signature'
) ?? node;
}
// A redirecting factory names the class it constructs after `=`, then
// perhaps that class's constructor (`= _$QuestionImpl.fromJson`, which
// parses as two type_identifiers). Neither the constructor nor an import
// prefix is a type, and Dart writes types UpperCamel.
if (sig.type === 'redirecting_factory_constructor_signature') {
for (const child of sig.namedChildren) {
if (child.type !== 'type_identifier' || /^[_$]*[A-Z]/.test(child.text)) {
this.extractTypeRefsFromSubtree(child, nodeId);
}
}
return;
}
this.extractTypeRefsFromSubtree(sig, nodeId);
return;
}
+76 -31
View File
@@ -1106,7 +1106,9 @@ export function matchFunctionRef(
// defines inside `test_custom_auth`. Those still count against a lone
// cross-file guess below — a name several functions use for themselves is
// as likely a local's.
let candidates = named.filter((n) => isLexicallyReachable(n, ref, context));
let candidates = named.filter((n) => isLexicallyReachable(n, ref, context) &&
// A Dart constructor is torn off through its class, never by a bare name.
!isDartConstructor(n, context));
if (candidates.length === 0) return null;
// A Python name the function around it binds — a parameter, an assignment —
// is that local's value: httpx's `auth_flow(self, request)` handing `request`
@@ -2866,6 +2868,32 @@ function isDartMethodInScope(method: Node, ref: UnresolvedRef, context: Resoluti
return dartMemberDepth(method, ref, context) < Infinity;
}
const DART_CONSTRUCTORS = new WeakMap<ResolutionContext, Map<string, boolean>>();
/**
* Whether a Dart method is a named constructor — `Foo.named(…)`, `factory
* Foo.of(…)`, `const Foo.value(…)`, `const factory Foo.x() = _X;` — read from
* its declaration. Dart reaches one only through its class (`Foo.value(…)`,
* the tear-off `Foo.value`), never by a bare name: a parameter `value` passed
* on is not `const BlocProvider.value(…)`.
*/
function isDartConstructor(n: Node, context: ResolutionContext): boolean {
if (n.language !== 'dart' || n.kind !== 'method') return false;
let memo = DART_CONSTRUCTORS.get(context);
if (!memo) DART_CONSTRUCTORS.set(context, (memo = new Map()));
const hit = memo.get(n.id);
if (hit !== undefined) return hit;
const cut = n.qualifiedName.lastIndexOf('::');
const owner = cut > 0 ? n.qualifiedName.slice(0, cut).split('::').pop()! : '';
const lines = context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split(/\r?\n/) ?? [];
const head = (lines[n.startLine - 1] ?? '').slice(n.startColumn);
const escape = (name: string): string => name.replace(/\$/g, '\\$');
const constructor = owner !== '' &&
new RegExp(String.raw`^(?:(?:const|factory|external)\s+)*${escape(owner)}\s*\.\s*${escape(n.name)}(?![\w$])`).test(head);
memo.set(n.id, constructor);
return constructor;
}
/**
* How many supertype steps separate the class a Dart call is written in from
* `method`'s owner: 0 for its own member, Infinity when the owner is not in
@@ -3461,25 +3489,21 @@ function dartStaticMember(typeName: string, name: string, ref: UnresolvedRef, co
const found = context.getNodesByName(name).filter((m) => m.language === 'dart' &&
(m.kind === 'method' || m.kind === 'function' || m.kind === 'constant' || m.kind === 'enum_member') &&
owners.some((o) => o.filePath === m.filePath && m.qualifiedName === `${o.qualifiedName}::${name}`) &&
!isDartSetter(m, context) && isDartStaticMember(m, typeName, context));
!isDartSetter(m, context) && isDartStaticMember(m, context));
if (found.length <= 1) return found[0] ?? null;
return found.find((m) => m.filePath === ref.filePath) ??
found.reduce((a, b) => (computePathProximity(ref.filePath, b.filePath) > computePathProximity(ref.filePath, a.filePath) ? b : a));
}
/**
* Whether a member of the Dart type `owner` is reached through the type
* itself: a static member, constant or enum value, or a named constructor or
* factory (`factory ProviderContainer.test(…)`) — not an instance member
* sharing a constructor's name (`AsyncValue`'s `error` getter beside its
* `AsyncValue.error(…)` factory).
* Whether a member of a Dart type is reached through the type itself: a
* static member, constant or enum value, or a named constructor or factory
* (`factory ProviderContainer.test(…)`) — not an instance member sharing a
* constructor's name (`AsyncValue`'s `error` getter beside its
* `AsyncValue.error(…)` factory, even declared on the line above it).
*/
function isDartStaticMember(m: Node, owner: string, context: ResolutionContext): boolean {
if (m.isStatic || m.kind === 'constant' || m.kind === 'enum_member') return true;
const lines = context.getFileLines?.(m.filePath) ?? context.readFile(m.filePath)?.split(/\r?\n/) ?? [];
const head = `${(lines[m.startLine - 1] ?? '').slice(m.startColumn)} ${lines[m.startLine] ?? ''} ${lines[m.startLine + 1] ?? ''}`;
const esc = (s: string): string => s.replace(/\$/g, '\\$');
return new RegExp(`(?:^|[^\\w$.])${esc(owner)}\\s*\\.\\s*${esc(m.name)}\\s*[(<]`).test(head);
function isDartStaticMember(m: Node, context: ResolutionContext): boolean {
return m.isStatic === true || m.kind === 'constant' || m.kind === 'enum_member' || isDartConstructor(m, context);
}
/**
@@ -3969,6 +3993,31 @@ function dartExtensionMemberOf(typeName: string, name: string, ref: UnresolvedRe
return found?.viaExtension ? found.node : null;
}
/**
* A Dart call through a type's name — `BlocProvider.value(…)`, riverpod's
* `FutureProvider.autoDispose(…)` — is to one of that type's own constructors
* or static members, or to nothing the project declares. Dart inherits none
* of them, so a guess past the type is always wrong: flutter_bloc's
* `BlocProvider.value(…)` went to `RepositoryProvider`'s `value` 89 times,
* riverpod's `Family2Family._()` to the `EmptyFamily2Family._` declared above
* it, and the SDK's `Uri.parse(…)`, past bloc_lint's `extension on Uri`, to a
* project `parse`. A static constant's callee is the value it holds (`static
* const autoDispose = AutoDisposeFutureProviderBuilder();`). The member is
* found as a chain's static link finds it (dartStaticMember). Undefined when
* the receiver names no Dart type of the project.
*/
function matchDartTypeMemberCall(
typeName: string,
member: string,
ref: UnresolvedRef,
context: ResolutionContext,
): ResolvedRef | null | undefined {
if (!context.getNodesByName(typeName).some((n) => n.language === 'dart' && DART_TYPE_KINDS.has(n.kind))) return undefined;
const own = dartStaticMember(typeName, member, ref, context);
return own && own.kind !== 'enum_member'
? { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'qualified-name' } : null;
}
/**
* How a bare Rust or Go name is written at its call: `path` after `::` (left
* to the path strategies), `chained` after a `.` — with the receiver it is
@@ -6683,6 +6732,11 @@ export function matchByExactName(
!isKotlinNumberBitwise(n, ref) &&
!(solidityBare && !isSolidityMemberInScope(n, ref, context)) &&
!(dartBare && isDartMember(n) && !isDartMethodInScope(n, ref, context)) &&
// A Dart constructor is reached through its class (`Foo.named(…)`,
// `@Foo.named(…)`), never by its name alone: not a bare call, not the
// last link of a chain, not an annotation (riverpod's 210 `@internal`
// annotations went to its providers' `internal` constructors).
!(ref.language === 'dart' && isDartConstructor(n, context)) &&
!(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))) &&
!(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)) &&
!(rustBare && !isRustNameInScope(n, ref, context)) &&
@@ -8387,6 +8441,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
DART_LINEAGES.delete(context);
DART_EXTENSION_OWNERS.delete(context);
DART_GETTERS.delete(context);
DART_CONSTRUCTORS.delete(context);
DART_SETTERS.delete(context);
DART_CODE.delete(context);
DART_RETURN_TYPES.delete(context);
@@ -9514,6 +9569,13 @@ export function matchMethodCall(
// receiver type we can try to infer from its local declaration.
const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch;
// A Dart call through a type's name is to that type's own member, or to
// nothing in the project.
if (ref.language === 'dart' && dotMatch) {
const typeMember = nmTimedT('mc-darttype', ref, () => matchDartTypeMemberCall(objectOrClass!, methodName!, ref, context));
if (typeMember !== undefined) return typeMember;
}
// Infer the receiver's type from its local declaration/initializer in the
// enclosing scope, then resolve the method on that type (#1108). C++ keeps its
// dedicated inferrer (header scan + `auto`); every other language uses the
@@ -9739,24 +9801,6 @@ export function matchMethodCall(
if (classNode.language !== ref.language) continue;
const nodesInFile = context.getNodesInFile(classNode.filePath);
// Dart: a call through a type's static constant invokes the value the
// constant holds. riverpod's `FutureProvider.autoDispose(…)` calls
// `static const autoDispose = AutoDisposeFutureProviderBuilder();`,
// which the lookups below gave to another builder's `autoDispose`
// method. A Dart type can't also declare a method of that name, and its
// static members are not inherited, so its own constant is the callee.
if (ref.language === 'dart') {
const holder = nodesInFile.find((n) =>
n.kind === 'constant' && n.name === methodName && n.qualifiedName === `${classNode.qualifiedName}::${methodName}`);
if (holder) {
return {
original: ref,
targetNodeId: holder.id,
confidence: 0.85,
resolvedBy: 'qualified-name',
};
}
}
const methodNode = nodesInFile.find(
(n) =>
n.kind === 'method' &&
@@ -11480,6 +11524,7 @@ export function matchFuzzy(
!(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)) &&
!(javaBare && n.kind === 'method' && !isJavaMethodInScope(n, ref, context)) &&
!(dartBare && isDartMember(n) && !isDartMethodInScope(n, ref, context)) &&
!(ref.language === 'dart' && isDartConstructor(n, context)) &&
!(kotlinCall && !isKotlinTopLevelVisible(n, ref, context)) &&
!(kotlinBare && !isKotlinMemberReachable(n, ref, context)) &&
!isKotlinNumberBitwise(n, ref) &&