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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- In Dart, a call written through an import prefix, like `http.get(…)` after `import 'package:http/http.dart' as http;`, or `utils.Listener()` and `kit.Box.create()` through a prefix of your own, now links to what the library imported with that prefix exports, following its `export` chains, `show` and `hide`, and to nothing when that library is a package outside your project. Before, the prefix was taken for a variable, so `http.get(…)` could link to an unrelated `Http` class's `get` method, and calls made through a prefix into your own libraries were missing from `callers` and impact. Re-index Dart projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
200 changes: 200 additions & 0 deletions __tests__/dart-import-prefix-calls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* A Dart call written through an import prefix — `http.get(uri)` after
* `import 'package:http/http.dart' as http;` — calls a top-level declaration
* the libraries imported with that prefix export, or nothing in the project.
* It is no method call on a receiver named `http`: riverpod's docs' 25
* `http.get(…)` / `http.post(…)` calls went to a docs example's `Http` class.
*
* Runs against the native kernel (when built) and the wasm extractor.
*/
import { describe, it, expect, afterAll, beforeAll } 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> = {
'pubspec.yaml': 'name: app\n',
// Namesakes of everything the prefixes below name, in a library beside the
// callers — nearer than the libraries the prefixes import.
'lib/legacy.dart': `class Http {
Future<String> get(Uri url) async => '';
}

class Client {
Client();
static Client fromEnv() => Client();
}

class Formatter {
String describe(Object value) => '';
}

class Loader {
int load() => 0;
}

class Report {
Report.empty();
static Report blank() => Report.empty();
}

class Box<T> {
const Box.named();
static Box<int> create() => const Box.named();
}

String describe(Object value) => 'legacy';
int shown() => 0;
`,
'lib/src/format.dart': `String describe(Object value) => '$value';

class Report {
Report();
Report.empty();
static Report blank() => Report();
}
`,
'lib/src/tools.dart': `int secret() => 1;
int open() => 2;
`,
'lib/lazy.dart': `int load() => 1;
`,
'packages/kit/pubspec.yaml': 'name: kit\n',
'packages/kit/lib/kit.dart': `export 'src/impl.dart' show shown, Widget, Box, named;
`,
'packages/kit/lib/src/impl.dart': `int shown() => 1;
int notShown() => 2;
int named() => 3;

class Widget {
Widget();
}

class Box<T> {
const Box.named();
static Box<int> create() => const Box.named();
}
`,
// package:http is not in the repository.
'lib/net.dart': `import 'package:http/http.dart' as http;

Future<void> fetch() async {
await http.get(Uri.parse('https://example.com'));
http.Client();
http.Client.fromEnv();
}
`,
'lib/app.dart': `import 'package:kit/kit.dart' as kit;
import 'src/format.dart' as fmt;
import 'src/tools.dart' as tools hide secret;
import 'lazy.dart' deferred as lazy;

part 'app_part.dart';

String run(Object value) {
fmt.Report();
fmt.Report.empty();
fmt.Report.blank();
kit.shown();
kit.notShown();
kit.Widget();
tools.open();
tools.secret();
final box = kit.Box<int>.named();
kit.Box.create();
const kit.Box.named();
return fmt.describe(value);
}

Future<int> boot() async {
await lazy.loadLibrary();
return lazy.load();
}
`,
'lib/app_part.dart': `part of 'app.dart';

String again(Object value) => fmt.describe(value);
`,
// A parameter named like the prefix is the parameter, there.
'lib/shadow.dart': `import 'package:http/http.dart' as http;
import 'legacy.dart';

Future<String> viaParameter(Http http) => http.get(Uri());
`,
};

/** `<kind> <line> <ref name> -> <target file>:<target qualified name>` for every call out of `file`. */
function callsFrom(cg: CodeGraph, file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => {
const t = cg.getNode(e.target)!;
return `${e.kind} ${e.line} ${String(e.metadata?.refName ?? '')} -> ${t.filePath}:${t.qualifiedName}`;
});
}

describe.each(['default', 'wasm'])('Dart calls through an import prefix (%s)', (backend) => {
let root = '';
let cg: CodeGraph | undefined;
let kernel: string | undefined;
const calls = (file: string): string[] => callsFrom(cg!, file);

beforeAll(async () => {
kernel = process.env.CODEGRAPH_KERNEL;
if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
else delete process.env.CODEGRAPH_KERNEL;
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-prefix-'));
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);
}
cg = await CodeGraph.init(root, { index: true });
});

afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL;
else process.env.CODEGRAPH_KERNEL = kernel;
});

it('links nothing for a call into a package outside the repository', () => {
expect(calls('lib/net.dart')).toEqual([]);
});

it("links a call to the prefixed library's function, not a namesake or a method", () => {
expect(calls('lib/app.dart')).toContain('calls 20 fmt.describe -> lib/src/format.dart:describe');
expect(calls('lib/app.dart')).toContain('calls 25 lazy.load -> lib/lazy.dart:load');
expect(calls('lib/app_part.dart')).toEqual(['calls 3 fmt.describe -> lib/src/format.dart:describe']);
});

