From a219a4cab0b9e0fde6a3a65cda753155d7c92781 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 6 Oct 2026 11:55:35 -0500 Subject: [PATCH] fix(dart): an import or export links the file its URI names, not a namesake A Dart `import` / `export` now resolves its URI through the library model in src/resolution/dart-libraries.ts: `package:/` is `/lib/` of the project package whose pubspec.yaml says `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 --- CHANGELOG.md | 1 + __tests__/dart-import-uris.test.ts | 200 +++++++++++++++++++++++++++++ src/resolution/dart-libraries.ts | 80 +++++++++++- src/resolution/import-resolver.ts | 19 +++ src/resolution/index.ts | 8 +- 5 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 __tests__/dart-import-uris.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd122aba..cb03bc15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/dart-import-uris.test.ts b/__tests__/dart-import-uris.test.ts new file mode 100644 index 000000000..62ed0396a --- /dev/null +++ b/__tests__/dart-import-uris.test.ts @@ -0,0 +1,200 @@ +/** + * A Dart `import` / `export` names a library by URI, and links the file the + * URI names or nothing. `package:/` is `/lib/` of the + * project package whose pubspec.yaml says `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): 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); + } +} + +/** ` -> ` 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 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']); + }); +}); diff --git a/src/resolution/dart-libraries.ts b/src/resolution/dart-libraries.ts index 18c2d7930..270fe3409 100644 --- a/src/resolution/dart-libraries.ts +++ b/src/resolution/dart-libraries.ts @@ -14,7 +14,9 @@ * pubspec.yaml says `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; packageOfDir: Map; packages: Map | null; + /** By package root and dependency name, the directories its `path:` dependencies of that name point at. */ + pathDependencies: Map; libraryNames: Map | null; libraryOf: Map; /** 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 ;`. */ function libraryFilesNamed(name: string, context: ResolutionContext, memo: Memo): readonly string[] { if (!memo.libraryNames) { diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 07236bfc9..b6a8e66bb 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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: ` diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 5fdbf2d9b..f94cf0a16 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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