Skip to content

fix(dart): a bare call or type name reaches only what its library can see - #2386

Merged
colbymchenry merged 1 commit into
mainfrom
fix/dart-library-visibility
Oct 6, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
fix/dart-library-visibility

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

What

A Dart name written without a receiver (a call, a constructor call, a type in a declaration, cast, generic argument, extends or implements) now resolves only to a top-level declaration the caller's library can see:

  • one the library declares itself, in the file or any of its parts, private _ names included;
  • or one exported by a library it imports without a prefix, following export chains and show/hide.

A type written through an import prefix (p.Report) means only what the imports as p export. A library's own declaration shadows an imported one. dart: libraries and packages that aren't in the repository (flutter, test, mockito, …) export nothing of the project.

It is a candidate filter ahead of the ranking in matchByExactName and matchFuzzy, like isJavaTypeVisible / isPhpClassVisible, so a visible namesake wins instead of the edge being dropped.

The library model is the new src/resolution/dart-libraries.ts:

  • every pubspec.yaml's name: maps to its package root (the nearest pubspec above an indexed .dart file, never above the project root), so package:<name>/<path> is <root>/lib/<path>. When several packages share a name, the importing file's own package wins, then the one enclosing it, else all of them count;
  • relative URIs, part / part of by URI or by library name, nested parts;
  • conditional imports and exports (import 'stub.dart' if (dart.library.io) 'io.dart') count as the union;
  • directives come from a small tokenizer over the file head (comments, annotations, strings), so every import is read, not only the first, and a show list with comments in it is read whole.

Where the library can't be established — a file with no pubspec above it (a mason __brick__ template), a part of naming no library it can find — nothing is filtered.

Triage of the first cut added four related fixes:

  • A bare call never reaches another type's enum constant or static const. Main's param gate (fix(dart): a bare call to a parameter or local links nothing #2379) drops calls to parameters; this covers the rest, e.g. bloc's brick hook calling mason's Logger.info(…) linked to bloc_lint's LinterRuleState.info.
  • matchFuzzy skips unnamed-extension nodes the way exact matching already does. Thinning a crowd had left fuzzy a lone same-file extension on Color to take Color type refs.
  • A generic unnamed extension, extension<T> on X, is now seen as unnamed. riverpod's extension<PointerT …, ProviderT …> on Map<…> was taking Map type references.
  • An unnamed extension applies in every part of its library, not only its own file: riverpod's override.providerOverride and listenable.debugListenedProvider (extensions in other parts of framework.dart) and riverpod_analyzer_utils' DartObject.toDependencyList now link.

Resolution only; extraction and the kernel are unchanged.

Why

Measured on rrousselGit/riverpod @4ba1be2 and felangel/bloc @b9be1e2, with main at 249d9a8 and the native kernel loaded. Edges into a library the caller cannot see, judged independently by the earlier offline prototype of the library model:

riverpod: main → this bloc: main → this
bare calls 651 → 0 7 → 0
constructor calls (instantiates) 213 → 0 21 → 1
type references 648 → 0 83 → 15
extends / implements 56 / 13 → 0 / 0 0 / 4 → 0 / 3

bloc's remainder is examples/flutter_login using its own authentication_repository package while examples/flutter_firebase_login has another of that name: both count, and proximity picks flutter_login's (its path: dependency).

Validation

Edges per call site (source node, line, column, ref name):

  • riverpod 86,129 → 85,482: 656 removed, 9 added, 1,335 re-targeted, 8,080 only gain confidence (one visible candidate left).
  • bloc 18,394 → 18,354: 40 removed, 0 added, 79 re-targeted.

Every changed site was triaged. Disagreements with the prototype were its blind spots: comments inside a show list (it reads them as export-all), import prefixes, duplicate package names.

  • Re-targeted, all to what the library sees:
    • generated .g.dart / .freezed.dart parts now reach their own library's declarations: test/integration/async.g.dart's return family(…) was annotated.dart's family, async.dart declares one; also the *.fix.g.dart lint fixtures;
    • tests reach what they import: errorsOf / Counter through ../src/utils.dart, CounterBloc through blocs/blocs.dart → counter/counter.dart, flutter_todos' MockTodosRepository through helpers/helpers.dart;
    • prefixed types and supertypes: weather_repository.Weather, open_meteo_api.Weather, and extends Notifier now reach riverpod's own Notifier.
  • Removed:
    • externals that had landed on project namesakes: fakeAsync (package:fake_async → a vendored copy), runApp and Builder (material_ui), unawaited (dart:async), Flutter's Page, Position/Range (lsp_server_ce), vm_service's Class, lsp.Diagnostic, firebase_auth.User, vm.Event, Map, Object, File;
    • dispose() inside a class mixing in package:listen's ChangeNotifier;
    • translated docs whose imports no longer export what they use: flutter_riverpod.dart's show list has no StateNotifierProvider / StateProvider, and some locales import files that only exist in the English docs.
  • Added (9):
    • the English docs' MyApp through ../provider_observer_logger.dart;
    • an extension's on type (_DataRecord, Mutation, _ProviderTransformerMixin) now reaching that type, not the extension node;
    • the four unnamed-extension members above.

Cost: about 4% more indexing CPU on riverpod (process CPU median 43.5 s → 45.4 s over 5 interleaved runs, wall 14.0 s → 14.6 s); bloc within noise.

Tests

  • New __tests__/dart-library-visibility.test.ts (14 cases): a generated part's call and type → its own library; a non-imported namesake → nothing; the imported library over an unimported copy; export chains with show, hide, a conditional export and an import prefix; part of by library name; own library over import; enum and static constants; unnamed and generic unnamed extensions; an unnamed extension across parts; files outside any package; the directive reader. On main 9 of them fail.
  • Full suite on Windows: 6,233 passed, 0 failed (80 skipped); kernel Dart parity included.

Not covered

  • Dart imports edges are still matched by file basename (riverpod: 223 external package: URIs link to a project file, e.g. package:flutter/foundation.dart → riverpod's foundation.dart).
  • A call through an import prefix (http.get(…) with import 'package:http/http.dart' as http) still guesses by name (25 on riverpod).

🤖 Generated with Claude Code

… see

A receiver-less Dart call, constructor call or type name now links only to
a top-level declaration its library can see: one in the library itself (the
file and its parts) or one exported by a library it imports without a
prefix, through export chains and show/hide. A type written through an
import prefix (`p.Report`) means what that import exports. It is a
candidate filter ahead of the ranking in matchByExactName and matchFuzzy,
so a visible namesake wins, and a library's own declaration shadows an
imported one. The library model (pubspec packages, package: and relative
URIs, part / part of, conditional imports) is src/resolution/dart-libraries.ts.

A bare call also never reaches another type's enum constant or static
constant, a generic `extension<T> on X` counts as unnamed, and an unnamed
extension applies in every part of its library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant