fix(dart): an annotation links the constant or constructor it names, even behind a comment (#2390)

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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-06 17:27:41 +00:00
committed by GitHub
parent 631df0138e
commit d2fb323e51
13 changed files with 761 additions and 30 deletions
+2
View File
@@ -57,6 +57,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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 `import` or `export` now links to the file it names: `package:my_app/src/cart.dart` to that package's `lib/src/cart.dart`, and a relative path to the file it points to from the importing file. Before, only the file name at the end was matched, so `import 'package:flutter/widgets.dart'` linked to whichever project file was called `widgets.dart`, and most imports of your own files didn't link to the file at all, which threw off `codegraph affected` and the files listed as using a file. An import of a Dart SDK library or of a package from outside your project now links nothing, and when packages in one repository share a name, the one the importing app depends on by `path:` is used. 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
+167
View File
@@ -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<string, string | undefined> = {};
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([]);
});
}
});
+167
View File
@@ -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<string, string> = {
'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;
});
/** `<decorated> @<refName> -> <target kind> <target file>:<target qualified name>` 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.
]);
});
});
@@ -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();
}
+9 -3
View File
@@ -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) {
+11 -1
View File
@@ -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;
}
+73 -22
View File
@@ -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<int>.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
+4
View File
@@ -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') {
+7
View File
@@ -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 */
+4 -1
View File
@@ -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
}
+11
View File
@@ -769,3 +769,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));
}
+4 -1
View File
@@ -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)) {
+127 -2
View File
@@ -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<int>.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;