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

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions