From eaee6735b047aef680cf2643b516d2bcd312f15a Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 6 Oct 2026 12:19:01 -0500 Subject: [PATCH] fix(dart): an annotation links the constant or constructor it names, even behind a comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Dart annotation is a constant expression: a const variable (`@riverpod`, `@meta.immutable`, a type's static constant or enum value) or a call of a const constructor (`@Riverpod(keepAlive: true)`, `@Foo.named(…)`). It was ranked like a Python decorator, which favours a function or method of that name over a class and scores a constant not at all, so riverpod's 506 `@riverpod` annotations went to riverpod_analyzer_utils' extension getter `riverpod` (0.4) instead of riverpod_annotation's `const riverpod = Riverpod();`. matchDartAnnotation (name-matcher.ts) now takes every Dart `decorates` ref before any other strategy, and nothing falls through. It reads the annotation as written from its `@` and links a constant or (called) a class the file's library can see, through the import prefix it is written with (isDartImportPrefix, dart-libraries.ts); a type's own static constant, enum value or (called) named constructor (isDartConstructor); or, on a member, a static constant of the type around it. Never a method, getter or function. The decorator scan also stopped at the first sibling that was not an annotation, so a comment between an annotation and its declaration (`@override` `// ignore: must_call_super` `void f()`, riverpod_lint's `@riverpod` `// expect_lint: …` fixtures) hid the annotation. Dart's decoratorStepOverTypes lists its two comment kinds, which the scan now steps over, and the kernel's extract_decorators_for steps over the same. Validated on rrousselGit/riverpod @4ba1be2 and felangel/bloc @b9be1e2 with the kernel loaded: riverpod's 506 `@riverpod` edges move to `const riverpod` and 16 annotations behind a comment add an edge each, with nothing removed; bloc gains 7 `@override` refs and no edge changes. Kernel and wasm full-index dumps are byte-identical, and the parity sweeps show 0 diffs. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + __tests__/dart-annotation-comments.test.ts | 167 +++++++++++++++++ __tests__/dart-annotation-targets.test.ts | 167 +++++++++++++++++ .../TortureAnnotatedComments.dart | 175 ++++++++++++++++++ __tests__/kernel-dart-parity.test.ts | 12 +- codegraph-kernel/src/dart.rs | 12 +- docs/design/dart-kernel-port-checklist.md | 95 +++++++--- src/extraction/languages/dart.ts | 4 + src/extraction/tree-sitter-types.ts | 7 + src/extraction/tree-sitter.ts | 5 +- src/resolution/dart-libraries.ts | 11 ++ src/resolution/index.ts | 5 +- src/resolution/name-matcher.ts | 129 ++++++++++++- 13 files changed, 761 insertions(+), 30 deletions(-) create mode 100644 __tests__/dart-annotation-comments.test.ts create mode 100644 __tests__/dart-annotation-targets.test.ts create mode 100644 __tests__/fixtures/kernel-parity/TortureAnnotatedComments.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd122aba..f36ec7b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a store whose name starts or ends with a `$`, like `const items$ = create(…)`, now has its actions indexed when an `export { … }` list on a later line exports it, as in `export { items$ };` or `export { $items as default };`. Before, those actions were missing from the graph, so their callers and impact came back empty. A store whose name only lacks the `$`, like `items`, is no longer treated as exported by that list. Re-index JavaScript and TypeScript projects after upgrading. - In Dart, a comment inside a chain of calls no longer loses part of it: a call with a comment before its arguments, like `box.grow /* by */ (3)`, now links to the method it calls, and a getter read or a static access written after a comment, like `Config //` followed by `.instance` on the next line (the empty `//` keeps `dart format` from joining the lines), now counts as a use. Re-index Dart projects after upgrading. - In Dart, a call, a constructor call or a type written without a receiver now links only to a declaration the file can see: one in its own library (the file and its `part` files) or one exported by a library it imports, with `export` chains, `show`, `hide` and `as` prefixes followed the way Dart follows them. So a generated `.g.dart` file reaches its own library's function or type instead of a same-named one in a neighbouring file, a call to a package function such as `fakeAsync`, `runApp` or `unawaited` no longer links to an unrelated project function of that name, and a bare call is never taken for another type's enum value or static constant. Re-index Dart projects after upgrading. +- In Dart, an annotation now links to the constant or constructor it names, read the way it is written: `@riverpod` reaches Riverpod's `const riverpod = Riverpod();`, `@Riverpod(keepAlive: true)` the `Riverpod` class, an annotation written through a class, like `@SomeClass.value` or `@SomeClass.named(…)`, that class's own constant, enum value or constructor, and `@meta.immutable` only what the `meta` import brings in. Before, an annotation was matched like a function decorator, so a method, getter or function that only shared its name won: every `@riverpod` provider in Riverpod's own repository was linked to an unrelated getter called `riverpod`, and `callers` and impact for the real annotation missed them. Re-index Dart projects after upgrading. +- In Dart, an annotation is no longer lost when a comment sits between it and the declaration, like `@override` followed by `// ignore: must_call_super`, a `///` comment written below the annotations, or `@riverpod` followed by an `// expect_lint: …` line. Before, the comment hid the annotation, so a provider written that way was missing from the annotation's `callers` and impact. Re-index Dart projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/dart-annotation-comments.test.ts b/__tests__/dart-annotation-comments.test.ts new file mode 100644 index 000000000..567678be0 --- /dev/null +++ b/__tests__/dart-annotation-comments.test.ts @@ -0,0 +1,167 @@ +/** + * A comment between a Dart annotation and the declaration it belongs to hid + * the annotation: + * + * @override + * // ignore: must_call_super + * void dispose() { … } + * + * Both extractors look for a member's annotations among the siblings before + * it, and stopped at the first one that was not an annotation, so the comment + * ended the scan and `@override` was recorded nowhere. felangel/bloc hid 7 + * annotations this way and rrousselGit/riverpod 55, among them its lint + * fixtures' `@riverpod` `// expect_lint: …` providers. + * + * An annotation always belongs to the declaration that follows it, so the + * scan now steps over line, block and doc comments of either form, wherever + * they sit among stacked annotations. Whatever else comes first (a field, the + * previous member's body, a top-level variable, an import) still ends it. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +const SOURCE = `import 'package:meta/meta.dart'; +// After the import. +@visibleForTesting +// Below the annotation. +void afterImport() {} + +@override // On the annotation's line. +void trailing() {} + +@riverpod +// expect_lint: functional_ref +int counter(Ref ref) => 0; + +@Deprecated('x') +/// Doc below the annotation. +/* And a block comment. */ +void docBelow() {} + +@a +// one +@b +/** two */ +@c +void stacked() {} + +@x +final answer = 42; +// After a variable. +void afterVariable() {} + +class Counter { + @override + // ignore: must_call_super + void dispose() {} + + @protected + // A comment. + void bodiless(); + + @literal + // A comment. + const Counter.zero(); + + @x /* inline */ void inline() {} + + @JsonKey(name: 'value') + final int value = 0; + // After a field. + void afterField() {} + + void before() {} // Trailing the previous member. + @override + void afterBody() {} +} +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +describe('Dart annotations behind a comment still decorate the declaration', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['dart']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource('lib/counter.dart', source, 'dart'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract('lib/counter.dart', source, 'dart'); + expect(result, 'kernel extraction').not.toBeNull(); + return result!; + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + it.each(backends)(`%s${crlf ? ' (CRLF)' : ''}`, (backend) => { + const result = extract(backend, crlf ? SOURCE.replace(/\n/g, '\r\n') : SOURCE); + /** The annotation names recorded on a declaration, in the order they were emitted. */ + const decorators = (qualifiedName: string): string[] => { + const node = result.nodes.find((n) => n.qualifiedName === qualifiedName); + expect(node, qualifiedName).toBeDefined(); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'decorates' && r.fromNodeId === node!.id) + .map((r) => r.referenceName); + }; + + // Line, block and doc comments between an annotation and its declaration. + expect(decorators('afterImport')).toEqual(['visibleForTesting']); + expect(decorators('trailing')).toEqual(['override']); + expect(decorators('counter')).toEqual(['riverpod']); + expect(decorators('docBelow')).toEqual(['Deprecated']); + // Stacked ones still come in reverse source order. + expect(decorators('stacked')).toEqual(['c', 'b', 'a']); + + // Members, one with no body and a `const` constructor (both read from + // before their `declaration`), and a comment on the member's line. + expect(decorators('Counter::dispose')).toEqual(['override']); + expect(decorators('Counter::bodiless')).toEqual(['protected']); + expect(decorators('Counter::zero')).toEqual(['literal']); + expect(decorators('Counter::inline')).toEqual(['x']); + expect(decorators('Counter::afterBody')).toEqual(['override']); + + // An annotation before a top-level variable or a field is theirs. + expect(decorators('afterVariable')).toEqual([]); + expect(decorators('Counter::afterField')).toEqual([]); + }); + } +}); diff --git a/__tests__/dart-annotation-targets.test.ts b/__tests__/dart-annotation-targets.test.ts new file mode 100644 index 000000000..7d5640a1c --- /dev/null +++ b/__tests__/dart-annotation-targets.test.ts @@ -0,0 +1,167 @@ +/** + * A Dart annotation is a constant expression: a reference to a `const` + * variable (`@riverpod`, `@override`, `@meta.immutable`, a type's static + * constant `@Riverpod.scoped` or enum value) or a call of a `const` + * constructor (`@Riverpod(keepAlive: true)`, `@Riverpod.forever()`). It is + * never a method, a getter or a function. + * + * The extractor records only an annotation's last name, and `decorates` + * references were ranked like Python decorators, which are functions: a + * function or method of that name scored above a class, and a constant scored + * nothing. So riverpod's 506 `@riverpod` annotations went to + * riverpod_analyzer_utils' extension getter `riverpod` instead of + * riverpod_annotation's `const riverpod = Riverpod();`. + * + * An annotation now means what its library can see, read from how it is + * written: a constant or a class (through its import prefix, if it has one), + * a constant, enum value or named constructor of the type it is written + * through, or, on a member, a static constant of the type around it. A + * comment between it and the declaration (riverpod_lint's `@riverpod` + * `// expect_lint: …` fixtures) no longer hides it. + * + * Runs against the native kernel (when built) and the wasm extractor. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const FILES: Record = { + 'packages/riverpod_annotation/pubspec.yaml': 'name: riverpod_annotation\n', + 'packages/riverpod_annotation/lib/riverpod_annotation.dart': `export 'src/riverpod_annotation.dart'; +`, + 'packages/riverpod_annotation/lib/src/riverpod_annotation.dart': `final class Riverpod { + const Riverpod({this.keepAlive = false}); + + const Riverpod.forever() : keepAlive = true; + + static const scoped = Riverpod(); + + final bool keepAlive; +} + +const riverpod = Riverpod(); + +enum Retry { never, always } +`, + // A getter and a function sharing annotation names, in a package the app + // does not import. + 'packages/riverpod_analyzer_utils/pubspec.yaml': 'name: riverpod_analyzer_utils\n', + 'packages/riverpod_analyzer_utils/lib/src/nodes.dart': `part 'nodes/annotation.dart'; + +const _ast = Object(); + +class AnnotatedNode {} + +bool protected(Object node) => true; +`, + 'packages/riverpod_analyzer_utils/lib/src/nodes/annotation.dart': `part of '../nodes.dart'; + +@_ast +extension RiverpodAnnotatedAnnotatedNodeOfX on AnnotatedNode { + Object? get riverpod => null; +} +`, + 'examples/counter/pubspec.yaml': 'name: counter\n', + 'examples/counter/lib/counter.dart': `import 'package:meta/meta.dart' as meta; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +@riverpod +int counter(Object ref) => 0; + +@riverpod +// expect_lint: functional_ref +int linted(Object ref) => 0; + +@Riverpod(keepAlive: true) +class Todos { + static const marker = Riverpod(); + + @marker + void add() {} + + @meta.protected + void reset() {} +} + +@Riverpod.forever() +int kept(Object ref) => 0; + +@Riverpod.scoped +int scopedCounter(Object ref) => 0; + +@Retry.always +int retried(Object ref) => 0; + +@meta.immutable +class Frozen {} +`, + // A constant of an annotation's name that counter.dart does not import. + 'examples/counter/lib/immutable.dart': `const immutable = Object(); +`, +}; + +describe('Dart annotations name a constant or a constructor', () => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-annotations-')); + 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; + }); + + /** ` @ -> :` for each `decorates` edge. */ + function decorations(graph: CodeGraph): string[] { + const files = Object.keys(FILES).filter((f) => f.endsWith('.dart')); + const ids = files.flatMap((f) => graph.getNodesInFile(f).map((n) => n.id)); + return graph.getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'decorates') + .map((e) => { + const from = graph.getNode(e.source)!; + const to = graph.getNode(e.target)!; + return `${from.qualifiedName} @${String(e.metadata?.refName ?? '')} -> ${to.kind} ${to.filePath}:${to.qualifiedName}`; + }) + .sort(); + } + + it.each(['default', 'wasm'])('link what the annotation is written as (%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 annotation = 'packages/riverpod_annotation/lib/src/riverpod_annotation.dart'; + const app = 'examples/counter/lib/counter.dart'; + expect(decorations(cg)).toEqual([ + // A private constant of the library, declared in the file the part belongs to. + `RiverpodAnnotatedAnnotatedNodeOfX @_ast -> constant packages/riverpod_analyzer_utils/lib/src/nodes.dart:_ast`, + // A constructor call names the class; a named one, its constructor. + `Todos @Riverpod -> class ${annotation}:Riverpod`, + // On a member, a static constant of the class around it. + `Todos::add @marker -> constant ${app}:Todos::marker`, + // The constant riverpod_annotation exports — not the analyzer's getter. + `counter @riverpod -> constant ${annotation}:riverpod`, + `kept @forever -> method ${annotation}:Riverpod::forever`, + // A comment between the annotation and the declaration hides nothing. + `linted @riverpod -> constant ${annotation}:riverpod`, + // An enum value and a static constant, written through their type. + `retried @always -> enum_member ${annotation}:Retry::always`, + `scopedCounter @scoped -> constant ${annotation}:Riverpod::scoped`, + // `@meta.protected` and `@meta.immutable` are package:meta's: not the + // project's function `protected`, nor a constant counter.dart cannot see. + ]); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/TortureAnnotatedComments.dart b/__tests__/fixtures/kernel-parity/TortureAnnotatedComments.dart new file mode 100644 index 000000000..87d32a23d --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureAnnotatedComments.dart @@ -0,0 +1,175 @@ +// An annotation belongs to the declaration after it, whatever comments come +// between them; the decorator scan steps over `//`, `/* */`, `///` and +// `/** */` comments. Anything else before the annotations still ends it. + +import 'package:meta/meta.dart'; +// A comment after the import. +@visibleForTesting +// And one below the annotation. +void afterImport() {} + +@x // A comment on the annotation's line. +void trailing() {} + +@override +// ignore: must_call_super +void lineBelow() {} + +@x +/// Doc below an annotation. +void docBelow() {} + +@x +/** Block doc below an annotation. */ +void blockDocBelow() {} + +@x +/* A block comment. */ +void blockBelow() {} + +@a +// one +@b +/// two +/* three */ +@c +// four +void interleaved() {} + +@riverpod +// expect_lint: functional_ref +int counter(Ref ref) => 0; + +@x +// A comment. + +void blankLines() {} + +@pragma('vm:external-name', 'f') +// A comment. +external int extTop(); + +@x +// A comment. +int get topGetter => 1; + +@x +final answer = 42; +// A comment. +void afterVariable() {} + +void before() {} // A trailing comment. +@x +void afterBody() {} + +class C { + @override + // ignore: must_call_super + void method() {} + + @x /* inline */ void inline() {} + + @override + // A comment. + void bodiless(); + + @x + // A comment. + C.named() {} + + @literal + // A comment. + const C.c(); + + @x + /// Doc below. + factory C.make() => C._(); + + @x + // A comment. + const factory C.r() = C.c; + + @x + // A comment. + static void s() {} + + @override + // A comment. + bool operator ==(Object other) => true; + + @override + // A comment. + int get value => 1; + + @override + // A comment. + set value(int v) {} + + @x + // A comment. + external void ext(); + + /// Doc above. + @a + // between + @b + void docAbove() {} + + @JsonKey(name: 'f') + final int f = 0; + // A comment after a field. + @override + void afterField() {} + + void prior() {} // A trailing comment. + @override + void afterMember() {} + + C._(); +} + +@immutable +// ignore: something +class D {} + +@internal +// A comment. +mixin M on C { + @mustCallSuper + // A comment. + void m() {} +} + +@internal +// A comment. +extension X on C { + @pragma('vm:prefer-inline') + // A comment. + void e() {} +} + +@internal +/// Doc below. +extension type Meters(double value) { + @redeclare + // A comment. + double km() => value / 1000; +} + +enum Mode { + @JsonValue('on') + // A comment. + on, + off; + + @override + // A comment. + String toString() => name; +} + +void outer() { + @pragma('vm:prefer-inline') + // A comment. + void local() {} + local(); +} diff --git a/__tests__/kernel-dart-parity.test.ts b/__tests__/kernel-dart-parity.test.ts index 104d6f423..08aec13e5 100644 --- a/__tests__/kernel-dart-parity.test.ts +++ b/__tests__/kernel-dart-parity.test.ts @@ -33,9 +33,10 @@ * `declaration`-wrapped member with no body, read from before the wrapper; * TortureCommentChains.dart: comments between a member chain's parts skipped * by every sibling step; TortureAnnotatedDocs.dart: the dartdoc above a - * member's annotations, read past them, and what still ends the walk) and - * their CRLF variants (derived in-memory — #1329), plus defer and - * generated-file pins. + * member's annotations, read past them, and what still ends the walk; + * TortureAnnotatedComments.dart: the comments between annotations and their + * declaration, stepped over by the decorator scan) 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 * with --max-deferral 0.3); this suite keeps the invariant alive in @@ -153,6 +154,11 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => { // import or the previous member's body still ends the walk. Class-like // declarations open with their annotations. ['TortureAnnotatedDocs.dart', 40], + // Comments of every form between annotations and the declaration they + // belong to — stacked, on the annotation's line, before members with no + // body — are stepped over by the decorator scan; a field, a variable, an + // import or the previous member's body still ends it. + ['TortureAnnotatedComments.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 98a83fa7c..4fdfcdee9 100644 --- a/codegraph-kernel/src/dart.rs +++ b/codegraph-kernel/src/dart.rs @@ -51,6 +51,12 @@ const MAX_VALUE_REF_NODES: usize = 20_000; /// `Widget build(…)`), so the docstring walk steps over them. const DOCSTRING_STEP_OVER: &[&str] = &["annotation"]; +/// decoratorStepOverTypes (dart.ts) — an annotation belongs to the next +/// declaration, whatever comments come between them (`@override` +/// `// ignore: must_call_super` `void f()`), so the decorator scan steps over +/// them. +const DECORATOR_STEP_OVER: &[&str] = &["comment", "documentation_comment"]; + /// NAME_STOPLIST (function-ref.ts). fn is_stoplisted(name: &str) -> bool { matches!( @@ -1457,7 +1463,8 @@ impl<'t> Walker<'t> { } // Scan 2: preceding siblings, backward, stop at the first // non-annotation — stacked annotations emit in REVERSE source order. - // A `declaration`-wrapped member's annotations precede the wrapper. + // A `declaration`-wrapped member's annotations precede the wrapper, + // and the comments between annotations and declaration are stepped over. let anchor = self.declaration_wrapper(decl).unwrap_or(decl); if let Some(parent) = anchor.parent() { let decl_start = anchor.start_byte(); @@ -1473,6 +1480,9 @@ impl<'t> Walker<'t> { if let Some(di) = decl_idx { for j in (0..di).rev() { let Some(sib) = parent.named_child(j) else { continue }; + if DECORATOR_STEP_OVER.contains(&sib.kind()) { + continue; + } if !matches!(sib.kind(), "decorator" | "annotation" | "marker_annotation") { break; } diff --git a/docs/design/dart-kernel-port-checklist.md b/docs/design/dart-kernel-port-checklist.md index ac165a0b8..8943485fe 100644 --- a/docs/design/dart-kernel-port-checklist.md +++ b/docs/design/dart-kernel-port-checklist.md @@ -871,12 +871,16 @@ enum emits nothing). Mechanics for dart: decorates `immutable`). Enums and typedefs are built the same way, but nothing scans them. - Scan #2 (preceding siblings :5002-5023): walk BACKWARD from the - declaration; `annotation` is in the accepted set (:5017); stop at the - first non-annotation sibling. consider(): target = first namedChild of - accepted types → the `identifier` (`override`, `deprecated`, `pragma`, - `immutable`, `Deprecated`) → `<`-strip + last-`.`-segment (`@ui.Widget` - style would strip to `Widget`) → **`decorates` ref {from the decorated - node, name, line/col of the ANNOTATION node}**. With-args annotations + declaration; `annotation` is in the accepted set (:5017); step over + `comment` / `documentation_comment` siblings (since 2026-10-06, below); + stop at the first other non-annotation sibling. consider(): target = + first namedChild of accepted types → the `identifier` (`override`, + `deprecated`, `pragma`, `immutable`, `Deprecated`) or `scoped_identifier` + (`meta.immutable`, `Foo.named`, `p.Foo.named`) → `<`-strip + last-`.`-segment + (`@meta.immutable` → `immutable`, `@p.Foo.named(…)` → `named`; the strip + runs first, so `@Foo.named(…)`, which parses with an ERROR inside the + name, → `Foo`) → **`decorates` ref {from the decorated node, name, + line/col of the ANNOTATION node, i.e. its `@`}**. With-args annotations emit their NAME; the argument expressions are never visited (no refs from `@Deprecated('use other')`'s string). - **Stacked annotations emit in REVERSE source order** (the backward walk): @@ -885,22 +889,34 @@ enum emits nothing). Mechanics for dart: - For a method: the previous member's function_body (or any declaration) breaks the chain correctly. (The docstring walk steps OVER annotations since 2026-10-06 — §Docstrings.) -- **A comment between an annotation and the declaration ends the scan**, so - the annotation above it attaches to nothing: `@override` `// ignore: - must_call_super` `void f()`, `@x` `/// Doc below.` `void f()` (pinned in - TortureAnnotatedDocs.dart). Measured 2026-10-06: bloc @b9be1e2 hides 7 - annotations this way (all `@override`), riverpod @4ba1be2 55 on 49 - declarations (`@override` 33, `@riverpod` 16 in the lint fixtures, - `@internal` 6). Left as is ON PURPOSE: stepping over comments here is - right for Dart (an annotation always belongs to the next declaration), but - Dart nodes store no decorator list, so the only effect would be edges, and - `override`/`internal` resolve to nothing, while `decorates` scoring prefers - function/method candidates (name-matcher.ts), so `@riverpod` lands on the - analyzer getter `RiverpodAnnotatedAnnotatedNodeOfX::riverpod` (0.4) - instead of `const riverpod = Riverpod()`. That is already true of - riverpod's 506 existing `@riverpod` edges. Fix that ranking for Dart (a - const or a class, never a method) first, then add `comment` / - `documentation_comment` to scan #2's step-over in both arms. +- **Comments between the annotations and the declaration are stepped over** + (since 2026-10-06). An annotation always belongs to the next declaration, + so dart.ts sets `decoratorStepOverTypes: ['comment', + 'documentation_comment']`, which scan #2 skips, and the kernel's + `extract_decorators_for` skips the same `DECORATOR_STEP_OVER` list. No + other language sets one. Until then the comment ended the scan and the + annotation above it attached to nothing. Now these all attach: `@override` + `// ignore: must_call_super` `void f()`, `@x` `/// Doc below.` `void f()`, + `@x /* c */ void f()`, a comment on the annotation's own line, and comments + between stacked annotations (still in reverse order). Pinned in + TortureAnnotatedComments.dart and dart-annotation-comments.test.ts. + - Still ends the scan, comments or not: a field's `declaration`, a + top-level variable, an import, the previous member's body (`void a() {} + // note` `@x void b()` gives `b` only `x`), a class-like declaration. + - Class-likes are unaffected. Their leading annotations are scan #1's, + which reads every child, comments among them included. + - Validation (bloc @b9be1e2 / riverpod @4ba1be2, kernel loaded, on top of + the annotation resolution below): `decorates` refs +7 / +54, edges +0 / + +16, nothing else in the dumps changed. bloc's 7 are all `@override`. + riverpod's 54 (on 48 declarations) are `@override` 32, `@internal` 6 and + `@riverpod` 16 (lint fixtures and one website snippet), and each + `@riverpod` links `const riverpod`. Kernel and wasm full-index dumps are + byte-identical, and the parity sweeps show 0 diffs. + - The AST scan's 55th riverpod site is `@override` `// ignore: …` on a + FIELD, which no extractor records. A scan that also counts annotation → + comment runs INSIDE class-like nodes finds riverpod's 24 `@riverpod` + `// ignore: …` `class X` lint fixtures and bloc's `@JsonEnum(…)` `/// …` + `enum`. Scan #1 already reads those (an enum's, never: see above). - Members with no body (declaration-wrapped ctors, const ctors, redirecting factories, abstract and `external` members): the signature's PARENT is the `declaration`, so until 2026-10-06 the backward scan ran over the @@ -914,6 +930,41 @@ enum emits nothing). Mechanics for dart: refs, all SDK or package:meta names (`useResult`, `override`, `internal`, `visibleForTesting`, …), so no edges; before #2380, riverpod's 23 new `@internal` refs on `X.internal(…)` ctors resolved to the ctor itself. +- **Resolution is TS-side and Dart-specific** (since 2026-10-06): + resolveOneInner hands every Dart `decorates` ref to name-matcher.ts's + `matchDartAnnotation` before any other strategy, and nothing falls + through. A Dart annotation is a const variable or a const constructor + call, never a method, getter or function, so the shared decorator ranking + (findBestMatch: +25 function/method, +15 class, nothing for a constant) + never sees one. That ranking sent riverpod's 506 `@riverpod` to the + analyzer's extension getter `RiverpodAnnotatedAnnotatedNodeOfX::riverpod` + (0.4) instead of `const riverpod = Riverpod();`. + - The matcher re-reads the written form from the `@` in comment-blanked + source. It is a call only when `(` follows on the same line, so a record + return type on the next line is not taken for arguments. + - `@x`: on a member, the enclosing type's own static `constant` / + `enum_member` `x` (a class-like's own annotations stand outside its + body); else a top-level `constant` the library can see (#2386's model, + the own library shadowing imports). `@X(…)`: a visible class `X`, not + an extension. + - `@p.x` / `@p.X(…)`, when `p` is an import prefix (dart-libraries.ts + `isDartImportPrefix`, the imports of the file's whole library): the same + through that prefix. Otherwise `@T.x` → T's own `constant` / + `enum_member` `x`, and `@T.named(…)` → T's named constructor (#2380's + `isDartConstructor`); `@p.T.x` / `@p.T.named(…)` likewise through `p`. + - A sole candidate links at 0.9 (`exact-match`, or `qualified-name` + through a type). Several (only where the library model can't establish + the library) fall back to findBestMatch's proximity at 0.7 / 0.4. + - Not modelled: a `final` is not told apart from a `const` (compiling code + never annotates with a visible `final`), nor a typedef used to call a + constructor. + - Validation (main 99246848, kernel loaded): bloc 0 edges changed (no + annotation there names a project declaration); riverpod exactly the 506 + `@riverpod` edges retargeted to `const riverpod` at 0.9, every other + `decorates` edge (`$internal`, `Riverpod`, `Dependencies`, …) identical, + nodes, refs and files identical. Every extracted annotation in both + repos is written with one name, so the prefixed and `T.x` forms are + pinned by dart-annotation-targets.test.ts only. ### Docstrings (tree-sitter-helpers.ts:95-127) — dartdoc is KEPT, both forms diff --git a/src/extraction/languages/dart.ts b/src/extraction/languages/dart.ts index 0997821c5..f5b7b8a25 100644 --- a/src/extraction/languages/dart.ts +++ b/src/extraction/languages/dart.ts @@ -539,6 +539,10 @@ export const dartExtractor: LanguageExtractor = { // this: its annotations open its own node, and the dartdoc above them is // that node's previous sibling. docstringStepOverTypes: ['annotation'], + // An annotation belongs to the next declaration, whatever comments come + // between them: `@override` `// ignore: must_call_super` `void f()`, or a + // dartdoc written below the annotations. The decorator scan steps over them. + decoratorStepOverTypes: ['comment', 'documentation_comment'], resolveBody: (node, bodyField) => { // Dart: function_body is a next sibling of function_signature/method_signature if (node.type === 'function_signature' || node.type === 'method_signature') { diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 5dfafd205..bb7c26f78 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -181,6 +181,13 @@ export interface LanguageExtractor { * each one is stepped over, and comments on either side of it still join. */ docstringStepOverTypes?: string[]; + /** + * Node types that may stand between a declaration and the decorators written + * before it without ending the scan for them. Dart writes comments there + * (`@override` `// ignore: must_call_super` `void f()`), and an annotation + * always belongs to the declaration that follows it. + */ + decoratorStepOverTypes?: string[]; /** Extract visibility from node */ getVisibility?: (node: SyntaxNode) => 'public' | 'private' | 'protected' | 'internal' | undefined; /** Check if node is exported */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index c3bd6fd98..29debc3a1 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -6213,8 +6213,10 @@ export class TreeSitterExtractor { // // A grammar that wraps the declaration (Dart's `declaration` // around a member with no body) puts the annotations before the - // wrapper, so the scan starts there. + // wrapper, so the scan starts there. One that lets comments sit + // between the annotations and the declaration (Dart) steps over them. const anchor = this.extractor?.getDeclarationWrapper?.(declNode) ?? declNode; + const stepOver = this.extractor?.decoratorStepOverTypes; const parent = anchor.parent; if (parent) { const declStart = anchor.startIndex; @@ -6230,6 +6232,7 @@ export class TreeSitterExtractor { for (let j = declIdx - 1; j >= 0; j--) { const sibling = parent.namedChild(j); if (!sibling) continue; + if (stepOver?.includes(sibling.type)) continue; if (sibling.type !== 'decorator' && sibling.type !== 'annotation' && sibling.type !== 'marker_annotation') { break; // non-decorator separator → stop consuming } diff --git a/src/resolution/dart-libraries.ts b/src/resolution/dart-libraries.ts index 18c2d7930..8fb2b9e0b 100644 --- a/src/resolution/dart-libraries.ts +++ b/src/resolution/dart-libraries.ts @@ -691,3 +691,14 @@ export function inSameDartLibrary(fromFile: string, declFile: string, context: R const scope = scopeOf(fromFile, context, memoFor(context)); return scope !== null && scope.own.has(declFile); } + +/** + * Whether `name` is an import prefix where `fromFile` is written: an import + * of its library is `as name` (`p` in `p.Foo`, not the `Foo` of `Foo.bar`). + * Where the library is unknown, the file's own imports say. + */ +export function isDartImportPrefix(fromFile: string, name: string, context: ResolutionContext): boolean { + const memo = memoFor(context); + const importers = scopeOf(fromFile, context, memo)?.importers ?? [fromFile]; + return importers.some((file) => (directivesOf(file, context, memo)?.imports ?? []).some((d) => d.prefix === name)); +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 5fdbf2d9b..5c06054d2 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isStdMethodName } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; @@ -1149,6 +1149,9 @@ export class ReferenceResolver { // `events.map(f).transform(…)`), which arrives by its bare name: a member // of what the chain's head and links are declared to be, or nothing (#750). if (isDartChainLink(ref, this.context)) return matchDartChainLink(ref, this.context); + // A Dart annotation (`@riverpod`, `@Riverpod(…)`) is a constant or a + // constructor call, as written — never a method or function by its name. + if (isDartAnnotation(ref)) return matchDartAnnotation(ref, this.context); // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 4b382c0a3..4ded3cfce 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -11,7 +11,7 @@ import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSuperty import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility'; -import { dartLibrarySees, dartPrefixSees, inSameDartLibrary } from './dart-libraries'; +import { dartLibrarySees, dartPrefixSees, inSameDartLibrary, isDartImportPrefix } from './dart-libraries'; import { breakVbTie, isVbMemberInScope, isVbNestedTypeInScope, isVbTypeQualifiedBy, matchVbTypedCall, preferVbProject, sameVbProject } from './vbnet-receivers'; import { isTestPath } from '../search/query-utils'; import { isMinifiedContent } from '../extraction/generated-detection'; @@ -4049,6 +4049,130 @@ export function matchDartMemberRead(ref: UnresolvedRef, context: ResolutionConte return { original: ref, targetNodeId: found.node.id, confidence: 0.9, resolvedBy: 'instance-method', edgeKind: 'calls' }; } +/** + * Whether a ref is a Dart annotation — `@riverpod`, `@Riverpod(keepAlive: + * true)`, `@meta.immutable` — which the extractor records by its last name, at + * the `@`. + */ +export function isDartAnnotation(ref: UnresolvedRef): boolean { + return ref.language === 'dart' && ref.referenceKind === 'decorates'; +} + +/** + * A Dart annotation is a constant expression: a `const` variable, or a call of + * a `const` constructor. So it links, as it is written, a constant or (called) + * a class its library can see, through the import prefix it is written with; + * a static constant, an enum value or (called) a named constructor of the + * type it is written through (`@Foo.value`, `@Foo.named(…)`); or, on a + * member, a static constant of the type around it. Never a method, a + * getter or a function: riverpod's 506 `@riverpod` annotations went to + * riverpod_analyzer_utils' extension getter `riverpod`, not riverpod_annotation's + * `const riverpod = Riverpod();`. + */ +export function matchDartAnnotation(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const name = ref.referenceName; + if (!context.getNodesByName(name).some((n) => n.language === 'dart')) return null; + const written = dartAnnotationAt(ref, context); + if (!written) return null; + const { names, call } = written; + let pool: Node[]; + let resolvedBy: ResolvedRef['resolvedBy'] = 'exact-match'; + if (names.length === 1 || (names.length === 2 && isDartImportPrefix(ref.filePath, names[0]!, context))) { + const prefix = names.length === 2 ? names[0]! : null; + const own = prefix === null && !call ? dartEnclosingStatic(name, ref, context) : null; + if (own) return { original: ref, targetNodeId: own.id, confidence: 0.9, resolvedBy }; + pool = context.getNodesByName(name).filter((n) => isDartLibraryDecl(n) && + (call ? isDartAnnotationClass(n, context) : n.kind === 'constant') && isDartTopLevelVisible(n, ref, context, prefix)); + if (prefix === null) pool = preferOwnDartLibrary(pool, ref, context); + } else if (names.length === 2 || (names.length === 3 && isDartImportPrefix(ref.filePath, names[0]!, context))) { + const prefix = names.length === 3 ? names[0]! : null; + let owners = context.getNodesByName(names[names.length - 2]!).filter((n) => + isDartLibraryDecl(n) && DART_TYPE_KINDS.has(n.kind) && isDartTopLevelVisible(n, ref, context, prefix)); + if (prefix === null) owners = preferOwnDartLibrary(owners, ref, context); + pool = context.getNodesByName(name).filter((m) => m.language === 'dart' && + owners.some((o) => o.filePath === m.filePath && m.qualifiedName === `${o.qualifiedName}::${name}`) && + (call ? isDartConstructor(m, context) : m.kind === 'constant' || m.kind === 'enum_member')); + resolvedBy = 'qualified-name'; + } else { + return null; + } + if (pool.length <= 1) return pool[0] ? { original: ref, targetNodeId: pool[0].id, confidence: 0.9, resolvedBy } : null; + // Libraries this cannot establish see every file's declarations. + const best = findBestMatch(ref, pool, context); + return best + ? { original: ref, targetNodeId: best.id, confidence: computePathProximity(ref.filePath, best.filePath) >= 30 ? 0.7 : 0.4, resolvedBy } + : null; +} + +/** A class a Dart annotation can call a constructor of: not an extension, which has none. */ +function isDartAnnotationClass(n: Node, context: ResolutionContext): boolean { + return n.kind === 'class' && dartExtensionDecl(n, context) === null; +} + +/** + * The static constant or enum value `name` of the type a Dart annotation is + * written in, which a member's annotation names bare. A class's own + * annotations stand outside its body. + */ +function dartEnclosingStatic(name: string, ref: UnresolvedRef, context: ResolutionContext): Node | null { + let own: Node | null = null; + for (const n of context.getNodesInFile(ref.filePath)) { + if (DART_TYPE_KINDS.has(n.kind) && n.id !== ref.fromNodeId && n.startLine <= ref.line && n.endLine >= ref.line && + (!own || n.startLine >= own.startLine)) own = n; + } + if (!own) return null; + const qualifiedName = `${own.qualifiedName}::${name}`; + return context.getNodesByName(name).find((m) => m.language === 'dart' && m.filePath === ref.filePath && + m.qualifiedName === qualifiedName && (m.kind === 'constant' || m.kind === 'enum_member')) ?? null; +} + +/** + * How a Dart annotation is written — the names of `@p.Foo.named(…)` up to the + * one its ref records, and whether it is called — read from its `@`. The + * extractor records the last name, or the one a type argument list follows + * (`@Foo.named(…)` is `Foo`'s). + */ +function dartAnnotationAt(ref: UnresolvedRef, context: ResolutionContext): { names: string[]; call: boolean } | null { + const code = dartCodeOf(ref.filePath, context); + const from = code?.lineStarts[ref.line - 1]; + if (!code || from === undefined) return null; + const to = code.lineStarts[ref.line] ?? code.text.length; + // On a line with non-ASCII text before it, the `@` whose names fit stands in. + const ats = [from + ref.column]; + for (let k = code.text.indexOf('@', from); k >= 0 && k < to; k = code.text.indexOf('@', k + 1)) { + if (k !== from + ref.column) ats.push(k); + } + for (const at of ats) { + const read = code.text[at] === '@' ? readDartAnnotation(code.text, at + 1) : null; + const end = read ? (read.typeArgsAt >= 0 ? read.typeArgsAt : read.names.length - 1) : -1; + if (read && read.names[end] === ref.referenceName) return { names: read.names.slice(0, end + 1), call: read.call }; + } + return null; +} + +/** A Dart annotation's dotted names, which of them a type argument list follows, and whether `(` comes next on its line. */ +function readDartAnnotation(text: string, i: number): { names: string[]; typeArgsAt: number; call: boolean } | null { + const names: string[] = []; + let typeArgsAt = -1; + const identifier = /[ \t]*([A-Za-z_$][\w$]*)[ \t]*/y; + while (names.length < 8) { + identifier.lastIndex = i; + const m = identifier.exec(text); + if (!m) return null; + names.push(m[1]!); + i = identifier.lastIndex; + if (text[i] === '<' && typeArgsAt < 0) { + const close = dartTypeArgumentsEnd(text, i); + if (close < 0) return null; + typeArgsAt = names.length - 1; + for (i = close + 1; text[i] === ' ' || text[i] === '\t'; i++); + } + if (text[i] !== '.') break; + i++; + } + return { names, typeArgsAt, call: text[i] === '(' }; +} + /** * A member an extension adds to `typeName` — `s.shout()` on an enum `Shape` * through `extension ShapeInfo on Shape` — when the type itself declares none @@ -11530,7 +11654,8 @@ function findBestMatch( // For decorator references (`@Foo`), prefer functions. Class // decorators (Python `@SomeClass`, Java annotation interfaces) - // also resolve here, hence the smaller class bonus. + // also resolve here, hence the smaller class bonus. (A Dart annotation, + // a constant or a constructor call, is matchDartAnnotation's.) if (ref.referenceKind === 'decorates') { if (candidate.kind === 'function' || candidate.kind === 'method') { score += 25;