Skip to content

Missing dependency providers silently pass build_runner and cause late compilation errors #7

Description

@mark-dropbear

Describe the bug

When a class or provider requests a dependency for which no provider is defined (or it is a third-party class that is not annotated with @provide and not declared in any module), the build_runner build step completes with a successful exit code (0).

However, the generator (InjectorGenerator) silently fails to generate the implementation helper method _create[Type]() for the missing dependency, while still generating calls to _create[Type]() inside dependent constructors.

This shifts the error detection from a compile-time build step check to a late Dart compilation error during run or compile phases.


To Reproduce

  1. Define a class A that depends on B (which has no @provide annotation and is not provided in any module):
    @provide
    class A {
      final B b;
      A(this.b);
    }
    
    class B {}
    
    @Injector()
    abstract class MissingInjector {
      static final create = g.MissingInjector$Injector.create;
    
      @provide
      A get a;
    }
  2. Run the build runner:
    dart run build_runner build
  3. Observe that the build completes successfully:
    Built with build_runner/aot in 43s; wrote 2 outputs.
    
  4. Run or compile the application:
    dart run lib/main.dart
  5. Observe the Dart compilation error:
    example/missing_demo/lib/missing_demo.inject.dart:17:29: Error: The method '_createB' isn't defined for the type 'MissingInjector$Injector'.
     - 'MissingInjector$Injector' is from 'package:missing_demo/missing_demo.inject.dart'.
    Try correcting the name to the name of an existing method, or defining a method named '_createB'.
      _i1.A _createA() => _i1.A(_createB());
                                ^^^^^^^^
    

Emitted Code Analysis

The generator silently generates a call to _createB() but does not emit the definition for _createB() inside missing_demo.inject.dart:

class MissingInjector$Injector extends _i1.MissingInjector {
  MissingInjector$Injector();

  static Future<_i1.MissingInjector> create() async {
    final injector = MissingInjector$Injector();
    return injector;
  }

  @override
  _i1.A get a => _createA();

  // _createB() is called here but is never defined in this class!
  _i1.A _createA() => _i1.A(_createB());
}

Context / Code Reference

In package/inject_compile_generator/lib/src/graph/injector_graph_resolver.dart, the resolveKey function silently returns if the dependency is not provided by a module and the target class does not contain @provide metadata:

    Future<void> resolveKey(
      LookupKey key, {
      required SymbolPath requestedBy,
    }) async {
      // Modules take precedence.
      if (providersByModules.containsKey(key) || injectables.containsKey(key)) {
        return;
      }

      if (!key.root.isGlobal) {
        final summary = await _reader.read(
          AssetId(key.root.package!, key.root.path!),
        );
        for (final injectable in summary.injectables) {
          if (injectable.clazz == key.root) {
            injectables[key] = injectable;
            for (final dep in injectable.constructor.dependencies) {
              await resolveKey(dep.lookupKey, requestedBy: injectable.clazz);
            }
            return;
          }
        }
        // Exits silently if it fails to resolve
      }
    }

Suggested Solution

  1. Maintain a map of all requested keys and the files/classes that requested them.
  2. Catch summary read exceptions (such as FileSystemException or AssetNotFoundException when looking up third-party classes that have no .inject.summary files).
  3. Validate that every requested dependency exists in providersByModules or injectables once resolution completes.
  4. Throw a descriptive StateError listing all missing dependencies and their requester sources.
   Future<InjectorGraph> resolve() async {
     final allModules = <ModuleSummary>[];
     final providersByModules = <LookupKey, DependencyProvidedByModule>{};
     final injectables = <LookupKey, InjectableSummary>{};
+    final requestedByMap = <LookupKey, Set<SymbolPath>>{};
+
+    void recordRequest(LookupKey key, SymbolPath requestedBy) {
+      requestedByMap.putIfAbsent(key, () => {}).add(requestedBy);
+    }
 
     // 1. Collect all modules starting from the injector.
     for (final modulePath in _injectorSummary.modules) {
       await _collectModules(modulePath, allModules, providersByModules);
     }
 
     // 2. Resolve all dependencies (both from injector and modules).
     Future<void> resolveKey(
       LookupKey key, {
       required SymbolPath requestedBy,
     }) async {
+      recordRequest(key, requestedBy);
+
       // Modules take precedence.
       if (providersByModules.containsKey(key) || injectables.containsKey(key)) {
         return;
       }
 
       if (!key.root.isGlobal) {
-        final summary = await _reader.read(
-          AssetId(key.root.package!, key.root.path!),
-        );
-        for (final injectable in summary.injectables) {
-          if (injectable.clazz == key.root) {
-            injectables[key] = injectable;
-            for (final dep in injectable.constructor.dependencies) {
-              await resolveKey(dep.lookupKey, requestedBy: injectable.clazz);
-            }
-            return;
-          }
-        }
+        try {
+          final summary = await _reader.read(
+            AssetId(key.root.package!, key.root.path!),
+          );
+          for (final injectable in summary.injectables) {
+            if (injectable.clazz == key.root) {
+              injectables[key] = injectable;
+              for (final dep in injectable.constructor.dependencies) {
+                await resolveKey(dep.lookupKey, requestedBy: injectable.clazz);
+              }
+              return;
+            }
+          }
+        } catch (_) {
+          // Treat unreadable summaries/external dependencies as non-injectable
+        }
       }
     }
 
     // Resolve dependencies for all providers in all modules.
     for (final module in allModules) {
       for (final provider in module.providers) {
         for (final dep in provider.dependencies) {
           await resolveKey(dep.lookupKey, requestedBy: module.clazz);
         }
       }
     }
 
     // Resolve dependencies for all injector providers.
     for (final provider in _injectorSummary.providers) {
       await resolveKey(
         provider.resultType.lookupKey,
         requestedBy: _injectorSummary.clazz,
       );
     }
 
+    // Check for missing dependency providers.
+    final missingDependencies = <LookupKey, Set<SymbolPath>>{};
+    for (final key in requestedByMap.keys) {
+      if (!providersByModules.containsKey(key) && !injectables.containsKey(key)) {
+        missingDependencies[key] = requestedByMap[key]!;
+      }
+    }
+
+    if (missingDependencies.isNotEmpty) {
+      final message = StringBuffer('Missing dependency providers:\n');
+      missingDependencies.forEach((key, requesters) {
+        message.writeln('  - ${key.toPrettyString()}');
+        message.writeln('    requested by:');
+        for (final req in requesters) {
+          message.writeln('      * ${req.symbol} (${req.toAbsoluteUri()})');
+        }
+      });
+      throw StateError(message.toString());
+    }
+
     // 3. Construct the merged dependencies map.

Metadata

Metadata

Assignees

Type

Fields

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions