From ea3277d2f52d5793384eaae804b01db13fd3dc8b Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 6 Oct 2026 00:48:53 -0500 Subject: [PATCH] fix(dart): link calls in top-level and field initializers Both Dart extractors (the TS walker and the native kernel) skipped the code in initializers. A `final`/`const` declaration minted its constant and stopped, and a field's or `var`'s initializer only had its types recorded (#2360). In Riverpod apps, where most wiring lives in provider closures (`final repoProvider = Provider((ref) => Repository(ref.watch(dioProvider)));`), what a provider builds with had no callers, and impact and explore flows stopped at the provider. - A `final`/`const` initializer is walked like a body, for its constant: calls, instantiations, static and member reads, types, closures with block bodies and local functions, function values. - A field's (instance, `static var`, typed, `late`) or a top-level `var`/`late final` initializer is walked for its class or the file, since neither mints a node. A local declaration's second variable (`for (var i = 0, j = n(); ...)`) stays its function's. - Each initializer is walked once: a new `walkInitializer` context method marks it, and the dispatcher's function-as-value scan skips it. Other languages' hooks are unchanged. - Resolver: a Dart call through a type's static constant (`FutureProvider.autoDispose(...)` on `static const autoDispose = ...Builder();`) links that constant, not another type's same-named method. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/dart-initializer-calls.test.ts | 245 ++++++++++++++++++ __tests__/dart-type-positions.test.ts | 3 +- .../kernel-parity/TortureInitializers.dart | 89 +++++++ __tests__/kernel-dart-parity.test.ts | 10 +- codegraph-kernel/src/dart.rs | 90 ++++--- docs/design/dart-kernel-port-checklist.md | 73 ++++-- src/extraction/languages/dart.ts | 60 +++-- src/extraction/tree-sitter-types.ts | 7 + src/extraction/tree-sitter.ts | 17 +- src/resolution/name-matcher.ts | 18 ++ 11 files changed, 517 insertions(+), 96 deletions(-) create mode 100644 __tests__/dart-initializer-calls.test.ts create mode 100644 __tests__/fixtures/kernel-parity/TortureInitializers.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcabc1eec..853caa0b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328) - Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326) - In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300) +- In Dart, what a top-level variable's or a field's initializer calls, creates and reads is now linked. In Riverpod apps, where most wiring lives in provider closures like `final repoProvider = Provider((ref) => Repository(ref.watch(dioProvider)));`, `callers`, impact and `codegraph_explore` now follow a provider to the code it builds instead of stopping at the provider. A `final` or `const` initializer belongs to its constant, and a `var`'s or a field's to its file or class. A call through a type's static constant, like `FutureProvider.autoDispose(…)`, now links to that constant instead of an unrelated method that shares its name. Re-index Dart projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/dart-initializer-calls.test.ts b/__tests__/dart-initializer-calls.test.ts new file mode 100644 index 0000000000..74c532aa90 --- /dev/null +++ b/__tests__/dart-initializer-calls.test.ts @@ -0,0 +1,245 @@ +/** + * Dart initializers are code: calls written in a top-level variable's or a + * field's initializer belong to the declaration. + * + * Both extractors skipped them. A `final`/`const` declaration minted its + * constant and stopped, and a field's or a `var`'s initializer only had its + * types recorded (#2327). So in a Riverpod app, where most wiring is written + * in provider closures — `final repoProvider = Provider((ref) => + * Repository(ref.watch(dioProvider)));` — `Repository` and every function a + * provider builds with had no callers, and impact and flows stopped at the + * provider. + * + * A `final`/`const` initializer belongs to its constant. A field's or a + * `var`'s initializer belongs to its class or to the file, because Dart mints + * no node for those declarations. + * + * A call through a type's static constant — `Provider.autoDispose(…)` on + * `static const autoDispose = AutoDisposeProviderBuilder();` — calls that + * constant, not a same-named method another type declares. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const FILES: Record = { + 'pubspec.yaml': 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n', + 'lib/riverpod.dart': `class Ref { + T watch(Object provider) => throw 0; + T read(Object provider) => throw 0; +} + +class Provider { + Provider(T Function(Ref ref) create); + + static const autoDispose = AutoDisposeProviderBuilder(); +} + +class AutoDisposeProviderBuilder { + const AutoDisposeProviderBuilder(); + + Provider call(T Function(Ref ref) create) => Provider(create); +} + +class ProviderFamilyBuilder { + Object autoDispose() => this; +} + +class StateNotifierProvider { + StateNotifierProvider(N Function(Ref ref) create); +} + +class FutureProvider { + FutureProvider(Future Function(Ref ref) create); + + static const family = FutureProviderFamilyBuilder(); +} + +class FutureProviderFamilyBuilder { + const FutureProviderFamilyBuilder(); + + FutureProvider call(Future Function(Ref ref, A arg) create) => throw 0; +} +`, + // A method named like `FutureProvider.family`, on another type in another file. + 'lib/builders.dart': `class AutoDisposeFutureProviderBuilder { + Object family() => this; +} +`, + 'lib/data.dart': `class Dio {} + +class Api { + Api(Dio dio); +} + +class Repository { + Repository(Dio dio); +} + +class Counter { + Counter(Api api); +} + +Dio createDio() => Dio(); +void register(void Function() cb) {} +int start() => 0; +`, + // The usual Riverpod provider shapes, and the providers they read. + 'lib/providers.dart': `import 'riverpod.dart'; +import 'data.dart'; + +final dioProvider = Provider((ref) => createDio()); +final apiProvider = Provider((ref) => Api(ref.watch(dioProvider))); +final repoProvider = Provider((ref) => Repository(ref.watch(dioProvider))); +final counterProvider = StateNotifierProvider((ref) => Counter(ref.read(apiProvider))); +`, + 'lib/more.dart': `import 'riverpod.dart'; +import 'data.dart'; +import 'providers.dart'; + +final typedProvider = Provider((Ref ref) => ref.read(dioProvider)); +final blockProvider = Provider((ref) { + final dio = createDio(); + return Repository(dio); +}); +void onTap() {} +final wired = register(onTap); +var cache = createDio(); +late final lazyRepo = Repository(createDio()); +final autoProvider = Provider.autoDispose((ref) => createDio()); +final userProvider = FutureProvider.family((ref, int id) async => Api(createDio())); + +Provider makeAuto() => Provider.autoDispose((ref) => Dio()); + +class Service { + static final instance = Service._(createDio()); + static var shared = Api(createDio()); + final repo = Repository(Dio()); + final Dio dio; + Service._(this.dio); + + int run() { + var total = 0; + for (var i = 0, j = start(); i < j; i++) { + total += i; + } + return total; + } +} +`, +}; + +describe('Dart initializer calls', () => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-initializers-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + kernel = process.env.CODEGRAPH_KERNEL; + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + it.each(['default', 'wasm'])('belong to the constant, class or file they initialize (%s)', async (backend) => { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const node = (file: string, qualifiedName: string): Node => { + const found = graph.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName); + expect(found, `${file} ${qualifiedName}`).toBeDefined(); + return found!; + }; + const targets = (from: Node, kind: string): string[] => + [...new Set(graph + .getOutgoingEdgesFrom([from.id]) + .filter((e) => e.kind === kind) + .map((e) => graph.getNode(e.target)!.qualifiedName))] + .sort(); + const sources = (to: Node, kind: string): string[] => + [...new Set(graph + .getIncomingEdgesTo([to.id], [kind]) + .map((e) => graph.getNode(e.source)!) + .map((n) => `${n.kind} ${n.qualifiedName}`))] + .sort(); + + // The issue's providers construct what they build and call what they build it with. + expect(targets(node('lib/providers.dart', 'dioProvider'), 'calls')).toEqual(['createDio']); + expect(targets(node('lib/providers.dart', 'apiProvider'), 'instantiates')).toEqual(['Api', 'Provider']); + expect(targets(node('lib/providers.dart', 'repoProvider'), 'instantiates')).toEqual(['Provider', 'Repository']); + expect(targets(node('lib/providers.dart', 'counterProvider'), 'instantiates')).toEqual(['Counter', 'StateNotifierProvider']); + + // A typed closure parameter types its receiver; a block-bodied closure is walked whole. + expect(targets(node('lib/more.dart', 'typedProvider'), 'calls')).toEqual(['Ref::read']); + expect(targets(node('lib/more.dart', 'blockProvider'), 'calls')).toEqual(['createDio']); + expect(targets(node('lib/more.dart', 'blockProvider'), 'instantiates')).toEqual(['Provider', 'Repository']); + + // A function passed as a value is the constant's reference, captured once. + expect(targets(node('lib/more.dart', 'wired'), 'calls')).toEqual(['register']); + expect(sources(node('lib/more.dart', 'onTap'), 'references')).toEqual(['constant wired']); + + // `var` and `late final` mint no node: the file runs them. + const file = node('lib/more.dart', 'lib/more.dart'); + expect(targets(file, 'calls')).toEqual(['createDio']); + expect(targets(file, 'instantiates')).toEqual(['Repository']); + + // A `static final` is the class's constant; a `static var` and an instance + // field mint no node, so they are the class's. + expect(targets(node('lib/more.dart', 'Service::instance'), 'calls')).toEqual(['Service::_', 'createDio']); + const service = node('lib/more.dart', 'Service'); + expect(targets(service, 'calls')).toEqual(['createDio']); + expect(targets(service, 'instantiates')).toEqual(['Api', 'Dio', 'Repository']); + + // `Provider.autoDispose(…)` calls the static constant, whose own + // initializer constructs the builder — from a provider and from a body + // alike — and not a same-named method of another type, in its file or not. + const autoDispose = node('lib/riverpod.dart', 'Provider::autoDispose'); + expect(autoDispose.kind).toBe('constant'); + expect(targets(node('lib/more.dart', 'autoProvider'), 'calls')).toEqual(['Provider::autoDispose', 'createDio']); + expect(targets(node('lib/more.dart', 'makeAuto'), 'calls')).toEqual(['Provider::autoDispose']); + expect(targets(autoDispose, 'instantiates')).toEqual(['AutoDisposeProviderBuilder']); + expect(sources(node('lib/riverpod.dart', 'ProviderFamilyBuilder::autoDispose'), 'calls')).toEqual([]); + expect(targets(node('lib/more.dart', 'userProvider'), 'calls')).toEqual(['FutureProvider::family', 'createDio']); + expect(sources(node('lib/builders.dart', 'AutoDisposeFutureProviderBuilder::family'), 'calls')).toEqual([]); + + // Each provider and declaration above is a caller of what it builds. + expect(sources(node('lib/data.dart', 'createDio'), 'calls')).toEqual([ + 'class Service', + 'constant Service::instance', + 'constant autoProvider', + 'constant blockProvider', + 'constant dioProvider', + 'constant userProvider', + 'file lib/more.dart', + ]); + expect(sources(node('lib/data.dart', 'Repository'), 'instantiates')).toEqual([ + 'class Service', + 'constant blockProvider', + 'constant repoProvider', + 'file lib/more.dart', + ]); + + // A local declaration's second variable is its function's, once. + const run = node('lib/more.dart', 'Service::run'); + expect(targets(run, 'calls')).toEqual(['start']); + expect(graph.getOutgoingEdgesFrom([run.id]).filter((e) => e.kind === 'calls')).toHaveLength(1); + expect(sources(node('lib/data.dart', 'start'), 'calls')).toEqual(['method Service::run']); + }); +}); diff --git a/__tests__/dart-type-positions.test.ts b/__tests__/dart-type-positions.test.ts index 79ead5c731..5eb6401c55 100644 --- a/__tests__/dart-type-positions.test.ts +++ b/__tests__/dart-type-positions.test.ts @@ -119,7 +119,8 @@ describe('Dart type positions (#2327)', () => { const body = cg.getNodesByQualifiedName('body')[0]!; const refs = cg.getOutgoingEdgesFrom([body.id], ['references']).filter((e) => cg.getNode(e.target)!.name === 'Score'); expect([...new Set(refs.map((e) => e.line))].sort()).toEqual([26, 27]); - expect(referrers('Score', 'instantiates')).toEqual(['function body']); + // `static const Score zero = Score(0);` constructs one too, from its constant. + expect(referrers('Score', 'instantiates')).toEqual(['constant Board::zero', 'function body']); const prefixRefs = cg.getUnresolvedReferencesInFile('lib/more.dart').filter((r) => r.referenceName === 'm'); expect(prefixRefs).toEqual([]); }); diff --git a/__tests__/fixtures/kernel-parity/TortureInitializers.dart b/__tests__/fixtures/kernel-parity/TortureInitializers.dart new file mode 100644 index 0000000000..35d71ff68d --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureInitializers.dart @@ -0,0 +1,89 @@ +// Calls, instantiations, reads and function values written in initializers: +// top-level variables and constants, and fields of every member body kind. +import 'package:app/model.dart'; + +int compute(int n) => n; +void onTap() {} +void register(Object o) {} +Widget build() => Widget(0); + +// final / const: the constant owns its initializer. +final repoProvider = Provider((ref) => Repository(ref.watch(dioProvider))); +final counterProvider = StateNotifierProvider((ref) => Counter(ref.read(apiProvider))); +final blockProvider = Provider((ref) { + final dio = Dio.create(compute(1)); + void local(int x) { + register(x); + } + local(2); + return Repository(dio)..init()..start(); +}); +const shared = Widget.named(3); +const made = const Pair(1, Widget(2)); +final created = new Widget(compute(4)); +final chained = Widget.factory().configure().run(); +final reads = Config.instance.name; +final label = 'total ${compute(5)} of $shared'; +final cb = onTap; +final wired = register(onTap); +final table = [onTap, build]; +final byName = {'tap': onTap, 'build': build}; +final first = compute(6), second = Widget(7); +final Provider typed = Provider((ref) => build()); +final asyncProvider = FutureProvider((ref) async => await load(ref)); +final viaTypedParam = Provider((Ref ref) => ref.watch(dioProvider).value); + +// var / typed / late: the file owns the initializer. +var counter = compute(8); +Widget top = build(); +late final lazy = Widget(compute(9)); +var list = [Widget(10), build()], other = compute(11); +int plain = 0; + +class Holder { + static final instance = Holder._(compute(12)); + static const empty = Holder._(0); + static var cache = Widget(compute(13)); + static Widget typedStatic = build(); + final made = Widget(14); + late final lazyField = compute(size); + var callbacks = [onTap]; + final Widget other = Widget.named(15), more = build(); + final int size; + Holder._(this.size); + + int run() { + var total = 0; + for (var i = 0, j = compute(16); i < j; i++) { + total += i; + } + final local = Widget(17), again = compute(18); + return total; + } +} + +mixin Tracking { + static var tracker = Tracker.start(); + final events = [Event('init')]; +} + +extension Formatting on String { + static final formatter = Formatter(compute(19)); + static var counter = compute(20); +} + +enum Mode { + fast, + slow; + + static final byName = {for (final m in Mode.values) m.name: m}; + static const fallback = Mode.fast; + final weight = 1; +} + +extension type Meters(int value) { + static var unit = Unit.create('m'); +} + +// Positions after multi-byte text are UTF-16 columns on both arms. +final emoji = 'é😀'; final afterEmoji = compute(21); var alsoAfter = Widget(22); diff --git a/__tests__/kernel-dart-parity.test.ts b/__tests__/kernel-dart-parity.test.ts index ad3396d833..cc8fd008e3 100644 --- a/__tests__/kernel-dart-parity.test.ts +++ b/__tests__/kernel-dart-parity.test.ts @@ -21,8 +21,10 @@ * twins; TortureMini/TortureSigs/TortureCtors/TortureVrefDart: signatures * verbatim, prefixed-return-type prefix bug, const factories invisible, * value-ref matrix with `$X` vs `${X}` asymmetry; TortureReadsTypes.dart: - * getter-read refs and the type positions outside signatures — #2338/#2327) - * and their CRLF variants + * getter-read refs and the type positions outside signatures — #2338/#2327; + * TortureInitializers.dart: initializers walked as the code their constant, + * class or file runs, every member-body kind, and a local declaration's + * second variable left to its function) and their CRLF variants * (derived in-memory — #1329), plus defer and generated-file pins. * * The full-repo sweeps live in scripts/kernel-parity.mjs (shelf/bloc/flutter @@ -116,6 +118,10 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => { // extension `on` types, field / enum-field / top-level types, constant and // field initializers, body types, and the read shapes that are not reads. ['TortureReadsTypes.dart', 20], + // Initializers (top-level, static and instance) walked as code: calls, + // instantiations, reads, closures with block bodies and local functions, + // and function values captured once, for the declaration. + ['TortureInitializers.dart', 30], ] as const; for (const [file, minNodes] of FIXTURES) { diff --git a/codegraph-kernel/src/dart.rs b/codegraph-kernel/src/dart.rs index 9deb1cbfff..b423a8f6ef 100644 --- a/codegraph-kernel/src/dart.rs +++ b/codegraph-kernel/src/dart.rs @@ -20,7 +20,9 @@ //! `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 -//! static_final_declaration → constant, via the hook); prefixed return +//! 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 //! types keep the PREFIX (`other.OtherClass f()` → returnType `other` — //! bug, preserved); enum `with` mixins emit nothing while enum `implements` //! works; deferred imports are invisible; named-argument callbacks are NOT @@ -629,24 +631,34 @@ impl<'t> Walker<'t> { }); let name = self.text(name_node).to_string(); let constant = self.create_node("constant", &name, node, Extra { signature, ..Default::default() }); - // The types its initializer names are the constant's (#2327). + // The initializer is code the constant runs — its calls, + // instantiations, reads and the types it names (#2327) are + // the constant's. walkInitializer: the body walk, and the + // dispatcher's fn-ref scan skips what it walked. if let Some(constant) = constant { - let mut value = name_node.next_named_sibling(); - while let Some(v) = value { - self.type_refs_dart(v, constant); - value = v.next_named_sibling(); - } + self.stack.push(Scope { row: constant, kind: "constant", name }); + self.visit_body(node); + self.stack.pop(); + return; } } self.scan_fn_ref_subtree(node, 0); return; } - // A field's declared type and initializer types are its class's (Dart - // fields mint no nodes); a top-level variable's are the file's (#2327). - // The visitNode hook's `declaration` / `program` rows (dart.ts). - // (Parent lookups walk down from the root, so only the node kinds that - // can be these positions ask for theirs.) + // A field's or top-level variable's initializer is code its class or + // the file runs (neither mints a node) — the hook's + // `initialized_identifier` row, walked like a body (walkInitializer). + if node.kind() == "initialized_identifier" && self.is_field_or_top_level_entry(node) { + self.visit_body(node); + return; + } + + // A field's declared type is its class's (Dart fields mint no nodes); + // a top-level variable's is the file's (#2327). The visitNode hook's + // `declaration` / `program` rows (dart.ts). (Parent lookups walk down + // from the root, so only the node kinds that can be these positions + // ask for theirs.) if node.kind() == "declaration" { let in_body = node .parent() @@ -661,26 +673,16 @@ impl<'t> Walker<'t> { if is_field { let owner = self.top_row(); for child in kids { - match child.kind() { - "initialized_identifier_list" => self.initializer_type_refs_dart(child, owner), - "static_final_declaration_list" => {} - _ => self.type_refs_dart(child, owner), + if !matches!(child.kind(), "initialized_identifier_list" | "static_final_declaration_list") { + self.type_refs_dart(child, owner); } } } - } else { - let top_level_type = - matches!(node.kind(), "type_identifier" | "type_arguments" | "function_type" | "record_type"); - if (top_level_type || node.kind() == "initialized_identifier_list") - && node.parent().map(|p| p.kind() == "program").unwrap_or(false) - { - let owner = self.top_row(); - if top_level_type { - self.type_refs_dart(node, owner); - } else { - self.initializer_type_refs_dart(node, owner); - } - } + } else if matches!(node.kind(), "type_identifier" | "type_arguments" | "function_type" | "record_type") + && node.parent().map(|p| p.kind() == "program").unwrap_or(false) + { + let owner = self.top_row(); + self.type_refs_dart(node, owner); } // maybeCaptureFnRefs (:990) — the double-walk fn-ref twin source. @@ -1387,21 +1389,23 @@ impl<'t> Walker<'t> { } } - /// pushDartInitializerTypeRefs (dart.ts) — each `initialized_identifier`'s - /// value (every named child after its name). - fn initializer_type_refs_dart(&mut self, list: Node<'t>, from_row: u32) { - let mut cursor = list.walk(); - let entries: Vec> = list.named_children(&mut cursor).collect(); - for entry in entries { - if entry.kind() != "initialized_identifier" { - continue; - } - let mut ec = entry.walk(); - let parts: Vec> = entry.named_children(&mut ec).skip(1).collect(); - for part in parts { - self.type_refs_dart(part, from_row); - } + /// isDartFieldOrTopLevelEntry (dart.ts) — an `initialized_identifier` + /// declaring a field or a top-level variable, not the second variable of + /// a local declaration (`for (var i = 0, j = n(); …)`). + fn is_field_or_top_level_entry(&self, node: Node<'t>) -> bool { + let Some(list) = node.parent() else { return false }; + if list.kind() != "initialized_identifier_list" { + return false; + } + let Some(owner) = list.parent() else { return false }; + if owner.kind() == "program" { + return true; } + owner.kind() == "declaration" + && owner + .parent() + .map(|p| matches!(p.kind(), "class_body" | "extension_body" | "enum_body")) + .unwrap_or(false) } /// dartMemberRead (dart.ts) — `x.area` / `x?.area` not followed by a call's diff --git a/docs/design/dart-kernel-port-checklist.md b/docs/design/dart-kernel-port-checklist.md index d354281eb9..6048396c4d 100644 --- a/docs/design/dart-kernel-port-checklist.md +++ b/docs/design/dart-kernel-port-checklist.md @@ -269,12 +269,31 @@ Hooks PRESENT (port each exactly — anchors into languages/dart.ts): expression node); initValue = its text `.slice(0,100)` (UTF-16); `ctx.createNode('constant', name, node, { signature: initValue ? \`= ${initValue}${initValue.length >= 100 ? '...' : ''}\` : undefined })`. - Return **true** (consumed — even when nameNode missing? NO: name found is - required for createNode but the hook returns true for EVERY + When the constant is minted, the hook pushes it and walks the WHOLE + declaration with the body walker (`ctx.walkInitializer(node)` — + visitFunctionBody that records the node as walked), then pops: the + initializer's calls, instantiations, static and member reads, types, + closures (block bodies and local functions included) and fn-ref + candidates (the `varinit` capture of the declaration itself included) are + the CONSTANT's. Return **true** (consumed — even when nameNode missing? NO: + name found is required for createNode but the hook returns true for EVERY static_final_declaration reached, node minted or not — transcribe the - early-return shape exactly: `if (nameNode) {…}; return true`). The - dispatcher then runs `scanFnRefSubtree(node, 0)` and never descends → - §Function-as-value for what still gets captured. Everything else → false. + early-return shape exactly: `if (nameNode) {…; if (constant) walk}; return + true`). The dispatcher then runs `scanFnRefSubtree(node, 0, walked)` — + which skips a walked declaration outright, so only an unminted one is + scanned — and never descends. +- **visitNode — the field / top-level variable branch.** `node.type === + 'initialized_identifier'` whose parent is an `initialized_identifier_list` + directly under `program`, or under a `declaration` in a + class_body/extension_body/enum_body → `ctx.walkInitializer(node)` with no + push (the stack top is the file or the class/enum), return **true**. These + declarations mint no node, so their initializers are the FILE's or the + CLASS's. An `initialized_identifier` anywhere else — the second variable of + a local `for (var i = 0, j = n(); …)`, under `initialized_variable_definition` + — is left to its function's body walk (the double-walk's pass 2a reaches it + too, so the parent check is load-bearing). The `declaration` and `program` + type rows push only the DECLARED type's refs; initializer types come from + the walk. Everything else → false. **Reality of the node type (probed, `probe2-cst.txt`):** `static_final_declaration` = a `final`/`const` declaration WITH an initializer that is **top-level** (`const SHARED_MAX = 10;`, `final @@ -412,7 +431,7 @@ Registration: `EXTRACTORS.dart` (languages/index.ts:56), `FN_REF_SPECS.dart` | Node | Branch | Behavior | |---|---|---| -| every node | visitNode hook first (:943) | `static_final_declaration` consumed (§Constants); handled → scanFnRefSubtree + STOP | +| every node | visitNode hook first (:943) | `static_final_declaration` consumed and body-walked for its constant; a field's or top-level variable's `initialized_identifier` consumed and body-walked for the class/file (§Constants); handled → scanFnRefSubtree (skipping what was walked) + STOP | | every node | maybeCaptureFnRefs (:990) | fires for `arguments`/`assignment_expression`/`pair`/`list_literal`/`static_final_declaration` in visitNode context — the source of the file/class-attributed fn-ref twins (§double-walk) | | `function_signature` | functionTypes:994 | methodTypes does NOT include it → **always extractFunction:1517**, even inside a class (isInsideClassLike && !methodTypes.includes → else-arm). In-class function_signatures occur only for ABSTRACT/bodiless methods, reached via their `declaration` wrapper (`declaration > function_signature`, probed) → **kind `function` contained by the class** (pinned: `AbstractT::mustImpl` is a function; the sibling `declaration > getter_signature` abstract getter stays invisible). skipChildren | | `class_definition` | classTypes:1005 | no classifyClassNode → always extractClass:1679 (abstract/sealed/base/etc. included) | @@ -423,7 +442,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; initialized_identifier/list, constant_constructor_signature, redirecting_factory_constructor_signature, initializers, annotations-in-place: nothing | +| `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 | | `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 | @@ -625,14 +644,19 @@ 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. **Initializer -side-effects:** hook-consumed constants' initializers are NOT walked → no -calls/instantiates from them (only scanFnRefSubtree capture — §fn-refs); -initialized_identifier fields' initializers ARE recursed by visitNode but -only INSTANTIATION_KINDS fires there → `int counter = 0;` emits nothing, -but a field `final w = new Widget();` would emit `instantiates Widget` from -the CLASS (and a top-level `var w = new Widget();` from the FILE). No -static-member refs from any of these contexts (body-walker only). +containers, `part`/`part of`/`library`/deferred imports. **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 +`Provider`, `Repository`, `ref.watch` from `repoProvider`), a field's +(instance, `static var`, typed, `late`) for its class, a top-level +`var`/typed/`late final` one for the FILE. The full body matrix applies: +calls, instantiates, static-member and member reads, type refs, closures +with block bodies (a local function inside one mints a `function` under +the constant, with the double-walk's pass 2b on its body), interpolation +calls, fn-ref candidates. Each is walked ONCE: the hook consumes the entry, +so the visitNode recursion that used to reach `new_expression` / +maybeCaptureFnRefs inside a field initializer no longer does. ### Calls — extractBareCall (dart.ts:305-379) in the body walker (:5159-5173) @@ -664,7 +688,8 @@ extract-mini.txt): | `throw StateError('bad')` | recursion | `StateError` | | `'sum ${a + compute()}'` | template_substitution recursion | `compute` (interpolation calls EMIT); `$name` → identifier_dollar_escaped → nothing | | local-lambda body `final lam = (int a) { helper(a); }` | function_expression recursed transparently | `helper` attributed to the ENCLOSING function; `lam(5)` → `lam` | -| ctor initializers (`: size = seed()`), enum-constant args (`ok(200)`), default param values, hook-consumed constant initializers | never body-walked | NOTHING | +| `final p = Provider((ref) => Repo(ref.watch(d)))`, `static var s = make()`, `final _c = Controller();` in a class, `var v = load();` | the hook's walkInitializer (§Constants) | the full matrix, from the constant / the class / the file | +| ctor initializers (`: size = seed()`), enum-constant args (`ok(200)`), default param values | never body-walked | NOTHING | extractCall (:3684), LITERAL_RECEIVER_TYPES (:373-388), SKIP_RECEIVERS, the parenthesized-conversion regex (:4530), template-strip — ALL UNREACHABLE for @@ -690,7 +715,8 @@ previousNamedSibling is an `identifier` matching `/^[A-Z][A-Za-z0-9_]*$/` → - `this.x` → prev is a `this` node → nothing. Case patterns (`case ColorT.blue:`) → `constant_pattern > qualified` shape, no selector → NOTHING (pinned gap). Cascade sections → no selector → nothing. - Class-field/constant initializers and visitNode contexts → never called. + visitNode contexts → never called; initializers are body-walked, so a + read written in one emits from its constant / class / file. ### Type-annotation references — dart ∈ TYPE_ANNOTATION_LANGUAGES (:5753), the dart branch (:5819-5833) is LIVE @@ -857,14 +883,17 @@ NO unwrap, NO ungatedModes, NO addressOfOnly. Pins: - `[topLevel, blockDoc]` list ✓ (locals' list_literals capture too — the dispatch fires wherever the node is walked); `{'k': topLevel}` pair ✓; `final aliasTop = aliased;` top-level/static → varinit bare-identifier ✓ - (pinned from=file / from=class); a LOCAL `final alias = topLevel;` → + (from the constant `aliasTop` — the hook walks the declaration with it + pushed); a LOCAL `final alias = topLevel;` → initialized_variable_definition NOT in dispatch → not captured. - obj.method member values (`final g = obj.method`) → the last child is a selector → normalizeValue [] → nothing (no member special for dart). -- Capture points: visitFunctionBody:5137 (bodies), visitNode:990 (the - §double-walk twins from file/class scope), scanFnRefSubtree (hook-consumed - constant initializers — halts at `function_expression` :609, so lambdas - inside a constant's initializer don't leak candidates). +- Capture points: visitFunctionBody:5137 (bodies, and every initializer + the hook walks — from the constant / class / file, lambdas included), + visitNode:990 (the §double-walk twins from file/class scope), + scanFnRefSubtree (only a `static_final_declaration` whose constant wasn't + minted — the scan skips what the hook walked, so nothing is captured + twice; it halts at `function_expression` :609). - **Flush gate (:639-728): effectively "defined in this file" ONLY** — dart import refs are URIs (`package:foo/util.dart`, `dart:async`) which match neither SIMPLE_NAME nor QUALIFIED_IMPORT (`:` and `/` excluded) → diff --git a/src/extraction/languages/dart.ts b/src/extraction/languages/dart.ts index 13583b983c..ad5b8c4bd0 100644 --- a/src/extraction/languages/dart.ts +++ b/src/extraction/languages/dart.ts @@ -49,16 +49,18 @@ const DART_MEMBER_BODIES: ReadonlySet = new Set(['class_body', 'extensio /** The named nodes a top-level variable's declared type is written as (`Report? x;`, `List xs = [];`). */ const DART_TOP_LEVEL_TYPES: ReadonlySet = new Set(['type_identifier', 'type_arguments', 'function_type', 'record_type']); -/** Type refs from `fromNodeId` for the initializers in an `initialized_identifier_list` (each entry is its name, then its value). */ -function pushDartInitializerTypeRefs( - list: SyntaxNode, - fromNodeId: string, - push: (ref: UnresolvedReference) => void, -): void { - for (const entry of list.namedChildren) { - if (entry.type !== 'initialized_identifier') continue; - for (const part of entry.namedChildren.slice(1)) pushDartTypeRefs(part, fromNodeId, push); - } +/** + * Whether an `initialized_identifier` declares a field or a top-level + * variable — `var cache = load();`, `final _ctl = TextEditingController();` + * in a class — rather than the second variable of a local declaration + * (`for (var i = 0, j = n(); …)`), which a function body's walk covers. + */ +function isDartFieldOrTopLevelEntry(node: SyntaxNode): boolean { + const list = node.parent; + if (list?.type !== 'initialized_identifier_list') return false; + const owner = list.parent; + if (owner?.type === 'program') return true; + return owner?.type === 'declaration' && owner.parent !== null && DART_MEMBER_BODIES.has(owner.parent.type); } /** @@ -238,17 +240,21 @@ export const dartExtractor: LanguageExtractor = { const constant = ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, { signature: initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined, }); - // The types its initializer names are the constant's — riverpod's - // `final reportProvider = Family();` (#2327). + // The initializer is code the constant runs: riverpod's `final + // repoProvider = Provider((ref) => Repository(ref.watch(dioProvider)));` + // calls `Provider`, `Repository` and `ref.watch`, and the types it + // names (`Family()`, #2327) are the constant's too. if (constant) { - for (let value = valueNode; value; value = value.nextNamedSibling) pushDartTypeRefs(value, constant.id, push); + ctx.pushScope(constant.id); + ctx.walkInitializer(node); + ctx.popScope(); } } return true; } - // A field's declared type, and the types its initializer names, are its - // class's: Dart fields mint no nodes of their own (`final Report report;`, - // #2327). A `static final` / `const` field's initializer is its constant's. + // A field's declared type is its class's: Dart fields mint no nodes of + // their own (`final Report report;`, #2327). Each initializer is walked + // as its entry is reached, below. if (node.type === 'declaration') { const owner = ctx.nodeStack[ctx.nodeStack.length - 1]; const inBody = node.parent !== null && DART_MEMBER_BODIES.has(node.parent.type); @@ -257,19 +263,25 @@ export const dartExtractor: LanguageExtractor = { ); if (owner && isField) { for (const child of node.namedChildren) { - if (child.type === 'initialized_identifier_list') pushDartInitializerTypeRefs(child, owner, push); - else if (child.type !== 'static_final_declaration_list') pushDartTypeRefs(child, owner, push); + if (child.type !== 'initialized_identifier_list' && child.type !== 'static_final_declaration_list') { + pushDartTypeRefs(child, owner, push); + } } } return false; } - // A top-level variable's declared type and its (non-constant) initializer - // are the file's — the grammar lays them out directly under `program`. - const topLevelType = DART_TOP_LEVEL_TYPES.has(node.type); - if ((topLevelType || node.type === 'initialized_identifier_list') && node.parent?.type === 'program') { + // A field's initializer, or a top-level variable's, is code its class or + // the file runs — neither declaration mints a node to own it. A `static + // final` / `const` one is its constant's (above). + if (node.type === 'initialized_identifier' && isDartFieldOrTopLevelEntry(node)) { + ctx.walkInitializer(node); + return true; + } + // A top-level variable's declared type is the file's — the grammar lays + // it out directly under `program`. + if (DART_TOP_LEVEL_TYPES.has(node.type) && node.parent?.type === 'program') { const owner = ctx.nodeStack[ctx.nodeStack.length - 1]; - if (owner && topLevelType) pushDartTypeRefs(node, owner, push); - else if (owner) pushDartInitializerTypeRefs(node, owner, push); + if (owner) pushDartTypeRefs(node, owner, push); } return false; }, diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 619dc097f1..1c2d4f374f 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -54,6 +54,13 @@ export interface ExtractorContext { visitNode(node: SyntaxNode): void; /** Visit a function body to extract calls */ visitFunctionBody(body: SyntaxNode, functionId: string): void; + /** + * Walk a declaration's initializer as code the scope on top of the stack + * runs, the way a function body is walked. When the hook then reports the + * node handled, the function-as-value scan the dispatcher runs over it skips + * what was walked here: the walk captured those candidates already. + */ + walkInitializer(node: SyntaxNode): void; /** Add an unresolved reference */ addUnresolvedReference(ref: UnresolvedReference): void; /** Push a node ID onto the scope stack (for containment/qualified name building) */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 031f200cf9..50ca4151c2 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1160,8 +1160,9 @@ export class TreeSitterExtractor { // The hook consumed this subtree, so the walkers below never descend // into it — scan it for function-as-value candidates (#756). Scala's // hook handles val/var definitions (`val table = Seq(targetCb)`), for - // example. The scan is capture-only and halts at nested functions. - this.scanFnRefSubtree(node, 0); + // example. The scan is capture-only and halts at nested functions; + // an initializer the hook walked has captured its own. + this.scanFnRefSubtree(node, 0, ctx.walked ?? undefined); return; } } @@ -1764,14 +1765,21 @@ export class TreeSitterExtractor { /** * Build an ExtractorContext for passing to language-specific visitNode hooks. + * `walked` collects the initializers the hook walks, for the dispatcher's + * function-as-value scan to skip. */ - private makeExtractorContext(): ExtractorContext { + private makeExtractorContext(): ExtractorContext & { walked: Set | null } { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; - return { + const ctx: ExtractorContext & { walked: Set | null } = { + walked: null, createNode: (kind, name, node, extra) => self.createNode(kind, name, node, extra), visitNode: (node) => self.visitNode(node), visitFunctionBody: (body, functionId) => self.visitFunctionBody(body, functionId), + walkInitializer: (node) => { + (ctx.walked ??= new Set()).add(node.id); + self.visitFunctionBody(node, ''); + }, addUnresolvedReference: (ref) => self.unresolvedReferences.push(ref), pushScope: (nodeId) => self.nodeStack.push(nodeId), popScope: () => self.nodeStack.pop(), @@ -1780,6 +1788,7 @@ export class TreeSitterExtractor { get nodeStack() { return self.nodeStack; }, get nodes() { return self.nodes; }, }; + return ctx; } /** diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index cf151c7aa8..c7d7a0e072 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8927,6 +8927,24 @@ 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' &&