From 33f04e9aba189513e7ce6336a8b79017b581c34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 5 Jun 2026 20:27:17 +0200 Subject: [PATCH 1/7] feat: accept Filtered.Types selections as dependency targets for architecture rules A reusable Filtered.Types selection (a "layer") can now act as a dependency target alongside the namespace and specific-type forms: - ThatType: DependsOn / DoesNotDependOn / DependsOnlyOn(Filtered.Types target, params Filtered.Types[] additional) - ThatTypes: DependOn / DoNotDependOn / DependOnlyOn (IEnumerable and IAsyncEnumerable subjects) - TypeFilters: WhichDependOn / WhichDoNotDependOn / WhichDependOnlyOn - All results and the filter result chain with OrOn(params Filtered.Types[]) to widen the targeted/allowed selections (Or is the inherited AndOrResult property and must not be hidden). Each target selection is resolved once per assertion into the union set; membership is by type identity, with a generic type definition in the set matching any of its constructions. The only-on family keeps the own-namespace allowance and the framework rule, so an empty selection allows exactly the own namespace and framework dependencies. All dependency resolution still goes through TypeHelpers.ResolveDependencies, so a customized resolver applies automatically. The first target is a regular parameter instead of params, because a pure params Filtered.Types[] overload would make the existing zero-argument calls (e.g. DependsOn()) ambiguous with the params IEnumerable namespace overloads (CS0121); this also enforces "at least one target" at compile time. The README gains an "Architecture rules" section documenting the pattern: layers as reusable selections, combining rules with Expect.ThatAll, the aggregated failure shape, and exemptions via the Except filter. --- README.md | 76 +++++++ .../Collections/Filtered.Types.cs | 34 ++++ .../Filters/TypeFilters.WhichDependOn.cs | 40 ++++ .../Filters/TypeFilters.WhichDependOnlyOn.cs | 29 +++ .../Helpers/TypeHelpers.cs | 62 ++++++ .../Options/TypeSetDependencyOptions.cs | 153 +++++++++++++++ .../Results/TypeSetDependencyResult.cs | 32 +++ .../aweXpect.Reflection/ThatType.DependsOn.cs | 88 +++++++++ .../ThatType.DependsOnlyOn.cs | 72 +++++++ .../aweXpect.Reflection/ThatTypes.DependOn.cs | 185 +++++++++++++++++- .../ThatTypes.DependOnlyOn.cs | 126 +++++++++++- .../Expected/aweXpect.Reflection_net10.0.txt | 20 ++ .../Expected/aweXpect.Reflection_net8.0.txt | 20 ++ .../aweXpect.Reflection_netstandard2.0.txt | 17 ++ .../ArchitectureRules.Tests.cs | 68 +++++++ .../TypeFilters.WhichDependOn.Tests.cs | 34 ++++ .../TypeFilters.WhichDependOnlyOn.Tests.cs | 26 +++ .../TypeFilters.WhichDoNotDependOn.Tests.cs | 23 +++ .../Dependencies/DependencyFixtures.cs | 7 + .../ThatType.DependsOn.Tests.cs | 170 ++++++++++++++++ .../ThatType.DependsOnlyOn.Tests.cs | 87 ++++++++ .../ThatType.DoesNotDependOn.Tests.cs | 58 ++++++ .../ThatTypes.DependOn.Tests.cs | 57 ++++++ .../ThatTypes.DependOnlyOn.Tests.cs | 72 +++++++ .../ThatTypes.DoNotDependOn.Tests.cs | 71 +++++++ 25 files changed, 1619 insertions(+), 8 deletions(-) create mode 100644 Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs create mode 100644 Source/aweXpect.Reflection/Results/TypeSetDependencyResult.cs create mode 100644 Tests/aweXpect.Reflection.Tests/ArchitectureRules.Tests.cs diff --git a/README.md b/README.md index 2575ff91..e0eae75b 100644 --- a/README.md +++ b/README.md @@ -492,6 +492,9 @@ await Expect.That(Types.InNamespace("MyApp.Domain")) await Expect.That(typeof(MyDomainType)).DoesNotDependOn().OrOn(); ``` +All three dependency families additionally accept a reusable `Filtered.Types` selection as target — see +[Architecture rules](#architecture-rules). + > **Framework dependencies are ignored unless you name one explicitly.** `DependOnlyOn` ignores dependencies > whose assembly name matches one of the > [`ExcludedAssemblyPrefixes`](#assembly-exclusions) at a name-segment boundary: `System` covers `System` @@ -554,6 +557,79 @@ Because the edges come from the same dependency resolution as the other dependen [custom dependency resolver](#dependency-resolver) (e.g. an IL-level one) also sharpens cycle detection: body-level references it surfaces can complete a cycle that the signature-level default cannot see. +### Architecture rules + +There is no separate rule engine: a "layer" is just a reusable `Filtered.Types` selection (with the full +filter vocabulary at your disposal), and an architecture rule is just an expectation on it. + +```csharp +Filtered.Types domain = Types.InNamespace("MyApp.Domain"); +Filtered.Types infrastructure = Types.InNamespace("MyApp.Infrastructure"); +Filtered.Types repositories = Types.InNamespace("MyApp.Data").WithName("Repository").AsSuffix(); +``` + +The dependency assertions and filters accept such a selection as a **target**, alongside the namespace and +specific-type forms: `DependsOn` / `DoesNotDependOn` / `DependsOnlyOn` (and the plural `DependOn` / +`DoNotDependOn` / `DependOnlyOn` and the `WhichDependOn` / `WhichDoNotDependOn` / `WhichDependOnlyOn` +filters) take one or more `Filtered.Types` arguments. Each target selection is resolved once per assertion; +a dependency matches when it is a member of the union of the resolved selections — by type identity, where a +generic type definition in the selection (e.g. a scanned `Repository<>`) matches any of its constructions. +Multiple targets and `.OrOn(…)` mean *any of*; for the *only-on* family the union is the allowed set, while +the own-namespace and framework rules apply unchanged (an empty selection thus allows only the own namespace +and framework dependencies). A selection is an explicit target, so framework types contained in it are +matched normally by `DependsOn` / `DoesNotDependOn`. + +```csharp +// Outgoing rule with a selection as target: +await Expect.That(domain).DoNotDependOn(infrastructure); + +// Incoming rules are written explicitly from the other side: +await Expect.That(infrastructure).DoNotDependOn(domain); + +// Allowed set as union of selections (own namespace + framework stay allowed): +await Expect.That(domain).DependOnlyOn(repositories).OrOn(infrastructure); +``` + +Combine several rules into a single verification with aweXpect's `Expect.ThatAll(…)` (see +[multiple expectations](https://docs.testably.org/aweXpect/advanced/multiple-expectations)) — every rule is +evaluated and all failures are reported together. Any assertion works on a selection, not just the +dependency ones, so naming conventions or sealing rules live in the same check: + +```csharp +await Expect.ThatAll( + Expect.That(domain).DoNotDependOn(infrastructure), + Expect.That(domain).DependOnlyOn(repositories).OrOn(infrastructure), + Expect.That(domain).AreSealed()); +``` + +A failing rule reports all violations, numbered per expectation: + +``` +Expected all of the following to succeed: + [01] Expected that domain all do not depend on types within namespace "MyApp.Infrastructure" in all loaded assemblies + [02] Expected that domain are all sealed +but + [01] it contained types with the dependency [ + OrderService +] + [02] it contained non-sealed types [ + Order, + Invoice +] +``` + +Exemptions to a rule use the [`Except` filter](#filters-and-the-matching-assertions) on the subject +selection: + +```csharp +await Expect.That(domain.Except()).DoNotDependOn(infrastructure); +await Expect.That(domain.Except(type => type.Name.StartsWith("Generated"))).AreSealed(); +``` + +A layer spanning several namespaces is built by widening a dependency *target* with additional selections +(or `.OrOn(…)`); for a *subject* spanning several namespaces, assert each namespace selection as its own +rule inside the same `Expect.ThatAll(…)`. + ### Methods In addition to [access modifiers](#access-modifiers), diff --git a/Source/aweXpect.Reflection/Collections/Filtered.Types.cs b/Source/aweXpect.Reflection/Collections/Filtered.Types.cs index 3330e6ee..81d5d4ba 100644 --- a/Source/aweXpect.Reflection/Collections/Filtered.Types.cs +++ b/Source/aweXpect.Reflection/Collections/Filtered.Types.cs @@ -543,5 +543,39 @@ public Types InEntryAssembly() public Types InExecutingAssembly() => In.ExecutingAssembly().Types().WithinNamespace(_namespace); } + + /// + /// A filtered collection of from a dependency filter whose targets are filtered + /// collections of types, allowing to widen the targeted/allowed collections. + /// + /// + /// Like all filtered collections, this is an immutable value object: does not mutate + /// this instance but rebuilds a fresh filter from the original base collection, so deriving multiple views + /// from the same instance cannot corrupt each other. + /// + public sealed class TypeSetDependencyFilterResult : Types + { + private readonly Func _build; + private readonly TypeSetDependencyOptions _options; + + internal TypeSetDependencyFilterResult( + TypeSetDependencyOptions options, + Func build) + : base(build(options)) + { + _options = options; + _build = build; + } + + /// + /// Widens the filter by the given . + /// + public TypeSetDependencyFilterResult OrOn(params Filtered.Types[] targets) + { + TypeSetDependencyOptions widened = _options.Copy(); + widened.OrOn(targets); + return new TypeSetDependencyFilterResult(widened, _build); + } + } } } diff --git a/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs b/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs index a91b6ea2..e7bf0c6f 100644 --- a/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs +++ b/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs @@ -28,4 +28,44 @@ public static Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn( options => @this.Which(Filter.Suffix( type => !options.IsMatchedBy(type), () => $"which do not depend on {options.Describe()} "))); + + /// + /// Filter for types which depend on (reference in their signature) at least one type in the filtered + /// collections of types or . + /// + /// + /// The target collections are resolved once per filter; a dependency matches when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + public static Filtered.Types.TypeSetDependencyFilterResult WhichDependOn( + this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional) + => new(new TypeSetDependencyOptions(target, additional), + options => @this.Which(Filter.Suffix( + async type => + { + await options.Resolve(); + return options.IsMatchedBy(type); + }, + () => $"which depend on {options.Describe()} "))); + + /// + /// Filter for types which do not depend on (do not reference in their signature) any type in the filtered + /// collections of types or . + /// + /// + /// The target collections are resolved once per filter; a dependency matches when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + public static Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn( + this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional) + => new(new TypeSetDependencyOptions(target, additional), + options => @this.Which(Filter.Suffix( + async type => + { + await options.Resolve(); + return !options.IsMatchedBy(type); + }, + () => $"which do not depend on {options.Describe()} "))); } diff --git a/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs b/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs index 794d5236..0adbc7e5 100644 --- a/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs +++ b/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs @@ -28,4 +28,33 @@ public static Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOn options => @this.Which(Filter.Suffix( type => !type.HasDependencyNamespaceViolations(options), () => $"which depend only on {options.Describe()} "))); + + /// + /// Filter for types which depend on (reference in their signature) only types in the filtered collections + /// of types or , their own namespace or framework + /// assemblies. + /// + /// + /// The target collections are resolved once per filter; a dependency is allowed when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + /// Dependencies on types whose assembly name matches one of the + /// at a + /// name-segment boundary (System covers System.Text.Json, but not + /// SystemsBiology.Core) are ignored, so + /// that framework types do not have to be included explicitly. The default prefixes include + /// Microsoft, so e.g. Microsoft.EntityFrameworkCore is also ignored; forbid such a dependency + /// explicitly via WhichDoNotDependOn or customize the prefixes. + /// + public static Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn( + this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional) + => new(new TypeSetDependencyOptions(target, additional), + options => @this.Which(Filter.Suffix( + async type => + { + await options.Resolve(); + return !type.HasDependencyTypeSetViolations(options); + }, + () => $"which depend only on {options.Describe()} "))); } diff --git a/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs b/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs index 95e7a6ef..02be370c 100644 --- a/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs +++ b/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs @@ -933,6 +933,68 @@ private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNames ? dependencyNamespace is null : NamespaceMatches(dependencyNamespace, ownNamespace, includeSubNamespaces); + /// + /// Collects the 's dependencies that are not allowed by the resolved target set in + /// , the type's own namespace, or the framework rule. + /// + /// + /// Same framework and own-namespace rules as , but the allowed + /// set is a concrete set of types, so the violations are reported as formatted type names instead of + /// namespaces. Requires to have been awaited before. + /// + internal static IReadOnlyList GetDependencyTypeSetViolations( + this Type type, TypeSetDependencyOptions allowed) + { + string? ownNamespace = type.Namespace; + string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get(); + List violations = []; + HashSet seen = new(StringComparer.Ordinal); + foreach (Type dependency in type.ResolveDependencies()) + { + if (!IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)) + { + continue; + } + + string display = Formatter.Format(dependency); + if (seen.Add(display)) + { + violations.Add(display); + } + } + + violations.Sort(StringComparer.Ordinal); + return violations; + } + + /// + /// Checks whether the has at least one dependency outside the resolved target set, + /// stopping at the first one. + /// + /// + /// Same rules as , for callers (like filters) that only need + /// a verdict and not the violation list. + /// + internal static bool HasDependencyTypeSetViolations(this Type type, TypeSetDependencyOptions allowed) + { + string? ownNamespace = type.Namespace; + string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get(); + return type.ResolveDependencies() + .Any(dependency => IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)); + } + + private static bool IsDependencyTypeSetViolation( + Type dependency, string? ownNamespace, TypeSetDependencyOptions allowed, string[] excludedPrefixes) + { + if (dependency.IsFrameworkDependency(excludedPrefixes)) + { + return false; + } + + return !IsOwnNamespace(dependency.Namespace, ownNamespace, true) && + !allowed.Matches(dependency); + } + /// /// Resolves the dependencies of the through which all assertions and filters go, /// using the resolver configured via the DependencyResolver customization diff --git a/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs b/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs new file mode 100644 index 00000000..1b8fbd67 --- /dev/null +++ b/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using aweXpect.Reflection.Collections; +using aweXpect.Reflection.Helpers; + +namespace aweXpect.Reflection.Options; + +/// +/// Options for a dependency assertion or filter whose targets are filtered collections of types +/// (). +/// +/// +/// The instance is shared between the chainable result and the underlying constraint/filter, so that +/// OrOn(…) widens the (lazily evaluated) expression. +/// +/// The target collections are resolved once per assertion via (the union of all +/// collections), so the resolved set can be reused across the subject's items. and +/// require to have been awaited before. +/// +internal sealed class TypeSetDependencyOptions +{ + private readonly List _targets = []; + private HashSet? _resolved; + + public TypeSetDependencyOptions(Filtered.Types target, Filtered.Types[] additional) + { + _targets.Add(target ?? throw new ArgumentNullException(nameof(target), + "The target collection of types must not be null.")); + if (additional is null) + { + throw new ArgumentNullException(nameof(additional), + "The additional target collections of types must not be null."); + } + + Add(additional); + } + + private TypeSetDependencyOptions(IEnumerable targets) + => _targets.AddRange(targets); + + /// + /// Creates an independent copy, so that refining a (reusable) filter does not mutate the shared instance. + /// + public TypeSetDependencyOptions Copy() + => new(_targets); + + /// + /// Widens the set of targeted/allowed types by the given . + /// + public void OrOn(Filtered.Types[] targets) + { + if (targets is null) + { + throw new ArgumentNullException(nameof(targets), "The target collections of types must not be null."); + } + + if (targets.Length == 0) + { + throw new ArgumentException("At least one collection of types must be specified."); + } + + Add(targets); + } + + private void Add(Filtered.Types[] targets) + { + foreach (Filtered.Types target in targets) + { + _targets.Add(target ?? throw new ArgumentNullException(nameof(targets), + "The target collections of types must not contain null.")); + } + + // Widening invalidates a previously resolved set. + _resolved = null; + } + + /// + /// Resolves the target collections once into their union set; subsequent / + /// calls use this set. + /// +#if NET8_0_OR_GREATER + public async ValueTask Resolve() + { + if (_resolved is not null) + { + return; + } + + HashSet resolved = []; + foreach (Filtered.Types target in _targets) + { + await foreach (Type type in target) + { + resolved.Add(type); + } + } + + _resolved = resolved; + } +#else + public Task Resolve() + { + if (_resolved is null) + { + HashSet resolved = []; + foreach (Filtered.Types target in _targets) + { + resolved.UnionWith(target); + } + + _resolved = resolved; + } + + return Task.CompletedTask; + } +#endif + + /// + /// Checks whether the is a member of the resolved target set. + /// + /// + /// Membership is by identity. Because dependencies keep constructed generic types as + /// written (e.g. List<Foo>) while a scanned target collection contains the generic type + /// definition (List<>), a dependency on any construction additionally matches when its + /// definition is a member of the set — mirroring how a generic type definition target matches any + /// construction in the specific-type overloads. + /// + public bool Matches(Type dependency) + { + HashSet resolved = _resolved + ?? throw new InvalidOperationException("Resolve must be awaited before Matches."); + return resolved.Contains(dependency) || + (dependency.IsGenericType && !dependency.IsGenericTypeDefinition && + resolved.Contains(dependency.GetGenericTypeDefinition())); + } + + /// + /// Checks whether the has at least one dependency in the resolved target set. + /// + public bool IsMatchedBy(Type type) + => type.ResolveDependencies().Any(Matches); + + /// + /// Describes the configured target collections for an expectation message. + /// + /// + /// The constructor guarantees at least one target, so an empty set cannot occur here. + /// + public string Describe() + => string.Join(" or ", _targets.Select(target => target.GetDescription().TrimEnd())); +} diff --git a/Source/aweXpect.Reflection/Results/TypeSetDependencyResult.cs b/Source/aweXpect.Reflection/Results/TypeSetDependencyResult.cs new file mode 100644 index 00000000..38d702f1 --- /dev/null +++ b/Source/aweXpect.Reflection/Results/TypeSetDependencyResult.cs @@ -0,0 +1,32 @@ +using aweXpect.Core; +using aweXpect.Reflection.Collections; +using aweXpect.Reflection.Options; +using aweXpect.Results; + +namespace aweXpect.Reflection.Results; + +/// +/// The result of a dependency assertion whose targets are filtered collections of types, allowing to widen +/// the targeted/allowed collections. +/// +public sealed class TypeSetDependencyResult + : AndOrResult> +{ + private readonly TypeSetDependencyOptions _options; + + internal TypeSetDependencyResult( + ExpectationBuilder expectationBuilder, + IThat subject, + TypeSetDependencyOptions options) + : base(expectationBuilder, subject) + => _options = options; + + /// + /// Widens the expression by the given . + /// + public TypeSetDependencyResult OrOn(params Filtered.Types[] targets) + { + _options.OrOn(targets); + return this; + } +} diff --git a/Source/aweXpect.Reflection/ThatType.DependsOn.cs b/Source/aweXpect.Reflection/ThatType.DependsOn.cs index 10c09d53..ca1b00e0 100644 --- a/Source/aweXpect.Reflection/ThatType.DependsOn.cs +++ b/Source/aweXpect.Reflection/ThatType.DependsOn.cs @@ -2,8 +2,11 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; +using System.Threading.Tasks; using aweXpect.Core; using aweXpect.Core.Constraints; +using aweXpect.Reflection.Collections; using aweXpect.Reflection.Helpers; using aweXpect.Reflection.Options; using aweXpect.Reflection.Results; @@ -48,6 +51,26 @@ public static partial class ThatType options); } + /// + /// Verifies that the depends on (references in its signature) at least one type in the + /// filtered collections of types or . + /// + /// + /// The target collections are resolved once per assertion; a dependency matches when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + public static TypeSetDependencyResult DependsOn( + this IThat subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult(subject.Get().ExpectationBuilder + .AddConstraint((it, grammars) + => new DependsOnTypeSetConstraint(it, grammars, options)), + subject, + options); + } + /// /// Verifies that the does not depend on (does not reference in its signature) any type in /// one of the (including sub-namespaces). @@ -86,6 +109,26 @@ public static partial class ThatType options); } + /// + /// Verifies that the does not depend on (does not reference in its signature) any type in + /// the filtered collections of types or . + /// + /// + /// The target collections are resolved once per assertion; a dependency matches when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + public static TypeSetDependencyResult DoesNotDependOn( + this IThat subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult(subject.Get().ExpectationBuilder + .AddConstraint((it, grammars) + => new DependsOnTypeSetConstraint(it, grammars, options).Invert()), + subject, + options); + } + private sealed class DependsOnNamespaceConstraint( string it, ExpectationGrammars grammars, @@ -183,4 +226,49 @@ protected override void AppendNegatedResult(StringBuilder stringBuilder, string? .ToArray()); } } + + private sealed class DependsOnTypeSetConstraint( + string it, + ExpectationGrammars grammars, + TypeSetDependencyOptions options) + : ConstraintResult.WithNotNullValue(it, grammars), + IAsyncConstraint + { + private Type[] _dependencies = []; + + public async Task IsMetBy(Type? actual, CancellationToken cancellationToken) + { + Actual = actual; + if (actual is null) + { + Outcome = Outcome.Failure; + return this; + } + + await options.Resolve(); + _dependencies = actual.ResolveDependencies(); + Outcome = _dependencies.Any(options.Matches) ? Outcome.Success : Outcome.Failure; + return this; + } + + protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("depends on ").Append(options.Describe()); + + protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append(It).Append(" did not"); + + protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("does not depend on ").Append(options.Describe()); + + protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null) + { + // The sorted matching types are only needed for this failure message, so they are built lazily. + stringBuilder.Append(It).Append(" depended on "); + Formatter.Format(stringBuilder, _dependencies + .Where(options.Matches) + .Distinct() + .OrderBy(type => type.FullName ?? type.Name, StringComparer.Ordinal) + .ToArray()); + } + } } diff --git a/Source/aweXpect.Reflection/ThatType.DependsOnlyOn.cs b/Source/aweXpect.Reflection/ThatType.DependsOnlyOn.cs index 5c30fb3b..0e271652 100644 --- a/Source/aweXpect.Reflection/ThatType.DependsOnlyOn.cs +++ b/Source/aweXpect.Reflection/ThatType.DependsOnlyOn.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; +using System.Threading.Tasks; using aweXpect.Core; using aweXpect.Core.Constraints; using aweXpect.Customization; +using aweXpect.Reflection.Collections; using aweXpect.Reflection.Helpers; using aweXpect.Reflection.Options; using aweXpect.Reflection.Results; @@ -36,6 +39,35 @@ public static partial class ThatType options); } + /// + /// Verifies that the depends on (references in its signature) only types in the filtered + /// collections of types or , its own namespace or + /// framework assemblies. + /// + /// + /// The target collections are resolved once per assertion; a dependency is allowed when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + /// Dependencies on types whose assembly name matches one of the + /// at a + /// name-segment boundary (System covers System.Text.Json, but not + /// SystemsBiology.Core) are ignored, so + /// that framework types do not have to be included explicitly. The default prefixes include + /// Microsoft, so e.g. Microsoft.EntityFrameworkCore is also ignored; forbid such a dependency + /// explicitly via DoesNotDependOn or customize the prefixes. + /// + public static TypeSetDependencyResult DependsOnlyOn( + this IThat subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult(subject.Get().ExpectationBuilder + .AddConstraint((it, grammars) + => new DependsOnlyOnTypeSetConstraint(it, grammars, options)), + subject, + options); + } + private sealed class DependsOnlyOnConstraint( string it, ExpectationGrammars grammars, @@ -74,4 +106,44 @@ protected override void AppendNegatedExpectation(StringBuilder stringBuilder, st protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null) => stringBuilder.Append(It).Append(" only depended on the allowed namespaces"); } + + private sealed class DependsOnlyOnTypeSetConstraint( + string it, + ExpectationGrammars grammars, + TypeSetDependencyOptions options) + : ConstraintResult.WithNotNullValue(it, grammars), + IAsyncConstraint + { + private IReadOnlyList _violations = []; + + public async Task IsMetBy(Type? actual, CancellationToken cancellationToken) + { + Actual = actual; + if (actual is null) + { + Outcome = Outcome.Failure; + return this; + } + + await options.Resolve(); + _violations = actual.GetDependencyTypeSetViolations(options); + Outcome = _violations.Count == 0 ? Outcome.Success : Outcome.Failure; + return this; + } + + protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("depends only on ").Append(options.Describe()); + + protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null) + { + stringBuilder.Append(It).Append(" also depended on "); + Formatter.Format(stringBuilder, _violations); + } + + protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("does not depend only on ").Append(options.Describe()); + + protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append(It).Append(" only depended on the allowed types"); + } } diff --git a/Source/aweXpect.Reflection/ThatTypes.DependOn.cs b/Source/aweXpect.Reflection/ThatTypes.DependOn.cs index fac15e68..47ed546d 100644 --- a/Source/aweXpect.Reflection/ThatTypes.DependOn.cs +++ b/Source/aweXpect.Reflection/ThatTypes.DependOn.cs @@ -1,15 +1,14 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; +using System.Threading.Tasks; using aweXpect.Core; using aweXpect.Core.Constraints; +using aweXpect.Reflection.Collections; using aweXpect.Reflection.Helpers; using aweXpect.Reflection.Options; using aweXpect.Reflection.Results; -#if NET8_0_OR_GREATER -using System.Threading; -using System.Threading.Tasks; -#endif // ReSharper disable PossibleMultipleEnumeration @@ -81,6 +80,94 @@ public static partial class ThatTypes } #endif + /// + /// Verifies that all items in the filtered collection of depend on (reference in their + /// signature) at least one type in the filtered collections of types or + /// . + /// + /// + /// The target collections are resolved once per assertion; a dependency matches when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + public static TypeSetDependencyResult> DependOn( + this IThat> subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult>(subject.Get().ExpectationBuilder + .AddConstraint>((it, grammars) + => new DependOnTypeSetConstraint(it, grammars, options)), + subject, + options); + } + +#if NET8_0_OR_GREATER + /// + /// Verifies that all items in the filtered collection of depend on (reference in their + /// signature) at least one type in the filtered collections of types or + /// . + /// + /// + /// The target collections are resolved once per assertion; a dependency matches when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + public static TypeSetDependencyResult> DependOn( + this IThat> subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult>(subject.Get().ExpectationBuilder + .AddConstraint>((it, grammars) + => new DependOnTypeSetConstraint(it, grammars, options)), + subject, + options); + } +#endif + + /// + /// Verifies that all items in the filtered collection of do not depend on (do not + /// reference in their signature) any type in the filtered collections of types or + /// . + /// + /// + /// The target collections are resolved once per assertion; a dependency matches when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + public static TypeSetDependencyResult> DoNotDependOn( + this IThat> subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult>(subject.Get().ExpectationBuilder + .AddConstraint>((it, grammars) + => new DoNotDependOnTypeSetConstraint(it, grammars, options)), + subject, + options); + } + +#if NET8_0_OR_GREATER + /// + /// Verifies that all items in the filtered collection of do not depend on (do not + /// reference in their signature) any type in the filtered collections of types or + /// . + /// + /// + /// The target collections are resolved once per assertion; a dependency matches when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + public static TypeSetDependencyResult> DoNotDependOn( + this IThat> subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult>(subject.Get().ExpectationBuilder + .AddConstraint>((it, grammars) + => new DoNotDependOnTypeSetConstraint(it, grammars, options)), + subject, + options); + } +#endif + private static bool DependsOnNamespace(Type? type, NamespaceDependencyOptions options) => type is not null && options.IsMatchedBy(type); @@ -162,4 +249,94 @@ protected override void AppendNegatedResult(StringBuilder stringBuilder, string? Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation)); } } + + private sealed class DependOnTypeSetConstraint( + string it, + ExpectationGrammars grammars, + TypeSetDependencyOptions options) + : CollectionConstraintResult(grammars), + IAsyncConstraint> +#if NET8_0_OR_GREATER + , IAsyncConstraint> +#endif + { +#if NET8_0_OR_GREATER + public async Task IsMetBy(IAsyncEnumerable actual, CancellationToken cancellationToken) + { + await options.Resolve(); + return await SetAsyncValue(actual, type => type is not null && options.IsMatchedBy(type)); + } +#endif + + public async Task IsMetBy(IEnumerable actual, CancellationToken cancellationToken) + { + await options.Resolve(); + return SetValue(actual, type => type is not null && options.IsMatchedBy(type)); + } + + protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("all depend on ").Append(options.Describe()); + + protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null) + { + stringBuilder.Append(it).Append(" contained types without the dependency "); + Formatter.Format(stringBuilder, NotMatching, FormattingOptions.Indented(indentation)); + } + + protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("not all depend on ").Append(options.Describe()); + + protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null) + { + stringBuilder.Append(it).Append(" only contained types with the dependency "); + Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation)); + } + } + + private sealed class DoNotDependOnTypeSetConstraint( + string it, + ExpectationGrammars grammars, + TypeSetDependencyOptions options) + : CollectionConstraintResult(grammars), + IAsyncConstraint> +#if NET8_0_OR_GREATER + , IAsyncConstraint> +#endif + { +#if NET8_0_OR_GREATER + public async Task IsMetBy(IAsyncEnumerable actual, CancellationToken cancellationToken) + { + await options.Resolve(); + // A null item's dependencies cannot be verified, so it fails the negative assertion just like the + // positive one (and like DependOnlyOn), instead of slipping through as "does not depend". + return await SetAsyncValue(actual, type => type is not null && !options.IsMatchedBy(type)); + } +#endif + + public async Task IsMetBy(IEnumerable actual, CancellationToken cancellationToken) + { + await options.Resolve(); + // A null item's dependencies cannot be verified, so it fails the negative assertion just like the + // positive one (and like DependOnlyOn), instead of slipping through as "does not depend". + return SetValue(actual, type => type is not null && !options.IsMatchedBy(type)); + } + + protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("all do not depend on ").Append(options.Describe()); + + protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null) + { + stringBuilder.Append(it).Append(" contained types with the dependency "); + Formatter.Format(stringBuilder, NotMatching, FormattingOptions.Indented(indentation)); + } + + protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("not all do not depend on ").Append(options.Describe()); + + protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null) + { + stringBuilder.Append(it).Append(" only contained types without the dependency "); + Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation)); + } + } } diff --git a/Source/aweXpect.Reflection/ThatTypes.DependOnlyOn.cs b/Source/aweXpect.Reflection/ThatTypes.DependOnlyOn.cs index 292d3282..5a10f189 100644 --- a/Source/aweXpect.Reflection/ThatTypes.DependOnlyOn.cs +++ b/Source/aweXpect.Reflection/ThatTypes.DependOnlyOn.cs @@ -1,16 +1,15 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; +using System.Threading.Tasks; using aweXpect.Core; using aweXpect.Core.Constraints; using aweXpect.Customization; +using aweXpect.Reflection.Collections; using aweXpect.Reflection.Helpers; using aweXpect.Reflection.Options; using aweXpect.Reflection.Results; -#if NET8_0_OR_GREATER -using System.Threading; -using System.Threading.Tasks; -#endif // ReSharper disable PossibleMultipleEnumeration @@ -70,6 +69,66 @@ public static partial class ThatTypes } #endif + /// + /// Verifies that all items in the filtered collection of depend on (reference in their + /// signature) only types in the filtered collections of types or + /// , their own namespace or framework assemblies. + /// + /// + /// The target collections are resolved once per assertion; a dependency is allowed when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + /// Dependencies on types whose assembly name matches one of the + /// at a + /// name-segment boundary (System covers System.Text.Json, but not + /// SystemsBiology.Core) are ignored, so + /// that framework types do not have to be included explicitly. The default prefixes include + /// Microsoft, so e.g. Microsoft.EntityFrameworkCore is also ignored; forbid such a dependency + /// explicitly via DoNotDependOn or customize the prefixes. + /// + public static TypeSetDependencyResult> DependOnlyOn( + this IThat> subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult>(subject.Get().ExpectationBuilder + .AddConstraint>((it, grammars) + => new DependOnlyOnTypeSetConstraint(it, grammars, options)), + subject, + options); + } + +#if NET8_0_OR_GREATER + /// + /// Verifies that all items in the filtered collection of depend on (reference in their + /// signature) only types in the filtered collections of types or + /// , their own namespace or framework assemblies. + /// + /// + /// The target collections are resolved once per assertion; a dependency is allowed when it is a member of the + /// union of the resolved collections (by identity; a generic type definition in a + /// collection matches any construction of it). + /// + /// Dependencies on types whose assembly name matches one of the + /// at a + /// name-segment boundary (System covers System.Text.Json, but not + /// SystemsBiology.Core) are ignored, so + /// that framework types do not have to be included explicitly. The default prefixes include + /// Microsoft, so e.g. Microsoft.EntityFrameworkCore is also ignored; forbid such a dependency + /// explicitly via DoNotDependOn or customize the prefixes. + /// + public static TypeSetDependencyResult> DependOnlyOn( + this IThat> subject, Filtered.Types target, params Filtered.Types[] additional) + { + TypeSetDependencyOptions options = new(target, additional); + return new TypeSetDependencyResult>(subject.Get().ExpectationBuilder + .AddConstraint>((it, grammars) + => new DependOnlyOnTypeSetConstraint(it, grammars, options)), + subject, + options); + } +#endif + private sealed class DependOnlyOnConstraint( string it, ExpectationGrammars grammars, @@ -122,4 +181,63 @@ protected override void AppendNegatedResult(StringBuilder stringBuilder, string? Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation)); } } + + private sealed class DependOnlyOnTypeSetConstraint( + string it, + ExpectationGrammars grammars, + TypeSetDependencyOptions options) + : CollectionConstraintResult(grammars), + IAsyncConstraint> +#if NET8_0_OR_GREATER + , IAsyncConstraint> +#endif + { + private readonly Dictionary> _violations = new(); + + private bool DependsOnlyOnAllowed(Type? type) + { + if (type is null) + { + return false; + } + + IReadOnlyList violations = type.GetDependencyTypeSetViolations(options); + if (violations.Count > 0) + { + _violations[type] = violations; + } + + return violations.Count == 0; + } + +#if NET8_0_OR_GREATER + public async Task IsMetBy(IAsyncEnumerable actual, CancellationToken cancellationToken) + { + await options.Resolve(); + return await SetAsyncValue(actual, DependsOnlyOnAllowed); + } +#endif + + public async Task IsMetBy(IEnumerable actual, CancellationToken cancellationToken) + { + await options.Resolve(); + return SetValue(actual, DependsOnlyOnAllowed); + } + + protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("all depend only on ").Append(options.Describe()); + + protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null) + => DependencyViolationRenderer.AppendItemsWithDisallowedDependencies(stringBuilder, it, + " contained types with disallowed dependencies ", NotMatching, _violations, indentation); + + protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null) + => stringBuilder.Append("not all depend only on ").Append(options.Describe()); + + protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null) + { + stringBuilder.Append(it).Append(" only contained types depending only on the allowed types "); + Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation)); + } + } } diff --git a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net10.0.txt b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net10.0.txt index 7a9a8965..3031c9d6 100644 --- a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net10.0.txt +++ b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net10.0.txt @@ -1619,10 +1619,13 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeContainingMembersResult ContainsProperties(this aweXpect.Core.IThat subject, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DependsOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject, System.Type type) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Type type) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Results.AndOrResult> DoesNotHave(this aweXpect.Core.IThat subject, bool inherit = true) where TAttribute : System.Attribute { } @@ -1802,10 +1805,16 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeContainingMembersResult> ContainProperties(this aweXpect.Core.IThat> subject, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Results.AndOrResult, aweXpect.Core.IThat>> DoNotHave(this aweXpect.Core.IThat> subject, bool inherit = true) where TAttribute : System.Attribute { } public static aweXpect.Results.AndOrResult, aweXpect.Core.IThat>> DoNotHave(this aweXpect.Core.IThat> subject, bool inherit = true) @@ -1943,8 +1952,11 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.TypeFilters.TypesContainingMembers WhichContainMethods(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.TypeFilters.TypesContainingMembers WhichContainProperties(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotHaveADefaultConstructor(this aweXpect.Reflection.Collections.Filtered.Types @this) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotImplement(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Type interfaceType, bool forceDirect = false) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotImplement(this aweXpect.Reflection.Collections.Filtered.Types @this, bool forceDirect = false) { } @@ -2230,6 +2242,10 @@ namespace aweXpect.Reflection.Collections public aweXpect.Reflection.Collections.Filtered.Types.StringEqualityResult AsWildcard() { } public aweXpect.Reflection.Collections.Filtered.Types.StringEqualityResult Exactly() { } } + public sealed class TypeSetDependencyFilterResult : aweXpect.Reflection.Collections.Filtered.Types + { + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } } public abstract class Filtered : System.Collections.Generic.IAsyncEnumerable @@ -2510,4 +2526,8 @@ namespace aweXpect.Reflection.Results public aweXpect.Reflection.Results.TypeDependencyResult OrOn(System.Type type) { } public aweXpect.Reflection.Results.TypeDependencyResult OrOn() { } } + public sealed class TypeSetDependencyResult : aweXpect.Results.AndOrResult> + { + public aweXpect.Reflection.Results.TypeSetDependencyResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } \ No newline at end of file diff --git a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net8.0.txt b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net8.0.txt index 6ef55401..ec68297c 100644 --- a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net8.0.txt +++ b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net8.0.txt @@ -1619,10 +1619,13 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeContainingMembersResult ContainsProperties(this aweXpect.Core.IThat subject, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DependsOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject, System.Type type) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Type type) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Results.AndOrResult> DoesNotHave(this aweXpect.Core.IThat subject, bool inherit = true) where TAttribute : System.Attribute { } @@ -1802,10 +1805,16 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeContainingMembersResult> ContainProperties(this aweXpect.Core.IThat> subject, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Results.AndOrResult, aweXpect.Core.IThat>> DoNotHave(this aweXpect.Core.IThat> subject, bool inherit = true) where TAttribute : System.Attribute { } public static aweXpect.Results.AndOrResult, aweXpect.Core.IThat>> DoNotHave(this aweXpect.Core.IThat> subject, bool inherit = true) @@ -1943,8 +1952,11 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.TypeFilters.TypesContainingMembers WhichContainMethods(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.TypeFilters.TypesContainingMembers WhichContainProperties(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotHaveADefaultConstructor(this aweXpect.Reflection.Collections.Filtered.Types @this) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotImplement(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Type interfaceType, bool forceDirect = false) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotImplement(this aweXpect.Reflection.Collections.Filtered.Types @this, bool forceDirect = false) { } @@ -2230,6 +2242,10 @@ namespace aweXpect.Reflection.Collections public aweXpect.Reflection.Collections.Filtered.Types.StringEqualityResult AsWildcard() { } public aweXpect.Reflection.Collections.Filtered.Types.StringEqualityResult Exactly() { } } + public sealed class TypeSetDependencyFilterResult : aweXpect.Reflection.Collections.Filtered.Types + { + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } } public abstract class Filtered : System.Collections.Generic.IAsyncEnumerable @@ -2510,4 +2526,8 @@ namespace aweXpect.Reflection.Results public aweXpect.Reflection.Results.TypeDependencyResult OrOn(System.Type type) { } public aweXpect.Reflection.Results.TypeDependencyResult OrOn() { } } + public sealed class TypeSetDependencyResult : aweXpect.Results.AndOrResult> + { + public aweXpect.Reflection.Results.TypeSetDependencyResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } \ No newline at end of file diff --git a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_netstandard2.0.txt b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_netstandard2.0.txt index 279732d5..1e174c1a 100644 --- a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_netstandard2.0.txt +++ b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_netstandard2.0.txt @@ -1343,10 +1343,13 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeContainingMembersResult ContainsProperties(this aweXpect.Core.IThat subject, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DependsOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject, System.Type type) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Type type) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Results.AndOrResult> DoesNotHave(this aweXpect.Core.IThat subject, bool inherit = true) where TAttribute : System.Attribute { } @@ -1476,8 +1479,11 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeContainingMembersResult> ContainMethods(this aweXpect.Core.IThat> subject, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Results.TypeContainingMembersResult> ContainProperties(this aweXpect.Core.IThat> subject, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Results.TypeSetDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Results.AndOrResult, aweXpect.Core.IThat>> DoNotHave(this aweXpect.Core.IThat> subject, bool inherit = true) where TAttribute : System.Attribute { } public static aweXpect.Results.AndOrResult, aweXpect.Core.IThat>> DoNotHaveADefaultConstructor(this aweXpect.Core.IThat> subject) { } @@ -1582,8 +1588,11 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.TypeFilters.TypesContainingMembers WhichContainMethods(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.TypeFilters.TypesContainingMembers WhichContainProperties(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Func filter, aweXpect.Reflection.Collections.MemberScope memberScope = 0) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotHaveADefaultConstructor(this aweXpect.Reflection.Collections.Filtered.Types @this) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotImplement(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Type interfaceType, bool forceDirect = false) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotImplement(this aweXpect.Reflection.Collections.Filtered.Types @this, bool forceDirect = false) { } @@ -1869,6 +1878,10 @@ namespace aweXpect.Reflection.Collections public aweXpect.Reflection.Collections.Filtered.Types.StringEqualityResult AsWildcard() { } public aweXpect.Reflection.Collections.Filtered.Types.StringEqualityResult Exactly() { } } + public sealed class TypeSetDependencyFilterResult : aweXpect.Reflection.Collections.Filtered.Types + { + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } } public abstract class Filtered : System.Collections.Generic.IEnumerable, System.Collections.IEnumerable @@ -2148,4 +2161,8 @@ namespace aweXpect.Reflection.Results public aweXpect.Reflection.Results.TypeDependencyResult OrOn(System.Type type) { } public aweXpect.Reflection.Results.TypeDependencyResult OrOn() { } } + public sealed class TypeSetDependencyResult : aweXpect.Results.AndOrResult> + { + public aweXpect.Reflection.Results.TypeSetDependencyResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } \ No newline at end of file diff --git a/Tests/aweXpect.Reflection.Tests/ArchitectureRules.Tests.cs b/Tests/aweXpect.Reflection.Tests/ArchitectureRules.Tests.cs new file mode 100644 index 00000000..206d89bd --- /dev/null +++ b/Tests/aweXpect.Reflection.Tests/ArchitectureRules.Tests.cs @@ -0,0 +1,68 @@ +using aweXpect.Reflection.Collections; +using aweXpect.Reflection.Tests.TestHelpers.Dependencies.Consumers; + +namespace aweXpect.Reflection.Tests; + +/// +/// End-to-end tests for the architecture-rules pattern: layers are reusable +/// selections, combined into one verification with Expect.ThatAll(…). +/// +public sealed class ArchitectureRulesTests +{ + private const string ConsumersNamespace = "aweXpect.Reflection.Tests.TestHelpers.Dependencies.Consumers"; + private const string Layer1Namespace = "aweXpect.Reflection.Tests.TestHelpers.Dependencies.Layer1"; + private const string Layer2Namespace = "aweXpect.Reflection.Tests.TestHelpers.Dependencies.Layer2"; + + [Fact] + public async Task ShouldVerifyMultipleRulesWithThatAll() + { + // A "layer" is just a reusable Filtered.Types selection. + Filtered.Types layer1 = Types.InNamespace(Layer1Namespace); + Filtered.Types layer2 = Types.InNamespace(Layer2Namespace); + + async Task Act() + => await ThatAll( + // Filtered.Types as dependency target: + That(layer2).DoNotDependOn(layer1), + // Namespace as dependency target on the same selection: + That(layer2).DoNotDependOn(Layer1Namespace), + // Any other assertion works on the same selection: + That(layer2).AreWithinNamespace(Layer2Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenARuleIsViolated_ShouldReportAggregatedFailure() + { + Filtered.Types layer1 = Types.InNamespace(Layer1Namespace); + Filtered.Types layer2 = Types.InNamespace(Layer2Namespace); + + async Task Act() + => await ThatAll( + // Fails: Layer1's TargetSeverityAttribute references Layer2's TargetSeverity. + That(layer1).DoNotDependOn(layer2), + // Succeeds: + That(layer2).AreWithinNamespace(Layer2Namespace)); + + await That(Act).ThrowsException() + .WithMessage("*TargetSeverityAttribute*").AsWildcard(); + } + + [Fact] + public async Task ShouldSupportExemptionsViaExcept() + { + Filtered.Types layer2 = Types.InNamespace(Layer2Namespace); + + async Task Act() + => await ThatAll( + // The consumers that intentionally reference Layer2 are exempted from the rule: + That(Types.InNamespace(ConsumersNamespace) + .Except() + .Except() + .Except()) + .DoNotDependOn(layer2)); + + await That(Act).DoesNotThrow(); + } +} diff --git a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs index 6d0ca50a..901c1f7a 100644 --- a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs @@ -50,5 +50,39 @@ await That(Act).Throws() .WithMessage("At least one namespace must be specified."); } } + + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task ShouldFilterForTypesDependingOnTargetCollection() + { + Filtered.Types types = Types.InNamespace(ConsumersNamespace) + .WhichDependOn(Types.InNamespace(Layer1Namespace)); + + await That(types).Contains(typeof(ViaField)); + await That(types).Contains(typeof(ViaSubNamespace)); + await That(types).DoesNotContain(typeof(FrameworkConsumer)); + } + + [Fact] + public async Task WhenWidenedWithOrOn_ShouldMatchEither() + { + Filtered.Types types = Types.InNamespace(ConsumersNamespace) + .WhichDependOn(Types.InNamespace("Non.Existent.Namespace")) + .OrOn(Types.InNamespace(Layer1Namespace)); + + await That(types).Contains(typeof(ViaField)); + } + + [Fact] + public async Task OrOn_ShouldNotAffectOriginalFilter() + { + Filtered.Types.TypeSetDependencyFilterResult original = Types.InNamespace(ConsumersNamespace) + .WhichDependOn(Types.InNamespace("Non.Existent.Namespace")); + _ = original.OrOn(Types.InNamespace(Layer1Namespace)); + + await That(original).DoesNotContain(typeof(ViaField)); + } + } } } diff --git a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs index 46dfdccf..dae4cf8b 100644 --- a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs @@ -22,5 +22,31 @@ public async Task ShouldFilterForTypesDependingOnlyOnAllowedNamespaces() await That(types).DoesNotContain(typeof(Layer1AndLayer2)); } } + + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task ShouldFilterForTypesDependingOnlyOnTargetCollection() + { + Filtered.Types types = Types.InNamespace(ConsumersNamespace) + .WhichDependOnlyOn(Types.InNamespace(Layer1Namespace)); + + await That(types).Contains(typeof(OnlyLayer1)); + await That(types).Contains(typeof(FrameworkConsumer)); + await That(types).Contains(typeof(ReferencesOwnNamespace)); + await That(types).DoesNotContain(typeof(Layer1AndLayer2)); + } + + [Fact] + public async Task WhenWidenedWithOrOn_ShouldAllowEither() + { + const string layer2Namespace = "aweXpect.Reflection.Tests.TestHelpers.Dependencies.Layer2"; + Filtered.Types types = Types.InNamespace(ConsumersNamespace) + .WhichDependOnlyOn(Types.InNamespace(Layer1Namespace)) + .OrOn(Types.InNamespace(layer2Namespace)); + + await That(types).Contains(typeof(Layer1AndLayer2)); + } + } } } diff --git a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDoNotDependOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDoNotDependOn.Tests.cs index f49728ba..ae6fbefd 100644 --- a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDoNotDependOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDoNotDependOn.Tests.cs @@ -21,5 +21,28 @@ public async Task ShouldFilterForTypesNotDependingOnNamespace() await That(types).DoesNotContain(typeof(ViaField)); } } + + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task ShouldFilterForTypesNotDependingOnTargetCollection() + { + Filtered.Types types = Types.InNamespace(ConsumersNamespace) + .WhichDoNotDependOn(Types.InNamespace(Layer1Namespace)); + + await That(types).Contains(typeof(FrameworkConsumer)); + await That(types).DoesNotContain(typeof(ViaField)); + } + + [Fact] + public async Task WhenTargetCollectionIsEmpty_ShouldKeepAllTypes() + { + Filtered.Types types = Types.InNamespace(ConsumersNamespace) + .WhichDoNotDependOn(Types.InNamespace("Non.Existent.Namespace")); + + await That(types).Contains(typeof(ViaField)); + await That(types).Contains(typeof(FrameworkConsumer)); + } + } } } diff --git a/Tests/aweXpect.Reflection.Tests/TestHelpers/Dependencies/DependencyFixtures.cs b/Tests/aweXpect.Reflection.Tests/TestHelpers/Dependencies/DependencyFixtures.cs index f529b1c6..a4151902 100644 --- a/Tests/aweXpect.Reflection.Tests/TestHelpers/Dependencies/DependencyFixtures.cs +++ b/Tests/aweXpect.Reflection.Tests/TestHelpers/Dependencies/DependencyFixtures.cs @@ -224,6 +224,13 @@ private sealed class Nested [TargetSeverity(TargetSeverity.High)] public class ViaEnumAttributeArgument; + // Layer1's TargetGeneric is referenced ONLY as the constructed TargetGeneric; a scanned + // target collection contains the open definition, which must match the construction. + public class ViaLayer1GenericConstruction + { + private TargetGeneric _target; + } + public class WithAsyncMethod { public static async void MethodAsync() => await Task.CompletedTask; diff --git a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOn.Tests.cs index 7ccfd0dc..504fed73 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOn.Tests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using aweXpect.Reflection.Collections; using aweXpect.Reflection.Tests.TestHelpers.Dependencies.Consumers; using aweXpect.Reflection.Tests.TestHelpers.Dependencies.Layer1; using aweXpect.Reflection.Tests.TestHelpers.Dependencies.Layer2; @@ -469,6 +470,175 @@ await That(Act).Throws() } } + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task WhenTypeDependsOnTypeInTargetCollection_ShouldSucceed() + { + Type subject = typeof(ViaField); + + async Task Act() + => await That(subject).DependsOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenTypeDoesNotDependOnAnyTypeInTargetCollection_ShouldFail() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DependsOn(Types.InNamespace(Layer2Namespace)); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + depends on types within namespace "{Layer2Namespace}" in all loaded assemblies, + but it did not + """); + } + + [Fact] + public async Task WhenAnyTargetCollectionMatches_ShouldSucceed() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DependsOn(Types.InNamespace(Layer2Namespace), Types.InNamespace(Layer1Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenWidenedWithOrOn_ShouldSucceed() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DependsOn(Types.InNamespace(Layer2Namespace)).OrOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenGenericTypeDefinitionIsInTargetCollection_ShouldMatchConstruction() + { + // ViaLayer1GenericConstruction references the constructed TargetGeneric; the scanned + // target collection contains the open definition TargetGeneric<>. + Type subject = typeof(ViaLayer1GenericConstruction); + + async Task Act() + => await That(subject).DependsOn(In.Type(typeof(TargetGeneric<>))); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenTargetCollectionContainsFrameworkTypes_ShouldMatchThem() + { + // A filtered collection is an explicit target, so framework types in it are checked normally. + Type subject = typeof(FrameworkConsumer); + + async Task Act() + => await That(subject).DependsOn(In.Type()); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenTargetCollectionIsEmpty_ShouldFail() + { + Type subject = typeof(ViaField); + + async Task Act() + => await That(subject).DependsOn(Types.InNamespace("Non.Existent.Namespace")); + + await That(Act).Throws(); + } + + [Fact] + public async Task WhenTypeIsNull_ShouldFail() + { + Type? subject = null; + + async Task Act() + => await That(subject).DependsOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).ThrowsException() + .WithMessage($""" + Expected that subject + depends on types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it was + """); + } + + [Fact] + public async Task WhenTargetIsNull_ShouldThrowArgumentNullException() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DependsOn((Filtered.Types)null!); + + await That(Act).Throws() + .WithMessage("The target collection of types must not be null.*").AsWildcard(); + } + + [Fact] + public async Task WhenAdditionalTargetsArrayIsNull_ShouldThrowArgumentNullException() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DependsOn(Types.InNamespace(Layer1Namespace), null!); + + await That(Act).Throws() + .WithMessage("The additional target collections of types must not be null.*").AsWildcard(); + } + + [Fact] + public async Task WhenAdditionalTargetIsNull_ShouldThrowArgumentNullException() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject) + .DependsOn(Types.InNamespace(Layer1Namespace), (Filtered.Types)null!, (Filtered.Types)null!); + + await That(Act).Throws() + .WithMessage("The target collections of types must not contain null.*").AsWildcard(); + } + + [Fact] + public async Task WhenWidenedWithoutTargets_ShouldThrowArgumentException() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DependsOn(Types.InNamespace(Layer1Namespace)).OrOn(); + + await That(Act).Throws() + .WithMessage("At least one collection of types must be specified."); + } + + [Fact] + public async Task WhenNegated_ShouldListMatchingDependencies() + { + Type subject = typeof(ViaField); + + async Task Act() + => await That(subject).DoesNotComplyWith(it => it.DependsOn(Types.InNamespace(Layer1Namespace))); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + does not depend on types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it depended on [TargetA] + """); + } + } + public sealed class NegatedTests { [Fact] diff --git a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs index d4ef2b14..acb6070b 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs @@ -183,6 +183,93 @@ async Task Act() } } + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task WhenAllDependenciesAreInTargetCollection_ShouldSucceed() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DependsOnlyOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenDependingOnTypeOutsideTargetCollections_ShouldFail() + { + Type subject = typeof(Layer1AndLayer2); + + async Task Act() + => await That(subject).DependsOnlyOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + depends only on types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it also depended on ["TargetB"] + """); + } + + [Fact] + public async Task WhenWidenedWithOrOn_ShouldSucceed() + { + Type subject = typeof(Layer1AndLayer2); + + async Task Act() + => await That(subject).DependsOnlyOn(Types.InNamespace(Layer1Namespace)) + .OrOn(Types.InNamespace(Layer2Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenTargetCollectionIsEmpty_ShouldStillAllowFrameworkDependencies() + { + Type subject = typeof(FrameworkConsumer); + + async Task Act() + => await That(subject).DependsOnlyOn(Types.InNamespace("Non.Existent.Namespace")); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenTargetCollectionIsEmpty_ShouldStillAllowOwnNamespace() + { + Type subject = typeof(ReferencesOwnNamespace); + + async Task Act() + => await That(subject).DependsOnlyOn(Types.InNamespace("Non.Existent.Namespace")); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenTargetCollectionIsEmpty_DisallowedDependencyShouldFail() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DependsOnlyOn(Types.InNamespace("Non.Existent.Namespace")); + + await That(Act).Throws(); + } + + [Fact] + public async Task WhenNegated_ShouldSucceedForDisallowedDependency() + { + Type subject = typeof(Layer1AndLayer2); + + async Task Act() + => await That(subject) + .DoesNotComplyWith(it => it.DependsOnlyOn(Types.InNamespace(Layer1Namespace))); + + await That(Act).DoesNotThrow(); + } + } + public sealed class NegatedTests { [Fact] diff --git a/Tests/aweXpect.Reflection.Tests/ThatType.DoesNotDependOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatType.DoesNotDependOn.Tests.cs index ae358c7d..02505c1b 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatType.DoesNotDependOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatType.DoesNotDependOn.Tests.cs @@ -341,6 +341,64 @@ Expected that subject } } + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task WhenTypeDoesNotDependOnAnyTypeInTargetCollection_ShouldSucceed() + { + Type subject = typeof(OnlyLayer1); + + async Task Act() + => await That(subject).DoesNotDependOn(Types.InNamespace(Layer2Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenTypeDependsOnTypeInTargetCollection_ShouldFail() + { + Type subject = typeof(ViaField); + + async Task Act() + => await That(subject).DoesNotDependOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + does not depend on types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it depended on [TargetA] + """); + } + + [Fact] + public async Task WhenTargetCollectionIsEmpty_ShouldSucceedTrivially() + { + Type subject = typeof(ViaField); + + async Task Act() + => await That(subject).DoesNotDependOn(Types.InNamespace("Non.Existent.Namespace")); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenWidenedWithOrOn_ShouldFailIfAnyMatches() + { + Type subject = typeof(Layer1AndLayer2); + + async Task Act() + => await That(subject).DoesNotDependOn(Types.InNamespace(Layer2Namespace)) + .OrOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + does not depend on types within namespace "{Layer2Namespace}" in all loaded assemblies or types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it depended on [TargetA, TargetB] + """); + } + } + public sealed class NegatedTests { [Fact] diff --git a/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOn.Tests.cs index 60316249..313b2cae 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOn.Tests.cs @@ -77,5 +77,62 @@ async Task Act() await That(Act).DoesNotThrow(); } } + + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task WhenAllTypesDependOnTargetCollection_ShouldSucceed() + { + IEnumerable subject = + [ + typeof(ViaField), + typeof(ViaProperty), + typeof(OnlyLayer1), + ]; + + async Task Act() + => await That(subject).DependOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenSomeTypeDoesNotDependOnTargetCollection_ShouldFail() + { + IEnumerable subject = + [ + typeof(ViaField), + typeof(FrameworkConsumer), + ]; + + async Task Act() + => await That(subject).DependOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + all depend on types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it contained types without the dependency [ + FrameworkConsumer + ] + """); + } + + [Fact] + public async Task WhenWidenedWithOrOn_ShouldSucceed() + { + IEnumerable subject = + [ + typeof(OnlyLayer1), + typeof(Layer1AndLayer2), + ]; + + async Task Act() + => await That(subject).DependOn(Types.InNamespace("Non.Existent.Namespace")) + .OrOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).DoesNotThrow(); + } + } } } diff --git a/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs index 94a3f4be..556a6b3a 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs @@ -88,5 +88,77 @@ async Task Act() await That(Act).DoesNotThrow(); } } + + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task WhenAllDependenciesAreInTargetCollection_ShouldSucceed() + { + IEnumerable subject = + [ + typeof(OnlyLayer1), + typeof(FrameworkConsumer), + typeof(ReferencesOwnNamespace), + ]; + + async Task Act() + => await That(subject).DependOnlyOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenSomeTypeDependsOnTypeOutsideTargetCollections_ShouldFail() + { + IEnumerable subject = + [ + typeof(OnlyLayer1), + typeof(Layer1AndLayer2), + ]; + + async Task Act() + => await That(subject).DependOnlyOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + all depend only on types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it contained types with disallowed dependencies [ + Layer1AndLayer2 depends on ["TargetB"] + ] + """); + } + + [Fact] + public async Task WhenTargetCollectionIsEmpty_ShouldStillAllowOwnNamespaceAndFramework() + { + IEnumerable subject = + [ + typeof(FrameworkConsumer), + typeof(ReferencesOwnNamespace), + ]; + + async Task Act() + => await That(subject).DependOnlyOn(Types.InNamespace("Non.Existent.Namespace")); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenWidenedWithOrOn_ShouldSucceed() + { + IEnumerable subject = + [ + typeof(OnlyLayer1), + typeof(Layer1AndLayer2), + ]; + + async Task Act() + => await That(subject).DependOnlyOn(Types.InNamespace(Layer1Namespace)) + .OrOn(Types.InNamespace(Layer2Namespace)); + + await That(Act).DoesNotThrow(); + } + } } } diff --git a/Tests/aweXpect.Reflection.Tests/ThatTypes.DoNotDependOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatTypes.DoNotDependOn.Tests.cs index 06b06cdb..b0fb33f7 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatTypes.DoNotDependOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatTypes.DoNotDependOn.Tests.cs @@ -74,5 +74,76 @@ but it contained types with the dependency [ """); } } + + public sealed class FilteredTypesTargetTests + { + [Fact] + public async Task WhenNoTypeDependsOnTargetCollection_ShouldSucceed() + { + IEnumerable subject = + [ + typeof(ViaField), + typeof(OnlyLayer1), + ]; + + async Task Act() + => await That(subject).DoNotDependOn(Types.InNamespace(Layer2Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenSomeTypeDependsOnTargetCollection_ShouldFail() + { + IEnumerable subject = + [ + typeof(OnlyLayer1), + typeof(Layer1AndLayer2), + ]; + + async Task Act() + => await That(subject).DoNotDependOn(Types.InNamespace(Layer2Namespace)); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + all do not depend on types within namespace "{Layer2Namespace}" in all loaded assemblies, + but it contained types with the dependency [ + Layer1AndLayer2 + ] + """); + } + + [Fact] + public async Task WhenTargetCollectionIsEmpty_ShouldSucceedTrivially() + { + IEnumerable subject = + [ + typeof(ViaField), + typeof(Layer1AndLayer2), + typeof(FrameworkConsumer), + ]; + + async Task Act() + => await That(subject).DoNotDependOn(Types.InNamespace("Non.Existent.Namespace")); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenWidenedWithOrOn_ShouldFailIfAnyMatches() + { + IEnumerable subject = + [ + typeof(OnlyLayer1), + ]; + + async Task Act() + => await That(subject).DoNotDependOn(Types.InNamespace(Layer2Namespace)) + .OrOn(Types.InNamespace(Layer1Namespace)); + + await That(Act).Throws(); + } + } } } From 754ab148d0ed45667fd5b1fca29c4c111ed6ef28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 5 Jun 2026 21:12:30 +0200 Subject: [PATCH 2/7] fix: harden type-set dependency targets after review - Normalize the resolved target set via the shared StripElementTypes, so array/by-ref/pointer types in a target selection match their element type, mirroring the specific-type overloads (an array target in DoesNotDependOn no longer passes silently). - Report type-set violations deduplicated by type identity and qualify distinct violators sharing a simple name with their namespace, so they no longer collapse into one indistinguishable entry. - Validate widening targets fully before mutating the shared options and report the actual parameter name of the caller (additional/targets). - Share the violation-collection core and the framework/own-namespace exemption predicate between the namespace-based and type-set-based helpers, so the two families cannot drift apart. - Factor the null-item predicates of the type-set collection constraints into static helpers, mirroring the namespace-based siblings. --- .../Helpers/TypeHelpers.cs | 115 ++++++++++-------- .../Options/TypeSetDependencyOptions.cs | 25 ++-- .../aweXpect.Reflection/ThatTypes.DependOn.cs | 20 +-- .../Dependencies/DependencyFixtures.cs | 20 +++ .../ThatType.DependsOn.Tests.cs | 18 ++- .../ThatType.DependsOnlyOn.Tests.cs | 15 +++ 6 files changed, 144 insertions(+), 69 deletions(-) diff --git a/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs b/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs index 02be370c..eeef36f9 100644 --- a/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs +++ b/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs @@ -788,10 +788,11 @@ internal static bool MatchesType(Type dependency, Type target) /// element type. /// /// - /// Shared between dependency unwrapping () and target matching - /// (), so that the two sides of the documented symmetry cannot drift apart. + /// Shared between dependency unwrapping (), target matching + /// () and target-set resolution (), + /// so that the sides of the documented symmetry cannot drift apart. /// - private static Type StripElementTypes(Type type) + internal static Type StripElementTypes(Type type) { while (type.HasElementType && type.GetElementType() is { } elementType) { @@ -877,17 +878,12 @@ private static bool IsFrameworkDependency(this Type type, string[] excludedPrefi internal static IReadOnlyList GetDependencyNamespaceViolations( this Type type, NamespaceDependencyOptions allowed) { - string? ownNamespace = type.Namespace; - string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get(); List violations = []; HashSet seen = new(StringComparer.Ordinal); - foreach (Type dependency in type.ResolveDependencies()) + foreach (Type dependency in type.GetDependencyViolations( + (dependency, ownNamespace, excludedPrefixes) + => IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes))) { - if (!IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes)) - { - continue; - } - string display = dependency.Namespace ?? GlobalNamespaceDisplay; if (seen.Add(display)) { @@ -908,24 +904,45 @@ internal static IReadOnlyList GetDependencyNamespaceViolations( /// a verdict and not the violation list. /// internal static bool HasDependencyNamespaceViolations(this Type type, NamespaceDependencyOptions allowed) - { - string? ownNamespace = type.Namespace; - string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get(); - return type.ResolveDependencies() - .Any(dependency => IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes)); - } + => type.GetDependencyViolations( + (dependency, ownNamespace, excludedPrefixes) + => IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes)).Any(); private static bool IsDependencyViolation( Type dependency, string? ownNamespace, NamespaceDependencyOptions allowed, string[] excludedPrefixes) - { - if (dependency.IsFrameworkDependency(excludedPrefixes)) - { - return false; - } + => !IsExemptDependency(dependency, ownNamespace, allowed.IncludeOwnSubNamespaces, excludedPrefixes) && + !allowed.Matches(dependency.Namespace); + + /// + /// Checks the exemptions shared by all only-on rules: framework dependencies and dependencies in the + /// type's own namespace are always allowed. + /// + /// + /// Shared between the namespace-based and type-set-based violation predicates, so that a future rule + /// change cannot be applied to one family and missed in the other. + /// + private static bool IsExemptDependency( + Type dependency, string? ownNamespace, bool includeOwnSubNamespaces, string[] excludedPrefixes) + => dependency.IsFrameworkDependency(excludedPrefixes) || + IsOwnNamespace(dependency.Namespace, ownNamespace, includeOwnSubNamespaces); - string? dependencyNamespace = dependency.Namespace; - return !IsOwnNamespace(dependencyNamespace, ownNamespace, allowed.IncludeOwnSubNamespaces) && - !allowed.Matches(dependencyNamespace); + /// + /// Enumerates the 's dependencies that the + /// predicate flags as violations, supplying the type's own namespace and the configured excluded + /// assembly prefixes. + /// + /// + /// Shared core of the namespace-based and type-set-based violation helpers, so that the two families + /// cannot drift apart in how the dependencies are walked. Lazy, so verdict-only callers stop at the + /// first violation. + /// + private static IEnumerable GetDependencyViolations( + this Type type, Func isViolation) + { + string? ownNamespace = type.Namespace; + string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get(); + return type.ResolveDependencies() + .Where(dependency => isViolation(dependency, ownNamespace, excludedPrefixes)); } private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNamespace, bool includeSubNamespaces) @@ -940,26 +957,30 @@ private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNames /// /// Same framework and own-namespace rules as , but the allowed /// set is a concrete set of types, so the violations are reported as formatted type names instead of - /// namespaces. Requires to have been awaited before. + /// namespaces. Distinct violators sharing a formatted name (the same simple name in different namespaces) + /// are qualified by their namespace, so they do not collapse into one indistinguishable entry. + /// Requires to have been awaited before. /// internal static IReadOnlyList GetDependencyTypeSetViolations( this Type type, TypeSetDependencyOptions allowed) { - string? ownNamespace = type.Namespace; - string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get(); List violations = []; - HashSet seen = new(StringComparer.Ordinal); - foreach (Type dependency in type.ResolveDependencies()) - { - if (!IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)) + foreach (IGrouping sameName in type.GetDependencyViolations( + (dependency, ownNamespace, excludedPrefixes) + => IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)) + .Distinct() + .GroupBy(dependency => Formatter.Format(dependency), StringComparer.Ordinal)) + { + Type[] violators = sameName.ToArray(); + if (violators.Length == 1) { - continue; + violations.Add(sameName.Key); } - - string display = Formatter.Format(dependency); - if (seen.Add(display)) + else { - violations.Add(display); + violations.AddRange(violators.Select(violator => violator.Namespace is null + ? sameName.Key + : $"{violator.Namespace}.{sameName.Key}")); } } @@ -976,24 +997,14 @@ internal static IReadOnlyList GetDependencyTypeSetViolations( /// a verdict and not the violation list. /// internal static bool HasDependencyTypeSetViolations(this Type type, TypeSetDependencyOptions allowed) - { - string? ownNamespace = type.Namespace; - string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get(); - return type.ResolveDependencies() - .Any(dependency => IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)); - } + => type.GetDependencyViolations( + (dependency, ownNamespace, excludedPrefixes) + => IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)).Any(); private static bool IsDependencyTypeSetViolation( Type dependency, string? ownNamespace, TypeSetDependencyOptions allowed, string[] excludedPrefixes) - { - if (dependency.IsFrameworkDependency(excludedPrefixes)) - { - return false; - } - - return !IsOwnNamespace(dependency.Namespace, ownNamespace, true) && - !allowed.Matches(dependency); - } + => !IsExemptDependency(dependency, ownNamespace, true, excludedPrefixes) && + !allowed.Matches(dependency); /// /// Resolves the dependencies of the through which all assertions and filters go, diff --git a/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs b/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs index 1b8fbd67..8827cd48 100644 --- a/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs +++ b/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs @@ -34,7 +34,7 @@ public TypeSetDependencyOptions(Filtered.Types target, Filtered.Types[] addition "The additional target collections of types must not be null."); } - Add(additional); + Add(additional, nameof(additional)); } private TypeSetDependencyOptions(IEnumerable targets) @@ -61,17 +61,18 @@ public void OrOn(Filtered.Types[] targets) throw new ArgumentException("At least one collection of types must be specified."); } - Add(targets); + Add(targets, nameof(targets)); } - private void Add(Filtered.Types[] targets) + private void Add(Filtered.Types[] targets, string paramName) { - foreach (Filtered.Types target in targets) + // Fully validate before mutating, so that a failed widening leaves the shared instance untouched. + if (targets.Contains(null!)) { - _targets.Add(target ?? throw new ArgumentNullException(nameof(targets), - "The target collections of types must not contain null.")); + throw new ArgumentNullException(paramName, "The target collections of types must not contain null."); } + _targets.AddRange(targets); // Widening invalidates a previously resolved set. _resolved = null; } @@ -80,6 +81,11 @@ private void Add(Filtered.Types[] targets) /// Resolves the target collections once into their union set; subsequent / /// calls use this set. /// + /// + /// Each member is normalized via : dependencies are stored + /// element-stripped at collection time, so array/by-ref/pointer targets must be unwrapped symmetrically + /// (mirroring in the specific-type overloads). + /// #if NET8_0_OR_GREATER public async ValueTask Resolve() { @@ -93,7 +99,7 @@ public async ValueTask Resolve() { await foreach (Type type in target) { - resolved.Add(type); + resolved.Add(TypeHelpers.StripElementTypes(type)); } } @@ -107,7 +113,10 @@ public Task Resolve() HashSet resolved = []; foreach (Filtered.Types target in _targets) { - resolved.UnionWith(target); + foreach (Type type in target) + { + resolved.Add(TypeHelpers.StripElementTypes(type)); + } } _resolved = resolved; diff --git a/Source/aweXpect.Reflection/ThatTypes.DependOn.cs b/Source/aweXpect.Reflection/ThatTypes.DependOn.cs index 47ed546d..42b6d44c 100644 --- a/Source/aweXpect.Reflection/ThatTypes.DependOn.cs +++ b/Source/aweXpect.Reflection/ThatTypes.DependOn.cs @@ -176,6 +176,14 @@ private static bool DependsOnNamespace(Type? type, NamespaceDependencyOptions op private static bool DoesNotDependOnNamespace(Type? type, NamespaceDependencyOptions options) => type is not null && !options.IsMatchedBy(type); + private static bool DependsOnTypeSet(Type? type, TypeSetDependencyOptions options) + => type is not null && options.IsMatchedBy(type); + + // A null item's dependencies cannot be verified, so it fails the negative assertion just like the + // positive one (and like DependOnlyOn), instead of slipping through as "does not depend". + private static bool DoesNotDependOnTypeSet(Type? type, TypeSetDependencyOptions options) + => type is not null && !options.IsMatchedBy(type); + private sealed class DependOnConstraint( string it, ExpectationGrammars grammars, @@ -264,14 +272,14 @@ private sealed class DependOnTypeSetConstraint( public async Task IsMetBy(IAsyncEnumerable actual, CancellationToken cancellationToken) { await options.Resolve(); - return await SetAsyncValue(actual, type => type is not null && options.IsMatchedBy(type)); + return await SetAsyncValue(actual, type => DependsOnTypeSet(type, options)); } #endif public async Task IsMetBy(IEnumerable actual, CancellationToken cancellationToken) { await options.Resolve(); - return SetValue(actual, type => type is not null && options.IsMatchedBy(type)); + return SetValue(actual, type => DependsOnTypeSet(type, options)); } protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) @@ -307,18 +315,14 @@ private sealed class DoNotDependOnTypeSetConstraint( public async Task IsMetBy(IAsyncEnumerable actual, CancellationToken cancellationToken) { await options.Resolve(); - // A null item's dependencies cannot be verified, so it fails the negative assertion just like the - // positive one (and like DependOnlyOn), instead of slipping through as "does not depend". - return await SetAsyncValue(actual, type => type is not null && !options.IsMatchedBy(type)); + return await SetAsyncValue(actual, type => DoesNotDependOnTypeSet(type, options)); } #endif public async Task IsMetBy(IEnumerable actual, CancellationToken cancellationToken) { await options.Resolve(); - // A null item's dependencies cannot be verified, so it fails the negative assertion just like the - // positive one (and like DependOnlyOn), instead of slipping through as "does not depend". - return SetValue(actual, type => type is not null && !options.IsMatchedBy(type)); + return SetValue(actual, type => DoesNotDependOnTypeSet(type, options)); } protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) diff --git a/Tests/aweXpect.Reflection.Tests/TestHelpers/Dependencies/DependencyFixtures.cs b/Tests/aweXpect.Reflection.Tests/TestHelpers/Dependencies/DependencyFixtures.cs index a4151902..c59494a2 100644 --- a/Tests/aweXpect.Reflection.Tests/TestHelpers/Dependencies/DependencyFixtures.cs +++ b/Tests/aweXpect.Reflection.Tests/TestHelpers/Dependencies/DependencyFixtures.cs @@ -231,6 +231,14 @@ public class ViaLayer1GenericConstruction private TargetGeneric _target; } + // Two distinct disallowed dependencies share the simple name "AmbiguousTarget"; the violation list + // must keep them apart by qualifying each with its namespace. + public class WithSameNamedDependencies + { + private AmbiguousA.AmbiguousTarget _a; + private AmbiguousB.AmbiguousTarget _b; + } + public class WithAsyncMethod { public static async void MethodAsync() => await Task.CompletedTask; @@ -329,3 +337,15 @@ public class CovariantReturnDerived : CovariantReturnBase } #endif } + +// Two namespaces deliberately declaring the same simple type name, so that violation messages must +// disambiguate (see WithSameNamedDependencies above). +namespace aweXpect.Reflection.Tests.TestHelpers.Dependencies.AmbiguousA +{ + public class AmbiguousTarget; +} + +namespace aweXpect.Reflection.Tests.TestHelpers.Dependencies.AmbiguousB +{ + public class AmbiguousTarget; +} diff --git a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOn.Tests.cs index 504fed73..58c7cfa3 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOn.Tests.cs @@ -534,6 +534,19 @@ async Task Act() await That(Act).DoesNotThrow(); } + [Fact] + public async Task WhenTargetCollectionContainsArrayType_ShouldMatchElementTypeDependency() + { + // Dependencies are stored element-stripped, so an array target matches like its element + // type, mirroring the specific-type overload DependsOn(typeof(TargetA[])). + Type subject = typeof(ViaField); + + async Task Act() + => await That(subject).DependsOn(In.Type(typeof(TargetA[]))); + + await That(Act).DoesNotThrow(); + } + [Fact] public async Task WhenTargetCollectionContainsFrameworkTypes_ShouldMatchThem() { @@ -606,8 +619,11 @@ async Task Act() => await That(subject) .DependsOn(Types.InNamespace(Layer1Namespace), (Filtered.Types)null!, (Filtered.Types)null!); + // The localized paramName suffix differs between frameworks ("(Parameter 'additional')" vs + // "Parametername: additional"), so only the shared part is matched. await That(Act).Throws() - .WithMessage("The target collections of types must not contain null.*").AsWildcard(); + .WithMessage("The target collections of types must not contain null.*additional*") + .AsWildcard(); } [Fact] diff --git a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs index acb6070b..c9a8cd33 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs @@ -1,4 +1,5 @@ using aweXpect.Reflection.Tests.TestHelpers.Dependencies.Consumers; +using aweXpect.Reflection.Tests.TestHelpers.Dependencies.Synthetic; using Xunit.Sdk; namespace aweXpect.Reflection.Tests; @@ -224,6 +225,20 @@ async Task Act() await That(Act).DoesNotThrow(); } + [Fact] + public async Task WhenDistinctViolatorsShareTheSimpleName_ShouldQualifyThemByNamespace() + { + // Both AmbiguousTarget dependencies are disallowed; they must stay apart in the message + // instead of collapsing into one indistinguishable "AmbiguousTarget" entry. + Type subject = typeof(WithSameNamedDependencies); + + async Task Act() + => await That(subject).DependsOnlyOn(In.Namespace(Layer1Namespace)); + + await That(Act).Throws() + .WithMessage("*AmbiguousA.AmbiguousTarget*AmbiguousB.AmbiguousTarget*").AsWildcard(); + } + [Fact] public async Task WhenTargetCollectionIsEmpty_ShouldStillAllowFrameworkDependencies() { From 1dd86bdc1953b0365e7653bf509292b01c37b50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 6 Jun 2026 03:51:22 +0200 Subject: [PATCH 3/7] refactor: harden type-set dependency targets after second review - DependsOnlyOn/DependOnlyOn/WhichDependOnlyOn with Filtered.Types targets return new only-on results exposing ExcludingOwnSubNamespaces, mirroring the namespace family (the own-sub-namespace exemption was hard-coded) - TypeSetDependencyOptions.Resolve returns a ResolvedTypeSet that performs all matching, so matching against an unresolved target set is unrepresentable; the assertion CancellationToken is forwarded - share the constructed-generic matching rule between MatchesType and the type-set matcher via TypeHelpers.GetGenericTypeDefinitionOfConstruction - parenthesize the target description in the type-set filter suffixes, so the target scope no longer runs into the subject collection scope - drop a provably dead Distinct() in GetDependencyTypeSetViolations - make all WhichDependOn/WhichDoNotDependOn/DependOn/DoNotDependOn overloads adjacent (Sonar S4136) - README: promote type dependencies and architecture rules into their own top-level section and avoid em-dashes --- README.md | 445 +++++++++--------- .../Collections/Filtered.Types.cs | 51 ++ .../Filters/TypeFilters.WhichDependOn.cs | 38 +- .../Filters/TypeFilters.WhichDependOnlyOn.cs | 14 +- .../Helpers/TypeHelpers.cs | 34 +- .../Options/TypeSetDependencyOptions.cs | 117 +++-- .../Results/TypeSetDependencyOnlyOnResult.cs | 47 ++ .../aweXpect.Reflection/ThatType.DependsOn.cs | 9 +- .../ThatType.DependsOnlyOn.cs | 12 +- .../aweXpect.Reflection/ThatTypes.DependOn.cs | 88 ++-- .../ThatTypes.DependOnlyOn.cs | 28 +- .../Expected/aweXpect.Reflection_net10.0.txt | 18 +- .../Expected/aweXpect.Reflection_net8.0.txt | 18 +- .../aweXpect.Reflection_netstandard2.0.txt | 16 +- .../TypeFilters.WhichDependOn.Tests.cs | 14 + .../TypeFilters.WhichDependOnlyOn.Tests.cs | 21 + .../ThatType.DependsOnlyOn.Tests.cs | 42 ++ .../ThatTypes.DependOnlyOn.Tests.cs | 23 + 18 files changed, 672 insertions(+), 363 deletions(-) create mode 100644 Source/aweXpect.Reflection/Results/TypeSetDependencyOnlyOnResult.cs diff --git a/README.md b/README.md index e0eae75b..296f8f64 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# aweXpect.Reflection +# aweXpect.Reflection [![Nuget](https://img.shields.io/nuget/v/aweXpect.Reflection)](https://www.nuget.org/packages/aweXpect.Reflection) [![Build](https://github.com/Testably/aweXpect.Reflection/actions/workflows/build.yml/badge.svg)](https://github.com/Testably/aweXpect.Reflection/actions/workflows/build.yml) @@ -413,223 +413,6 @@ In.AllLoadedAssemblies().Methods() .WhichAreGeneric().WithArgument("TKey").AtIndex(0) ``` -### Type dependencies - -Layering and architecture rules over the types a type references **in its signature**: - -| | Filter | Assert (single) | Assert (many) | -|--------------------------|------------------------------|-------------------------|------------------------| -| depends on namespace | `.WhichDependOn("x", …)` | `.DependsOn("x", …)` | `.DependOn("x", …)` | -| does not depend on | `.WhichDoNotDependOn("x", …)`| `.DoesNotDependOn("x", …)` | `.DoNotDependOn("x", …)` | -| depends only on set | `.WhichDependOnlyOn("x", …)` | `.DependsOnlyOn("x", …)`| `.DependOnlyOn("x", …)`| - -```csharp -// Presentation must not reference the data layer -await Expect.That(Types.InNamespace("MyApp.Presentation")) - .DoNotDependOn("MyApp.Data"); - -// The API layer may only reference the application and domain layers -await Expect.That(Types.InNamespace("MyApp.Api")) - .DependOnlyOn("MyApp.Application", "MyApp.Domain"); - -// Filter for the types that depend on a namespace -In.AllLoadedAssemblies().Types().WhichDependOn("System.Data") -``` - -A type *depends on* every type referenced in its **declared signature**: the base type and directly -implemented interfaces, generic arguments and parameter constraints, field/property/event types, indexer -parameters, method return/parameter/generic-argument types, constructor parameters and the types of attributes -applied to the type, its members, their parameters and return values (including `typeof(…)` and enum attribute -arguments). Element types of -arrays/pointers/by-ref and generic type arguments are unwrapped (`List` depends on `List`, -which also matches a `List<>` target, and on `Infra.Foo`; a closed-generic target like `List` -only matches that exact construction). Purely synthetic references that you never wrote are ignored: -compiler-generated members, the implicit `object`/`ValueType`/`Enum` base type, interfaces inherited from the -base type, records' synthesized `IEquatable`, delegates' runtime infrastructure (only the `Invoke` -signature counts), enums' underlying-value plumbing and the attributes the compiler emits onto authored code -(nullability metadata, required members, async/iterator state machines, …), so the compiler's own plumbing -never counts. Should a future compiler version emit a marker attribute this library does not know about yet, -exclude it yourself via `Customize.aweXpect.Reflection().ExcludedAttributeTypes()` (full attribute type names; -extends the built-in set). Types you write in authored signatures always do count, including primitives and -`void` return types (namespace `System`); in practice, almost every type with members *does* depend on -`System`. - -> **Signature-level only:** dependencies are computed from reflection metadata, so body-level references such -> as `new Infra.Foo()`, static calls and local variables are **not** detected. Function-pointer signatures -> (`delegate*<…>`) are not decomposed either; the types inside them are invisible to dependency assertions. -> Nested types are separate types with their own dependency surface: asserting on `typeof(Outer)` does not -> include what `Outer.Inner` references. The collection-based assertions (e.g. over `Types.InNamespace(…)`) -> enumerate nested types as their own items and therefore cover them. For IL/body-level accuracy, plug in -> your own resolver via `Customize.aweXpect.Reflection().DependencyResolver()` (see -> [Configuration](#dependency-resolver)). - -Namespace matching is ordinal and case-sensitive and, like `WithinNamespace`, includes sub-namespaces by -default (so `Foo.Bar` matches `Foo.Bar.Baz` but not `Foo.BarBaz`). A dependency in the **global namespace** -can be targeted or allowed with an empty string (`""`). Each result is chainable: - -```csharp -// Widen the set with .OrOn(…) -await Expect.That(Types.InNamespace("MyApp.Api")) - .DependOnlyOn("MyApp.Application").OrOn("MyApp.Domain"); - -// Opt out of sub-namespace matching for the whole expression -await Expect.That(types).DoNotDependOn("MyApp.Data").ExcludingSubNamespaces(); -``` - -For `DependsOnlyOn` a type's own namespace is always allowed, and by default so are its sub-namespaces. Use -`.ExcludingOwnSubNamespaces()` (only available on the *only-on* family) to also forbid references into a -type's own sub-namespaces: - -```csharp -await Expect.That(Types.InNamespace("MyApp.Domain")) - .DependOnlyOn("MyApp.Domain").ExcludingSubNamespaces().ExcludingOwnSubNamespaces(); -``` - -`DependsOn` and `DoesNotDependOn` (single types only) also accept a **specific type** via `()` or -`(Type)`, with `.OrOn()` / `.OrOn(Type)` to widen: - -```csharp -await Expect.That(typeof(MyDomainType)).DoesNotDependOn().OrOn(); -``` - -All three dependency families additionally accept a reusable `Filtered.Types` selection as target — see -[Architecture rules](#architecture-rules). - -> **Framework dependencies are ignored unless you name one explicitly.** `DependOnlyOn` ignores dependencies -> whose assembly name matches one of the -> [`ExcludedAssemblyPrefixes`](#assembly-exclusions) at a name-segment boundary: `System` covers `System` -> and `System.Text.Json`, but not an assembly named `SystemsBiology` (so you never have to whitelist -> `System.*` and unrelated assemblies are never swallowed by a prefix), while a -> type's **own namespace** is always allowed. `DependsOn` / `DoesNotDependOn` / `WhichDependOn` still match a -> framework namespace when you name it explicitly (e.g. `DoesNotDependOn("System.Data")`). -> -> ⚠️ The default prefixes include `Microsoft`, so `DependOnlyOn` also ignores dependencies on e.g. -> `Microsoft.EntityFrameworkCore`, `Microsoft.AspNetCore` and `Microsoft.Extensions.*`; a domain entity -> inheriting `DbContext` does **not** fail `DependOnlyOn("MyApp.Domain")`. To forbid such dependencies, name -> them explicitly (`DoesNotDependOn()` or `DoNotDependOn("Microsoft.EntityFrameworkCore")`) or -> customize the [`ExcludedAssemblyPrefixes`](#assembly-exclusions). Note that the customization also affects -> assembly scanning and assembly-level dependency assertions. - -#### Dependency cycles - -The "slices should be free of cycles" architecture rule: assert that the namespaces of a set of types do not -(transitively) depend on each other. - -```csharp -// No dependency cycles among the namespaces under MyApp -await Expect.That(Types.InNamespace("MyApp")) - .HaveNoDependencyCycles(); -``` - -A namespace `A` *depends on* a namespace `B` when some type in `A` references a type in `B` (in its -[signature](#type-dependencies), read through the same resolver as the other dependency assertions). The -namespaces of the analyzed types form the nodes of a directed graph, and each -[strongly-connected component](https://en.wikipedia.org/wiki/Strongly_connected_component) with more than one -node is reported as a cycle, e.g. `MyApp.Orders -> MyApp.Billing -> MyApp.Orders`. Only namespaces present in -the analyzed set form nodes, so dependencies on framework or otherwise out-of-set namespaces never create an -edge, and a namespace referencing itself is not a cycle. - -By default a namespace and its sub-namespaces collapse into a single node (a family), consistent with how the -other dependency assertions treat a type's own sub-namespaces. So a reference between a namespace and its -ancestor/descendant (e.g. `MyApp.Orders` ↔ `MyApp.Orders.Domain`) never creates an edge and cannot by itself form -a cycle. But because the family is one node (not just a suppressed pair of edges), a cycle that leaves the family -and returns through a *different* member of it (e.g. `MyApp.Orders -> MyApp.Billing -> MyApp.Orders.Domain`) is -still detected. Use `ExcludingSubNamespaces()` to treat every namespace as its own node, so that such a -parent/child reference becomes an edge (and can form a cycle): - -```csharp -// Treat every namespace as its own node (MyApp.Orders ↔ MyApp.Orders.Domain can now form a cycle) -await Expect.That(Types.InNamespace("MyApp")) - .HaveNoDependencyCycles().ExcludingSubNamespaces(); -``` - -Pass a **slice root** to group all namespaces below it into one slice each (by the namespace segment immediately -following the root), so that, for example, `MyApp.Orders`, `MyApp.Orders.Domain` and `MyApp.Orders.Api` collapse -into the single slice `MyApp.Orders`: - -```csharp -// Group MyApp.Orders.* / MyApp.Billing.* / … into one slice each before looking for cycles -await Expect.That(Types.InNamespace("MyApp")) - .HaveNoDependencyCycles("MyApp"); -``` - -Because the edges come from the same dependency resolution as the other dependency assertions, configuring a -[custom dependency resolver](#dependency-resolver) (e.g. an IL-level one) also sharpens cycle -detection: body-level references it surfaces can complete a cycle that the signature-level default cannot see. - -### Architecture rules - -There is no separate rule engine: a "layer" is just a reusable `Filtered.Types` selection (with the full -filter vocabulary at your disposal), and an architecture rule is just an expectation on it. - -```csharp -Filtered.Types domain = Types.InNamespace("MyApp.Domain"); -Filtered.Types infrastructure = Types.InNamespace("MyApp.Infrastructure"); -Filtered.Types repositories = Types.InNamespace("MyApp.Data").WithName("Repository").AsSuffix(); -``` - -The dependency assertions and filters accept such a selection as a **target**, alongside the namespace and -specific-type forms: `DependsOn` / `DoesNotDependOn` / `DependsOnlyOn` (and the plural `DependOn` / -`DoNotDependOn` / `DependOnlyOn` and the `WhichDependOn` / `WhichDoNotDependOn` / `WhichDependOnlyOn` -filters) take one or more `Filtered.Types` arguments. Each target selection is resolved once per assertion; -a dependency matches when it is a member of the union of the resolved selections — by type identity, where a -generic type definition in the selection (e.g. a scanned `Repository<>`) matches any of its constructions. -Multiple targets and `.OrOn(…)` mean *any of*; for the *only-on* family the union is the allowed set, while -the own-namespace and framework rules apply unchanged (an empty selection thus allows only the own namespace -and framework dependencies). A selection is an explicit target, so framework types contained in it are -matched normally by `DependsOn` / `DoesNotDependOn`. - -```csharp -// Outgoing rule with a selection as target: -await Expect.That(domain).DoNotDependOn(infrastructure); - -// Incoming rules are written explicitly from the other side: -await Expect.That(infrastructure).DoNotDependOn(domain); - -// Allowed set as union of selections (own namespace + framework stay allowed): -await Expect.That(domain).DependOnlyOn(repositories).OrOn(infrastructure); -``` - -Combine several rules into a single verification with aweXpect's `Expect.ThatAll(…)` (see -[multiple expectations](https://docs.testably.org/aweXpect/advanced/multiple-expectations)) — every rule is -evaluated and all failures are reported together. Any assertion works on a selection, not just the -dependency ones, so naming conventions or sealing rules live in the same check: - -```csharp -await Expect.ThatAll( - Expect.That(domain).DoNotDependOn(infrastructure), - Expect.That(domain).DependOnlyOn(repositories).OrOn(infrastructure), - Expect.That(domain).AreSealed()); -``` - -A failing rule reports all violations, numbered per expectation: - -``` -Expected all of the following to succeed: - [01] Expected that domain all do not depend on types within namespace "MyApp.Infrastructure" in all loaded assemblies - [02] Expected that domain are all sealed -but - [01] it contained types with the dependency [ - OrderService -] - [02] it contained non-sealed types [ - Order, - Invoice -] -``` - -Exemptions to a rule use the [`Except` filter](#filters-and-the-matching-assertions) on the subject -selection: - -```csharp -await Expect.That(domain.Except()).DoNotDependOn(infrastructure); -await Expect.That(domain.Except(type => type.Name.StartsWith("Generated"))).AreSealed(); -``` - -A layer spanning several namespaces is built by widening a dependency *target* with additional selections -(or `.OrOn(…)`); for a *subject* spanning several namespaces, assert each namespace selection as its own -rule inside the same `Expect.ThatAll(…)`. - ### Methods In addition to [access modifiers](#access-modifiers), @@ -895,6 +678,232 @@ await Expect.That(subject).HasVersion().WithMajor.GreaterThanOrEqualTo(2); await Expect.That(subjects).HaveVersion().WithMajor.EqualTo(1); ``` +## Type dependencies and architecture rules + +Layering and architecture rules are expressed over the types a type references **in its signature**: +[Type dependencies](#type-dependencies) covers the dependency filters and assertions (including +[dependency cycles](#dependency-cycles)), and [Architecture rules](#architecture-rules) shows how to +combine them with reusable type selections into a full architecture test suite. + +### Type dependencies + +The dependency filters and assertions follow the familiar filter/assert pairing: + +| | Filter | Assert (single) | Assert (many) | +|--------------------------|------------------------------|-------------------------|------------------------| +| depends on namespace | `.WhichDependOn("x", …)` | `.DependsOn("x", …)` | `.DependOn("x", …)` | +| does not depend on | `.WhichDoNotDependOn("x", …)`| `.DoesNotDependOn("x", …)` | `.DoNotDependOn("x", …)` | +| depends only on set | `.WhichDependOnlyOn("x", …)` | `.DependsOnlyOn("x", …)`| `.DependOnlyOn("x", …)`| + +```csharp +// Presentation must not reference the data layer +await Expect.That(Types.InNamespace("MyApp.Presentation")) + .DoNotDependOn("MyApp.Data"); + +// The API layer may only reference the application and domain layers +await Expect.That(Types.InNamespace("MyApp.Api")) + .DependOnlyOn("MyApp.Application", "MyApp.Domain"); + +// Filter for the types that depend on a namespace +In.AllLoadedAssemblies().Types().WhichDependOn("System.Data") +``` + +A type *depends on* every type referenced in its **declared signature**: the base type and directly +implemented interfaces, generic arguments and parameter constraints, field/property/event types, indexer +parameters, method return/parameter/generic-argument types, constructor parameters and the types of attributes +applied to the type, its members, their parameters and return values (including `typeof(…)` and enum attribute +arguments). Element types of +arrays/pointers/by-ref and generic type arguments are unwrapped (`List` depends on `List`, +which also matches a `List<>` target, and on `Infra.Foo`; a closed-generic target like `List` +only matches that exact construction). Purely synthetic references that you never wrote are ignored: +compiler-generated members, the implicit `object`/`ValueType`/`Enum` base type, interfaces inherited from the +base type, records' synthesized `IEquatable`, delegates' runtime infrastructure (only the `Invoke` +signature counts), enums' underlying-value plumbing and the attributes the compiler emits onto authored code +(nullability metadata, required members, async/iterator state machines, …), so the compiler's own plumbing +never counts. Should a future compiler version emit a marker attribute this library does not know about yet, +exclude it yourself via `Customize.aweXpect.Reflection().ExcludedAttributeTypes()` (full attribute type names; +extends the built-in set). Types you write in authored signatures always do count, including primitives and +`void` return types (namespace `System`); in practice, almost every type with members *does* depend on +`System`. + +> **Signature-level only:** dependencies are computed from reflection metadata, so body-level references such +> as `new Infra.Foo()`, static calls and local variables are **not** detected. Function-pointer signatures +> (`delegate*<…>`) are not decomposed either; the types inside them are invisible to dependency assertions. +> Nested types are separate types with their own dependency surface: asserting on `typeof(Outer)` does not +> include what `Outer.Inner` references. The collection-based assertions (e.g. over `Types.InNamespace(…)`) +> enumerate nested types as their own items and therefore cover them. For IL/body-level accuracy, plug in +> your own resolver via `Customize.aweXpect.Reflection().DependencyResolver()` (see +> [Configuration](#dependency-resolver)). + +Namespace matching is ordinal and case-sensitive and, like `WithinNamespace`, includes sub-namespaces by +default (so `Foo.Bar` matches `Foo.Bar.Baz` but not `Foo.BarBaz`). A dependency in the **global namespace** +can be targeted or allowed with an empty string (`""`). Each result is chainable: + +```csharp +// Widen the set with .OrOn(…) +await Expect.That(Types.InNamespace("MyApp.Api")) + .DependOnlyOn("MyApp.Application").OrOn("MyApp.Domain"); + +// Opt out of sub-namespace matching for the whole expression +await Expect.That(types).DoNotDependOn("MyApp.Data").ExcludingSubNamespaces(); +``` + +For `DependsOnlyOn` a type's own namespace is always allowed, and by default so are its sub-namespaces. Use +`.ExcludingOwnSubNamespaces()` (only available on the *only-on* family) to also forbid references into a +type's own sub-namespaces: + +```csharp +await Expect.That(Types.InNamespace("MyApp.Domain")) + .DependOnlyOn("MyApp.Domain").ExcludingSubNamespaces().ExcludingOwnSubNamespaces(); +``` + +`DependsOn` and `DoesNotDependOn` (single types only) also accept a **specific type** via `()` or +`(Type)`, with `.OrOn()` / `.OrOn(Type)` to widen: + +```csharp +await Expect.That(typeof(MyDomainType)).DoesNotDependOn().OrOn(); +``` + +All three dependency families additionally accept a reusable `Filtered.Types` selection as target; see +[Architecture rules](#architecture-rules). + +> **Framework dependencies are ignored unless you name one explicitly.** `DependOnlyOn` ignores dependencies +> whose assembly name matches one of the +> [`ExcludedAssemblyPrefixes`](#assembly-exclusions) at a name-segment boundary: `System` covers `System` +> and `System.Text.Json`, but not an assembly named `SystemsBiology` (so you never have to whitelist +> `System.*` and unrelated assemblies are never swallowed by a prefix), while a +> type's **own namespace** is always allowed. `DependsOn` / `DoesNotDependOn` / `WhichDependOn` still match a +> framework namespace when you name it explicitly (e.g. `DoesNotDependOn("System.Data")`). +> +> ⚠️ The default prefixes include `Microsoft`, so `DependOnlyOn` also ignores dependencies on e.g. +> `Microsoft.EntityFrameworkCore`, `Microsoft.AspNetCore` and `Microsoft.Extensions.*`; a domain entity +> inheriting `DbContext` does **not** fail `DependOnlyOn("MyApp.Domain")`. To forbid such dependencies, name +> them explicitly (`DoesNotDependOn()` or `DoNotDependOn("Microsoft.EntityFrameworkCore")`) or +> customize the [`ExcludedAssemblyPrefixes`](#assembly-exclusions). Note that the customization also affects +> assembly scanning and assembly-level dependency assertions. + +#### Dependency cycles + +The "slices should be free of cycles" architecture rule: assert that the namespaces of a set of types do not +(transitively) depend on each other. + +```csharp +// No dependency cycles among the namespaces under MyApp +await Expect.That(Types.InNamespace("MyApp")) + .HaveNoDependencyCycles(); +``` + +A namespace `A` *depends on* a namespace `B` when some type in `A` references a type in `B` (in its +[signature](#type-dependencies), read through the same resolver as the other dependency assertions). The +namespaces of the analyzed types form the nodes of a directed graph, and each +[strongly-connected component](https://en.wikipedia.org/wiki/Strongly_connected_component) with more than one +node is reported as a cycle, e.g. `MyApp.Orders -> MyApp.Billing -> MyApp.Orders`. Only namespaces present in +the analyzed set form nodes, so dependencies on framework or otherwise out-of-set namespaces never create an +edge, and a namespace referencing itself is not a cycle. + +By default a namespace and its sub-namespaces collapse into a single node (a family), consistent with how the +other dependency assertions treat a type's own sub-namespaces. So a reference between a namespace and its +ancestor/descendant (e.g. `MyApp.Orders` ↔ `MyApp.Orders.Domain`) never creates an edge and cannot by itself form +a cycle. But because the family is one node (not just a suppressed pair of edges), a cycle that leaves the family +and returns through a *different* member of it (e.g. `MyApp.Orders -> MyApp.Billing -> MyApp.Orders.Domain`) is +still detected. Use `ExcludingSubNamespaces()` to treat every namespace as its own node, so that such a +parent/child reference becomes an edge (and can form a cycle): + +```csharp +// Treat every namespace as its own node (MyApp.Orders ↔ MyApp.Orders.Domain can now form a cycle) +await Expect.That(Types.InNamespace("MyApp")) + .HaveNoDependencyCycles().ExcludingSubNamespaces(); +``` + +Pass a **slice root** to group all namespaces below it into one slice each (by the namespace segment immediately +following the root), so that, for example, `MyApp.Orders`, `MyApp.Orders.Domain` and `MyApp.Orders.Api` collapse +into the single slice `MyApp.Orders`: + +```csharp +// Group MyApp.Orders.* / MyApp.Billing.* / … into one slice each before looking for cycles +await Expect.That(Types.InNamespace("MyApp")) + .HaveNoDependencyCycles("MyApp"); +``` + +Because the edges come from the same dependency resolution as the other dependency assertions, configuring a +[custom dependency resolver](#dependency-resolver) (e.g. an IL-level one) also sharpens cycle +detection: body-level references it surfaces can complete a cycle that the signature-level default cannot see. + +### Architecture rules + +There is no separate rule engine: a "layer" is just a reusable `Filtered.Types` selection (with the full +filter vocabulary at your disposal), and an architecture rule is just an expectation on it. + +```csharp +Filtered.Types domain = Types.InNamespace("MyApp.Domain"); +Filtered.Types infrastructure = Types.InNamespace("MyApp.Infrastructure"); +Filtered.Types repositories = Types.InNamespace("MyApp.Data").WithName("Repository").AsSuffix(); +``` + +The dependency assertions and filters accept such a selection as a **target**, alongside the namespace and +specific-type forms: `DependsOn` / `DoesNotDependOn` / `DependsOnlyOn` (and the plural `DependOn` / +`DoNotDependOn` / `DependOnlyOn` and the `WhichDependOn` / `WhichDoNotDependOn` / `WhichDependOnlyOn` +filters) take one or more `Filtered.Types` arguments. Each target selection is resolved once per assertion; +a dependency matches when it is a member of the union of the resolved selections. Matching is by type +identity, where a generic type definition in the selection (e.g. a scanned `Repository<>`) matches any of +its constructions. +Multiple targets and `.OrOn(…)` mean *any of*; for the *only-on* family the union is the allowed set, while +the own-namespace and framework rules apply unchanged, including the `.ExcludingOwnSubNamespaces()` opt-out +(an empty selection thus allows only the own namespace +and framework dependencies). A selection is an explicit target, so framework types contained in it are +matched normally by `DependsOn` / `DoesNotDependOn`. + +```csharp +// Outgoing rule with a selection as target: +await Expect.That(domain).DoNotDependOn(infrastructure); + +// Incoming rules are written explicitly from the other side: +await Expect.That(infrastructure).DoNotDependOn(domain); + +// Allowed set as union of selections (own namespace + framework stay allowed): +await Expect.That(domain).DependOnlyOn(repositories).OrOn(infrastructure); +``` + +Combine several rules into a single verification with aweXpect's `Expect.ThatAll(…)` (see +[multiple expectations](https://docs.testably.org/aweXpect/advanced/multiple-expectations)): every rule is +evaluated and all failures are reported together. Any assertion works on a selection, not just the +dependency ones, so naming conventions or sealing rules live in the same check: + +```csharp +await Expect.ThatAll( + Expect.That(domain).DoNotDependOn(infrastructure), + Expect.That(domain).DependOnlyOn(repositories).OrOn(infrastructure), + Expect.That(domain).AreSealed()); +``` + +A failing rule reports all violations, numbered per expectation: + +``` +Expected all of the following to succeed: + [01] Expected that domain all do not depend on types within namespace "MyApp.Infrastructure" in all loaded assemblies + [02] Expected that domain are all sealed +but + [01] it contained types with the dependency [ + OrderService +] + [02] it contained non-sealed types [ + Order, + Invoice +] +``` + +Exemptions to a rule use the [`Except` filter](#filters-and-the-matching-assertions) on the subject +selection: + +```csharp +await Expect.That(domain.Except()).DoNotDependOn(infrastructure); +await Expect.That(domain.Except(type => type.Name.StartsWith("Generated"))).AreSealed(); +``` + +A layer spanning several namespaces is built by widening a dependency *target* with additional selections +(or `.OrOn(…)`); for a *subject* spanning several namespaces, assert each namespace selection as its own +rule inside the same `Expect.ThatAll(…)`. + ## Combining filters Filters chain naturally (each narrows the previous result). Several filters offer an `Or…` companion to diff --git a/Source/aweXpect.Reflection/Collections/Filtered.Types.cs b/Source/aweXpect.Reflection/Collections/Filtered.Types.cs index 81d5d4ba..0b3c5a89 100644 --- a/Source/aweXpect.Reflection/Collections/Filtered.Types.cs +++ b/Source/aweXpect.Reflection/Collections/Filtered.Types.cs @@ -577,5 +577,56 @@ public TypeSetDependencyFilterResult OrOn(params Filtered.Types[] targets) return new TypeSetDependencyFilterResult(widened, _build); } } + + /// + /// A filtered collection of from a depends-only-on filter whose allowed + /// targets are filtered collections of types, allowing to widen the allowed collections and to opt out + /// of the implicit allowance of the type's own sub-namespaces. + /// + /// + /// Like all filtered collections, this is an immutable value object: and + /// do not mutate this instance but rebuild a fresh filter from + /// the original base collection, so deriving multiple views from the same instance cannot corrupt each + /// other. + /// + public sealed class TypeSetDependencyOnlyOnFilterResult : Types + { + private readonly Func _build; + private readonly TypeSetDependencyOptions _options; + + internal TypeSetDependencyOnlyOnFilterResult( + TypeSetDependencyOptions options, + Func build) + : base(build(options)) + { + _options = options; + _build = build; + } + + /// + /// Widens the filter by the given . + /// + public TypeSetDependencyOnlyOnFilterResult OrOn(params Filtered.Types[] targets) + { + TypeSetDependencyOptions widened = _options.Copy(); + widened.OrOn(targets); + return new TypeSetDependencyOnlyOnFilterResult(widened, _build); + } + + /// + /// Excludes sub-namespaces of the type's own namespace from being implicitly allowed (so a Foo + /// type referencing Foo.Bar is filtered out unless Foo.Bar types are part of an allowed + /// collection). + /// + /// + /// The type's own namespace itself is always allowed. + /// + public TypeSetDependencyOnlyOnFilterResult ExcludingOwnSubNamespaces() + { + TypeSetDependencyOptions refined = _options.Copy(); + refined.ExcludingOwnSubNamespaces(); + return new TypeSetDependencyOnlyOnFilterResult(refined, _build); + } + } } } diff --git a/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs b/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs index e7bf0c6f..2558634d 100644 --- a/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs +++ b/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs @@ -18,17 +18,6 @@ public static Filtered.Types.NamespaceDependencyFilterResult WhichDependOn( type => options.IsMatchedBy(type), () => $"which depend on {options.Describe()} "))); - /// - /// Filter for types which do not depend on (do not reference in their signature) any type in one of the - /// (including sub-namespaces). - /// - public static Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn( - this Filtered.Types @this, params IEnumerable namespaces) - => new(new NamespaceDependencyOptions(namespaces), - options => @this.Which(Filter.Suffix( - type => !options.IsMatchedBy(type), - () => $"which do not depend on {options.Describe()} "))); - /// /// Filter for types which depend on (reference in their signature) at least one type in the filtered /// collections of types or . @@ -44,10 +33,23 @@ public static Filtered.Types.TypeSetDependencyFilterResult WhichDependOn( options => @this.Which(Filter.Suffix( async type => { - await options.Resolve(); - return options.IsMatchedBy(type); + ResolvedTypeSet targetSet = await options.Resolve(); + return targetSet.IsMatchedBy(type); }, - () => $"which depend on {options.Describe()} "))); + // The parentheses delimit the target description (which ends in the target's source scope, + // e.g. "in all loaded assemblies") from the subject collection's own source suffix. + () => $"which depend on ({options.Describe()}) "))); + + /// + /// Filter for types which do not depend on (do not reference in their signature) any type in one of the + /// (including sub-namespaces). + /// + public static Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn( + this Filtered.Types @this, params IEnumerable namespaces) + => new(new NamespaceDependencyOptions(namespaces), + options => @this.Which(Filter.Suffix( + type => !options.IsMatchedBy(type), + () => $"which do not depend on {options.Describe()} "))); /// /// Filter for types which do not depend on (do not reference in their signature) any type in the filtered @@ -64,8 +66,10 @@ public static Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn( options => @this.Which(Filter.Suffix( async type => { - await options.Resolve(); - return !options.IsMatchedBy(type); + ResolvedTypeSet targetSet = await options.Resolve(); + return !targetSet.IsMatchedBy(type); }, - () => $"which do not depend on {options.Describe()} "))); + // The parentheses delimit the target description (which ends in the target's source scope, + // e.g. "in all loaded assemblies") from the subject collection's own source suffix. + () => $"which do not depend on ({options.Describe()}) "))); } diff --git a/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs b/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs index 0adbc7e5..4e176696 100644 --- a/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs +++ b/Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs @@ -37,7 +37,9 @@ public static Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOn /// /// The target collections are resolved once per filter; a dependency is allowed when it is a member of the /// union of the resolved collections (by identity; a generic type definition in a - /// collection matches any construction of it). + /// collection matches any construction of it). A type's own namespace is always allowed, including its + /// sub-namespaces unless + /// is used. /// /// Dependencies on types whose assembly name matches one of the /// at a @@ -47,14 +49,16 @@ public static Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOn /// Microsoft, so e.g. Microsoft.EntityFrameworkCore is also ignored; forbid such a dependency /// explicitly via WhichDoNotDependOn or customize the prefixes. /// - public static Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn( + public static Filtered.Types.TypeSetDependencyOnlyOnFilterResult WhichDependOnlyOn( this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional) => new(new TypeSetDependencyOptions(target, additional), options => @this.Which(Filter.Suffix( async type => { - await options.Resolve(); - return !type.HasDependencyTypeSetViolations(options); + ResolvedTypeSet allowed = await options.Resolve(); + return !type.HasDependencyTypeSetViolations(allowed); }, - () => $"which depend only on {options.Describe()} "))); + // The parentheses delimit the target description (which ends in the target's source scope, + // e.g. "in all loaded assemblies") from the subject collection's own source suffix. + () => $"which depend only on ({options.Describe()}) "))); } diff --git a/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs b/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs index eeef36f9..dd74f308 100644 --- a/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs +++ b/Source/aweXpect.Reflection/Helpers/TypeHelpers.cs @@ -773,16 +773,24 @@ internal static bool MatchesType(Type dependency, Type target) { target = StripElementTypes(target); - if (dependency == target) - { - return true; - } - - return target.IsGenericTypeDefinition && - dependency.IsGenericType && - dependency.GetGenericTypeDefinition() == target; + return dependency == target || + dependency.GetGenericTypeDefinitionOfConstruction() == target; } + /// + /// Returns the generic type definition (e.g. List<>) when the + /// is a constructed generic type (e.g. List<Foo>), otherwise . + /// + /// + /// The single encoding of the rule that a dependency on any construction also matches its generic type + /// definition; shared between and , so + /// that the specific-type and the type-set matchers cannot drift apart. + /// + internal static Type? GetGenericTypeDefinitionOfConstruction(this Type dependency) + => dependency is { IsGenericType: true, IsGenericTypeDefinition: false, } + ? dependency.GetGenericTypeDefinition() + : null; + /// /// Strips array/by-ref/pointer wrappers from the , returning the innermost /// element type. @@ -959,16 +967,14 @@ private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNames /// set is a concrete set of types, so the violations are reported as formatted type names instead of /// namespaces. Distinct violators sharing a formatted name (the same simple name in different namespaces) /// are qualified by their namespace, so they do not collapse into one indistinguishable entry. - /// Requires to have been awaited before. /// internal static IReadOnlyList GetDependencyTypeSetViolations( - this Type type, TypeSetDependencyOptions allowed) + this Type type, ResolvedTypeSet allowed) { List violations = []; foreach (IGrouping sameName in type.GetDependencyViolations( (dependency, ownNamespace, excludedPrefixes) => IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)) - .Distinct() .GroupBy(dependency => Formatter.Format(dependency), StringComparer.Ordinal)) { Type[] violators = sameName.ToArray(); @@ -996,14 +1002,14 @@ internal static IReadOnlyList GetDependencyTypeSetViolations( /// Same rules as , for callers (like filters) that only need /// a verdict and not the violation list. /// - internal static bool HasDependencyTypeSetViolations(this Type type, TypeSetDependencyOptions allowed) + internal static bool HasDependencyTypeSetViolations(this Type type, ResolvedTypeSet allowed) => type.GetDependencyViolations( (dependency, ownNamespace, excludedPrefixes) => IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)).Any(); private static bool IsDependencyTypeSetViolation( - Type dependency, string? ownNamespace, TypeSetDependencyOptions allowed, string[] excludedPrefixes) - => !IsExemptDependency(dependency, ownNamespace, true, excludedPrefixes) && + Type dependency, string? ownNamespace, ResolvedTypeSet allowed, string[] excludedPrefixes) + => !IsExemptDependency(dependency, ownNamespace, allowed.IncludeOwnSubNamespaces, excludedPrefixes) && !allowed.Matches(dependency); /// diff --git a/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs b/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs index 8827cd48..88108149 100644 --- a/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs +++ b/Source/aweXpect.Reflection/Options/TypeSetDependencyOptions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using aweXpect.Reflection.Collections; using aweXpect.Reflection.Helpers; @@ -13,16 +14,17 @@ namespace aweXpect.Reflection.Options; /// /// /// The instance is shared between the chainable result and the underlying constraint/filter, so that -/// OrOn(…) widens the (lazily evaluated) expression. +/// OrOn(…) and ExcludingOwnSubNamespaces(…) widen or refine the (lazily evaluated) expression. /// /// The target collections are resolved once per assertion via (the union of all -/// collections), so the resolved set can be reused across the subject's items. and -/// require to have been awaited before. +/// collections); matching happens on the returned , so it cannot be used +/// before the resolution completed. /// internal sealed class TypeSetDependencyOptions { private readonly List _targets = []; - private HashSet? _resolved; + private bool _excludeOwnSubNamespaces; + private ResolvedTypeSet? _resolved; public TypeSetDependencyOptions(Filtered.Types target, Filtered.Types[] additional) { @@ -37,14 +39,26 @@ public TypeSetDependencyOptions(Filtered.Types target, Filtered.Types[] addition Add(additional, nameof(additional)); } - private TypeSetDependencyOptions(IEnumerable targets) - => _targets.AddRange(targets); + private TypeSetDependencyOptions(IEnumerable targets, bool excludeOwnSubNamespaces) + { + _targets.AddRange(targets); + _excludeOwnSubNamespaces = excludeOwnSubNamespaces; + } + + /// + /// Indicates whether sub-namespaces of the type's own namespace are still allowed (for depends-only-on). + /// + /// + /// The type's own namespace is always allowed; its sub-namespaces stay allowed unless the caller opted into + /// . + /// + public bool IncludeOwnSubNamespaces => !_excludeOwnSubNamespaces; /// /// Creates an independent copy, so that refining a (reusable) filter does not mutate the shared instance. /// public TypeSetDependencyOptions Copy() - => new(_targets); + => new(_targets, _excludeOwnSubNamespaces); /// /// Widens the set of targeted/allowed types by the given . @@ -64,6 +78,15 @@ public void OrOn(Filtered.Types[] targets) Add(targets, nameof(targets)); } + /// + /// Excludes sub-namespaces of the type's own namespace from being implicitly allowed (for depends-only-on). + /// + /// + /// Only the exemption rule changes, not the resolved target set, so a previously resolved set stays valid. + /// + public void ExcludingOwnSubNamespaces() + => _excludeOwnSubNamespaces = true; + private void Add(Filtered.Types[] targets, string paramName) { // Fully validate before mutating, so that a failed widening leaves the shared instance untouched. @@ -78,8 +101,8 @@ private void Add(Filtered.Types[] targets, string paramName) } /// - /// Resolves the target collections once into their union set; subsequent / - /// calls use this set. + /// Resolves the target collections once into their union set and returns it as a + /// , which performs all matching. /// /// /// Each member is normalized via : dependencies are stored @@ -87,26 +110,27 @@ private void Add(Filtered.Types[] targets, string paramName) /// (mirroring in the specific-type overloads). /// #if NET8_0_OR_GREATER - public async ValueTask Resolve() + public async ValueTask Resolve(CancellationToken cancellationToken = default) { if (_resolved is not null) { - return; + return _resolved; } HashSet resolved = []; foreach (Filtered.Types target in _targets) { - await foreach (Type type in target) + await foreach (Type type in target.WithCancellation(cancellationToken)) { resolved.Add(TypeHelpers.StripElementTypes(type)); } } - _resolved = resolved; + _resolved = new ResolvedTypeSet(resolved, this); + return _resolved; } #else - public Task Resolve() + public Task Resolve(CancellationToken cancellationToken = default) { if (_resolved is null) { @@ -115,17 +139,55 @@ public Task Resolve() { foreach (Type type in target) { + cancellationToken.ThrowIfCancellationRequested(); resolved.Add(TypeHelpers.StripElementTypes(type)); } } - _resolved = resolved; + _resolved = new ResolvedTypeSet(resolved, this); } - return Task.CompletedTask; + return Task.FromResult(_resolved); } #endif + /// + /// Describes the configured target collections for an expectation message. + /// + /// + /// The constructor guarantees at least one target, so an empty set cannot occur here. + /// + public string Describe() + => string.Join(" or ", _targets.Select(target => target.GetDescription().TrimEnd())); +} + +/// +/// The resolved union set of the target collections of a , returned +/// by . +/// +/// +/// All matching goes through this type, so matching against an unresolved target set is unrepresentable. +/// +internal sealed class ResolvedTypeSet +{ + private readonly TypeSetDependencyOptions _options; + private readonly HashSet _resolved; + + internal ResolvedTypeSet(HashSet resolved, TypeSetDependencyOptions options) + { + _resolved = resolved; + _options = options; + } + + /// + /// Indicates whether sub-namespaces of the type's own namespace are still allowed (for depends-only-on). + /// + /// + /// Delegates to the owning options, so that a later refinement is honored even when the set was + /// already resolved (the exemption rule is independent of the resolved set). + /// + public bool IncludeOwnSubNamespaces => _options.IncludeOwnSubNamespaces; + /// /// Checks whether the is a member of the resolved target set. /// @@ -133,30 +195,17 @@ public Task Resolve() /// Membership is by identity. Because dependencies keep constructed generic types as /// written (e.g. List<Foo>) while a scanned target collection contains the generic type /// definition (List<>), a dependency on any construction additionally matches when its - /// definition is a member of the set — mirroring how a generic type definition target matches any - /// construction in the specific-type overloads. + /// definition is a member of the set (the same rule as in the specific-type overloads, shared via + /// ). /// public bool Matches(Type dependency) - { - HashSet resolved = _resolved - ?? throw new InvalidOperationException("Resolve must be awaited before Matches."); - return resolved.Contains(dependency) || - (dependency.IsGenericType && !dependency.IsGenericTypeDefinition && - resolved.Contains(dependency.GetGenericTypeDefinition())); - } + => _resolved.Contains(dependency) || + (dependency.GetGenericTypeDefinitionOfConstruction() is { } definition && + _resolved.Contains(definition)); /// /// Checks whether the has at least one dependency in the resolved target set. /// public bool IsMatchedBy(Type type) => type.ResolveDependencies().Any(Matches); - - /// - /// Describes the configured target collections for an expectation message. - /// - /// - /// The constructor guarantees at least one target, so an empty set cannot occur here. - /// - public string Describe() - => string.Join(" or ", _targets.Select(target => target.GetDescription().TrimEnd())); } diff --git a/Source/aweXpect.Reflection/Results/TypeSetDependencyOnlyOnResult.cs b/Source/aweXpect.Reflection/Results/TypeSetDependencyOnlyOnResult.cs new file mode 100644 index 00000000..a260919c --- /dev/null +++ b/Source/aweXpect.Reflection/Results/TypeSetDependencyOnlyOnResult.cs @@ -0,0 +1,47 @@ +using aweXpect.Core; +using aweXpect.Reflection.Collections; +using aweXpect.Reflection.Options; +using aweXpect.Results; + +namespace aweXpect.Reflection.Results; + +/// +/// The result of a depends-only-on assertion whose allowed targets are filtered collections of types, +/// allowing to widen the allowed collections and to opt out of the implicit allowance of the type's own +/// sub-namespaces. +/// +public sealed class TypeSetDependencyOnlyOnResult + : AndOrResult> +{ + private readonly TypeSetDependencyOptions _options; + + internal TypeSetDependencyOnlyOnResult( + ExpectationBuilder expectationBuilder, + IThat subject, + TypeSetDependencyOptions options) + : base(expectationBuilder, subject) + => _options = options; + + /// + /// Widens the expression by the given . + /// + public TypeSetDependencyOnlyOnResult OrOn(params Filtered.Types[] targets) + { + _options.OrOn(targets); + return this; + } + + /// + /// Excludes sub-namespaces of the type's own namespace from being implicitly allowed (so a Foo type + /// referencing Foo.Bar becomes a violation unless Foo.Bar types are part of an allowed + /// collection). + /// + /// + /// The type's own namespace itself is always allowed. + /// + public TypeSetDependencyOnlyOnResult ExcludingOwnSubNamespaces() + { + _options.ExcludingOwnSubNamespaces(); + return this; + } +} diff --git a/Source/aweXpect.Reflection/ThatType.DependsOn.cs b/Source/aweXpect.Reflection/ThatType.DependsOn.cs index ca1b00e0..43680c17 100644 --- a/Source/aweXpect.Reflection/ThatType.DependsOn.cs +++ b/Source/aweXpect.Reflection/ThatType.DependsOn.cs @@ -235,6 +235,7 @@ private sealed class DependsOnTypeSetConstraint( IAsyncConstraint { private Type[] _dependencies = []; + private ResolvedTypeSet? _targetSet; public async Task IsMetBy(Type? actual, CancellationToken cancellationToken) { @@ -245,9 +246,10 @@ public async Task IsMetBy(Type? actual, CancellationToken canc return this; } - await options.Resolve(); + ResolvedTypeSet targetSet = await options.Resolve(cancellationToken); + _targetSet = targetSet; _dependencies = actual.ResolveDependencies(); - Outcome = _dependencies.Any(options.Matches) ? Outcome.Success : Outcome.Failure; + Outcome = _dependencies.Any(targetSet.Matches) ? Outcome.Success : Outcome.Failure; return this; } @@ -263,9 +265,10 @@ protected override void AppendNegatedExpectation(StringBuilder stringBuilder, st protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null) { // The sorted matching types are only needed for this failure message, so they are built lazily. + // _targetSet can only be null when the subject was null, in which case _dependencies is empty. stringBuilder.Append(It).Append(" depended on "); Formatter.Format(stringBuilder, _dependencies - .Where(options.Matches) + .Where(dependency => _targetSet?.Matches(dependency) == true) .Distinct() .OrderBy(type => type.FullName ?? type.Name, StringComparer.Ordinal) .ToArray()); diff --git a/Source/aweXpect.Reflection/ThatType.DependsOnlyOn.cs b/Source/aweXpect.Reflection/ThatType.DependsOnlyOn.cs index 0e271652..566b72df 100644 --- a/Source/aweXpect.Reflection/ThatType.DependsOnlyOn.cs +++ b/Source/aweXpect.Reflection/ThatType.DependsOnlyOn.cs @@ -47,7 +47,9 @@ public static partial class ThatType /// /// The target collections are resolved once per assertion; a dependency is allowed when it is a member of the /// union of the resolved collections (by identity; a generic type definition in a - /// collection matches any construction of it). + /// collection matches any construction of it). The type's own namespace is always allowed, including its + /// sub-namespaces unless + /// is used. /// /// Dependencies on types whose assembly name matches one of the /// at a @@ -57,11 +59,11 @@ public static partial class ThatType /// Microsoft, so e.g. Microsoft.EntityFrameworkCore is also ignored; forbid such a dependency /// explicitly via DoesNotDependOn or customize the prefixes. /// - public static TypeSetDependencyResult DependsOnlyOn( + public static TypeSetDependencyOnlyOnResult DependsOnlyOn( this IThat subject, Filtered.Types target, params Filtered.Types[] additional) { TypeSetDependencyOptions options = new(target, additional); - return new TypeSetDependencyResult(subject.Get().ExpectationBuilder + return new TypeSetDependencyOnlyOnResult(subject.Get().ExpectationBuilder .AddConstraint((it, grammars) => new DependsOnlyOnTypeSetConstraint(it, grammars, options)), subject, @@ -125,8 +127,8 @@ public async Task IsMetBy(Type? actual, CancellationToken canc return this; } - await options.Resolve(); - _violations = actual.GetDependencyTypeSetViolations(options); + ResolvedTypeSet allowed = await options.Resolve(cancellationToken); + _violations = actual.GetDependencyTypeSetViolations(allowed); Outcome = _violations.Count == 0 ? Outcome.Success : Outcome.Failure; return this; } diff --git a/Source/aweXpect.Reflection/ThatTypes.DependOn.cs b/Source/aweXpect.Reflection/ThatTypes.DependOn.cs index 42b6d44c..b244251f 100644 --- a/Source/aweXpect.Reflection/ThatTypes.DependOn.cs +++ b/Source/aweXpect.Reflection/ThatTypes.DependOn.cs @@ -48,38 +48,6 @@ public static partial class ThatTypes } #endif - /// - /// Verifies that all items in the filtered collection of do not depend on (do not reference - /// in their signature) any type in one of the (including sub-namespaces). - /// - public static NamespaceDependencyResult> DoNotDependOn( - this IThat> subject, params IEnumerable namespaces) - { - NamespaceDependencyOptions options = new(namespaces); - return new NamespaceDependencyResult>(subject.Get().ExpectationBuilder - .AddConstraint>((it, grammars) - => new DoNotDependOnConstraint(it, grammars, options)), - subject, - options); - } - -#if NET8_0_OR_GREATER - /// - /// Verifies that all items in the filtered collection of do not depend on (do not reference - /// in their signature) any type in one of the (including sub-namespaces). - /// - public static NamespaceDependencyResult> DoNotDependOn( - this IThat> subject, params IEnumerable namespaces) - { - NamespaceDependencyOptions options = new(namespaces); - return new NamespaceDependencyResult>(subject.Get().ExpectationBuilder - .AddConstraint>((it, grammars) - => new DoNotDependOnConstraint(it, grammars, options)), - subject, - options); - } -#endif - /// /// Verifies that all items in the filtered collection of depend on (reference in their /// signature) at least one type in the filtered collections of types or @@ -124,6 +92,38 @@ public static partial class ThatTypes } #endif + /// + /// Verifies that all items in the filtered collection of do not depend on (do not reference + /// in their signature) any type in one of the (including sub-namespaces). + /// + public static NamespaceDependencyResult> DoNotDependOn( + this IThat> subject, params IEnumerable namespaces) + { + NamespaceDependencyOptions options = new(namespaces); + return new NamespaceDependencyResult>(subject.Get().ExpectationBuilder + .AddConstraint>((it, grammars) + => new DoNotDependOnConstraint(it, grammars, options)), + subject, + options); + } + +#if NET8_0_OR_GREATER + /// + /// Verifies that all items in the filtered collection of do not depend on (do not reference + /// in their signature) any type in one of the (including sub-namespaces). + /// + public static NamespaceDependencyResult> DoNotDependOn( + this IThat> subject, params IEnumerable namespaces) + { + NamespaceDependencyOptions options = new(namespaces); + return new NamespaceDependencyResult>(subject.Get().ExpectationBuilder + .AddConstraint>((it, grammars) + => new DoNotDependOnConstraint(it, grammars, options)), + subject, + options); + } +#endif + /// /// Verifies that all items in the filtered collection of do not depend on (do not /// reference in their signature) any type in the filtered collections of types or @@ -176,13 +176,13 @@ private static bool DependsOnNamespace(Type? type, NamespaceDependencyOptions op private static bool DoesNotDependOnNamespace(Type? type, NamespaceDependencyOptions options) => type is not null && !options.IsMatchedBy(type); - private static bool DependsOnTypeSet(Type? type, TypeSetDependencyOptions options) - => type is not null && options.IsMatchedBy(type); + private static bool DependsOnTypeSet(Type? type, ResolvedTypeSet targetSet) + => type is not null && targetSet.IsMatchedBy(type); // A null item's dependencies cannot be verified, so it fails the negative assertion just like the // positive one (and like DependOnlyOn), instead of slipping through as "does not depend". - private static bool DoesNotDependOnTypeSet(Type? type, TypeSetDependencyOptions options) - => type is not null && !options.IsMatchedBy(type); + private static bool DoesNotDependOnTypeSet(Type? type, ResolvedTypeSet targetSet) + => type is not null && !targetSet.IsMatchedBy(type); private sealed class DependOnConstraint( string it, @@ -271,15 +271,15 @@ private sealed class DependOnTypeSetConstraint( #if NET8_0_OR_GREATER public async Task IsMetBy(IAsyncEnumerable actual, CancellationToken cancellationToken) { - await options.Resolve(); - return await SetAsyncValue(actual, type => DependsOnTypeSet(type, options)); + ResolvedTypeSet targetSet = await options.Resolve(cancellationToken); + return await SetAsyncValue(actual, type => DependsOnTypeSet(type, targetSet)); } #endif public async Task IsMetBy(IEnumerable actual, CancellationToken cancellationToken) { - await options.Resolve(); - return SetValue(actual, type => DependsOnTypeSet(type, options)); + ResolvedTypeSet targetSet = await options.Resolve(cancellationToken); + return SetValue(actual, type => DependsOnTypeSet(type, targetSet)); } protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) @@ -314,15 +314,15 @@ private sealed class DoNotDependOnTypeSetConstraint( #if NET8_0_OR_GREATER public async Task IsMetBy(IAsyncEnumerable actual, CancellationToken cancellationToken) { - await options.Resolve(); - return await SetAsyncValue(actual, type => DoesNotDependOnTypeSet(type, options)); + ResolvedTypeSet targetSet = await options.Resolve(cancellationToken); + return await SetAsyncValue(actual, type => DoesNotDependOnTypeSet(type, targetSet)); } #endif public async Task IsMetBy(IEnumerable actual, CancellationToken cancellationToken) { - await options.Resolve(); - return SetValue(actual, type => DoesNotDependOnTypeSet(type, options)); + ResolvedTypeSet targetSet = await options.Resolve(cancellationToken); + return SetValue(actual, type => DoesNotDependOnTypeSet(type, targetSet)); } protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) diff --git a/Source/aweXpect.Reflection/ThatTypes.DependOnlyOn.cs b/Source/aweXpect.Reflection/ThatTypes.DependOnlyOn.cs index 5a10f189..88f36a5c 100644 --- a/Source/aweXpect.Reflection/ThatTypes.DependOnlyOn.cs +++ b/Source/aweXpect.Reflection/ThatTypes.DependOnlyOn.cs @@ -77,7 +77,9 @@ public static partial class ThatTypes /// /// The target collections are resolved once per assertion; a dependency is allowed when it is a member of the /// union of the resolved collections (by identity; a generic type definition in a - /// collection matches any construction of it). + /// collection matches any construction of it). A type's own namespace is always allowed, including its + /// sub-namespaces unless + /// is used. /// /// Dependencies on types whose assembly name matches one of the /// at a @@ -87,11 +89,11 @@ public static partial class ThatTypes /// Microsoft, so e.g. Microsoft.EntityFrameworkCore is also ignored; forbid such a dependency /// explicitly via DoNotDependOn or customize the prefixes. /// - public static TypeSetDependencyResult> DependOnlyOn( + public static TypeSetDependencyOnlyOnResult> DependOnlyOn( this IThat> subject, Filtered.Types target, params Filtered.Types[] additional) { TypeSetDependencyOptions options = new(target, additional); - return new TypeSetDependencyResult>(subject.Get().ExpectationBuilder + return new TypeSetDependencyOnlyOnResult>(subject.Get().ExpectationBuilder .AddConstraint>((it, grammars) => new DependOnlyOnTypeSetConstraint(it, grammars, options)), subject, @@ -107,7 +109,9 @@ public static partial class ThatTypes /// /// The target collections are resolved once per assertion; a dependency is allowed when it is a member of the /// union of the resolved collections (by identity; a generic type definition in a - /// collection matches any construction of it). + /// collection matches any construction of it). A type's own namespace is always allowed, including its + /// sub-namespaces unless + /// is used. /// /// Dependencies on types whose assembly name matches one of the /// at a @@ -117,11 +121,11 @@ public static partial class ThatTypes /// Microsoft, so e.g. Microsoft.EntityFrameworkCore is also ignored; forbid such a dependency /// explicitly via DoNotDependOn or customize the prefixes. /// - public static TypeSetDependencyResult> DependOnlyOn( + public static TypeSetDependencyOnlyOnResult> DependOnlyOn( this IThat> subject, Filtered.Types target, params Filtered.Types[] additional) { TypeSetDependencyOptions options = new(target, additional); - return new TypeSetDependencyResult>(subject.Get().ExpectationBuilder + return new TypeSetDependencyOnlyOnResult>(subject.Get().ExpectationBuilder .AddConstraint>((it, grammars) => new DependOnlyOnTypeSetConstraint(it, grammars, options)), subject, @@ -194,14 +198,14 @@ private sealed class DependOnlyOnTypeSetConstraint( { private readonly Dictionary> _violations = new(); - private bool DependsOnlyOnAllowed(Type? type) + private bool DependsOnlyOnAllowed(Type? type, ResolvedTypeSet allowed) { if (type is null) { return false; } - IReadOnlyList violations = type.GetDependencyTypeSetViolations(options); + IReadOnlyList violations = type.GetDependencyTypeSetViolations(allowed); if (violations.Count > 0) { _violations[type] = violations; @@ -213,15 +217,15 @@ private bool DependsOnlyOnAllowed(Type? type) #if NET8_0_OR_GREATER public async Task IsMetBy(IAsyncEnumerable actual, CancellationToken cancellationToken) { - await options.Resolve(); - return await SetAsyncValue(actual, DependsOnlyOnAllowed); + ResolvedTypeSet allowed = await options.Resolve(cancellationToken); + return await SetAsyncValue(actual, type => DependsOnlyOnAllowed(type, allowed)); } #endif public async Task IsMetBy(IEnumerable actual, CancellationToken cancellationToken) { - await options.Resolve(); - return SetValue(actual, DependsOnlyOnAllowed); + ResolvedTypeSet allowed = await options.Resolve(cancellationToken); + return SetValue(actual, type => DependsOnlyOnAllowed(type, allowed)); } protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null) diff --git a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net10.0.txt b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net10.0.txt index 3031c9d6..99151c76 100644 --- a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net10.0.txt +++ b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net10.0.txt @@ -1622,7 +1622,7 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Type type) { } public static aweXpect.Reflection.Results.TypeSetDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } @@ -1809,8 +1809,8 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } - public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeSetDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } @@ -1954,7 +1954,7 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotHaveADefaultConstructor(this aweXpect.Reflection.Collections.Filtered.Types @this) { } @@ -2246,6 +2246,11 @@ namespace aweXpect.Reflection.Collections { public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } } + public sealed class TypeSetDependencyOnlyOnFilterResult : aweXpect.Reflection.Collections.Filtered.Types + { + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult ExcludingOwnSubNamespaces() { } + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } } public abstract class Filtered : System.Collections.Generic.IAsyncEnumerable @@ -2526,6 +2531,11 @@ namespace aweXpect.Reflection.Results public aweXpect.Reflection.Results.TypeDependencyResult OrOn(System.Type type) { } public aweXpect.Reflection.Results.TypeDependencyResult OrOn() { } } + public sealed class TypeSetDependencyOnlyOnResult : aweXpect.Results.AndOrResult> + { + public aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult ExcludingOwnSubNamespaces() { } + public aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } public sealed class TypeSetDependencyResult : aweXpect.Results.AndOrResult> { public aweXpect.Reflection.Results.TypeSetDependencyResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } diff --git a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net8.0.txt b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net8.0.txt index ec68297c..28c2442d 100644 --- a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net8.0.txt +++ b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_net8.0.txt @@ -1622,7 +1622,7 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Type type) { } public static aweXpect.Reflection.Results.TypeSetDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } @@ -1809,8 +1809,8 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } - public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeSetDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } @@ -1954,7 +1954,7 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotHaveADefaultConstructor(this aweXpect.Reflection.Collections.Filtered.Types @this) { } @@ -2246,6 +2246,11 @@ namespace aweXpect.Reflection.Collections { public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } } + public sealed class TypeSetDependencyOnlyOnFilterResult : aweXpect.Reflection.Collections.Filtered.Types + { + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult ExcludingOwnSubNamespaces() { } + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } } public abstract class Filtered : System.Collections.Generic.IAsyncEnumerable @@ -2526,6 +2531,11 @@ namespace aweXpect.Reflection.Results public aweXpect.Reflection.Results.TypeDependencyResult OrOn(System.Type type) { } public aweXpect.Reflection.Results.TypeDependencyResult OrOn() { } } + public sealed class TypeSetDependencyOnlyOnResult : aweXpect.Results.AndOrResult> + { + public aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult ExcludingOwnSubNamespaces() { } + public aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } public sealed class TypeSetDependencyResult : aweXpect.Results.AndOrResult> { public aweXpect.Reflection.Results.TypeSetDependencyResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } diff --git a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_netstandard2.0.txt b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_netstandard2.0.txt index 1e174c1a..0126d718 100644 --- a/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_netstandard2.0.txt +++ b/Tests/aweXpect.Reflection.Api.Tests/Expected/aweXpect.Reflection_netstandard2.0.txt @@ -1346,7 +1346,7 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.TypeDependencyResult DependsOn(this aweXpect.Core.IThat subject) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Results.TypeSetDependencyResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult DependsOnlyOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, System.Type type) { } public static aweXpect.Reflection.Results.TypeSetDependencyResult DoesNotDependOn(this aweXpect.Core.IThat subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } @@ -1481,7 +1481,7 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Results.NamespaceDependencyResult> DependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Results.TypeSetDependencyResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult> DependOnlyOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Results.NamespaceDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Results.TypeSetDependencyResult> DoNotDependOn(this aweXpect.Core.IThat> subject, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Results.AndOrResult, aweXpect.Core.IThat>> DoNotHave(this aweXpect.Core.IThat> subject, bool inherit = true) @@ -1590,7 +1590,7 @@ namespace aweXpect.Reflection public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } - public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } + public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult WhichDependOnlyOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, System.Collections.Generic.IEnumerable namespaces) { } public static aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn(this aweXpect.Reflection.Collections.Filtered.Types @this, aweXpect.Reflection.Collections.Filtered.Types target, params aweXpect.Reflection.Collections.Filtered.Types[] additional) { } public static aweXpect.Reflection.Collections.Filtered.Types WhichDoNotHaveADefaultConstructor(this aweXpect.Reflection.Collections.Filtered.Types @this) { } @@ -1882,6 +1882,11 @@ namespace aweXpect.Reflection.Collections { public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } } + public sealed class TypeSetDependencyOnlyOnFilterResult : aweXpect.Reflection.Collections.Filtered.Types + { + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult ExcludingOwnSubNamespaces() { } + public aweXpect.Reflection.Collections.Filtered.Types.TypeSetDependencyOnlyOnFilterResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } } } public abstract class Filtered : System.Collections.Generic.IEnumerable, System.Collections.IEnumerable @@ -2161,6 +2166,11 @@ namespace aweXpect.Reflection.Results public aweXpect.Reflection.Results.TypeDependencyResult OrOn(System.Type type) { } public aweXpect.Reflection.Results.TypeDependencyResult OrOn() { } } + public sealed class TypeSetDependencyOnlyOnResult : aweXpect.Results.AndOrResult> + { + public aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult ExcludingOwnSubNamespaces() { } + public aweXpect.Reflection.Results.TypeSetDependencyOnlyOnResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } + } public sealed class TypeSetDependencyResult : aweXpect.Results.AndOrResult> { public aweXpect.Reflection.Results.TypeSetDependencyResult OrOn(params aweXpect.Reflection.Collections.Filtered.Types[] targets) { } diff --git a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs index 901c1f7a..cd231c82 100644 --- a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs @@ -83,6 +83,20 @@ public async Task OrOn_ShouldNotAffectOriginalFilter() await That(original).DoesNotContain(typeof(ViaField)); } + + [Fact] + public async Task ShouldDelimitTargetDescriptionFromSourceSuffix() + { + // The parentheses keep the target's trailing source scope apart from the subject collection's + // own source suffix, so the two "in all loaded assemblies" do not run into each other. + Filtered.Types types = In.Namespace(ConsumersNamespace) + .WhichDependOn(In.Namespace(Layer1Namespace)); + + await That(types.GetDescription()).IsEqualTo( + $"types within namespace \"{ConsumersNamespace}\" which depend on " + + $"(types within namespace \"{Layer1Namespace}\" in all loaded assemblies) " + + "in all loaded assemblies"); + } } } } diff --git a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs index dae4cf8b..edebdc9e 100644 --- a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs @@ -47,6 +47,27 @@ public async Task WhenWidenedWithOrOn_ShouldAllowEither() await That(types).Contains(typeof(Layer1AndLayer2)); } + + [Fact] + public async Task WhenExcludingOwnSubNamespaces_ShouldFilterOutTypesReferencingOwnSubNamespace() + { + Filtered.Types types = In.Namespace(ConsumersNamespace) + .WhichDependOnlyOn(In.Namespace(Layer1Namespace)) + .ExcludingOwnSubNamespaces(); + + await That(types).Contains(typeof(OnlyLayer1)); + await That(types).DoesNotContain(typeof(ReferencesOwnSubNamespace)); + } + + [Fact] + public async Task ExcludingOwnSubNamespaces_ShouldNotAffectOriginalFilter() + { + Filtered.Types.TypeSetDependencyOnlyOnFilterResult original = In.Namespace(ConsumersNamespace) + .WhichDependOnlyOn(In.Namespace(Layer1Namespace)); + _ = original.ExcludingOwnSubNamespaces(); + + await That(original).Contains(typeof(ReferencesOwnSubNamespace)); + } } } } diff --git a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs index c9a8cd33..b7ae1c00 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs @@ -283,6 +283,48 @@ async Task Act() await That(Act).DoesNotThrow(); } + + [Fact] + public async Task WhenReferencingOwnSubNamespace_ShouldSucceedByDefault() + { + Type subject = typeof(ReferencesOwnSubNamespace); + + async Task Act() + => await That(subject).DependsOnlyOn(In.Namespace(Layer1Namespace)); + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenExcludingOwnSubNamespaces_OwnSubNamespaceBecomesViolation() + { + Type subject = typeof(ReferencesOwnSubNamespace); + + async Task Act() + => await That(subject).DependsOnlyOn(In.Namespace(Layer1Namespace)) + .ExcludingOwnSubNamespaces(); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + depends only on types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it also depended on ["OwnSubTarget"] + """); + } + + [Fact] + public async Task WhenExcludingOwnSubNamespaces_TargetCollectionCoveringTheSubNamespaceStillAllowsIt() + { + // The own sub-namespace is no longer implicitly allowed, but the target collection contains the + // referenced OwnSub type, which covers it explicitly. + Type subject = typeof(ReferencesOwnSubNamespace); + + async Task Act() + => await That(subject).DependsOnlyOn(In.Namespace(ConsumersNamespace)) + .ExcludingOwnSubNamespaces(); + + await That(Act).DoesNotThrow(); + } } public sealed class NegatedTests diff --git a/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs index 556a6b3a..f9872126 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs @@ -159,6 +159,29 @@ async Task Act() await That(Act).DoesNotThrow(); } + + [Fact] + public async Task WhenExcludingOwnSubNamespaces_OwnSubNamespaceBecomesViolation() + { + IEnumerable subject = + [ + typeof(OnlyLayer1), + typeof(ReferencesOwnSubNamespace), + ]; + + async Task Act() + => await That(subject).DependOnlyOn(In.Namespace(Layer1Namespace)) + .ExcludingOwnSubNamespaces(); + + await That(Act).Throws() + .WithMessage($""" + Expected that subject + all depend only on types within namespace "{Layer1Namespace}" in all loaded assemblies, + but it contained types with disallowed dependencies [ + ReferencesOwnSubNamespace depends on ["OwnSubTarget"] + ] + """); + } } } } From 8404108aad5de86f47961471f4b0301b31c7e236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 6 Jun 2026 04:20:52 +0200 Subject: [PATCH 4/7] Rename 'Type dependencies and architecture rules' to 'Architecture rules' Updated section headers for clarity and consistency. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 296f8f64..8530b2f4 100644 --- a/README.md +++ b/README.md @@ -678,7 +678,7 @@ await Expect.That(subject).HasVersion().WithMajor.GreaterThanOrEqualTo(2); await Expect.That(subjects).HaveVersion().WithMajor.EqualTo(1); ``` -## Type dependencies and architecture rules +## Architecture rules Layering and architecture rules are expressed over the types a type references **in its signature**: [Type dependencies](#type-dependencies) covers the dependency filters and assertions (including From 85c51e02a5dae8eebfdd1943402d68a7182e3403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 6 Jun 2026 04:31:31 +0200 Subject: [PATCH 5/7] fix: migrate remaining In.Namespace usages to Types.InNamespace after rebase --- .../Filters/TypeFilters.WhichDependOn.Tests.cs | 4 ++-- .../Filters/TypeFilters.WhichDependOnlyOn.Tests.cs | 8 ++++---- .../ThatType.DependsOnlyOn.Tests.cs | 8 ++++---- .../ThatTypes.DependOnlyOn.Tests.cs | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs index cd231c82..e6d2bf4a 100644 --- a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOn.Tests.cs @@ -89,8 +89,8 @@ public async Task ShouldDelimitTargetDescriptionFromSourceSuffix() { // The parentheses keep the target's trailing source scope apart from the subject collection's // own source suffix, so the two "in all loaded assemblies" do not run into each other. - Filtered.Types types = In.Namespace(ConsumersNamespace) - .WhichDependOn(In.Namespace(Layer1Namespace)); + Filtered.Types types = Types.InNamespace(ConsumersNamespace) + .WhichDependOn(Types.InNamespace(Layer1Namespace)); await That(types.GetDescription()).IsEqualTo( $"types within namespace \"{ConsumersNamespace}\" which depend on " + diff --git a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs index edebdc9e..8cb47748 100644 --- a/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/Filters/TypeFilters.WhichDependOnlyOn.Tests.cs @@ -51,8 +51,8 @@ public async Task WhenWidenedWithOrOn_ShouldAllowEither() [Fact] public async Task WhenExcludingOwnSubNamespaces_ShouldFilterOutTypesReferencingOwnSubNamespace() { - Filtered.Types types = In.Namespace(ConsumersNamespace) - .WhichDependOnlyOn(In.Namespace(Layer1Namespace)) + Filtered.Types types = Types.InNamespace(ConsumersNamespace) + .WhichDependOnlyOn(Types.InNamespace(Layer1Namespace)) .ExcludingOwnSubNamespaces(); await That(types).Contains(typeof(OnlyLayer1)); @@ -62,8 +62,8 @@ public async Task WhenExcludingOwnSubNamespaces_ShouldFilterOutTypesReferencingO [Fact] public async Task ExcludingOwnSubNamespaces_ShouldNotAffectOriginalFilter() { - Filtered.Types.TypeSetDependencyOnlyOnFilterResult original = In.Namespace(ConsumersNamespace) - .WhichDependOnlyOn(In.Namespace(Layer1Namespace)); + Filtered.Types.TypeSetDependencyOnlyOnFilterResult original = Types.InNamespace(ConsumersNamespace) + .WhichDependOnlyOn(Types.InNamespace(Layer1Namespace)); _ = original.ExcludingOwnSubNamespaces(); await That(original).Contains(typeof(ReferencesOwnSubNamespace)); diff --git a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs index b7ae1c00..afe1f6f1 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatType.DependsOnlyOn.Tests.cs @@ -233,7 +233,7 @@ public async Task WhenDistinctViolatorsShareTheSimpleName_ShouldQualifyThemByNam Type subject = typeof(WithSameNamedDependencies); async Task Act() - => await That(subject).DependsOnlyOn(In.Namespace(Layer1Namespace)); + => await That(subject).DependsOnlyOn(Types.InNamespace(Layer1Namespace)); await That(Act).Throws() .WithMessage("*AmbiguousA.AmbiguousTarget*AmbiguousB.AmbiguousTarget*").AsWildcard(); @@ -290,7 +290,7 @@ public async Task WhenReferencingOwnSubNamespace_ShouldSucceedByDefault() Type subject = typeof(ReferencesOwnSubNamespace); async Task Act() - => await That(subject).DependsOnlyOn(In.Namespace(Layer1Namespace)); + => await That(subject).DependsOnlyOn(Types.InNamespace(Layer1Namespace)); await That(Act).DoesNotThrow(); } @@ -301,7 +301,7 @@ public async Task WhenExcludingOwnSubNamespaces_OwnSubNamespaceBecomesViolation( Type subject = typeof(ReferencesOwnSubNamespace); async Task Act() - => await That(subject).DependsOnlyOn(In.Namespace(Layer1Namespace)) + => await That(subject).DependsOnlyOn(Types.InNamespace(Layer1Namespace)) .ExcludingOwnSubNamespaces(); await That(Act).Throws() @@ -320,7 +320,7 @@ public async Task WhenExcludingOwnSubNamespaces_TargetCollectionCoveringTheSubNa Type subject = typeof(ReferencesOwnSubNamespace); async Task Act() - => await That(subject).DependsOnlyOn(In.Namespace(ConsumersNamespace)) + => await That(subject).DependsOnlyOn(Types.InNamespace(ConsumersNamespace)) .ExcludingOwnSubNamespaces(); await That(Act).DoesNotThrow(); diff --git a/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs b/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs index f9872126..7c176340 100644 --- a/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs +++ b/Tests/aweXpect.Reflection.Tests/ThatTypes.DependOnlyOn.Tests.cs @@ -170,7 +170,7 @@ public async Task WhenExcludingOwnSubNamespaces_OwnSubNamespaceBecomesViolation( ]; async Task Act() - => await That(subject).DependOnlyOn(In.Namespace(Layer1Namespace)) + => await That(subject).DependOnlyOn(Types.InNamespace(Layer1Namespace)) .ExcludingOwnSubNamespaces(); await That(Act).Throws() From 2a9f256801f55933bc688866a9ae20a6a3303907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 6 Jun 2026 04:31:31 +0200 Subject: [PATCH 6/7] docs: move architecture-rules section directly before configuration --- README.md | 136 +++++++++++++++++++++++++++--------------------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 8530b2f4..78026bee 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# aweXpect.Reflection +# aweXpect.Reflection [![Nuget](https://img.shields.io/nuget/v/aweXpect.Reflection)](https://www.nuget.org/packages/aweXpect.Reflection) [![Build](https://github.com/Testably/aweXpect.Reflection/actions/workflows/build.yml/badge.svg)](https://github.com/Testably/aweXpect.Reflection/actions/workflows/build.yml) @@ -678,6 +678,73 @@ await Expect.That(subject).HasVersion().WithMajor.GreaterThanOrEqualTo(2); await Expect.That(subjects).HaveVersion().WithMajor.EqualTo(1); ``` +## Combining filters + +Filters chain naturally (each narrows the previous result). Several filters offer an `Or…` companion to +widen a single step: + +```csharp +// Any of several attributes +In.AllLoadedAssemblies().Methods() + .With().OrWith() + +// Any of several return types +In.AllLoadedAssemblies().Methods() + .WhichReturn().OrReturn() + +// Any of several property/field types +In.AllLoadedAssemblies().Properties() + .OfType().OrOfType() +``` + +## String matching options + +Every name and namespace filter/assertion uses the same string matching options as the core aweXpect +library (see [the docs](https://docs.testably.org/aweXpect/common-types/string#equality)): + +| Option | Effect | +|------------------------------------------------------------------|---------------------------------------------------| +| *(none)* | exact match (default) | +| `.AsPrefix()` | the value must start with the expected string | +| `.AsSuffix()` | the value must end with the expected string | +| `.AsWildcard()` | match using `*` and `?` wildcards | +| `.AsRegex()` | match using a regular expression | +| `.IgnoringCase()` | case-insensitive comparison | +| `.IgnoringLeadingWhiteSpace()` / `.IgnoringTrailingWhiteSpace()` | trim before comparing | +| `.Using(comparer)` | compare with a custom `IEqualityComparer` | + +```csharp +await Expect.That(types).HaveName("Service").AsSuffix(); +await Expect.That(types).HaveName("*Test*").AsWildcard(); +await Expect.That(types).HaveName(@"^Test\w+$").AsRegex(); +await Expect.That(methods).HaveName("Get*Async").AsWildcard().IgnoringCase(); +``` + +## Collections and quantifiers + +Every expectation works with both a single item and a collection. A collection can be an array, +any `IEnumerable` or, on .NET 8 and later, an `IAsyncEnumerable`. The plural assertions already +require **every** item to match; for ad-hoc predicates use aweXpect's `Satisfies(…)` (single subject) and +`All()` / `Any()` quantifiers with `Satisfy(…)` (collections), and combine selections with LINQ: + +```csharp +// The plural assertion already means "every item": +await Expect.That(types).ArePublic(); + +// Ad-hoc predicate on a single subject: +await Expect.That(type).Satisfies(type => type.IsSealed); + +// Ad-hoc predicate across the whole collection: +await Expect.That(types).All().Satisfy(type => type.IsSealed); +await Expect.That(types).Any().Satisfy(type => type.IsAbstract); + +// Mix with LINQ (assign to IEnumerable so Where binds to LINQ): +IEnumerable publicClasses = In.AllLoadedAssemblies().Types() + .WhichAreClasses().WhichArePublic(); +var managers = publicClasses.Where(type => type!.GetInterfaces().Length > 2); +await Expect.That(managers).HaveName("Manager").AsSuffix(); +``` + ## Architecture rules Layering and architecture rules are expressed over the types a type references **in its signature**: @@ -904,73 +971,6 @@ A layer spanning several namespaces is built by widening a dependency *target* w (or `.OrOn(…)`); for a *subject* spanning several namespaces, assert each namespace selection as its own rule inside the same `Expect.ThatAll(…)`. -## Combining filters - -Filters chain naturally (each narrows the previous result). Several filters offer an `Or…` companion to -widen a single step: - -```csharp -// Any of several attributes -In.AllLoadedAssemblies().Methods() - .With().OrWith() - -// Any of several return types -In.AllLoadedAssemblies().Methods() - .WhichReturn().OrReturn() - -// Any of several property/field types -In.AllLoadedAssemblies().Properties() - .OfType().OrOfType() -``` - -## String matching options - -Every name and namespace filter/assertion uses the same string matching options as the core aweXpect -library (see [the docs](https://docs.testably.org/aweXpect/common-types/string#equality)): - -| Option | Effect | -|------------------------------------------------------------------|---------------------------------------------------| -| *(none)* | exact match (default) | -| `.AsPrefix()` | the value must start with the expected string | -| `.AsSuffix()` | the value must end with the expected string | -| `.AsWildcard()` | match using `*` and `?` wildcards | -| `.AsRegex()` | match using a regular expression | -| `.IgnoringCase()` | case-insensitive comparison | -| `.IgnoringLeadingWhiteSpace()` / `.IgnoringTrailingWhiteSpace()` | trim before comparing | -| `.Using(comparer)` | compare with a custom `IEqualityComparer` | - -```csharp -await Expect.That(types).HaveName("Service").AsSuffix(); -await Expect.That(types).HaveName("*Test*").AsWildcard(); -await Expect.That(types).HaveName(@"^Test\w+$").AsRegex(); -await Expect.That(methods).HaveName("Get*Async").AsWildcard().IgnoringCase(); -``` - -## Collections and quantifiers - -Every expectation works with both a single item and a collection. A collection can be an array, -any `IEnumerable` or, on .NET 8 and later, an `IAsyncEnumerable`. The plural assertions already -require **every** item to match; for ad-hoc predicates use aweXpect's `Satisfies(…)` (single subject) and -`All()` / `Any()` quantifiers with `Satisfy(…)` (collections), and combine selections with LINQ: - -```csharp -// The plural assertion already means "every item": -await Expect.That(types).ArePublic(); - -// Ad-hoc predicate on a single subject: -await Expect.That(type).Satisfies(type => type.IsSealed); - -// Ad-hoc predicate across the whole collection: -await Expect.That(types).All().Satisfy(type => type.IsSealed); -await Expect.That(types).Any().Satisfy(type => type.IsAbstract); - -// Mix with LINQ (assign to IEnumerable so Where binds to LINQ): -IEnumerable publicClasses = In.AllLoadedAssemblies().Types() - .WhichAreClasses().WhichArePublic(); -var managers = publicClasses.Where(type => type!.GetInterfaces().Length > 2); -await Expect.That(managers).HaveName("Manager").AsSuffix(); -``` - ## Configuration ### Assembly exclusions From 6aa214beced8a29dce7bb58a82bdce41a146b0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 6 Jun 2026 04:36:31 +0200 Subject: [PATCH 7/7] docs: rename nested architecture-rules section to avoid duplicate headline --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 78026bee..4d4d14b1 100644 --- a/README.md +++ b/README.md @@ -749,8 +749,8 @@ await Expect.That(managers).HaveName("Manager").AsSuffix(); Layering and architecture rules are expressed over the types a type references **in its signature**: [Type dependencies](#type-dependencies) covers the dependency filters and assertions (including -[dependency cycles](#dependency-cycles)), and [Architecture rules](#architecture-rules) shows how to -combine them with reusable type selections into a full architecture test suite. +[dependency cycles](#dependency-cycles)), and [Layers as type selections](#layers-as-type-selections) shows +how to combine them with reusable type selections into a full architecture test suite. ### Type dependencies @@ -832,7 +832,7 @@ await Expect.That(typeof(MyDomainType)).DoesNotDependOn().OrOn **Framework dependencies are ignored unless you name one explicitly.** `DependOnlyOn` ignores dependencies > whose assembly name matches one of the @@ -896,7 +896,7 @@ Because the edges come from the same dependency resolution as the other dependen [custom dependency resolver](#dependency-resolver) (e.g. an IL-level one) also sharpens cycle detection: body-level references it surfaces can complete a cycle that the signature-level default cannot see. -### Architecture rules +### Layers as type selections There is no separate rule engine: a "layer" is just a reusable `Filtered.Types` selection (with the full filter vocabulary at your disposal), and an architecture rule is just an expectation on it.