diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ab7ec64..9bd122aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.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(repoProvider)`, `ref.watch(counterProvider)`, `Provider((ref) => …)`, `BlocProvider.of(context)` or `RepositoryProvider(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(context, listen: false).increment()` or Provider's `Provider.of(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. diff --git a/__tests__/dart-annotated-member-docs.test.ts b/__tests__/dart-annotated-member-docs.test.ts new file mode 100644 index 000000000..c66d6480b --- /dev/null +++ b/__tests__/dart-annotated-member-docs.test.ts @@ -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 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 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 = {}; + + 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']); + }); + } +}); diff --git a/__tests__/fixtures/kernel-parity/TortureAnnotatedDocs.dart b/__tests__/fixtures/kernel-parity/TortureAnnotatedDocs.dart new file mode 100644 index 000000000..efd5b9562 --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureAnnotatedDocs.dart @@ -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(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(); +} diff --git a/__tests__/fixtures/kernel-parity/torture.dart b/__tests__/fixtures/kernel-parity/torture.dart index fa4f73b38..a76203e8a 100644 --- a/__tests__/fixtures/kernel-parity/torture.dart +++ b/__tests__/fixtures/kernel-parity/torture.dart @@ -16,7 +16,7 @@ int blockDoc() => 1; // Plain comment doc. String plainDoc() => 'x'; -/// Broken by annotation. +/// Kept past the annotation. @deprecated void annotated() {} diff --git a/__tests__/kernel-dart-parity.test.ts b/__tests__/kernel-dart-parity.test.ts index 6fe570177..104d6f423 100644 --- a/__tests__/kernel-dart-parity.test.ts +++ b/__tests__/kernel-dart-parity.test.ts @@ -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 * ``, 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) { diff --git a/codegraph-kernel/src/dart.rs b/codegraph-kernel/src/dart.rs index 66426688b..98a83fa7c 100644 --- a/codegraph-kernel/src/dart.rs +++ b/codegraph-kernel/src/dart.rs @@ -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 { - 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). diff --git a/codegraph-kernel/src/docstring.rs b/codegraph-kernel/src/docstring.rs index de9a55afc..9b307ca83 100644 --- a/codegraph-kernel/src/docstring.rs +++ b/codegraph-kernel/src/docstring.rs @@ -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 { + 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 { 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 { 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") + ); + } } diff --git a/docs/design/dart-kernel-port-checklist.md b/docs/design/dart-kernel-port-checklist.md index fa8022335..ac165a0b8 100644 --- a/docs/design/dart-kernel-port-checklist.md +++ b/docs/design/dart-kernel-port-checklist.md @@ -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. `` 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 diff --git a/src/extraction/languages/dart.ts b/src/extraction/languages/dart.ts index 2b74652b1..0997821c5 100644 --- a/src/extraction/languages/dart.ts +++ b/src/extraction/languages/dart.ts @@ -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') { diff --git a/src/extraction/tree-sitter-helpers.ts b/src/extraction/tree-sitter-helpers.ts index 8d355a7a0..8ef15f41d 100644 --- a/src/extraction/tree-sitter-helpers.ts +++ b/src/extraction/tree-sitter-helpers.ts @@ -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; } diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index d82a66b3c..5dfafd205 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -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 */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 561f7e643..c3bd6fd98 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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;