diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b80ad26..5216219e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript files with Windows (CRLF) line endings, as a Windows checkout usually has them, a store exported on a later line, like `const useStore = create(…)` followed by `export default useStore;`, now has its actions indexed, as it already did with Unix line endings. Before, those actions were missing from the graph on Windows, so their callers and impact came back empty. Re-index JavaScript and TypeScript projects after upgrading. - 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. ## [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 000000000..74c532aa9 --- /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 79ead5c73..5eb6401c5 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 000000000..35d71ff68 --- /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 ad3396d83..cc8fd008e 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 9deb1cbff..b423a8f6e 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 d354281eb..6048396c4 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 13583b983..ad5b8c4bd 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 619dc097f..1c2d4f374 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 4964f396c..da8dc9bbe 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 cf151c7aa..c7d7a0e07 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' &&