it('follows the export chain, show and hide of the prefixed import', () => {
const edges = calls('lib/app.dart');
expect(edges).toContain('calls 12 kit.shown -> packages/kit/lib/src/impl.dart:shown');
expect(edges).toContain('instantiates 14 kit.Widget -> packages/kit/lib/src/impl.dart:Widget');
expect(edges).toContain('calls 15 tools.open -> lib/src/tools.dart:open');
// Not shown by kit.dart, hidden from `tools`, the deferred library's own `loadLibrary`.
expect(edges.filter((e) => / (?:kit\.notShown|tools\.secret|lazy\.loadLibrary) /.test(e))).toEqual([]);
});

it("constructs and calls through the prefixed library's type", () => {
const edges = calls('lib/app.dart');
expect(edges).toContain('instantiates 9 fmt.Report -> lib/src/format.dart:Report');
expect(edges).toContain('calls 10 empty -> lib/src/format.dart:Report::empty');
expect(edges).toContain('calls 11 blank -> lib/src/format.dart:Report::blank');
expect(edges).toContain('calls 17 Box.named -> packages/kit/lib/src/impl.dart:Box::named');
expect(edges).toContain('calls 18 create -> packages/kit/lib/src/impl.dart:Box::create');
});

it('never lands a prefixed call on another library', () => {
expect(calls('lib/app.dart').filter((e) => e.includes('lib/legacy.dart'))).toEqual([]);
// `const kit.Box.named()` is no call of kit's top-level `named`.
expect(calls('lib/app.dart').filter((e) => e.startsWith('calls 19 '))).toEqual([]);
});

it('leaves a name a parameter shadows to the parameter', () => {
expect(calls('lib/shadow.dart')).toEqual(['calls 4 http.get -> lib/legacy.dart:Http::get']);
});
});
2 changes: 1 addition & 1 deletion docs/design/dart-kernel-port-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@ enum emits nothing). Mechanics for dart:
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
`dartImportPrefixes`, 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`.
Expand Down
35 changes: 24 additions & 11 deletions src/resolution/dart-libraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ interface Memo {
members: Map<string, ReadonlySet<string>>;
exports: Map<string, ReadonlyMap<string, readonly NameFilter[]>>;
scopes: Map<string, FileScope | null>;
prefixes: Map<string, ReadonlySet<string>>;
}

const memos = new WeakMap<ResolutionContext, Memo>();
Expand All @@ -386,6 +387,7 @@ function memoFor(context: ResolutionContext): Memo {
members: new Map(),
exports: new Map(),
scopes: new Map(),
prefixes: new Map(),
};
memos.set(context, memo);
}
Expand Down Expand Up @@ -763,20 +765,31 @@ export function dartPrefixSees(fromFile: string, prefix: string, declFile: strin
return filters === null || (!name.startsWith('_') && filters.some((f) => lets(f, name)));
}

/**
* The import prefixes written in `fromFile` can stand for — the `p` of
* `import '…' as p` and `deferred as p` — read from the imports of its
* library, which a part (`part of …`) takes from the file it is part of.
*/
export function dartImportPrefixes(fromFile: string, context: ResolutionContext): ReadonlySet<string> {
const memo = memoFor(context);
const hit = memo.prefixes.get(fromFile);
if (hit) return hit;
const prefixes = new Set<string>();
// Records each part's parent, a library in no package included.
libraryOf(fromFile, context, memo);
let file: string | undefined = fromFile;
for (let hops = 0; file !== undefined && hops < 32; file = memo.parentOf.get(file), hops++) {
for (const directive of directivesOf(file, context, memo)?.imports ?? []) {
if (directive.prefix !== null) prefixes.add(directive.prefix);
}
}
memo.prefixes.set(fromFile, prefixes);
return prefixes;
}

/** Whether `declFile` is a file of the library `fromFile` belongs to (false when that library is unknown). */
export function inSameDartLibrary(fromFile: string, declFile: string, context: ResolutionContext): boolean {
if (fromFile === declFile) return true;
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));
}
10 changes: 10 additions & 0 deletions src/resolution/dart-local-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ export function gateDartLocal(resolved: ResolvedRef | null, ref: UnresolvedRef,
return !nearest || nearest.fn ? resolved : null;
}

/**
* Whether a parameter, local or local function `name` is bound at offset `at`
* of the file's source — where it hides a library name, an import prefix
* among them: `http.get(…)` with a parameter `http` calls the parameter's.
*/
export function isDartLocallyBound(filePath: string, name: string, at: number, context: ResolutionContext): boolean {
const bound = fileScopes(filePath, context)?.names.get(name);
return bound !== undefined && bound.some((scope) => at >= scope.start && at <= scope.end);
}

/**
* Where on its line a reference's name starts. A Dart ref's column sits just
* past the name (a function value's at it); a column counted in bytes misses
Expand Down
Loading