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 @@ -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

Expand Down
200 changes: 200 additions & 0 deletions __tests__/dart-import-uris.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
80 changes: 79 additions & 1 deletion src/resolution/dart-libraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand All @@ -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(),
Expand Down Expand Up @@ -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:')) {
Expand All @@ -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}`));
}
Expand All @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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>`
Expand Down
Loading