From d932f59ece3f3edf7a283573d456577efa7855b7 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 6 Oct 2026 09:36:27 -0500 Subject: [PATCH 1/2] 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.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 --- CHANGELOG.md | 1 + __tests__/dart-const-constructors.test.ts | 277 ++++++++++++++++++ .../kernel-parity/TortureConstCtors.dart | 86 ++++++ __tests__/kernel-dart-parity.test.ts | 11 +- codegraph-kernel/src/dart.rs | 119 ++++++-- docs/design/dart-kernel-port-checklist.md | 50 +++- src/extraction/languages/dart.ts | 68 ++++- src/extraction/tree-sitter.ts | 12 + src/resolution/name-matcher.ts | 94 ++++-- 9 files changed, 659 insertions(+), 59 deletions(-) create mode 100644 __tests__/dart-const-constructors.test.ts create mode 100644 __tests__/fixtures/kernel-parity/TortureConstCtors.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5216219e9..bad244b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. +- 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.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. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/dart-const-constructors.test.ts b/__tests__/dart-const-constructors.test.ts new file mode 100644 index 000000000..438c84f3a --- /dev/null +++ b/__tests__/dart-const-constructors.test.ts @@ -0,0 +1,277 @@ +/** + * Dart `const` constructors and redirecting factories are methods, and a call + * through a type's name is to that type's own member or to nothing. + * + * `const Foo.bar(…)` and `const factory Foo.bar() = _Bar;` parse as signature + * kinds of their own, which neither extractor indexed. So flutter_bloc's + * `BlocProvider.value(…)` found no `value` on BlocProvider, and the resolver + * guessed the one other `value` — RepositoryProvider's non-const constructor — + * for all 89 calls in felangel/bloc. + * + * Dart inherits no static member and no constructor, so a call through a type + * the project declares never means another type's method: not a same-named + * one guessed by name, not a supertype's, not a same-file look-alike's. And + * the SDK's `Uri.parse(…)` is not a project `parse` because the project has an + * `extension on Uri`. + * + * A constructor is only ever called through its class. Written with type + * arguments — `BlocProvider.value(…)`, most of bloc's tests — + * the call still names the type, and a bare name or a chain's last link + * (`box.inner.value(1)`) never means a constructor. + * + * 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 = { + 'pubspec.yaml': 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n', + 'lib/bloc_provider.dart': `class BlocProvider { + const BlocProvider({required T Function() create}) : _create = create, _value = null; + + const BlocProvider.value({required T value}) : this._(value: value); + + const BlocProvider._({T? value}) : _create = null, _value = value; + + final T Function()? _create; + final T? _value; + + void handle(Object value) => accept(value); +} + +void accept(Object o) {} +`, + 'lib/repository_provider.dart': `class RepositoryProvider { + RepositoryProvider({required T Function() create}); + + RepositoryProvider.value({required T value}); + + static T of(Object context) => throw 0; +} +`, + 'lib/new_car_state.dart': `class NewCarState { + const NewCarState._({this.brands = const []}); + + const NewCarState.initial() : this._(); + + const factory NewCarState.loading() = _Loading; + + const factory NewCarState() = _Loading.named; + + const factory NewCarState.fromJson(Map json) = _Loading.fromJson; + + final List brands; +} + +class _Loading extends NewCarState { + const _Loading() : super._(); + + const _Loading.named() : super._(); + + const _Loading.fromJson(Map json) : super._(); +} +`, + 'lib/profile_wizard_state.dart': `class ProfileWizardState { + ProfileWizardState.initial() : step = 0; + + final int step; + + Object fromJson(Object json) => json; +} +`, + // A look-alike declared first in the same file. + 'lib/family.dart': `class EmptyFamily extends Family { + EmptyFamily._() : super._(); +} + +class Family { + const Family._(); + + static Family make() => Family._(); +} +`, + 'lib/sub.dart': `abstract class Base { + const Base(); + + factory Base.named() => const _BaseImpl(); +} + +class _BaseImpl extends Base { + const _BaseImpl(); +} + +class Sub extends Base { + const Sub(); + + const factory Sub.named() = _SubImpl; +} + +class _SubImpl extends Sub { + const _SubImpl(); +} +`, + 'lib/analysis_options.dart': `class AnalysisOptions { + const AnalysisOptions(this.path); + + factory AnalysisOptions.parse(String path) => AnalysisOptions(path); + + final String path; +} +`, + 'lib/linter.dart': `extension on Uri { + String get canonicalizedPath => path; +} +`, + // An instance getter declared before a constructor of the same name. + 'lib/async_value.dart': `sealed class AsyncValue { + const AsyncValue._(); + + Object? get error => null; + + const factory AsyncValue.error(Object error) = AsyncError; +} + +class AsyncError extends AsyncValue { + const AsyncError(this.error) : super._(); + + @override + final Object error; +} + +Object fail(Object e) => AsyncValue.error(e); +`, + 'lib/change_notifier_provider.dart': `class ChangeNotifierProvider { + const ChangeNotifierProvider.internal(); +} +`, + // package:meta's `internal` annotation is a constant outside the project. + 'lib/scope.dart': `import 'package:meta/meta.dart'; + +@internal +Object guarded() => 0; +`, + 'lib/app.dart': `import 'bloc_provider.dart'; +import 'repository_provider.dart'; +import 'new_car_state.dart'; +import 'sub.dart'; + +Object build(Object counter) => BlocProvider.value(value: counter); +Object typed(Object counter) => BlocProvider.value(value: counter); +Object typedRepository(Object repository) => RepositoryProvider.value(value: repository); +NewCarState start() => NewCarState.initial(); +NewCarState load() => NewCarState.loading(); +Sub sub() => Sub.named(); +Object find(Object context) => BlocProvider.of(context); +Uri link() => Uri.parse('https://example.com'); +Object tail(dynamic box) => box.inner.value(1); +`, +}; + +describe('Dart const constructors and redirecting factories', () => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-const-ctors-')); + 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'])('are indexed and called through their own type (%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 find = (file: string, qualifiedName: string): Node | undefined => + graph.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName); + const node = (file: string, qualifiedName: string): Node => { + const found = find(file, 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): string[] => + [...new Set(graph + .getIncomingEdgesTo([to.id]) + .filter((e) => e.kind !== 'contains') + .map((e) => `${e.kind} ${graph.getNode(e.source)!.qualifiedName}`))] + .sort(); + + // Named `const` constructors and redirecting factories are methods named + // by the constructor, returning their class; the unnamed ones stay + // unindexed, as the unnamed generative constructor is, and a redirecting + // factory's target (`= _Loading.named`) names nothing. + const value = node('lib/bloc_provider.dart', 'BlocProvider::value'); + expect(value.kind).toBe('method'); + expect(value.returnType).toBe('BlocProvider'); + expect(value.signature).toBe('({required T value})'); + node('lib/bloc_provider.dart', 'BlocProvider::_'); + expect(find('lib/bloc_provider.dart', 'BlocProvider::BlocProvider')).toBeUndefined(); + node('lib/new_car_state.dart', 'NewCarState::initial'); + const loading = node('lib/new_car_state.dart', 'NewCarState::loading'); + expect(loading.signature).toBe('()'); + expect(find('lib/new_car_state.dart', 'NewCarState::NewCarState')).toBeUndefined(); + expect(find('lib/new_car_state.dart', 'NewCarState::named')).toBeUndefined(); + node('lib/new_car_state.dart', '_Loading::named'); + node('lib/sub.dart', 'Sub::named'); + // A redirecting factory refers to the class it constructs, and not to + // that class's constructor as if it were a type (`= _Loading.fromJson`). + expect(targets(loading, 'references')).toEqual(['_Loading']); + expect(targets(node('lib/new_car_state.dart', 'NewCarState::fromJson'), 'references')).toEqual(['_Loading']); + expect(sources(node('lib/profile_wizard_state.dart', 'ProfileWizardState::fromJson'))).toEqual([]); + + // Each call reaches the constructor of the type it names, type arguments + // or not... + expect(targets(node('lib/app.dart', 'build'), 'calls')).toEqual(['BlocProvider::value']); + expect(targets(node('lib/app.dart', 'typed'), 'calls')).toEqual(['BlocProvider::value']); + expect(targets(node('lib/app.dart', 'typedRepository'), 'calls')).toEqual(['RepositoryProvider::value']); + expect(targets(node('lib/app.dart', 'start'), 'calls')).toEqual(['NewCarState::initial']); + expect(targets(node('lib/app.dart', 'load'), 'calls')).toEqual(['NewCarState::loading']); + expect(targets(node('lib/app.dart', 'sub'), 'calls')).toEqual(['Sub::named']); + expect(targets(node('lib/family.dart', 'Family::make'), 'calls')).toEqual(['Family::_']); + // A constructor, not the instance getter that shares its name. + const errorFactory = graph.getNodesInFile('lib/async_value.dart') + .find((n) => n.qualifiedName === 'AsyncValue::error' && n.signature === '(Object error)'); + expect(errorFactory).toBeDefined(); + expect(graph.getOutgoingEdgesFrom([node('lib/async_value.dart', 'fail').id]) + .filter((e) => e.kind === 'calls').map((e) => e.target)).toEqual([errorFactory!.id]); + // ...and never another type's member of that name. + expect(sources(node('lib/repository_provider.dart', 'RepositoryProvider::value'))).toEqual(['calls typedRepository']); + expect(sources(node('lib/profile_wizard_state.dart', 'ProfileWizardState::initial'))).toEqual([]); + expect(sources(node('lib/family.dart', 'EmptyFamily::_'))).toEqual([]); + expect(sources(node('lib/sub.dart', 'Base::named'))).toEqual([]); + // A member the named type does not declare is outside the project. + expect(targets(node('lib/app.dart', 'find'), 'calls')).toEqual([]); + expect(sources(node('lib/repository_provider.dart', 'RepositoryProvider::of'))).toEqual([]); + expect(targets(node('lib/app.dart', 'link'), 'calls')).toEqual([]); + expect(sources(node('lib/analysis_options.dart', 'AnalysisOptions::parse'))).toEqual([]); + + // A constructor is only ever reached through its class: a parameter that + // shares its name, the last link of a chain, or an annotation is not it. + expect(targets(node('lib/app.dart', 'tail'), 'calls')).toEqual([]); + expect(sources(value)).toEqual(['calls build', 'calls typed']); + expect(sources(node('lib/change_notifier_provider.dart', 'ChangeNotifierProvider::internal'))).toEqual([]); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/TortureConstCtors.dart b/__tests__/fixtures/kernel-parity/TortureConstCtors.dart new file mode 100644 index 000000000..3d23bce80 --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureConstCtors.dart @@ -0,0 +1,86 @@ +import 'package:meta/meta.dart'; +import 'package:other/other.dart' as p; + +const kDepth = 3; + +/// A provider with const constructors and redirecting factories. +class ProviderT { + /// The unnamed const constructor: no node. + const ProviderT({required this.create, this.lazy = true}) : _value = null; + + /// A named const constructor that redirects. + const ProviderT.value({required T value, int depth = kDepth}) + : this._(value: value); + + @visibleForTesting + const ProviderT._({T? value}) + : create = null, + lazy = false, + _value = value; + + const factory ProviderT.redirect({int a}) = _ImplT; + + factory ProviderT.plain() = _ImplT.named; + + const factory ProviderT.json(Map json) = _ImplT.fromJson; + + const factory ProviderT.prefixed() = p.OtherT; + + const factory ProviderT.prefixedNamed() = p.OtherT.named; + + const factory ProviderT.unnamedTarget() = _ImplT; + + const factory ProviderT() = _ImplT.named; + + final Object? create; + final bool lazy; + final T? _value; + + void handle(Object value) => accept(value); + + static ProviderT make() => ProviderT.value(value: 1); + + static Object typed(dynamic box) { + ProviderT.value(value: 2); + final made = ProviderT.value(value: 3); + accept(ProviderT.value(value: 4)); + wrap(child: ProviderT.value(value: 5)); + box.inner.value(6); + return p.OtherT.named(made); + } +} + +class _ImplT extends ProviderT { + const _ImplT({int a = 0}) : super._(); + + const _ImplT.named() : super._(); + + const _ImplT.fromJson(Map json) : super._(); +} + +class _$GeneratedT { + const _$GeneratedT.create(); +} + +enum StatusT { + ok(200), + gone.named(410); + + const StatusT(this.code); + + const StatusT.named(this.code); + + final int code; +} + +extension type const MetersT(int value) { + const MetersT.zero() : this(0); +} + +final topProvider = ProviderT.value(value: 7); + +Object build() => ProviderT.value(value: 8); + +void accept(Object o) {} + +void wrap({required Object child}) {} diff --git a/__tests__/kernel-dart-parity.test.ts b/__tests__/kernel-dart-parity.test.ts index cc8fd008e..7943d3477 100644 --- a/__tests__/kernel-dart-parity.test.ts +++ b/__tests__/kernel-dart-parity.test.ts @@ -19,12 +19,15 @@ * and the exact duplicated-ref interleave; TortureFnrefDart.dart: fn-ref * capture channels incl. named-argument non-capture and the file/class * twins; TortureMini/TortureSigs/TortureCtors/TortureVrefDart: signatures - * verbatim, prefixed-return-type prefix bug, const factories invisible, + * verbatim, prefixed-return-type prefix bug, a const factory as a method, * value-ref matrix with `$X` vs `${X}` asymmetry; TortureReadsTypes.dart: * 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 + * second variable left to its function; TortureConstCtors.dart: `const` + * constructors and redirecting factories as methods with the unnamed ones + * skipped, every redirect-target form, and constructors called with type + * arguments, as statements and as expressions) 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 @@ -122,6 +125,10 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => { // instantiations, reads, closures with block bodies and local functions, // and function values captured once, for the declaration. ['TortureInitializers.dart', 30], + // `const` constructors and redirecting factories as methods (the unnamed + // ones skipped), every redirect-target form, and constructors called + // with type arguments in statements and expressions. + ['TortureConstCtors.dart', 20], ] as const; for (const [file, minNodes] of FIXTURES) { diff --git a/codegraph-kernel/src/dart.rs b/codegraph-kernel/src/dart.rs index b423a8f6e..458ecc5c4 100644 --- a/codegraph-kernel/src/dart.rs +++ b/codegraph-kernel/src/dart.rs @@ -18,8 +18,9 @@ //! `.`); `ConfigT.load()` double-emits (calls + a static-member references //! ref — no callee-of-call skip in the dart branch); operator methods mint //! `method ""`; the unnamed constructor is skipped -//! (isMisparsedFunction) while named ctors/factories are named by the CTOR -//! name with the class as returnType; instance fields mint NO nodes (only +//! (isMisparsedFunction) while named ctors/factories — `const` ones and +//! redirecting factories included — are named by the CTOR name with the +//! class as returnType; instance fields mint NO nodes (only //! static_final_declaration → constant, via the hook); every initializer is //! body-walked once — a constant's from the constant, a field's or a //! top-level variable's from the class or the file; prefixed return @@ -90,6 +91,23 @@ fn angle_args_re() -> &'static Regex { RE.get_or_init(|| Regex::new(r"<[^>]*>").unwrap()) } +/// The UpperCamel test of isDartTypeName (`/^[_$]*[A-Z]/`) — how Dart writes +/// a type, `_Private` and `$Generated` ones included. +fn is_upper_camel(text: &str) -> bool { + text.trim_start_matches(&['_', '$'][..]) + .chars() + .next() + .map(|c| c.is_ascii_uppercase()) + .unwrap_or(false) +} + +/// DART_BODILESS_CTORS (dart.ts) — `const Foo.bar();` and a redirecting +/// factory `const factory Foo.bar() = _Bar;`, each a signature kind of its +/// own inside a `declaration`. +fn is_bodiless_ctor(kind: &str) -> bool { + matches!(kind, "constant_constructor_signature" | "redirecting_factory_constructor_signature") +} + struct Scope { row: u32, kind: &'static str, @@ -385,7 +403,9 @@ impl<'t> Walker<'t> { /// dartConstructorSignature (dart.ts:25-35). fn constructor_signature(&self, node: Node<'t>) -> Option> { - if matches!(node.kind(), "factory_constructor_signature" | "constructor_signature") { + if matches!(node.kind(), "factory_constructor_signature" | "constructor_signature") + || is_bodiless_ctor(node.kind()) + { return Some(node); } if node.kind() == "method_signature" { @@ -415,9 +435,12 @@ impl<'t> Walker<'t> { /// dartCtorInfo (dart.ts:61-70). fn ctor_info(&self, node: Node<'t>) -> Option<(String, String)> { let ctor = self.constructor_signature(node)?; + // The names before the parameters: a redirecting factory names its + // target after them (`factory Foo() = _Impl.named;`). let mut cursor = ctor.walk(); let ids: Vec> = ctor .named_children(&mut cursor) + .take_while(|c| c.kind() != "formal_parameter_list") .filter(|c| c.kind() == "identifier") .collect(); let class_name = self.enclosing_type_name(node)?; @@ -448,11 +471,13 @@ impl<'t> Walker<'t> { Some(last.to_string()) } - /// isMisparsedFunction (dart.ts:177-188) — skip the UNNAMED constructor. - fn is_unnamed_ctor(&self, node: Node<'t>) -> bool { + /// isMisparsedFunction (dart.ts:177-188) — skip the UNNAMED constructor, + /// and a `const` ctor / redirecting factory that names no enclosing type + /// (error recovery's). + fn is_misparsed_function(&self, node: Node<'t>) -> bool { match self.ctor_info(node) { Some((class_name, ctor_name)) => ctor_name == class_name, - None => false, + None => is_bodiless_ctor(node.kind()), } } @@ -463,10 +488,15 @@ impl<'t> Walker<'t> { let params = sig .named_children(&mut c1) .find(|c| c.kind() == "formal_parameter_list"); + // A constructor has no return type: the type a redirecting factory + // names is its target (`= _Impl`). let mut c2 = sig.walk(); - let ret = sig - .named_children(&mut c2) - .find(|c| matches!(c.kind(), "type_identifier" | "void_type")); + let ret = if is_bodiless_ctor(sig.kind()) { + None + } else { + sig.named_children(&mut c2) + .find(|c| matches!(c.kind(), "type_identifier" | "void_type")) + }; if params.is_none() && ret.is_none() { return None; } @@ -704,7 +734,10 @@ impl<'t> Walker<'t> { self.extract_class(node); return; } - "method_signature" | "constructor_signature" => { + "method_signature" + | "constructor_signature" + | "constant_constructor_signature" + | "redirecting_factory_constructor_signature" => { self.extract_method(node); return; } @@ -753,7 +786,7 @@ impl<'t> Walker<'t> { } // isMisparsedFunction: the unnamed constructor is skipped — node // suppressed, body still walked (attributed to the current stack top). - if self.is_unnamed_ctor(node) { + if self.is_misparsed_function(node) { if let Some(body) = self.resolve_body(node) { self.visit_body(body); } @@ -800,7 +833,7 @@ impl<'t> Walker<'t> { } let name = self.extract_name(node); // isMisparsedFunction — the unnamed ctor: body-only walk. - if self.is_unnamed_ctor(node) { + if self.is_misparsed_function(node) { if let Some(body) = self.resolve_body(node) { self.visit_body(body); } @@ -1035,6 +1068,11 @@ impl<'t> Walker<'t> { if ap.kind() == "identifier" { return Some(format!("{}.{}", self.text(ap), self.text(method_id))); } + // A constructor called with type arguments names + // its type all the same (`BlocProvider.value`). + if let Some(type_name) = self.type_arguments_receiver(ap) { + return Some(format!("{}.{}", type_name, self.text(method_id))); + } // Chained static-factory: the receiver is itself // a call — re-encode `().` when // the chain starts capitalized (#750). @@ -1091,6 +1129,21 @@ impl<'t> Walker<'t> { }; } + // `=> BlocProvider.value(…)` — a constructor called + // with type arguments, as most expressions parse it. The type is the + // LAST type_identifier: an import prefix (`p.X.named`) is one too. + if node.kind() == "constructor_invocation" { + let mut c1 = node.walk(); + let type_id = node.named_children(&mut c1).filter(|c| c.kind() == "type_identifier").last(); + let mut c2 = node.walk(); + let name_id = node.named_children(&mut c2).find(|c| c.kind() == "identifier"); + return match (type_id, name_id) { + (Some(t), Some(n)) => Some(format!("{}.{}", self.text(t), self.text(n))), + (Some(t), None) => Some(self.text(t).to_string()), + _ => None, + }; + } + None } @@ -1119,6 +1172,9 @@ impl<'t> Walker<'t> { if ap.kind() == "identifier" { return Some(format!("{}.{}", self.text(ap), self.text(method_id))); } + if let Some(type_name) = self.type_arguments_receiver(ap) { + return Some(format!("{}.{}", type_name, self.text(method_id))); + } } return Some(self.text(method_id).to_string()); } @@ -1126,6 +1182,23 @@ impl<'t> Walker<'t> { None } + /// dartTypeArgumentsReceiver (dart.ts) — `BlocProvider` in + /// `BlocProvider.value(…)`, when `selector` is the `<…>` + /// between the type and `.value`. Only a constructor is called that way. + fn type_arguments_receiver(&self, selector: Node<'t>) -> Option<&'t str> { + if selector.kind() != "selector" || selector.named_child_count() != 1 { + return None; + } + if selector.named_child(0)?.kind() != "type_arguments" { + return None; + } + let type_node = selector.prev_named_sibling()?; + if type_node.kind() != "identifier" { + return None; + } + Some(self.text(type_node)) + } + // --- extractStaticMemberRef — the dart branch (:4759-4767) ------------ fn extract_static_member_ref(&mut self, node: Node<'t>) { @@ -1326,6 +1399,20 @@ impl<'t> Walker<'t> { } else { node }; + // A redirecting factory names the class it constructs after `=`, then + // perhaps that class's constructor (`= _$QuestionImpl.fromJson`, two + // type_identifiers). Neither the constructor nor an import prefix is + // a type, and Dart writes types UpperCamel. + if sig.kind() == "redirecting_factory_constructor_signature" { + let mut cursor = sig.walk(); + let kids: Vec> = sig.named_children(&mut cursor).collect(); + for child in kids { + if child.kind() != "type_identifier" || is_upper_camel(self.text(child)) { + self.type_refs_from_subtree(child, row); + } + } + return; + } self.type_refs_from_subtree(sig, row); } @@ -1354,13 +1441,7 @@ impl<'t> Walker<'t> { /// constructor call names. fn is_type_name_dart(&self, node: Node<'t>) -> bool { let text = self.text(node); - let upper = text - .trim_start_matches(&['_', '$'][..]) - .chars() - .next() - .map(|c| c.is_ascii_uppercase()) - .unwrap_or(false); - if !upper || is_builtin_type(text) { + if !is_upper_camel(text) || is_builtin_type(text) { return false; } if node.next_sibling().map(|s| s.kind() == ".").unwrap_or(false) { diff --git a/docs/design/dart-kernel-port-checklist.md b/docs/design/dart-kernel-port-checklist.md index 6048396c4..e2575d6ef 100644 --- a/docs/design/dart-kernel-port-checklist.md +++ b/docs/design/dart-kernel-port-checklist.md @@ -190,7 +190,9 @@ change dart's grammar, which is exactly the hazard this vendor kills. Types: functionTypes=[`function_signature`] (:119); classTypes= [`class_definition`] (:120); methodTypes=[`method_signature`, -`constructor_signature`] (:126); interfaceTypes=[] ; structTypes=[]; +`constructor_signature`, `constant_constructor_signature`, +`redirecting_factory_constructor_signature`] (:126 — the last two since +2026-10-06); interfaceTypes=[] ; structTypes=[]; enumTypes=[`enum_declaration`] (:129); enumMemberTypes=[`enum_constant`] (:130); typeAliasTypes=[`type_alias`] (:131); importTypes=[`import_or_export`] (:132); **callTypes=[] (:133 — extractCall NEVER runs for dart; all call refs @@ -237,9 +239,14 @@ Grammar-shape facts the config leans on (all probed, `mini-cst.txt` / initializers]`) — reached by the walker via plain recursion into `declaration`. `const` constructors are `declaration > constant_constructor_signature` and redirecting factories are - `declaration > redirecting_factory_constructor_signature` — **NEITHER is - in methodTypes → const ctors and `const factory X.r() = Impl;` are - INVISIBLE (no node, no refs)**. Fields are `declaration > + `declaration > redirecting_factory_constructor_signature` — both carry + NO fields (children: `const_builtin`? `"factory"`? identifier [`.` + identifier] formal_parameter_list, then for a redirect `=` and the + target: `type_identifier` [type_arguments] [`.` name], where the target + constructor's name is a `type_identifier` without type arguments and an + `identifier` after them). Until 2026-10-06 neither was in methodTypes + (no node, no refs); now both mint methods exactly like the bare + `constructor_signature` (§isMisparsedFunction, §resolveName). Fields are `declaration > (static)? (final|const|type|var) > initialized_identifier_list | static_final_declaration_list` (§Constants). - **Statements/expressions:** calls have NO call node — a postfix chain is @@ -349,7 +356,12 @@ Hooks PRESENT (port each exactly — anchors into languages/dart.ts): extension_declaration/enum_declaration and reads ITS name field) and `reduce` ≠ `Action` → treated as the method it is (pinned: `extract-probe4.txt` — method `reduce`, position starting AT `reduce`, - sig/ret undefined, decorates override intact). + sig/ret undefined, decorates override intact). dartCtorInfo reads only the + identifiers BEFORE the formal_parameter_list, so a redirecting factory's + target (`factory Foo() = _Impl.named;`) never names it. A + `constant_constructor_signature` / `redirecting_factory_constructor_signature` + that dartCtorInfo rejects (names no enclosing type — error recovery) is + skipped too: it can be nothing else. - **getSignature (:189-208)** — method_signature unwraps to function/getter/setter signature (NOT ctor/factory/operator); params = find namedChild `formal_parameter_list`; retType = find namedChild @@ -365,7 +377,9 @@ Hooks PRESENT (port each exactly — anchors into languages/dart.ts): formal_parameter_list on method_signature); **bodiless named ctor (declaration > constructor_signature) → `"()"`-style params-only sig** (node IS the ctor signature; its formal_parameter_list is found by type) — - pin BOTH ctor sig shapes; operators → undefined. + pin BOTH ctor sig shapes; operators → undefined. `const` ctors and + redirecting factories → params only too: no retType is looked up for them + (a redirect's `= _Impl` type_identifier is its target, not a return type). - **getVisibility (:209-222)** — method_signature → unwrap to function/getter/setter signature → its first `identifier` child; other nodes → childForFieldName('name'). Name starts `_` → 'private', else @@ -386,9 +400,9 @@ Hooks PRESENT (port each exactly — anchors into languages/dart.ts): children for type `'static'` → true; else false. `static WidgetT make()` → true ✓ (the `static` keyword is an anon child of method_signature); bare constructor_signature → false. Top-level functions → false. -- **resolveName (:244-260)** — dartCtorInfo: named ctor/factory → the ctor - name (`named`, `create`, `_`); unnamed ctor or non-ctor → undefined (falls - to extractName §below). +- **resolveName (:244-260)** — dartCtorInfo: named ctor/factory (`const` + and redirecting ones included) → the ctor name (`named`, `create`, `_`); + unnamed ctor or non-ctor → undefined (falls to extractName §below). - **extractImport (:261-304)** — importText = trimmed full node slice; moduleName = the URI string content: `library_import > import_specification > configurable_uri > uri > string_literal` (else the @@ -442,7 +456,7 @@ Registration: `EXTRACTORS.dart` (languages/index.ts:56), `FN_REF_SPECS.dart` | `import_or_export` | importTypes:1209 → extractImport:3170 | §Extractor config | | `new_expression` | INSTANTIATION_KINDS:1255 (`new_expression` ∈ :354-361) | extractInstantiation:4610 → ctor field lookups null → namedChild(0) = type_identifier → `instantiates` ref from stack top; `<`-strip + last-`.`-segment apply (`new p.Foo()` → `Foo`). findAnonymousClassBody → always null for dart. Children still recursed | | `function_body` (sibling of a consumed signature) | **no branch** | recursed → THE DOUBLE-WALK (§below) | -| `declaration` (fields, bodiless ctors) | no branch | recursed → constructor_signature hits methodTypes; each field `initialized_identifier` hits the hook (body-walked for the class); constant_constructor_signature, redirecting_factory_constructor_signature, initializers, annotations-in-place: nothing | +| `declaration` (fields, bodiless ctors) | no branch | recursed → constructor_signature, constant_constructor_signature and redirecting_factory_constructor_signature hit methodTypes; each field `initialized_identifier` hits the hook (body-walked for the class); initializers, annotations-in-place: nothing | | `getter_signature` / `setter_signature` BARE (top level) | no branch | **top-level getters/setters are INVISIBLE** (no node; their sibling function_body is visitNode-recursed where calls don't extract) — in classes they're method_signature-wrapped → methods | | `const_object_expression`, `selector`, `cascade_section`, `assignment_expression`, `local_variable_declaration`, patterns, `extension_type_declaration`, `part_directive`, `library_name`, lambdas | no branch | recursed; calls only extract in the BODY walker (`extractBareCall` is not consulted by visitNode!) — §Calls for the consequences | | `property_signature`/`method_signature` TS branch (:1282) | **shadowed** | method_signature is consumed at :1027 first; property_signature isn't a dart kind — branch unreachable | @@ -643,8 +657,9 @@ chain → FIRST value child only); `static final sharedInst = WidgetT(0)` → constant under the class, sig `= WidgetT`; multi-declarations → one node each with own columns. **NO nodes ever**: instance fields (typed/untyped/ late/var), `static var`, top-level var/typed vars, top-level getters/ -setters, const constructors, redirecting factories, extension_type -containers, `part`/`part of`/`library`/deferred imports. **Initializers +setters, extension_type containers, `part`/`part of`/`library`/deferred +imports. (Named `const` constructors and redirecting factories mint +methods since 2026-10-06; the unnamed ones still mint nothing.) **Initializers are code:** every initializer is walked with the body walker — a constant's for the constant (riverpod's `final repoProvider = Provider((ref) => Repository(ref.watch(dioProvider)));` → calls @@ -682,7 +697,9 @@ extract-mini.txt): | `w?.render()` | conditional_assignable_selector | `w.render` — **`?.` is encoded exactly like `.`** | | `y2..add(1)..add(2)` (cascades) | cascade_section (argument_part NOT inside a selector) | **NOTHING — cascade calls are completely invisible** | | `new WidgetT(2)` | new_expression → INSTANTIATION branch :5145 FIRST | `instantiates WidgetT` (extractBareCall's new_expression arm :363-367 is DEAD — the else-if never reaches it); args still recursed | -| `pad(const EdgeInsetsT.all(8.0))` | const_object_expression :369-376 | `EdgeInsetsT.all` at the CONST node position (typeId + '.' + nameId; type-only form → `EdgeInsetsT`); children recursed after | +| `pad(const EdgeInsetsT.all(8.0))` | const_object_expression :369-376 | `EdgeInsetsT.all` at the CONST node position (typeId + '.' + nameId; type-only form → `EdgeInsetsT`); children recursed after. The FIRST type_identifier: `const p.X.named()` → `p.named` (prefix bug, preserved) | +| `BlocProviderT.value(…);` as a statement | args selector; prev = `.value` selector; accessorPrev = a selector holding only `type_arguments`, whose prev is the identifier | `BlocProviderT.value` (dartTypeArgumentsReceiver, since 2026-10-06 — bare `value` before); the `.value().tail` re-encode reads it the same way | +| `=> BlocProviderT.value(…)`, and as an initializer, argument or `return` value | `constructor_invocation` (type_identifier [`.` type_identifier] type_arguments `.` identifier arguments) | `BlocProviderT.value` at the node position — the LAST type_identifier, so `p.X.named()` → `X.named` (since 2026-10-06; nothing before) | | `generic(5)` | args selector (type args ride argument_part) | bare `generic` | | `await fetch()` | recursion through unary/await_expression | `fetch` at the selector | | `throw StateError('bad')` | recursion | `StateError` | @@ -746,7 +763,12 @@ BUILTIN_TYPES (:5768-5782), at each leaf's position. Consequences (pinned): = constructor_signature → param types emit (`Widget.named(WidgetT w)` → references WidgetT); `this.`-params (constructor_param) hold no type_identifier → nothing. Bodiless declaration-wrapped ctors: extractMethod - runs on the bare constructor_signature → sig = node → same. + runs on the bare constructor_signature → sig = node → same; so do `const` + ctors. A redirecting factory walks its named children one by one and + skips a DIRECT type_identifier that is not UpperCamel (`/^[_$]*[A-Z]/`): + after `=` those are an import prefix (`= p.Impl`) or the target + constructor's name (`= _$QuestionImpl.fromJson`, where `fromJson` is a + type_identifier) — `references _$QuestionImpl` only. - Getters: references from the getter's TYPE (suppressed if builtin — `int get area` → nothing; `WidgetT get w` → references WidgetT). - extractVariableTypeAnnotation (:6074) needs a `type_annotation` child — diff --git a/src/extraction/languages/dart.ts b/src/extraction/languages/dart.ts index ad5b8c4bd..fdac302a7 100644 --- a/src/extraction/languages/dart.ts +++ b/src/extraction/languages/dart.ts @@ -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 = 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.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.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 `().` so resolution can infer @@ -512,6 +559,17 @@ export const dartExtractor: LanguageExtractor = { return undefined; } + // `=> BlocProvider.value(…)` — a constructor called with type + // arguments, as most expressions parse it. The type is the last + // `type_identifier`: an import prefix (`p.X.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, diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index da8dc9bbe..cb499d9ce 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -7169,6 +7169,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; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c7d7a0e07..d72bceba0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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>(); + +/** + * 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 @@ -3168,6 +3196,39 @@ 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();`). Undefined when + * the receiver names no Dart type of the project. + */ +function matchDartTypeMemberCall( + typeName: string, + member: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null | undefined { + const types = context.getNodesByName(typeName).filter((n) => n.language === 'dart' && DART_TYPE_KINDS.has(n.kind)); + if (types.length === 0) return undefined; + for (const type of preferCallSiteFile(types, ref.filePath)) { + // An `extension on X` has no name to call a member through. + if (dartExtensionDecl(type, context)?.named === false) continue; + const qualifiedName = `${type.qualifiedName}::${member}`; + // Never an instance member of that name: riverpod's `AsyncValue.error(…)` + // is `const factory AsyncValue.error(…)`, not `Object? get error`. + const own = context.getNodesInFile(type.filePath).find((n) => n.qualifiedName === qualifiedName && + (n.kind === 'constant' || (n.kind === 'method' && (n.isStatic === true || isDartConstructor(n, context))))); + if (own) return { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'qualified-name' }; + } + return 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 @@ -5875,6 +5936,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)) && @@ -8702,6 +8768,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 @@ -8927,24 +9000,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' && @@ -10667,6 +10722,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) && From 97a1c07d2246e822691eb9a4f32ed08b012cb1a3 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 6 Oct 2026 09:43:15 -0500 Subject: [PATCH 2/2] 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 --- src/resolution/name-matcher.ts | 41 +++++++++++++--------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9d908131c..d9b1cd4cd 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -3489,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); } /** @@ -4006,7 +4002,8 @@ function dartExtensionMemberOf(typeName: string, name: string, ref: UnresolvedRe * 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();`). Undefined when + * 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( @@ -4015,19 +4012,10 @@ function matchDartTypeMemberCall( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null | undefined { - const types = context.getNodesByName(typeName).filter((n) => n.language === 'dart' && DART_TYPE_KINDS.has(n.kind)); - if (types.length === 0) return undefined; - for (const type of preferCallSiteFile(types, ref.filePath)) { - // An `extension on X` has no name to call a member through. - if (dartExtensionDecl(type, context)?.named === false) continue; - const qualifiedName = `${type.qualifiedName}::${member}`; - // Never an instance member of that name: riverpod's `AsyncValue.error(…)` - // is `const factory AsyncValue.error(…)`, not `Object? get error`. - const own = context.getNodesInFile(type.filePath).find((n) => n.qualifiedName === qualifiedName && - (n.kind === 'constant' || (n.kind === 'method' && (n.isStatic === true || isDartConstructor(n, context))))); - if (own) return { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'qualified-name' }; - } - return null; + 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; } /** @@ -8453,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);