fix(dart): an import or export links the file its URI names, not a namesake (#2388)

A Dart `import` / `export` now resolves its URI through the library model
in src/resolution/dart-libraries.ts: `package:<name>/<path>` is
`<root>/lib/<path>` of the project package whose pubspec.yaml says
`name: <name>`, any other URI is a path from the importing file, and a
`dart:` library, a package from outside the project or a file the index
does not hold links nothing. The ref never reaches the name-matcher, which
took the URI's last segment for a file name (`package:flutter/widgets.dart`
reached riverpod_analyzer_utils' widgets.dart from 130 files, bloc_tools'
`package:args/command_runner.dart` its own command_runner.dart) or bound
it to the importing file's own `import` node.

When packages share a name and neither the importer's own package nor one
enclosing it settles which, the importing package's `path:` dependency of
that name (pubspec_overrides.yaml, then pubspec.yaml) does: bloc's two
example apps each keep an authentication_repository. Library visibility
reads the same answer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-06 17:09:46 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9924684899
commit 631df0138e
5 changed files with 304 additions and 4 deletions
+1
View File
@@ -56,6 +56,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- 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.
## [1.6.2] - 2026-10-03
+200
View File
@@ -0,0 +1,200 @@
/**
* A Dart `import` / `export` names a library by URI, and links the file the
* URI names or nothing. `package:<name>/<path>` is `<root>/lib/<path>` of the
* project package whose pubspec.yaml says `name: <name>`; any other URI is a
* path from the importing file; `dart:` and a package from outside the project
* name no project file. The URI's last path segment is not a file name to
* match: riverpod's `import 'package:flutter/foundation.dart'` went to its own
* packages/riverpod/lib/src/core/foundation.dart, bloc_tools'
* `package:args/command_runner.dart` to the importing file itself, and the
* import of an indexed project file went to the importing file's own `import`
* node, which says nothing.
*/
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';
function writeTree(root: string, files: Record<string, string>): void {
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);
}
}
/** `<line> -> <target>` for every `imports` edge out of `file`: a file by its path, anything else by kind, file and name. */
function importsFrom(cg: CodeGraph, file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids, ['imports'])
.map((e) => {
const t = cg.getNode(e.target)!;
return `${e.line} -> ${t.kind === 'file' ? t.filePath : `${t.kind} ${t.filePath}#${t.name}`}`;
})
.sort();
}
/** The URIs of `file`'s imports left unresolved. */
function unresolvedImportsOf(cg: CodeGraph, file: string): string[] {
const fileNode = cg.getNodesInFile(file).find((n) => n.kind === 'file')!;
return cg.getUnresolvedReferencesFrom(fileNode.id)
.filter((r) => r.referenceKind === 'imports')
.map((r) => r.referenceName)
.sort();
}
describe('Dart import and export URIs', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-uris-'));
writeTree(root, {
// riverpod: an example app imports flutter's foundation.dart and the
// project's own riverpod package; riverpod has a foundation.dart too.
'packages/riverpod/pubspec.yaml': 'name: riverpod\n',
'packages/riverpod/lib/riverpod.dart': `export 'src/core/foundation.dart';
export 'package:meta/meta.dart' show internal;
`,
'packages/riverpod/lib/src/core/foundation.dart': `class ProviderBase {}
`,
'packages/riverpod/lib/src/core/container.dart': `import 'dart:async';
import 'package:meta/meta.dart';
import 'foundation.dart';
class Container extends ProviderBase {}
`,
'examples/pub/pubspec.yaml': 'name: pub\n',
'examples/pub/lib/pub_repository.dart': `import 'package:flutter/foundation.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod/src/core/foundation.dart' as core;
class PubRepository {}
`,
// bloc_tools imports package:args' command_runner.dart from its own command_runner.dart.
'packages/bloc_tools/pubspec.yaml': 'name: bloc_tools\n',
'packages/bloc_tools/lib/src/command_runner.dart': `import 'package:args/command_runner.dart';
class BlocToolsCommandRunner {}
`,
// An integration test imports package:integration_test; a driver beside it is named integration_test.dart.
'examples/flutter_counter/pubspec.yaml': 'name: flutter_counter\n',
'examples/flutter_counter/integration_test/app_test.dart': `import 'package:integration_test/integration_test.dart';
void main() {}
`,
'examples/flutter_counter/test_driver/integration_test.dart': `import 'package:integration_test/integration_test_driver.dart';
Future<void> main() async {}
`,
// A relative import, and an old file with the same name.
'pubspec.yaml': 'name: app\n',
'test/src/utils.dart': `int errorsOf() => 0;
`,
'test/old/utils.dart': `int errorsOf() => 0;
`,
'test/feature/uni_directional_test.dart': `import '../src/utils.dart';
void main() {}
`,
// A translated copy of a docs page whose sibling was never translated.
'website/docs/cancel/detail_screen/codegen.dart': `class DetailScreen {}
`,
'website/i18n/it/cancel/home_screen.dart': `import 'detail_screen/codegen.dart';
class HomeScreen {}
`,
// Two apps each keep a package named auth_repo, and each app's pubspec says which by path.
'examples/login_a/pubspec.yaml': `name: login_a
dependencies:
auth_repo:
# the app's own copy
path: packages/auth_repo
bloc: ^9.0.0
`,
'examples/login_a/packages/auth_repo/pubspec.yaml': 'name: auth_repo\n',
'examples/login_a/packages/auth_repo/lib/auth_repo.dart': `class AuthRepo {}
`,
'examples/login_a/lib/app.dart': `import 'package:auth_repo/auth_repo.dart';
class AppA {}
`,
'examples/login_b/pubspec.yaml': `name: login_b
dependencies:
auth_repo: { path: "packages/auth_repo" }
`,
'examples/login_b/packages/auth_repo/pubspec.yaml': 'name: auth_repo\n',
'examples/login_b/packages/auth_repo/lib/auth_repo.dart': `class AuthRepo {}
`,
'examples/login_b/lib/app.dart': `import 'package:auth_repo/auth_repo.dart';
class AppB {}
`,
// A third app says nothing about which auth_repo it means.
'examples/login_c/pubspec.yaml': 'name: login_c\n',
'examples/login_c/lib/app.dart': `import 'package:auth_repo/auth_repo.dart';
class AppC {}
`,
// Packages named example: each one's own package: imports mean itself.
'packages/a/example/pubspec.yaml': 'name: example\n',
'packages/a/example/lib/main.dart': `import 'package:example/src/screen.dart';
void main() {}
`,
'packages/a/example/lib/src/screen.dart': `class ScreenA {}
`,
'packages/b/example/pubspec.yaml': 'name: example\n',
'packages/b/example/lib/src/screen.dart': `class ScreenB {}
`,
// A package import of a file the project doesn't have (a generated file nobody committed).
'packages/riverpod/lib/src/missing_user.dart': `import 'package:riverpod/src/core/foundation.g.dart';
import 'package:riverpod/src/missing_user.dart';
`,
});
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
it("links an outside package's import to no project file of the same name", () => {
expect(importsFrom(cg, 'examples/pub/lib/pub_repository.dart')).toEqual([
'2 -> packages/riverpod/lib/riverpod.dart',
'3 -> packages/riverpod/lib/src/core/foundation.dart',
]);
expect(unresolvedImportsOf(cg, 'examples/pub/lib/pub_repository.dart')).toEqual(['package:flutter/foundation.dart']);
});
it('never links a file to itself or a namesake for an outside package', () => {
expect(importsFrom(cg, 'packages/bloc_tools/lib/src/command_runner.dart')).toEqual([]);
expect(importsFrom(cg, 'examples/flutter_counter/integration_test/app_test.dart')).toEqual([]);
expect(importsFrom(cg, 'examples/flutter_counter/test_driver/integration_test.dart')).toEqual([]);
});
it('links a relative import and an export to the file they name, and dart: or outside packages to nothing', () => {
expect(importsFrom(cg, 'packages/riverpod/lib/src/core/container.dart')).toEqual([
'5 -> packages/riverpod/lib/src/core/foundation.dart',
]);
expect(unresolvedImportsOf(cg, 'packages/riverpod/lib/src/core/container.dart')).toEqual(['dart:async', 'package:meta/meta.dart']);
expect(importsFrom(cg, 'packages/riverpod/lib/riverpod.dart')).toEqual(['1 -> packages/riverpod/lib/src/core/foundation.dart']);
expect(importsFrom(cg, 'test/feature/uni_directional_test.dart')).toEqual(['1 -> test/src/utils.dart']);
});
it('links nothing when the file a URI names is not in the project', () => {
expect(importsFrom(cg, 'website/i18n/it/cancel/home_screen.dart')).toEqual([]);
// Nor does a file's import of itself say anything.
expect(importsFrom(cg, 'packages/riverpod/lib/src/missing_user.dart')).toEqual([]);
});
it("tells same-named packages apart by the importing package's own name or its path dependency", () => {
expect(importsFrom(cg, 'examples/login_a/lib/app.dart')).toEqual(['1 -> examples/login_a/packages/auth_repo/lib/auth_repo.dart']);
expect(importsFrom(cg, 'examples/login_b/lib/app.dart')).toEqual(['1 -> examples/login_b/packages/auth_repo/lib/auth_repo.dart']);
expect(importsFrom(cg, 'examples/login_c/lib/app.dart')).toEqual([]);
expect(importsFrom(cg, 'packages/a/example/lib/main.dart')).toEqual(['1 -> packages/a/example/lib/src/screen.dart']);
});
});
+79 -1
View File
@@ -14,7 +14,9 @@
* pubspec.yaml says `name: <name>`: the nearest pubspec.yaml above an indexed
* .dart file, never above the project root. When packages share a name
* (example apps), the importing file's own package wins, then the one
* enclosing it, else all of them count.
* enclosing it, then the one its package depends on by `path:`, else all of
* them count. An `import` / `export` edge links the one file its URI names
* (`dartDirectiveFile`).
*
* Directives are read from the head of each file at resolution time;
* extraction, and the kernel's, are unchanged. Where the library cannot be
@@ -357,6 +359,8 @@ interface Memo {
directives: Map<string, DartDirectives | null>;
packageOfDir: Map<string, PubPackage | null>;
packages: Map<string, PubPackage[]> | null;
/** By package root and dependency name, the directories its `path:` dependencies of that name point at. */
pathDependencies: Map<string, readonly string[]>;
libraryNames: Map<string, string[]> | null;
libraryOf: Map<string, string | null>;
/** A part's parent: the file its `part of` names. */
@@ -375,6 +379,7 @@ function memoFor(context: ResolutionContext): Memo {
directives: new Map(),
packageOfDir: new Map(),
packages: null,
pathDependencies: new Map(),
libraryNames: null,
libraryOf: new Map(),
parentOf: new Map(),
@@ -450,6 +455,62 @@ function packagesNamed(name: string, context: ResolutionContext, memo: Memo): re
return memo.packages.get(name) ?? [];
}
/**
* The `path:` a pubspec gives its dependency `name`, written as a block
* (`name:`, then `path: x` below it) or a flow mapping (`name: {path: x}`).
* A git dependency's `path:` (inside its `git:` mapping) is a path in that
* repository, so only `path:` directly under the name counts.
*/
function yamlPathDependencies(text: string, name: string): string[] {
const out: string[] = [];
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const key = /^([ \t]+)(['"]?)([A-Za-z_]\w*)\2[ \t]*:(?:[ \t]+(.*))?$/.exec(lines[i]!);
if (!key || key[3] !== name) continue;
const value = (key[4] ?? '').replace(/(?:^|[ \t])#.*$/, '').trim();
if (value !== '') {
const flow = /^\{[^{}]*\}$/.test(value)
? /[{,][ \t]*path[ \t]*:[ \t]*(?:"([^"]*)"|'([^']*)'|([^,}\s]+))/.exec(value)
: null;
if (flow) out.push(flow[1] ?? flow[2] ?? flow[3]!);
continue;
}
let childIndent = -1;
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j]!;
if (/^[ \t]*(?:#.*)?$/.test(line)) continue;
const indent = /^[ \t]*/.exec(line)![0].length;
if (indent <= key[1]!.length) break;
if (childIndent < 0) childIndent = indent;
const dep = indent === childIndent ? /^[ \t]*path[ \t]*:[ \t]*(?:"([^"]*)"|'([^']*)'|([^\s#]+))/.exec(line) : null;
if (dep) out.push(dep[1] ?? dep[2] ?? dep[3]!);
}
}
return out;
}
/** The project directories `pkg`'s pubspec_overrides.yaml and pubspec.yaml give as the `path:` of its dependency `name`. */
function pathDependencies(pkg: PubPackage, name: string, context: ResolutionContext, memo: Memo): readonly string[] {
const key = `${pkg.root}\0${name}`;
const hit = memo.pathDependencies.get(key);
if (hit) return hit;
const dirs: string[] = [];
for (const file of ['pubspec_overrides.yaml', 'pubspec.yaml']) {
const pubspec = pkg.root ? `${pkg.root}/${file}` : file;
const text = context.fileExists(pubspec) ? context.readFile(pubspec) : null;
for (const written of text ? yamlPathDependencies(text, name) : []) {
const dep = written.replace(/\\/g, '/');
if (dep.startsWith('/') || /^[A-Za-z]:/.test(dep)) continue;
const dir = path.posix.normalize(path.posix.join(pkg.root || '.', dep)).replace(/\/+$/, '');
if (dir === '..' || dir.startsWith('../')) continue;
const root = dir === '.' ? '' : dir;
if (!dirs.includes(root)) dirs.push(root);
}
}
memo.pathDependencies.set(key, dirs);
return dirs;
}
/** The project files a URI written in `from` names: none for `dart:` and other schemes, or a package outside the project. */
function resolveDartUri(from: string, uri: string, context: ResolutionContext, memo: Memo): string[] {
if (uri.startsWith('package:')) {
@@ -463,6 +524,13 @@ function resolveDartUri(from: string, uri: string, context: ResolutionContext, m
const enclosing = chosen.filter((p) => p.root === '' || from.startsWith(`${p.root}/`));
if (own && own.name === name) chosen = [own];
else if (enclosing.length > 0) chosen = [enclosing.reduce((a, b) => (b.root.length > a.root.length ? b : a))];
else if (own) {
// Example apps that each keep a same-named package (bloc's two
// authentication_repository) name theirs as a path dependency.
const dirs = pathDependencies(own, name, context, memo);
const declared = chosen.filter((p) => dirs.includes(p.root));
if (declared.length === 1) chosen = declared;
}
}
return chosen.map((p) => path.posix.normalize(p.root ? `${p.root}/lib/${rest}` : `lib/${rest}`));
}
@@ -471,6 +539,16 @@ function resolveDartUri(from: string, uri: string, context: ResolutionContext, m
return joined === '..' || joined.startsWith('../') || joined.startsWith('/') ? [] : [joined];
}
/**
* The project file a Dart `import` or `export` in `from` names by `uri`, or
* null: a `dart:` library, a package from outside the project, a path out of
* it, or a package name the project repeats with nothing to say which.
*/
export function dartDirectiveFile(from: string, uri: string, context: ResolutionContext): string | null {
const files = resolveDartUri(from, uri, context, memoFor(context));
return files.length === 1 ? files[0]! : null;
}
/** The files declaring `library <name>;`. */
function libraryFilesNamed(name: string, context: ResolutionContext, memo: Memo): readonly string[] {
if (!memo.libraryNames) {
+19
View File
@@ -12,6 +12,7 @@ import { applyAliases } from './path-aliases';
import { extractLocalExportAliases } from './alias-binding';
import { resolveWorkspaceImport } from './workspace-packages';
import { stripCommentsForRegex } from './strip-comments';
import { dartDirectiveFile } from './dart-libraries';
import {
resolveMethodOnType,
resolveObjectLiteralMember,
@@ -892,6 +893,17 @@ export function isCobolCopybookRef(ref: UnresolvedRef): boolean {
return ref.language === 'cobol' && ref.referenceKind === 'imports';
}
/**
* Is this a Dart `import` / `export`? Its name is the URI, which names one
* library file (see ./dart-libraries) or none — `dart:async`, a package from
* outside the project — and never a symbol: the name-matcher took the URI's
* last segment for a file name (`package:flutter/foundation.dart` went to
* riverpod's own foundation.dart) or bound it to the file's own `import` node.
*/
export function isDartImportRef(ref: UnresolvedRef): boolean {
return ref.language === 'dart' && ref.referenceKind === 'imports';
}
/**
* Resolve a PHP include/require path to a project-relative file path.
*
@@ -1639,6 +1651,13 @@ export function resolveViaImport(
const fileNode = file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined;
if (fileNode) return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
}
// A Dart `import` / `export` URI names a library file: `package:app/x.dart`
// is app's lib/x.dart, any other path is from the importing file.
if (isDartImportRef(ref)) {
const file = dartDirectiveFile(ref.filePath, ref.referenceName, context);
const fileNode = file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined;
return fileNode ? { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' } : null;
}
// C/C++ #include references — resolve directly to the included file
// (file→file edge), bypassing symbol lookup. The extractor emits these
// with `referenceKind: 'imports'` and `referenceName: <include path>`
+5 -3
View File
@@ -29,7 +29,7 @@ import { clearDartLibraryMemos } from './dart-libraries';
import { clearVbnetReceiverMemos, isVbMemberRead, isVbPathCall, matchVbMemberRead, matchVbPathCall } from './vbnet-receivers';
import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters';
import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope';
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver';
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver';
import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
import { resolveAliasBinding } from './alias-binding';
import { detectFrameworks } from './frameworks';
@@ -1354,8 +1354,10 @@ export class ReferenceResolver {
// bind outside its module directory), so the name-matcher's
// qualified-name fallback would only ever add wrong cross-module edges.
// Nix static path imports are file references for the same reason —
// falling through would let "./x.nix" name-match an unrelated node.
if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || ref.language === 'terraform') {
// falling through would let "./x.nix" name-match an unrelated node. So
// is a Dart import's URI: `package:flutter/foundation.dart` matched by
// its last segment went to riverpod's own foundation.dart.
if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || isDartImportRef(ref) || ref.language === 'terraform') {
return candidates.length > 0
? candidates.reduce((best, curr) =>
curr.confidence > best.confidence ? curr : best