Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
167 changes: 167 additions & 0 deletions __tests__/dart-annotation-comments.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 167 additions & 0 deletions __tests__/dart-annotation-targets.test.ts
Original file line number Diff line number Diff line change
@@ -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.
]);
});
});
Loading