fix(dart): a member keeps the dartdoc written above its annotations (#2387)

A Dart member's annotations are siblings that stand between it and the
dartdoc written above them:

    /// Builds the widget.
    @override
    Widget build(BuildContext context) { ... }

Both extractors read a docstring from the comments directly before the
declaration (or its `declaration` wrapper) and stopped at the first node
that was not a comment, so the annotation ended the run before the `///`
was reached. Members behind `@override`, `@protected`, `@mustCallSuper`,
`@pragma(...)`, `@internal` and the like lost their documentation, and
searching for words in it didn't find them.

Extraction (TS + kernel, at parity):
- getPrecedingDocstring takes an optional list of sibling types to step
  over, and Dart sets `docstringStepOverTypes: ['annotation']`. Stacked,
  multi-line and inline annotations are passed, and comments on either
  side of one still join (`/// a` `@x` `// b` gives "a" and "b" on two
  lines), as adjacent comments already did. Any other node still ends
  the walk: the previous member's body, a field, a top-level variable,
  an import or another declaration.
- The kernel's shared docstring.rs gains preceding_docstring_stepping_over.
  preceding_docstring passes an empty list, so every other language is
  unchanged; only Dart's docstring_of passes ["annotation"].
- Class-like declarations needed nothing: a class, mixin, extension,
  extension type, enum or typedef node starts at its first annotation,
  so the dartdoc above it is already the node's previous sibling.

Validated on felangel/bloc @b9be1e2 and rrousselGit/riverpod @4ba1be2 at
main 249d9a86 (and at 1b752de2) with the kernel loaded: docstrings bloc
+26, riverpod +1,072 with 9 changed and none removed. Nodes otherwise,
edges, refs and files are byte-identical, full-index kernel and wasm
dumps are byte-identical, and the kernel parity sweeps show 0 diffs.

Not changed: a comment between an annotation and the declaration still
hides the annotation from the decorator scan. Recording those would only
add wrong edges until Dart `decorates` resolution stops preferring
methods: riverpod's `@riverpod` edges land on an analyzer getter rather
than `const riverpod`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-06 16:29:29 +00:00
committed by GitHub
parent 0c860c00b3
commit 9924684899
12 changed files with 610 additions and 19 deletions
+1
View File
@@ -51,6 +51,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Dart, `const` constructors and redirecting factories, like flutter_bloc's `const BlocProvider.value(…)` or freezed's `const factory LoginState.loading() = _Loading;`, are now indexed, so a call such as `BlocProvider.value(…)` or `BlocProvider<CounterCubit>.value(…)` links to that constructor instead of another class's member with the same name. A call through a class name now reaches only that class's own constructors and static members, never a look-alike class, a parent class, an instance getter of the same name, or a project method that merely shares its name with an SDK call like `Uri.parse(…)`, and a constructor is no longer linked from a bare name, the end of a call chain or an annotation like `@internal`. Re-index Dart projects after upgrading.
- In Dart, a call that passes a type argument and arguments, like `ref.read<Repo>(repoProvider)`, `ref.watch<int>(counterProvider)`, `Provider<User>((ref) => …)`, `BlocProvider.of<CounterCubit>(context)` or `RepositoryProvider<Repo>(create: …)`, now links to the method or class it calls, and the type it passes counts as used there. Depending on the code around it, the parser often read such a call as two comparisons, so it linked nothing, and Riverpod providers, repository reads and bloc lookups were missing from `callers`, impact and `codegraph_explore`. A real comparison like `a < b` is still left alone. Re-index Dart projects after upgrading.
- In Dart, a constructor or member written without a body, like flutter_bloc's `const BlocProvider.value(…)`, `Foo._();`, `Point.origin() : x = 0, y = 0;` or an abstract or `external` method, now keeps the `///` documentation and the annotations written above it, the way one with a body already did. Before, that documentation was lost, so searching for words in it didn't find the symbol, and an annotation like `@visibleForTesting` or `@protected` on it wasn't recorded. Re-index Dart projects after upgrading.
- In Dart, a function or method now keeps the `///` documentation written above its annotations, like the doc on a widget's `@override Widget build(…)` or on a member marked `@protected`, `@mustCallSuper`, `@pragma(…)` or `@internal`. Before, the annotation between the documentation and the declaration hid it, so searching for words in it didn't find the symbol. Re-index Dart projects after upgrading.
- In Dart, a call on what a generic lookup returns, like flutter_bloc's `BlocProvider.of<CounterCubit>(context, listen: false).increment()` or Provider's `Provider.of<CartModel>(context, listen: false).add(item)`, now links to that method of the type in the angle brackets, whether the lookup is declared in your project or comes from a package. Before, such a call often linked nothing — for example whenever the lookup passed `listen: false` — so `callers` and impact missed those methods. A lookup that names no type, like `BlocProvider.of(context)`, still links nothing. Re-index Dart projects after upgrading. (#750)
- 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.
@@ -0,0 +1,247 @@
/**
* A Dart member's annotations sit between it and the dartdoc written above
* them:
*
* /// Builds the widget.
* @override
* Widget build(BuildContext context) { … }
*
* Both extractors read a docstring from the comments directly before the
* declaration and stopped at the first node that was not a comment, so the
* annotation ended the run before the `///` was reached and the member lost
* its documentation. `@override`, `@protected`, `@mustCallSuper`, `@pragma`,
* `@internal` and `@visibleForTesting` are everywhere in Flutter code.
*
* The walk now steps over annotations, stacked and multi-line ones included,
* and a comment between an annotation and the member joins the dartdoc above
* the way adjacent comments already did. Whatever else comes first (a field,
* the previous member's body, a top-level variable, an import) still ends it.
* A class, mixin, extension, enum or typedef kept its dartdoc already: its
* annotations open its own node, so the dartdoc above them is that node's
* previous sibling.
*
* 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 = `// Copyright 2026 the authors.
import 'package:meta/meta.dart';
@visibleForTesting
void afterImport() {}
/// Runs at startup.
@pragma('vm:entry-point')
void main() {}
/// Calls into the platform.
@pragma('vm:external-name', 'version')
external int platformVersion();
/// Watches the counter.
@riverpod
@Deprecated('Use the generated provider')
int counter(Ref ref) => 0;
/// The answer.
final answer = 42;
@visibleForTesting
void afterVariable() {}
abstract class Repository {
/// Loads the user with [id].
@protected
Future<User> load(String id);
}
/// A counter.
@immutable
class Counter {
/// Starts at zero.
@literal
const Counter.zero() : value = 0;
/// Reads a counter from JSON.
@visibleForTesting
factory Counter.fromJson(Map<String, Object?> json) => const Counter.zero();
/// Builds the widget.
@override
Widget build(BuildContext context) => const Text('0');
/// Releases resources.
///
/// Call it once.
@protected
@mustCallSuper
void dispose() {}
/// The current count.
@override
int get count => value;
/// Use [build] instead.
@Deprecated(
'Use build. '
'Removed in 2.0.',
)
Widget render() => const Text('');
/// Joined with the comment below the annotation.
@protected
// ignore: must_call_super
void joined() {}
/// The cached value.
@JsonKey(name: 'value')
final int value;
@override
void afterField() {}
void before() {}
@override
void afterBody() {}
}
/// A mixin.
@internal
mixin Logging on Counter {}
/// An extension.
@internal
extension CounterX on Counter {}
/// An enum.
@JsonEnum()
enum Mode { light, dark }
/// A typedef.
@internal
typedef Callback = void Function();
@visibleForTesting
void afterTypedef() {}
`;
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
describe('Dart members keep the dartdoc written above their annotations', () => {
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);
const node = (qualifiedName: string) => {
const found = result.nodes.find((n) => n.qualifiedName === qualifiedName);
expect(found, qualifiedName).toBeDefined();
return found!;
};
const doc = (qualifiedName: string) => node(qualifiedName).docstring;
const decorators = (qualifiedName: string): string[] => {
const id = node(qualifiedName).id;
return result.unresolvedReferences
.filter((r) => r.referenceKind === 'decorates' && r.fromNodeId === id)
.map((r) => r.referenceName)
.sort();
};
// Top-level functions, an external one and stacked annotations.
expect(doc('main')).toBe('Runs at startup.');
expect(doc('platformVersion')).toBe('Calls into the platform.');
expect(doc('counter')).toBe('Watches the counter.');
// Members: methods, a getter, a factory, a member with no body and a
// `const` constructor (both read from before their `declaration`).
expect(doc('Counter::build')).toBe('Builds the widget.');
expect(doc('Counter::count')).toBe('The current count.');
expect(doc('Counter::fromJson')).toBe('Reads a counter from JSON.');
expect(doc('Repository::load')).toBe('Loads the user with [id].');
expect(doc('Counter::zero')).toBe('Starts at zero.');
expect(doc('Counter::dispose')).toBe('Releases resources.\n\nCall it once.');
// A multi-line annotation, with a blank line before the member.
expect(doc('Counter::render')).toBe('Use [build] instead.');
// A comment between the annotation and the member joins the dartdoc,
// as adjacent comments do.
expect(doc('Counter::joined')).toBe(
'Joined with the comment below the annotation.\nignore: must_call_super'
);
// What comes before the annotations still ends the walk: an import, a
// top-level variable, a field (whose dartdoc and annotation are its
// own), the previous member's body and another declaration.
expect(doc('afterImport')).toBeUndefined();
expect(doc('afterVariable')).toBeUndefined();
expect(doc('Counter::afterField')).toBeUndefined();
expect(doc('Counter::afterBody')).toBeUndefined();
expect(doc('afterTypedef')).toBeUndefined();
// Class-like declarations kept theirs already: the annotations open
// their own node.
expect(doc('Counter')).toBe('A counter.');
expect(doc('Logging')).toBe('A mixin.');
expect(doc('CounterX')).toBe('An extension.');
expect(doc('Mode')).toBe('An enum.');
expect(doc('Callback')).toBe('A typedef.');
// The annotations are still recorded where they were.
expect(decorators('counter')).toEqual(['Deprecated', 'riverpod']);
expect(decorators('Counter::build')).toEqual(['override']);
expect(decorators('Counter::dispose')).toEqual(['mustCallSuper', 'protected']);
expect(decorators('Counter::zero')).toEqual(['literal']);
expect(decorators('Counter::afterField')).toEqual(['override']);
});
}
});
@@ -0,0 +1,193 @@
// A member's annotations sit between it and the dartdoc written above them;
// the docstring walk steps over them. A class-like declaration opens with its
// annotations, so the dartdoc above them precedes its node.
import 'package:meta/meta.dart';
@visibleForTesting
void afterImport() {}
/// Top function doc.
@pragma('vm:entry-point')
void top() {}
/// External top doc.
@pragma('vm:external-name', 'f')
external int extTop();
/// Stacked, prefixed and generic annotations.
@meta.immutable
@JsonSerializable<Foo>(explicitToJson: true)
@Deprecated('x')
void stacked() {}
/// Top getter doc (no node).
@x
int get topGetter => 1;
/** Block dartdoc above an annotation. */
@x
void blockDoc() {}
// Plain comment above an annotation.
@x
void plainAbove() {}
/* Inline block */ @x void inlineBlock() {}
/// a
@x
// b
@y
/* c */
void interleaved() {}
/// Doc, then blank lines.
@x
void blanks() {}
/// A variable's doc.
final answer = 42;
@x
void afterVariable() {}
const kLimit = 3;
@x
void afterConst() {}
class C {
/// Operator doc.
@override
bool operator ==(Object other) => true;
/// Factory doc.
@x
factory C.make() => C._();
/// Bodied constructor doc.
@x
C.named() {}
/// Static method doc.
@x
static void s() {}
/// External member doc.
@x
external void extMember();
/// Bodiless member doc.
@override
void bodiless();
/// Const constructor doc.
@literal
const C.c();
/// Redirecting factory doc.
@x
const factory C.r() = C.c;
/// Static field doc.
@x
static const int k = 1;
@x
void afterStaticField() {}
/// Multi-line annotation.
@Deprecated(
'Use other. '
'Removed in 3.0',
)
void multi() {}
@override // ignore: must_call_super
void trailingComment() {}
/// Doc above, ignore below.
@override
// ignore: invalid_use_of_protected_member
void ignoreBelow() {}
@x
/// Doc below an annotation.
void docBelow() {}
/// Field doc.
@JsonKey(name: 'x')
final int field = 0;
@override
void afterField() {}
void before() {} // trailing comment
@override
void afterBody() {}
/// Getter doc.
@override
int get value => 1;
/// Setter doc.
@override
set value(int v) {}
C._();
}
/// Class doc above a comment.
@immutable
// ignore: something
class D {}
@immutable
/// Class doc below an annotation.
class E {}
/// Mixin doc.
@internal
mixin M on C {
/// Mixin member doc.
@mustCallSuper
void m() {}
}
/// Extension doc.
@internal
extension X on C {
/// Extension member doc.
@pragma('vm:prefer-inline')
void e() {}
}
/// Extension type doc.
@internal
extension type Meters(double value) {
/// Extension type member doc.
@redeclare
double km() => value / 1000;
}
/// Enum doc.
@JsonEnum()
enum Mode {
@JsonValue('on')
on,
off;
/// Enum member doc.
@override
String toString() => name;
}
/// Typedef doc.
@internal
typedef Cb = void Function();
void outer() {
/// Local function doc.
@pragma('vm:prefer-inline')
void local() {}
local();
}
@@ -16,7 +16,7 @@ int blockDoc() => 1;
// Plain comment doc.
String plainDoc() => 'x';
/// Broken by annotation.
/// Kept past the annotation.
@deprecated
void annotated() {}
+11 -4
View File
@@ -6,8 +6,8 @@
* ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
* unresolved refs compared as canonicalized multisets — over the checked-in
* fixtures (torture.dart: the master inventory — imports incl. deferred
* invisibility, dartdoc in all three comment forms with the
* annotation-broken chain, stacked annotations in reverse order, the
* invisibility, dartdoc in all three comment forms and above an
* annotation, stacked annotations in reverse order, the
* static_final_declaration constants hook, the full ctor set with the
* unnamed-ctor skip and named-ctor renaming, operator methods as
* `<anonymous>`, the extractBareCall matrix incl. cascade invisibility and
@@ -32,8 +32,10 @@
* TortureDeclarationDocs.dart: the dartdoc and annotations of a
* `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) and their CRLF variants (derived in-memory — #1329),
* plus defer and generated-file pins.
* 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.
*
* 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
@@ -146,6 +148,11 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => {
// dartdoc, block comments, one between a member and its arguments — are
// skipped by every sibling step: calls, chains, reads, static references.
['TortureCommentChains.dart', 10],
// The dartdoc above a member's annotations (stacked, multi-line, inline,
// with comments between them) is read past them; a field, a variable, an
// import or the previous member's body still ends the walk. Class-like
// declarations open with their annotations.
['TortureAnnotatedDocs.dart', 40],
] as const;
for (const [file, minNodes] of FIXTURES) {
+12 -3
View File
@@ -36,7 +36,7 @@ use crate::buffers::{
RefRow, StrRef, Tables, FLAG_IS_ASYNC, FLAG_IS_EXPORTED, FLAG_IS_STATIC, FUNCTION_REF_CODE,
NONE, NONE_STR,
};
use crate::docstring::preceding_docstring;
use crate::docstring::preceding_docstring_stepping_over;
use crate::ids;
use crate::textutil as util;
use regex::Regex;
@@ -46,6 +46,11 @@ use tree_sitter::{Node, Parser};
const MAX_VALUE_REF_NODES: usize = 20_000;
/// docstringStepOverTypes (dart.ts) — a member's annotations stand between it
/// and the dartdoc written above them (`/// Builds the widget.` `@override`
/// `Widget build(…)`), so the docstring walk steps over them.
const DOCSTRING_STEP_OVER: &[&str] = &["annotation"];
/// NAME_STOPLIST (function-ref.ts).
fn is_stoplisted(name: &str) -> bool {
matches!(
@@ -625,9 +630,13 @@ impl<'t> Walker<'t> {
}
/// docstringFor (tree-sitter.ts) — the preceding comment run, looked up
/// from the declaration wrapper when there is one.
/// from the declaration wrapper when there is one, past annotations.
fn docstring_of(&self, node: Node<'t>) -> Option<String> {
preceding_docstring(self.declaration_wrapper(node).unwrap_or(node), self.src)
preceding_docstring_stepping_over(
self.declaration_wrapper(node).unwrap_or(node),
self.src,
DOCSTRING_STEP_OVER,
)
}
/// resolveBody (dart.ts:158-171).
+37
View File
@@ -131,6 +131,18 @@ pub fn clean_comment_markers(comment: &str) -> String {
/// Returns None when there is no preceding comment (a PRESENT-but-empty
/// docstring after cleaning still returns Some(""), matching the TS helper).
pub fn preceding_docstring(node: Node, src: &str) -> Option<String> {
preceding_docstring_stepping_over(node, src, &[])
}
/// getPrecedingDocstring's `stepOver` — sibling kinds that may stand between
/// the declaration and its comments without ending the run (Dart's
/// `annotation`: `/// Builds it.` `@override` `Widget build(…)`). They are not
/// part of the docstring; the comments on either side of one still join.
pub fn preceding_docstring_stepping_over(
node: Node,
src: &str,
step_over: &[&str],
) -> Option<String> {
let mut anchor = node;
while let Some(parent) = anchor.parent() {
if is_wrapper(parent.kind()) {
@@ -146,6 +158,8 @@ pub fn preceding_docstring(node: Node, src: &str) -> Option<String> {
if is_comment(s.kind()) {
comments.push(&src[s.byte_range()]);
sibling = s.prev_named_sibling();
} else if step_over.contains(&s.kind()) {
sibling = s.prev_named_sibling();
} else {
break;
}
@@ -194,4 +208,27 @@ mod tests {
"a\r\nb"
);
}
/// A `step_over` kind is passed without ending the run, and the comments
/// on either side of it join; any other node still ends it. With no
/// step-over kinds the walk stops at the first non-comment, as before.
#[test]
fn steps_over_only_the_listed_kinds() {
let grammar = crate::langs::grammar_for("dart").expect("dart grammar");
let mut parser = tree_sitter::Parser::new();
parser.set_language(&grammar).unwrap();
let src = "void g() {}\n/// a\n@x\n// b\nvoid f() {}\n";
let tree = parser.parse(src, None).unwrap();
let root = tree.root_node();
let f = (0..root.named_child_count())
.filter_map(|i| root.named_child(i))
.filter(|n| n.kind() == "function_signature")
.nth(1)
.expect("f's signature");
assert_eq!(preceding_docstring(f, src).as_deref(), Some("b"));
assert_eq!(
preceding_docstring_stepping_over(f, src, &["annotation"]).as_deref(),
Some("a\nb")
);
}
}
+77 -9
View File
@@ -855,14 +855,21 @@ BUILTIN_TYPES (:5768-5782), at each leaf's position. Consequences (pinned):
Dart annotations are `annotation` (with `name:` field; args form has an
`arguments` child) or `marker_annotation`-free — probed: both `@override`
(bare) and `@Deprecated('x')` (args) are node type `annotation`, PRECEDING
SIBLINGS of the declaration they decorate (inside program / class_body).
SIBLINGS of the function or method they decorate (inside program /
class_body); a class-like declaration's annotations open its own node
instead (scan #1).
extractDecoratorsFor (:4897) is called for classes (:1710), functions
(:1599), methods (:1819) — NOT for hook-minted constants, enums(!), or
type aliases (extractEnum/extractTypeAlias never call it — an annotated
enum emits nothing). Mechanics for dart:
- Scan #1 (direct children :4976-4988): annotations are never children of
the signature → inert (no `modifiers` node either).
the signature → inert for functions and methods (no `modifiers` node
either). A class-like declaration is different: class, mixin, extension
and extension type nodes START at their first annotation, which is the
node's own leading child, so scan #1 emits it (`@immutable class Foo` →
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
@@ -876,8 +883,24 @@ enum emits nothing). Mechanics for dart:
`@Deprecated('x')\n@pragma('vm:entry-point')\nvoid f()` → decorates
`pragma` FIRST, then `Deprecated` (pinned).
- For a method: the previous member's function_body (or any declaration)
breaks the chain correctly. The annotation-BETWEEN-doc-and-decl also
breaks the DOCSTRING chain (§Docstrings).
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.
- 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
@@ -908,10 +931,55 @@ shared code, port nothing**. DOCSTRING_WRAPPER_TYPES contains no dart kinds
getDeclarationWrapper's `declaration` for a member with no body (until
2026-10-06 those looked inside the wrapper and found nothing, e.g.
`const BlocProvider.value(…)`'s dartdoc; validation added 32 docstrings on
bloc and 126 on riverpod, none changed or removed). **An `annotation` between the comment run and the
declaration BREAKS the chain** (pinned: `/// Broken by annotation.`
`@deprecated` `void annotated()` → doc undefined — the dominant real-world
loss since `@override` is ubiquitous). Docstrings attach to: functions,
bloc and 126 on riverpod, none changed or removed).
**The walk steps OVER `annotation` siblings** (since 2026-10-06). Until then
an annotation between the comment run and the declaration ended the walk:
`/// Builds it.` `@override` `Widget build(…)` lost its dartdoc, the
dominant real-world loss since `@override` is everywhere. Now dart.ts sets
`docstringStepOverTypes: ['annotation']`, which docstringFor hands to
getPrecedingDocstring's `stepOver`. The kernel's `docstring_of` calls
docstring.rs's `preceding_docstring_stepping_over(…, &["annotation"])`.
`preceding_docstring` and every other language keep the empty list.
- Stepped over: stacked annotations, multi-line ones (`@Deprecated(` …
`)`), inline ones (`/* c */ @x void f()`, freezed's `@optionalTypeArgs
TResult maybeMap…` on the declaration line), and blank lines, which are
not nodes. This works from a `declaration` wrapper too.
- Comments on either side of an annotation keep the join semantics:
`/// a` `@x` `// b` `void f()` → `a\nb` (was `b`), and `/// Doc.`
`@override` `// ignore: …` → `Doc.\nignore: …`.
- Still ends the walk: any other named sibling, such as the previous
member's `function_body`, a field's or a bodiless member's `declaration`,
a top-level variable (`static_final_declaration_list` /
`initialized_identifier_list`), an `import_or_export` or `part_directive`,
or a class-like declaration. A comment trailing the previous member on its
line (`void a() {} // note`) is collected, as it already was for a member
with no annotation.
- Class-like declarations needed nothing. A class, mixin, extension,
extension type, enum or typedef node starts at its first annotation
(§Decorators), so the dartdoc above the annotations is the node's previous
sibling and was always kept. A comment written BETWEEN such an annotation
and the keyword (`@JsonEnum(…)` `/// The state…` `enum LinterRuleState`)
is inside the node and is NOT read (pinned). bloc has 1 such dartdoc,
riverpod none (23 `// ignore:` lines and 1 plain comment). A member's
comment below its annotation IS read: it is the signature's previous
sibling.
- Local functions get no docstring with or without annotations: the
signature sits in `local_function_declaration > lambda_expression`, with
no previous sibling.
- Validation (bloc @b9be1e2 / riverpod @4ba1be2, 3f96f80f plus the
declaration-wrapper fix, kernel loaded): docstrings +26 / +1,072, changed
0 / 9 (each a `// ignore:` line below the annotation, now joined after
the dartdoc above), removed 0. Everything else in the dumps (nodes,
edges, refs, files) is byte-identical; kernel and wasm full-index dumps
are byte-identical; the parity sweeps show 0 diffs. bloc's 26 are all
hand-written dartdoc, mostly behind `@mustCallSuper` / `@override`. Of
riverpod's 1,072, 667 runs hold a dartdoc (401 in generated
`.freezed.dart` / `.g.dart` files, 266 hand-written) and 405 are plain
comments only (`// ignore: riverpod_lint/…` in the lint fixtures, the
website's snippet markers).
Docstrings attach to: functions,
methods (incl. `<anonymous>` operators), classes/mixins/extensions, enums,
type aliases. NOT to: hook-minted constants (extra carries only signature —
pinned drop), enum members, imports, the file node. No comment-gluing into
@@ -1152,7 +1220,7 @@ NO unwrap, NO ungatedModes, NO addressOfOnly. Pins:
`extract-torture.txt` is the expected-output pin). Inventory by branch:
imports (dart:, package: with `as`+`show`, export with `hide`,
**deferred → invisible**, part → invisible); doc shapes (`///` run,
`/** */` kept, `//` kept, annotation-broken chain); annotations (bare,
`/** */` kept, `//` kept, a `///` above an annotation); annotations (bare,
with-args, stacked → reverse order, on class); top-level constants
(CAPS/lowercase/multi/typed/derived, sig truncation ≥100 chars);
top-level var/typed-var/getter/setter (all invisible); async vs
+7
View File
@@ -532,6 +532,13 @@ export const dartExtractor: LanguageExtractor = {
if (parent?.type !== 'declaration') return undefined;
return parent.firstNamedChild?.equals(node) ? parent : undefined;
},
// A member's annotations stand between it and the dartdoc written above them
// (`/// Builds the widget.` `@override` `Widget build(…)`), so the docstring
// walk steps over them instead of stopping there. A class-like declaration
// (class, mixin, extension, extension type, enum, typedef) needs none of
// this: its annotations open its own node, and the dartdoc above them is
// that node's previous sibling.
docstringStepOverTypes: ['annotation'],
resolveBody: (node, bodyField) => {
// Dart: function_body is a next sibling of function_signature/method_signature
if (node.type === 'function_signature' || node.type === 'method_signature') {
+12 -1
View File
@@ -129,8 +129,17 @@ function cleanCommentMarkers(comment: string): string {
/**
* Get the docstring/comment preceding a node
*
* `stepOver` lists node types that may stand between the comments and the
* declaration without ending the run: Dart's annotations, in `/// Builds it.`
* `@override` `Widget build(…)`. They are not part of the docstring, and the
* comments on either side of one join as if it weren't there. Default: none.
*/
export function getPrecedingDocstring(node: SyntaxNode, source: string): string | undefined {
export function getPrecedingDocstring(
node: SyntaxNode,
source: string,
stepOver: readonly string[] = []
): string | undefined {
// Climb out of any wrapper(s) so a comment preceding the WHOLE construct
// (export-, decorator-, or const-arrow-wrapped) is reachable as a sibling.
// The emitted node's own `previousNamedSibling` is empty (export/const) or a
@@ -153,6 +162,8 @@ export function getPrecedingDocstring(node: SyntaxNode, source: string): string
) {
comments.unshift(getNodeText(sibling, source));
sibling = sibling.previousNamedSibling;
} else if (stepOver.includes(sibling.type)) {
sibling = sibling.previousNamedSibling;
} else {
break;
}
+7
View File
@@ -174,6 +174,13 @@ export interface LanguageExtractor {
* `void m();`) in a `declaration` node. Returns undefined when there is none.
*/
getDeclarationWrapper?: (node: SyntaxNode) => SyntaxNode | undefined;
/**
* Node types that may stand between a declaration and the comments written
* above it without ending the comment run its docstring is read from. Dart
* writes annotations there (`/// Builds it.` `@override` `Widget build(…)`):
* each one is stepped over, and comments on either side of it still join.
*/
docstringStepOverTypes?: string[];
/** Extract visibility from node */
getVisibility?: (node: SyntaxNode) => 'public' | 'private' | 'protected' | 'internal' | undefined;
/** Check if node is exported */
+5 -1
View File
@@ -563,7 +563,11 @@ export class TreeSitterExtractor {
*/
private docstringFor(node: SyntaxNode): string | undefined {
const anchor = this.extractor?.getDeclarationWrapper?.(node) ?? node;
const preceding = getPrecedingDocstring(anchor, this.source);
const preceding = getPrecedingDocstring(
anchor,
this.source,
this.extractor?.docstringStepOverTypes
);
const body = this.extractor?.getBodyDocstring?.(node, this.source);
if (preceding && body) return `${preceding}\n\n${body}`;
return body || preceding;