diff --git a/docs/docs/how-to/execution-and-dependencies.md b/docs/docs/how-to/execution-and-dependencies.md index cafb172d32..4c4f811a03 100644 --- a/docs/docs/how-to/execution-and-dependencies.md +++ b/docs/docs/how-to/execution-and-dependencies.md @@ -5,7 +5,7 @@ sidebar_position: 3 # Execution and Dependencies -The default behaviour is for modules to run in parallel, to speed up a pipeline as much as possible. +The default behaviour is for modules to run in parallel, to speed up a pipeline as much as possible. If you don't want a particular module to start until another one has finished, then you simply add a `[DependsOn]` attribute to your module class. @@ -17,4 +17,131 @@ public class Module2 : Module { ... } -``` \ No newline at end of file +``` + +## Required vs Optional Dependencies + +By default, dependencies declared with `[DependsOn]` are **required**. This means: + +1. **Auto-registration**: If the dependency module is not explicitly registered, ModularPipelines will automatically register it for you +2. **Validation**: The pipeline validates that all required dependencies can be resolved before execution + +```csharp +// Required dependency (default) +// Module1 will be auto-registered if not explicitly added +[DependsOn] +public class Module2 : Module +{ + protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + // Safe to call - Module1 is guaranteed to be registered + var result = await context.GetModule(); + return result.Value; + } +} +``` + +### Auto-Registration + +When you declare a required dependency, you don't need to explicitly register it: + +```csharp +await PipelineHostBuilder.Create() + .AddModule() // Module1 is auto-registered because Module2 depends on it + .ExecutePipelineAsync(); +``` + +This simplifies pipeline configuration and ensures all required dependencies are always present. Auto-registration also handles transitive dependencies - if Module1 depends on Module0, both will be auto-registered. + +## Optional Dependencies + +Use `Optional = true` when a dependency may or may not be present: + +```csharp +// Optional dependency - won't be auto-registered +[DependsOn(Optional = true)] +public class Module2 : Module +{ + protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + // Use GetModuleIfRegistered for optional dependencies + var module1 = context.GetModuleIfRegistered(); + + if (module1 != null) + { + var result = await module1; + return $"Got result: {result.Value}"; + } + + return "Module1 not available"; + } +} +``` + +Optional dependencies are useful when: + +- A module can work with or without another module's output +- You're using category filters and some dependencies may be excluded +- You want conditional behavior based on what modules are registered + +### Category Filters and Optional Dependencies + +When using `RunOnlyCategories` to filter which modules run, dependencies in other categories may not execute. Mark such dependencies as optional: + +```csharp +[ModuleCategory("test")] +[DependsOn(Optional = true)] // CompileModule is in "compile" category +public class TestModule : Module +{ + protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + var compile = context.GetModuleIfRegistered(); + + if (compile == null) + { + // CompileModule was filtered out - handle gracefully + return "Running tests without compile"; + } + + var result = await compile; + return result.IsSkipped + ? "Compile was skipped" + : $"Compile result: {result.Value}"; + } +} +``` + +## Accessing Dependency Results + +Use `GetModule()` for required dependencies - it throws if the module is not registered: + +```csharp +var result = await context.GetModule(); +``` + +Use `GetModuleIfRegistered()` for optional dependencies - it returns null if not registered: + +```csharp +var module = context.GetModuleIfRegistered(); +if (module != null) +{ + var result = await module; + // Use the result +} +``` + +## Programmatic Dependencies + +You can also declare dependencies programmatically by overriding `DeclareDependencies`: + +```csharp +public class Module2 : Module +{ + protected override void DeclareDependencies(IDependencyDeclaration deps) + { + deps.DependsOn(); // Required + deps.DependsOnOptional(); // Optional + deps.DependsOnIf(someCondition); // Conditional + } +} +``` diff --git a/src/ModularPipelines.Examples/Modules/FailedModule.cs b/src/ModularPipelines.Examples/Modules/FailedModule.cs index 5e6193a522..b52f97fc79 100644 --- a/src/ModularPipelines.Examples/Modules/FailedModule.cs +++ b/src/ModularPipelines.Examples/Modules/FailedModule.cs @@ -4,7 +4,7 @@ namespace ModularPipelines.Examples.Modules; -[DependsOn(IgnoreIfNotRegistered = true)] +[DependsOn(Optional = true)] public class FailedModule : Module?> { /// diff --git a/src/ModularPipelines/Attributes/DependsOnAttribute.cs b/src/ModularPipelines/Attributes/DependsOnAttribute.cs index fefda58aab..8cff01d1ee 100644 --- a/src/ModularPipelines/Attributes/DependsOnAttribute.cs +++ b/src/ModularPipelines/Attributes/DependsOnAttribute.cs @@ -3,9 +3,51 @@ namespace ModularPipelines.Attributes; +/// +/// Declares a dependency on another module. The current module will not execute until the dependency has completed. +/// +/// +/// +/// By default, dependencies are required. Required dependencies are automatically registered +/// if not explicitly added to the pipeline. This ensures all dependencies are always present. +/// +/// +/// Use = true for dependencies that may or may not be present. +/// Optional dependencies are not auto-registered and won't cause validation errors if missing. +/// +/// +/// +/// // Required dependency - Module1 will be auto-registered if not present +/// [DependsOn<Module1>] +/// public class Module2 : Module<string> { } +/// +/// // Optional dependency - Module1 won't be auto-registered +/// [DependsOn<Module1>(Optional = true)] +/// public class Module3 : Module<string> +/// { +/// protected override async Task<string?> ExecuteAsync(IModuleContext context, CancellationToken ct) +/// { +/// // Use GetModuleIfRegistered for optional dependencies +/// var module1 = context.GetModuleIfRegistered<Module1>(); +/// if (module1 != null) +/// { +/// var result = await module1; +/// return result.Value; +/// } +/// return "Module1 not available"; +/// } +/// } +/// +/// +/// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true, Inherited = true)] public class DependsOnAttribute : Attribute { + /// + /// Initializes a new instance of the class. + /// + /// The type of module this module depends on. + /// Thrown when the type does not implement . [Obsolete("Use the generic DependsOnAttribute instead for compile-time type safety. This constructor will be removed in a future version.")] public DependsOnAttribute(Type type) { @@ -25,16 +67,71 @@ internal DependsOnAttribute(Type type, bool skipValidation) Type = type; } + /// + /// Gets the type of module this module depends on. + /// public Type Type { get; } - public bool IgnoreIfNotRegistered { get; set; } + /// + /// Gets or sets whether this dependency is optional. + /// + /// + /// + /// When false (default), the dependency is required: + /// + /// + /// The dependency module will be auto-registered if not explicitly added to the pipeline + /// Use GetModule<T>() to access the dependency - it is guaranteed to be present + /// + /// + /// When true, the dependency is optional: + /// + /// + /// The dependency module will not be auto-registered + /// Use GetModuleIfRegistered<T>() to safely check if the dependency exists + /// Useful when using category filters where dependencies may be excluded + /// + /// + /// + /// true if the dependency is optional; false if the dependency is required. Default is false. + /// + public bool Optional { get; set; } = false; } +/// +/// Declares a dependency on another module of type . +/// The current module will not execute until the dependency has completed. +/// +/// The type of module this module depends on. +/// +/// +/// By default, dependencies are required. Required dependencies are automatically registered +/// if not explicitly added to the pipeline. This ensures all dependencies are always present. +/// +/// +/// Use = true for dependencies that may or may not be present. +/// Optional dependencies are not auto-registered and won't cause validation errors if missing. +/// +/// +/// +/// +/// // Required dependency - Module1 will be auto-registered if not present +/// [DependsOn<Module1>] +/// public class Module2 : Module<string> { } +/// +/// // Optional dependency - Module1 won't be auto-registered +/// [DependsOn<Module1>(Optional = true)] +/// public class Module3 : Module<string> { } +/// +/// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true, Inherited = true)] public class DependsOnAttribute : DependsOnAttribute where TModule : IModule { + /// + /// Initializes a new instance of the class. + /// public DependsOnAttribute() : base(typeof(TModule), skipValidation: true) { } -} \ No newline at end of file +} diff --git a/src/ModularPipelines/Context/IModuleContext.cs b/src/ModularPipelines/Context/IModuleContext.cs index c24dca5656..f2a32b9f73 100644 --- a/src/ModularPipelines/Context/IModuleContext.cs +++ b/src/ModularPipelines/Context/IModuleContext.cs @@ -149,7 +149,7 @@ TModule GetModule() /// Example usage: /// /// - /// [DependsOn<BuildModule>(IgnoreIfNotRegistered = true)] + /// [DependsOn<BuildModule>(Optional = true)] /// public class DeployModule : Module<DeployResult> /// { /// protected override async Task<DeployResult> ExecuteAsync( @@ -170,7 +170,7 @@ TModule GetModule() /// /// /// Important: If you use this method with a module that may be registered, consider using - /// on your dependency attribute + /// on your dependency attribute /// to properly handle the optional dependency in the execution graph. /// /// diff --git a/src/ModularPipelines/Engine/DependencyGraphValidator.cs b/src/ModularPipelines/Engine/DependencyGraphValidator.cs index b0d7d4f1d9..7af4c1a680 100644 --- a/src/ModularPipelines/Engine/DependencyGraphValidator.cs +++ b/src/ModularPipelines/Engine/DependencyGraphValidator.cs @@ -84,15 +84,15 @@ private static IEnumerable GetDependencyTypes(Type moduleType, HashSet()) { // Only include if this dependency type is actually being registered - // Also handle IgnoreIfNotRegistered - if the dependency is not registered and - // IgnoreIfNotRegistered is true, we skip it for cycle detection + // Also handle Optional - if the dependency is not registered and + // Optional is true, we skip it for cycle detection if (availableModuleTypes.Contains(attribute.Type)) { yield return attribute.Type; } - else if (!attribute.IgnoreIfNotRegistered) + else if (!attribute.Optional) { - // If the dependency is not registered and IgnoreIfNotRegistered is false, + // If the dependency is not registered and Optional is false, // we still yield it so the runtime can fail appropriately later. // For cycle detection, we only care about registered modules. } diff --git a/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs b/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs index 5a59d91d44..7fa8e1798e 100644 --- a/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs +++ b/src/ModularPipelines/Engine/Execution/DependencyWaiter.cs @@ -26,7 +26,7 @@ public async Task WaitForDependenciesAsync(ModuleState moduleState, IModuleSched // Get both attribute-based and programmatic dependencies var dependencies = GetAllDependencies(moduleState); - foreach (var (dependencyType, ignoreIfNotRegistered) in dependencies) + foreach (var (dependencyType, optional) in dependencies) { var dependencyTask = scheduler.GetModuleCompletionTask(dependencyType); @@ -46,15 +46,13 @@ public async Task WaitForDependenciesAsync(ModuleState moduleState, IModuleSched depLogger.LogError(e, "Ignoring Exception due to 'AlwaysRun' set"); } } - else if (!ignoreIfNotRegistered) + else if (!optional) { - var message = $"Module '{moduleState.ModuleType.Name}' depends on '{dependencyType.Name}', " + - $"but '{dependencyType.Name}' has not been registered in the pipeline.\n\n" + + var message = $"Module '{moduleState.ModuleType.Name}' requires '{dependencyType.Name}', " + + $"but '{dependencyType.Name}' has not been registered and could not be auto-registered.\n\n" + $"Suggestions:\n" + - $" 1. Add '.AddModule<{dependencyType.Name}>()' to your pipeline configuration before '.AddModule<{moduleState.ModuleType.Name}>()'\n" + - $" 2. Use 'deps.DependsOnOptional<{dependencyType.Name}>()' or '[DependsOn<{dependencyType.Name}>(IgnoreIfNotRegistered = true)]' if this dependency is optional\n" + - $" 3. Check for typos in the dependency type name\n" + - $" 4. Verify that '{dependencyType.Name}' is in a project referenced by your pipeline project"; + $" 1. Add '.AddModule<{dependencyType.Name}>()' to your pipeline configuration\n" + + $" 2. Use '[DependsOn<{dependencyType.Name}>(Optional = true)]' if this dependency is optional"; throw new ModuleNotRegisteredException(message, null); } } @@ -63,7 +61,7 @@ public async Task WaitForDependenciesAsync(ModuleState moduleState, IModuleSched /// /// Gets all dependencies for a module, combining attribute-based and programmatic dependencies. /// - private static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetAllDependencies(ModuleState moduleState) + private static IEnumerable<(Type DependencyType, bool Optional)> GetAllDependencies(ModuleState moduleState) { // Attribute-based dependencies foreach (var dep in ModuleDependencyResolver.GetDependencies(moduleState.ModuleType)) diff --git a/src/ModularPipelines/Engine/Executors/IgnoredModuleResultRegistrar.cs b/src/ModularPipelines/Engine/Executors/IgnoredModuleResultRegistrar.cs index 76b6040885..48710e92eb 100644 --- a/src/ModularPipelines/Engine/Executors/IgnoredModuleResultRegistrar.cs +++ b/src/ModularPipelines/Engine/Executors/IgnoredModuleResultRegistrar.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; using Microsoft.Extensions.Logging; using ModularPipelines.Context; using ModularPipelines.Engine.Execution; @@ -54,6 +56,9 @@ public async Task RegisterIgnoredModuleResultsAsync(IReadOnlyList _logger.LogDebug("Using historical result for ignored module {ModuleName}", moduleType.Name); _resultRegistry.RegisterResult(moduleType, usedHistoryResult); + + // Set the completion source so awaiting the module returns immediately + SetModuleCompletionSource(module, resultType, usedHistoryResult); continue; } } @@ -70,9 +75,22 @@ public async Task RegisterIgnoredModuleResultsAsync(IReadOnlyList var result = ModuleResultFactory.CreateSkipped(resultType, executionContext); _resultRegistry.RegisterResult(moduleType, result); + + // Set the completion source so awaiting the module returns immediately + SetModuleCompletionSource(module, resultType, result); } } + /// + /// Sets the completion source on a module so that awaiting the module returns immediately. + /// This is necessary for ignored modules so that dependent modules don't wait forever. + /// + private static void SetModuleCompletionSource(IModule module, Type resultType, IModuleResult result) + { + var setter = CompletionSourceSetterCache.GetOrCreate(resultType); + setter(module, result); + } + /// /// Attempts to get a historical result for a module using compiled delegates to call the generic GetResultAsync method. /// @@ -94,3 +112,54 @@ public async Task RegisterIgnoredModuleResultsAsync(IReadOnlyList } } } + +/// +/// Cache for compiled delegates that set the completion source on a module. +/// +internal static class CompletionSourceSetterCache +{ + private static readonly ConcurrentDictionary> Cache = new(); + + /// + /// Gets or creates a compiled delegate that sets the completion source on a module. + /// + public static Action GetOrCreate(Type resultType) + { + return Cache.GetOrAdd(resultType, CreateSetter); + } + + private static Action CreateSetter(Type resultType) + { + // Create compiled delegate for: ((Module)module).CompletionSource.TrySetResult((ModuleResult)result) + var moduleType = typeof(Module<>).MakeGenericType(resultType); + + // Parameters + var moduleParam = Expression.Parameter(typeof(IModule), "module"); + var resultParam = Expression.Parameter(typeof(IModuleResult), "result"); + + // Cast module to Module + var castModule = Expression.Convert(moduleParam, moduleType); + + // Access CompletionSource property + var completionSourceProp = moduleType.GetProperty("CompletionSource", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"CompletionSource property not found on {moduleType.Name}"); + var completionSource = Expression.Property(castModule, completionSourceProp); + + // Get the actual property type and its TrySetResult method + // This ensures we use the correct generic type as declared on the property + var completionSourceType = completionSourceProp.PropertyType; + var moduleResultType = completionSourceType.GetGenericArguments()[0]; // ModuleResult + + // Cast result to ModuleResult + var castResult = Expression.Convert(resultParam, moduleResultType); + + // Call TrySetResult using the method from the actual property type + var trySetResultMethod = completionSourceType.GetMethod("TrySetResult")!; + var callTrySetResult = Expression.Call(completionSource, trySetResultMethod, castResult); + + // Compile to Action + var lambda = Expression.Lambda>(callTrySetResult, moduleParam, resultParam); + return lambda.Compile(); + } +} diff --git a/src/ModularPipelines/Engine/ModuleAutoRegistrar.cs b/src/ModularPipelines/Engine/ModuleAutoRegistrar.cs new file mode 100644 index 0000000000..bdda7ad73b --- /dev/null +++ b/src/ModularPipelines/Engine/ModuleAutoRegistrar.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.DependencyInjection; +using ModularPipelines.Modules; + +namespace ModularPipelines.Engine; + +/// +/// Automatically registers missing required dependencies for modules. +/// When a module has a required dependency (Optional = false) that is not registered, +/// this class will auto-register it so the pipeline can run successfully. +/// +internal static class ModuleAutoRegistrar +{ + /// + /// Scans all registered modules and auto-registers any missing required dependencies. + /// This is called during pipeline construction, before the host is built. + /// + /// The service collection containing module registrations. + public static void AutoRegisterMissingDependencies(IServiceCollection services) + { + var registeredModuleTypes = GetRegisteredModuleTypes(services); + var modulesToAdd = new HashSet(); + + // Keep iterating until no new modules need to be added + // (handles transitive dependencies) + bool addedAny; + do + { + addedAny = false; + var allKnownTypes = registeredModuleTypes.Union(modulesToAdd).ToHashSet(); + + foreach (var moduleType in allKnownTypes.ToList()) + { + var dependencies = ModuleDependencyResolver.GetDependencies(moduleType); + + foreach (var (dependencyType, optional) in dependencies) + { + // Skip optional dependencies - they don't need auto-registration + if (optional) + { + continue; + } + + // Skip if already registered or queued for registration + if (allKnownTypes.Contains(dependencyType)) + { + continue; + } + + // Skip if not a valid module type + if (!IsValidModuleType(dependencyType)) + { + continue; + } + + modulesToAdd.Add(dependencyType); + addedAny = true; + } + } + } + while (addedAny); + + // Register all discovered missing dependencies + foreach (var moduleType in modulesToAdd) + { + services.AddSingleton(typeof(IModule), moduleType); + } + } + + /// + /// Gets all module types currently registered in the service collection. + /// + private static HashSet GetRegisteredModuleTypes(IServiceCollection services) + { + return services + .Where(sd => sd.ServiceType == typeof(IModule)) + .Select(sd => sd.ImplementationType ?? sd.ImplementationInstance?.GetType()) + .Where(t => t != null) + .Cast() + .ToHashSet(); + } + + /// + /// Checks if a type is a valid module type that can be instantiated. + /// + private static bool IsValidModuleType(Type type) + { + return type.IsClass + && !type.IsAbstract + && !type.IsGenericTypeDefinition + && type.IsAssignableTo(typeof(IModule)); + } +} diff --git a/src/ModularPipelines/Engine/ModuleDependencyResolver.cs b/src/ModularPipelines/Engine/ModuleDependencyResolver.cs index 05debc2a2e..46efd2993a 100644 --- a/src/ModularPipelines/Engine/ModuleDependencyResolver.cs +++ b/src/ModularPipelines/Engine/ModuleDependencyResolver.cs @@ -24,11 +24,11 @@ internal static class ModuleDependencyResolver /// Gets all dependencies declared on a module type via DependsOn attributes. /// This overload only handles DependsOnAttribute, not DependsOnAllModulesInheritingFromAttribute. /// - public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetDependencies(Type moduleType) + public static IEnumerable<(Type DependencyType, bool Optional)> GetDependencies(Type moduleType) { foreach (var attribute in moduleType.GetCustomAttributesIncludingBaseInterfaces()) { - yield return (attribute.Type, attribute.IgnoreIfNotRegistered); + yield return (attribute.Type, attribute.Optional); } } @@ -36,7 +36,7 @@ internal static class ModuleDependencyResolver /// Gets all dependencies declared on a module type via DependsOn attributes, /// including DependsOnAllModulesInheritingFromAttribute which requires the list of available modules. /// - public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetDependencies( + public static IEnumerable<(Type DependencyType, bool Optional)> GetDependencies( Type moduleType, IEnumerable availableModuleTypes) { @@ -51,7 +51,7 @@ internal static class ModuleDependencyResolver /// All available module types in the pipeline. /// Context providing access to module metadata (tags, categories, attributes). /// Required for predicate-based dependencies. If null, predicate-based dependencies are skipped. - public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetDependencies( + public static IEnumerable<(Type DependencyType, bool Optional)> GetDependencies( Type moduleType, IEnumerable availableModuleTypes, IDependencyContext? dependencyContext) @@ -61,7 +61,7 @@ internal static class ModuleDependencyResolver // Handle regular DependsOn attributes foreach (var attribute in moduleType.GetCustomAttributesIncludingBaseInterfaces()) { - yield return (attribute.Type, attribute.IgnoreIfNotRegistered); + yield return (attribute.Type, attribute.Optional); } // Handle DependsOnAllModulesInheritingFrom attributes @@ -99,8 +99,8 @@ internal static class ModuleDependencyResolver /// The module type to get predicate dependencies for. /// All available module types to evaluate against predicates. /// Context providing access to module metadata. - /// Enumerable of dependency tuples (DependencyType, IgnoreIfNotRegistered). - public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetPredicateDependencies( + /// Enumerable of dependency tuples (DependencyType, Optional). + public static IEnumerable<(Type DependencyType, bool Optional)> GetPredicateDependencies( Type moduleType, IReadOnlyList availableModuleTypes, IDependencyContext dependencyContext) @@ -136,7 +136,7 @@ internal static class ModuleDependencyResolver /// /// Gets all dependencies declared on a module via DependsOn attributes. /// - public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetDependencies(IModule module) + public static IEnumerable<(Type DependencyType, bool Optional)> GetDependencies(IModule module) { return GetDependencies(module.GetType()); } @@ -145,8 +145,8 @@ internal static class ModuleDependencyResolver /// Gets programmatic dependencies declared via DeclareDependencies method on a module instance. /// /// The module instance to get programmatic dependencies from. - /// An enumerable of dependency tuples (DependencyType, IgnoreIfNotRegistered). - public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetProgrammaticDependencies(IModule module) + /// An enumerable of dependency tuples (DependencyType, Optional). + public static IEnumerable<(Type DependencyType, bool Optional)> GetProgrammaticDependencies(IModule module) { // Use cached reflection lookup for GetDeclaredDependencies method var moduleType = module.GetType(); @@ -168,7 +168,7 @@ internal static class ModuleDependencyResolver foreach (var dep in dependencies) { - yield return (dep.ModuleType, dep.IgnoreIfNotRegistered); + yield return (dep.ModuleType, dep.IsOptional); } } @@ -176,7 +176,7 @@ internal static class ModuleDependencyResolver /// Gets all dependencies declared on a module type via DependsOn attributes, /// including both static (attribute-based) and dynamic (runtime-added) dependencies. /// - public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetAllDependencies( + public static IEnumerable<(Type DependencyType, bool Optional)> GetAllDependencies( Type moduleType, IEnumerable availableModuleTypes, IModuleDependencyRegistry? dynamicRegistry = null) @@ -203,7 +203,7 @@ internal static class ModuleDependencyResolver /// - Programmatic dependencies from DeclareDependencies method /// - Dynamic dependencies from the registry /// - public static IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetAllDependencies( + public static IEnumerable<(Type DependencyType, bool Optional)> GetAllDependencies( IModule module, IEnumerable availableModuleTypes, IModuleDependencyRegistry? dynamicRegistry = null) diff --git a/src/ModularPipelines/Engine/ModuleScheduler.cs b/src/ModularPipelines/Engine/ModuleScheduler.cs index 2f8691492b..d42e016d16 100644 --- a/src/ModularPipelines/Engine/ModuleScheduler.cs +++ b/src/ModularPipelines/Engine/ModuleScheduler.cs @@ -198,7 +198,7 @@ public void InitializeModules(IEnumerable modules) /// /// Gets all dependencies for a module including predicate-based dependencies. /// - private IEnumerable<(Type DependencyType, bool IgnoreIfNotRegistered)> GetAllDependenciesIncludingPredicate( + private IEnumerable<(Type DependencyType, bool Optional)> GetAllDependenciesIncludingPredicate( IModule module, IReadOnlyList availableModuleTypes) { diff --git a/src/ModularPipelines/Engine/UnusedModuleDetector.cs b/src/ModularPipelines/Engine/UnusedModuleDetector.cs index 193dc3c66e..a4905852ef 100644 --- a/src/ModularPipelines/Engine/UnusedModuleDetector.cs +++ b/src/ModularPipelines/Engine/UnusedModuleDetector.cs @@ -42,7 +42,7 @@ public void Log() var unregisteredDependencies = registeredModuleTypes .SelectMany(moduleType => moduleType.GetCustomAttributes(typeof(DependsOnAttribute), inherit: true) .Cast()) - .Where(attr => !attr.IgnoreIfNotRegistered) + .Where(attr => !attr.Optional) .Select(attr => attr.Type) .Distinct() .Where(depType => !registeredServices.Contains(depType)) diff --git a/src/ModularPipelines/Exceptions/ModuleNotRegisteredException.cs b/src/ModularPipelines/Exceptions/ModuleNotRegisteredException.cs index 0f2ec12729..af075d71f7 100644 --- a/src/ModularPipelines/Exceptions/ModuleNotRegisteredException.cs +++ b/src/ModularPipelines/Exceptions/ModuleNotRegisteredException.cs @@ -29,7 +29,7 @@ namespace ModularPipelines.Exceptions; /// Resolution: /// /// Add the missing module: .AddModule<MissingModule>() -/// Use optional dependencies: [DependsOn<T>(IgnoreIfNotRegistered = true)] +/// Use optional dependencies: [DependsOn<T>(Optional = true)] /// Check for typos in the dependency type name /// Verify the dependency module is in a referenced project /// diff --git a/src/ModularPipelines/Models/DeclaredDependency.cs b/src/ModularPipelines/Models/DeclaredDependency.cs index d443da11dc..99416441d5 100644 --- a/src/ModularPipelines/Models/DeclaredDependency.cs +++ b/src/ModularPipelines/Models/DeclaredDependency.cs @@ -7,11 +7,11 @@ namespace ModularPipelines.Models; /// /// The type of the module being depended on. /// The kind of dependency (Required, Optional, Lazy, Conditional). -/// Whether to ignore this dependency if not registered. +/// Whether this dependency is optional (module runs even if dependency is not registered or skipped). public readonly record struct DeclaredDependency( Type ModuleType, DependencyType Kind, - bool IgnoreIfNotRegistered) + bool IsOptional) { /// /// Creates a required dependency. diff --git a/src/ModularPipelines/Modules/ModuleDependencyValidator.cs b/src/ModularPipelines/Modules/ModuleDependencyValidator.cs index 784f7671d7..372abdcd02 100644 --- a/src/ModularPipelines/Modules/ModuleDependencyValidator.cs +++ b/src/ModularPipelines/Modules/ModuleDependencyValidator.cs @@ -60,7 +60,7 @@ private static void ValidateSelfReferences(HashSet moduleTypes) } /// - /// Validates that all dependencies are registered. + /// Validates that all required (non-optional) dependencies are registered. /// private static void ValidateMissingDependencies(HashSet moduleTypes) { @@ -68,9 +68,10 @@ private static void ValidateMissingDependencies(HashSet moduleTypes) { var dependencies = ModuleDependencyResolver.GetDependencies(moduleType, moduleTypes); - foreach (var (dependencyType, ignoreIfNotRegistered) in dependencies) + foreach (var (dependencyType, optional) in dependencies) { - if (ignoreIfNotRegistered) + // Skip validation for optional dependencies + if (optional) { continue; } @@ -78,9 +79,9 @@ private static void ValidateMissingDependencies(HashSet moduleTypes) if (!moduleTypes.Contains(dependencyType)) { throw new ModuleNotRegisteredException( - $"Module '{moduleType.Name}' depends on '{dependencyType.Name}', " + - $"but '{dependencyType.Name}' is not registered. " + - "Either register the dependency module or set IgnoreIfNotRegistered = true on the [DependsOn] attribute.", null); + $"Module '{moduleType.Name}' requires '{dependencyType.Name}', " + + $"but '{dependencyType.Name}' is not registered and could not be auto-registered. " + + "Either register the dependency module or use [DependsOn(Optional = true)] if the dependency is optional.", null); } } } diff --git a/src/ModularPipelines/PipelineBuilder.cs b/src/ModularPipelines/PipelineBuilder.cs index 2bc7c33874..cf955331ca 100644 --- a/src/ModularPipelines/PipelineBuilder.cs +++ b/src/ModularPipelines/PipelineBuilder.cs @@ -288,6 +288,9 @@ private async Task BuildPipelineAsync() services.Add(descriptor); } + // Auto-register any missing required dependencies + ModuleAutoRegistrar.AutoRegisterMissingDependencies(services); + // Register context delegates from loaded ModularPipeline assemblies foreach (var contextRegistrationDelegate in ModularPipelinesContextRegistry.ContextRegistrationDelegates) { diff --git a/test/ModularPipelines.UnitTests/Dependencies/CategoryFilterDependencyTests.cs b/test/ModularPipelines.UnitTests/Dependencies/CategoryFilterDependencyTests.cs new file mode 100644 index 0000000000..6b619423a3 --- /dev/null +++ b/test/ModularPipelines.UnitTests/Dependencies/CategoryFilterDependencyTests.cs @@ -0,0 +1,106 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.Modules; +using ModularPipelines.TestHelpers; +using Status = ModularPipelines.Enums.Status; + +namespace ModularPipelines.UnitTests.Dependencies; + +/// +/// Tests for issue #2164: DependsOn and ModuleCategory interaction +/// +public class CategoryFilterDependencyTests : TestBase +{ + [ModuleCategory("compile")] + private class CompileModule : SimpleTestModule + { + protected override string Result => "compiled"; + } + + [ModuleCategory("test")] + [ModularPipelines.Attributes.DependsOn(Optional = true)] // Optional - gracefully handle if dependency is filtered + private class TestModuleWithOptionalDep : Module + { + protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + var compile = context.GetModuleIfRegistered(); + if (compile == null) + { + return "test-without-compile"; + } + + var result = await compile; + return result.IsSkipped ? "test-compile-skipped" : $"test-with-{result.ValueOrDefault}"; + } + } + + [ModuleCategory("test")] + [ModularPipelines.Attributes.DependsOn(Optional = true)] // Must be optional when dependency might be filtered by category + private class TestModuleWithOptionalDepForCategoryFilter : Module + { + protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + var compile = context.GetModuleIfRegistered(); + if (compile == null) + { + return "test-without-compile"; + } + + var result = await compile; + return result.IsSkipped ? "test-compile-skipped" : $"test-with-{result.ValueOrDefault}"; + } + } + + [Test] + public async Task Optional_Dependency_Works_When_Filtered_By_Category() + { + // Issue #2164: Running only "test" category with optional deps should work + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ConfigurePipelineOptions(opt => opt.RunOnlyCategories = ["test"]) + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + + var testModule = pipelineSummary.Modules.OfType().Single(); + var result = await testModule; + // CompileModule is filtered out (skipped), TestModule handles gracefully + await Assert.That(result.ValueOrDefault).IsEqualTo("test-compile-skipped"); + } + + [Test] + public async Task Optional_Dependency_Is_Skipped_When_Filtered_By_Category() + { + // When using category filters, dependencies in other categories should be marked optional + // This test verifies that optional deps work correctly with category filtering + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ConfigurePipelineOptions(opt => opt.RunOnlyCategories = ["test"]) + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + + var testModule = pipelineSummary.Modules.OfType().Single(); + var result = await testModule; + // CompileModule was skipped due to category filter + await Assert.That(result.ValueOrDefault).IsEqualTo("test-compile-skipped"); + } + + [Test] + public async Task Both_Categories_Run_Successfully() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .AddModule() + .ConfigurePipelineOptions(opt => opt.RunOnlyCategories = ["compile", "test"]) + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + + var testModule = pipelineSummary.Modules.OfType().Single(); + var result = await testModule; + await Assert.That(result.ValueOrDefault).IsEqualTo("test-with-compiled"); + } +} diff --git a/test/ModularPipelines.UnitTests/Dependencies/DependsOnAllInheritingFromTests.cs b/test/ModularPipelines.UnitTests/Dependencies/DependsOnAllInheritingFromTests.cs index 812f050911..a97c8ce222 100644 --- a/test/ModularPipelines.UnitTests/Dependencies/DependsOnAllInheritingFromTests.cs +++ b/test/ModularPipelines.UnitTests/Dependencies/DependsOnAllInheritingFromTests.cs @@ -69,7 +69,7 @@ protected internal override async Task ExecuteAsync(IModuleContext context } } - [ModularPipelines.Attributes.DependsOn(IgnoreIfNotRegistered = true)] + [ModularPipelines.Attributes.DependsOn(Optional = true)] private class Module3 : BaseModule { protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) diff --git a/test/ModularPipelines.UnitTests/Dependencies/DependsOnTests.cs b/test/ModularPipelines.UnitTests/Dependencies/DependsOnTests.cs index e10144d0c7..3a72460393 100644 --- a/test/ModularPipelines.UnitTests/Dependencies/DependsOnTests.cs +++ b/test/ModularPipelines.UnitTests/Dependencies/DependsOnTests.cs @@ -14,19 +14,19 @@ private class Module1 : SimpleTestModule protected override bool Result => true; } - [ModularPipelines.Attributes.DependsOn] + [ModularPipelines.Attributes.DependsOn] // Required by default private class Module2 : SimpleTestModule { protected override bool Result => true; } - [ModularPipelines.Attributes.DependsOn(IgnoreIfNotRegistered = true)] + [ModularPipelines.Attributes.DependsOn(Optional = true)] // Optional - won't auto-register private class Module3 : SimpleTestModule { protected override bool Result => true; } - [ModularPipelines.Attributes.DependsOn(IgnoreIfNotRegistered = true)] + [ModularPipelines.Attributes.DependsOn(Optional = true)] private class Module3WithGetIfRegistered : Module { protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) @@ -37,7 +37,7 @@ protected internal override async Task ExecuteAsync(IModuleContext context } } - [ModularPipelines.Attributes.DependsOn(IgnoreIfNotRegistered = true)] + [ModularPipelines.Attributes.DependsOn(Optional = true)] private class Module3WithGet : Module { protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) @@ -81,7 +81,7 @@ public async Task No_Exception_Thrown_When_Dependent_Module_Present() } [Test] - public async Task No_Exception_Thrown_When_Dependent_Module_Present2() + public async Task No_Exception_Thrown_When_Dependent_Module_Present_With_Optional() { var pipelineSummary = await TestPipelineHostBuilder.Create() .AddModule() @@ -91,25 +91,32 @@ public async Task No_Exception_Thrown_When_Dependent_Module_Present2() } [Test] - public async Task Exception_Thrown_When_Dependent_Module_Missing_And_No_Ignore_On_Attribute() + public async Task Required_Dependency_Is_Auto_Registered_When_Missing() { - await Assert.That(async () => await TestPipelineHostBuilder.Create() - .AddModule() - .ExecutePipelineAsync()) - .ThrowsException(); + // New behavior: Required dependencies are auto-registered if not present + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + // Module1 should have been auto-registered + await Assert.That(pipelineSummary.Modules.Count()).IsEqualTo(2); } [Test] - public async Task No_Exception_Thrown_When_Dependent_Module_Missing_And_Ignore_On_Attribute() + public async Task Optional_Dependency_Not_Auto_Registered_When_Missing() { + // Optional dependencies are NOT auto-registered var pipelineSummary = await TestPipelineHostBuilder.Create() .AddModule() .ExecutePipelineAsync(); await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + // Only Module3 should be registered (Module1 not auto-registered for optional dep) + await Assert.That(pipelineSummary.Modules.Count()).IsEqualTo(1); } [Test] - public async Task No_Exception_Thrown_When_Dependent_Module_Missing_And_Get_If_Registered_Called() + public async Task No_Exception_Thrown_When_Optional_Dependency_Missing_And_Get_If_Registered_Called() { var pipelineSummary = await TestPipelineHostBuilder.Create() .AddModule() @@ -118,8 +125,9 @@ public async Task No_Exception_Thrown_When_Dependent_Module_Missing_And_Get_If_R } [Test] - public async Task Exception_Thrown_When_Dependent_Module_Missing_And_Get_Module_Called() + public async Task Exception_Thrown_When_Optional_Dependency_Missing_And_Get_Module_Called() { + // GetModule throws when module is not registered, even for optional deps await Assert.That(async () => await TestPipelineHostBuilder.Create() .AddModule() .ExecutePipelineAsync()). @@ -144,4 +152,61 @@ await Assert.That(async () => await TestPipelineHostBuilder.Create() Throws() .And.HasMessageEqualTo("ModularPipelines.Exceptions.ModuleFailedException is not a Module (does not implement IModule)"); } -} \ No newline at end of file + + [ModularPipelines.Attributes.DependsOn(Optional = true)] + private class ModuleWithOptionalDep : SimpleTestModule + { + protected override bool Result => true; + } + + [Test] + public async Task Optional_Dependency_Works_When_Missing() + { + // Optional deps don't require the module to be present + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } + + [ModularPipelines.Attributes.DependsOn] // Required by default + private class ModuleWithRequiredDep : SimpleTestModule + { + protected override bool Result => true; + } + + [Test] + public async Task Required_Dependency_Auto_Registers_Missing_Module() + { + // Required dependencies get auto-registered + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + // Module1 was auto-registered + var module1 = pipelineSummary.Modules.OfType().SingleOrDefault(); + await Assert.That(module1).IsNotNull(); + } + + [ModularPipelines.Attributes.DependsOn(Optional = true)] + private class ModuleCheckingUnregisteredDep : Module + { + protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + var dep = context.GetModuleIfRegistered(); + await Task.Yield(); + return dep == null; // Should be null since Module1 is not registered (optional dep) + } + } + + [Test] + public async Task Optional_Dependency_Returns_Null_When_GetModuleIfRegistered_Called_On_Unregistered() + { + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + } +} diff --git a/test/ModularPipelines.UnitTests/Dependencies/DynamicDependencyDeclarationTests.cs b/test/ModularPipelines.UnitTests/Dependencies/DynamicDependencyDeclarationTests.cs index de52fbfea8..ecf545563b 100644 --- a/test/ModularPipelines.UnitTests/Dependencies/DynamicDependencyDeclarationTests.cs +++ b/test/ModularPipelines.UnitTests/Dependencies/DynamicDependencyDeclarationTests.cs @@ -484,7 +484,7 @@ public async Task DependencyDeclaration_Required_Has_Correct_DependencyType() await Assert.That(deps).HasCount().EqualTo(1); await Assert.That(deps[0].Kind).IsEqualTo(DependencyType.Required); - await Assert.That(deps[0].IgnoreIfNotRegistered).IsEqualTo(false); + await Assert.That(deps[0].IsOptional).IsEqualTo(false); } [Test] @@ -497,7 +497,7 @@ public async Task DependencyDeclaration_Optional_Has_Correct_DependencyType() await Assert.That(deps).HasCount().EqualTo(1); await Assert.That(deps[0].Kind).IsEqualTo(DependencyType.Optional); - await Assert.That(deps[0].IgnoreIfNotRegistered).IsEqualTo(true); + await Assert.That(deps[0].IsOptional).IsEqualTo(true); } [Test] @@ -510,7 +510,7 @@ public async Task DependencyDeclaration_Lazy_Has_Correct_DependencyType() await Assert.That(deps).HasCount().EqualTo(1); await Assert.That(deps[0].Kind).IsEqualTo(DependencyType.Lazy); - await Assert.That(deps[0].IgnoreIfNotRegistered).IsEqualTo(true); + await Assert.That(deps[0].IsOptional).IsEqualTo(true); } [Test] @@ -523,7 +523,7 @@ public async Task DependencyDeclaration_Conditional_Has_Correct_DependencyType() await Assert.That(deps).HasCount().EqualTo(1); await Assert.That(deps[0].Kind).IsEqualTo(DependencyType.Conditional); - await Assert.That(deps[0].IgnoreIfNotRegistered).IsEqualTo(false); + await Assert.That(deps[0].IsOptional).IsEqualTo(false); } [Test] diff --git a/test/ModularPipelines.UnitTests/Dependencies/ModuleNotRegisteredExceptionTests.cs b/test/ModularPipelines.UnitTests/Dependencies/ModuleNotRegisteredExceptionTests.cs index 4db8387c28..0e8245f98b 100644 --- a/test/ModularPipelines.UnitTests/Dependencies/ModuleNotRegisteredExceptionTests.cs +++ b/test/ModularPipelines.UnitTests/Dependencies/ModuleNotRegisteredExceptionTests.cs @@ -3,6 +3,7 @@ using ModularPipelines.Exceptions; using ModularPipelines.Modules; using ModularPipelines.TestHelpers; +using Status = ModularPipelines.Enums.Status; namespace ModularPipelines.UnitTests.Dependencies; @@ -17,8 +18,21 @@ protected internal override async Task ExecuteAsync(IModuleContext context } } + // Uses optional dependency - validation passes, but GetModule fails at runtime + [ModularPipelines.Attributes.DependsOn(Optional = true)] + private class Module2WithOptionalDep : Module + { + protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + _ = await context.GetModule(); + await Task.Yield(); + return true; + } + } + + // Uses required dependency (default) - Module1 will be auto-registered [ModularPipelines.Attributes.DependsOn] - private class Module2 : Module + private class Module2WithRequiredDep : Module { protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) { @@ -29,22 +43,37 @@ protected internal override async Task ExecuteAsync(IModuleContext context } [Test] - public async Task Module_Getting_Non_Registered_Module_Throws_Exception() + public async Task Module_Getting_Non_Registered_Module_With_Optional_Dep_Throws_ModuleFailedException() { - await Assert.ThrowsAsync(() => + // With Optional dependency, validation passes but GetModule fails at runtime + // The exception is wrapped in ModuleFailedException + await Assert.ThrowsAsync(() => TestPipelineHostBuilder.Create() - .AddModule() + .AddModule() .ExecutePipelineAsync() ); } + [Test] + public async Task Module_With_Required_Dependency_Auto_Registers_Missing_Module() + { + // With Required dependency (default), missing modules are auto-registered + var pipelineSummary = await TestPipelineHostBuilder.Create() + .AddModule() + .ExecutePipelineAsync(); + + await Assert.That(pipelineSummary.Status).IsEqualTo(Status.Successful); + // Module1 was auto-registered + await Assert.That(pipelineSummary.Modules.Count()).IsEqualTo(2); + } + [Test] public async Task Module_Getting_Registered_Module_Does_Not_Throw_Exception() { await Assert.That(() => TestPipelineHostBuilder.Create() .AddModule() - .AddModule() + .AddModule() .ExecutePipelineAsync() ).ThrowsNothing(); } diff --git a/test/ModularPipelines.UnitTests/Dependencies/SingleTypeParameterGetModuleTests.cs b/test/ModularPipelines.UnitTests/Dependencies/SingleTypeParameterGetModuleTests.cs index a5a498d8ea..3c4fa455c3 100644 --- a/test/ModularPipelines.UnitTests/Dependencies/SingleTypeParameterGetModuleTests.cs +++ b/test/ModularPipelines.UnitTests/Dependencies/SingleTypeParameterGetModuleTests.cs @@ -89,7 +89,7 @@ protected internal override async Task ExecuteAsync(IModuleContext context, /// /// A module that uses GetModuleIfRegistered with single type parameter. /// - [ModularPipelines.Attributes.DependsOn(IgnoreIfNotRegistered = true)] + [ModularPipelines.Attributes.DependsOn(Optional = true)] private class OptionalConsumerModule : Module { protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) diff --git a/test/ModularPipelines.UnitTests/Execution/FailedPipelineTests.cs b/test/ModularPipelines.UnitTests/Execution/FailedPipelineTests.cs index 6b657a7179..d3c3b99444 100644 --- a/test/ModularPipelines.UnitTests/Execution/FailedPipelineTests.cs +++ b/test/ModularPipelines.UnitTests/Execution/FailedPipelineTests.cs @@ -26,7 +26,7 @@ protected internal override Task ExecuteAsync(IModuleContext context, Canc } } - [ModularPipelines.Attributes.DependsOn(IgnoreIfNotRegistered = true)] + [ModularPipelines.Attributes.DependsOn(Optional = true)] private class Module3 : Module { protected internal override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) diff --git a/test/ModularPipelines.UnitTests/Validation/ValidationTests.cs b/test/ModularPipelines.UnitTests/Validation/ValidationTests.cs index 2cb26bf524..9f367c28ea 100644 --- a/test/ModularPipelines.UnitTests/Validation/ValidationTests.cs +++ b/test/ModularPipelines.UnitTests/Validation/ValidationTests.cs @@ -54,8 +54,8 @@ private class ModuleC : Module => Task.FromResult("C"); } - // Module with missing dependency (not registered) - [ModularPipelines.Attributes.DependsOn] + // Module with missing required dependency (not registered) + [ModularPipelines.Attributes.DependsOn(Optional = false)] private class ModuleWithMissingDep : Module { protected internal override Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) @@ -69,7 +69,7 @@ private class MissingModule : Module } // Module with optional dependency (missing but ignored) - [ModularPipelines.Attributes.DependsOn(IgnoreIfNotRegistered = true)] + [ModularPipelines.Attributes.DependsOn(Optional = true)] private class ModuleWithOptionalDep : Module { protected internal override Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) @@ -138,18 +138,20 @@ await Assert.ThrowsAsync(async () => } [Test] - public async Task ValidateAsync_WithMissingDependency_ReturnsError() + public async Task ValidateAsync_WithMissingRequiredDependency_ReturnsNoError_BecauseAutoRegistered() { // Arrange var builder = Pipeline.CreateBuilder(); - builder.Services.AddModule(); // MissingModule is not registered + builder.Services.AddModule(); // MissingModule is not registered but will be auto-registered // Act var result = await builder.ValidateAsync(); - // Assert - await Assert.That(result.HasErrors).IsTrue(); - await Assert.That(result.Errors.Any(e => e.Category == ValidationErrorCategory.Dependency)).IsTrue(); + // Assert - Required dependencies are auto-registered, so no dependency error + var hasDependencyError = result.Errors.Any(e => + e.Category == ValidationErrorCategory.Dependency && + e.Message.Contains("MissingModule")); + await Assert.That(hasDependencyError).IsFalse(); } [Test]