Skip to content

Commit 9a8b7c1

Browse files
authored
feat: accept Filtered.Types selections as dependency targets for architecture rules (#325)
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<string> 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.
1 parent cb41239 commit 9a8b7c1

26 files changed

Lines changed: 2175 additions & 180 deletions

README.md

Lines changed: 226 additions & 141 deletions
Large diffs are not rendered by default.

Source/aweXpect.Reflection/Collections/Filtered.Types.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,5 +543,90 @@ public Types InEntryAssembly()
543543
public Types InExecutingAssembly()
544544
=> In.ExecutingAssembly().Types().WithinNamespace(_namespace);
545545
}
546+
547+
/// <summary>
548+
/// A filtered collection of <see cref="System.Type" /> from a dependency filter whose targets are filtered
549+
/// collections of types, allowing to widen the targeted/allowed collections.
550+
/// </summary>
551+
/// <remarks>
552+
/// Like all filtered collections, this is an immutable value object: <see cref="OrOn" /> does not mutate
553+
/// this instance but rebuilds a fresh filter from the original base collection, so deriving multiple views
554+
/// from the same instance cannot corrupt each other.
555+
/// </remarks>
556+
public sealed class TypeSetDependencyFilterResult : Types
557+
{
558+
private readonly Func<TypeSetDependencyOptions, Types> _build;
559+
private readonly TypeSetDependencyOptions _options;
560+
561+
internal TypeSetDependencyFilterResult(
562+
TypeSetDependencyOptions options,
563+
Func<TypeSetDependencyOptions, Types> build)
564+
: base(build(options))
565+
{
566+
_options = options;
567+
_build = build;
568+
}
569+
570+
/// <summary>
571+
/// Widens the filter by the given <paramref name="targets" />.
572+
/// </summary>
573+
public TypeSetDependencyFilterResult OrOn(params Filtered.Types[] targets)
574+
{
575+
TypeSetDependencyOptions widened = _options.Copy();
576+
widened.OrOn(targets);
577+
return new TypeSetDependencyFilterResult(widened, _build);
578+
}
579+
}
580+
581+
/// <summary>
582+
/// A filtered collection of <see cref="System.Type" /> from a depends-only-on filter whose allowed
583+
/// targets are filtered collections of types, allowing to widen the allowed collections and to opt out
584+
/// of the implicit allowance of the type's own sub-namespaces.
585+
/// </summary>
586+
/// <remarks>
587+
/// Like all filtered collections, this is an immutable value object: <see cref="OrOn" /> and
588+
/// <see cref="ExcludingOwnSubNamespaces" /> do not mutate this instance but rebuild a fresh filter from
589+
/// the original base collection, so deriving multiple views from the same instance cannot corrupt each
590+
/// other.
591+
/// </remarks>
592+
public sealed class TypeSetDependencyOnlyOnFilterResult : Types
593+
{
594+
private readonly Func<TypeSetDependencyOptions, Types> _build;
595+
private readonly TypeSetDependencyOptions _options;
596+
597+
internal TypeSetDependencyOnlyOnFilterResult(
598+
TypeSetDependencyOptions options,
599+
Func<TypeSetDependencyOptions, Types> build)
600+
: base(build(options))
601+
{
602+
_options = options;
603+
_build = build;
604+
}
605+
606+
/// <summary>
607+
/// Widens the filter by the given <paramref name="targets" />.
608+
/// </summary>
609+
public TypeSetDependencyOnlyOnFilterResult OrOn(params Filtered.Types[] targets)
610+
{
611+
TypeSetDependencyOptions widened = _options.Copy();
612+
widened.OrOn(targets);
613+
return new TypeSetDependencyOnlyOnFilterResult(widened, _build);
614+
}
615+
616+
/// <summary>
617+
/// Excludes sub-namespaces of the type's own namespace from being implicitly allowed (so a <c>Foo</c>
618+
/// type referencing <c>Foo.Bar</c> is filtered out unless <c>Foo.Bar</c> types are part of an allowed
619+
/// collection).
620+
/// </summary>
621+
/// <remarks>
622+
/// The type's own namespace itself is always allowed.
623+
/// </remarks>
624+
public TypeSetDependencyOnlyOnFilterResult ExcludingOwnSubNamespaces()
625+
{
626+
TypeSetDependencyOptions refined = _options.Copy();
627+
refined.ExcludingOwnSubNamespaces();
628+
return new TypeSetDependencyOnlyOnFilterResult(refined, _build);
629+
}
630+
}
546631
}
547632
}

Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,28 @@ public static Filtered.Types.NamespaceDependencyFilterResult WhichDependOn(
1818
type => options.IsMatchedBy(type),
1919
() => $"which depend on {options.Describe()} ")));
2020

21+
/// <summary>
22+
/// Filter for types which depend on (reference in their signature) at least one type in the filtered
23+
/// collections of types <paramref name="target" /> or <paramref name="additional" />.
24+
/// </summary>
25+
/// <remarks>
26+
/// The target collections are resolved once per filter; a dependency matches when it is a member of the
27+
/// union of the resolved collections (by <see cref="Type" /> identity; a generic type definition in a
28+
/// collection matches any construction of it).
29+
/// </remarks>
30+
public static Filtered.Types.TypeSetDependencyFilterResult WhichDependOn(
31+
this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional)
32+
=> new(new TypeSetDependencyOptions(target, additional),
33+
options => @this.Which(Filter.Suffix<Type>(
34+
async type =>
35+
{
36+
ResolvedTypeSet targetSet = await options.Resolve();
37+
return targetSet.IsMatchedBy(type);
38+
},
39+
// The parentheses delimit the target description (which ends in the target's source scope,
40+
// e.g. "in all loaded assemblies") from the subject collection's own source suffix.
41+
() => $"which depend on ({options.Describe()}) ")));
42+
2143
/// <summary>
2244
/// Filter for types which do not depend on (do not reference in their signature) any type in one of the
2345
/// <paramref name="namespaces" /> (including sub-namespaces).
@@ -28,4 +50,26 @@ public static Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(
2850
options => @this.Which(Filter.Suffix<Type>(
2951
type => !options.IsMatchedBy(type),
3052
() => $"which do not depend on {options.Describe()} ")));
53+
54+
/// <summary>
55+
/// Filter for types which do not depend on (do not reference in their signature) any type in the filtered
56+
/// collections of types <paramref name="target" /> or <paramref name="additional" />.
57+
/// </summary>
58+
/// <remarks>
59+
/// The target collections are resolved once per filter; a dependency matches when it is a member of the
60+
/// union of the resolved collections (by <see cref="Type" /> identity; a generic type definition in a
61+
/// collection matches any construction of it).
62+
/// </remarks>
63+
public static Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn(
64+
this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional)
65+
=> new(new TypeSetDependencyOptions(target, additional),
66+
options => @this.Which(Filter.Suffix<Type>(
67+
async type =>
68+
{
69+
ResolvedTypeSet targetSet = await options.Resolve();
70+
return !targetSet.IsMatchedBy(type);
71+
},
72+
// The parentheses delimit the target description (which ends in the target's source scope,
73+
// e.g. "in all loaded assemblies") from the subject collection's own source suffix.
74+
() => $"which do not depend on ({options.Describe()}) ")));
3175
}

Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOnlyOn.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,37 @@ public static Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOn
2828
options => @this.Which(Filter.Suffix<Type>(
2929
type => !type.HasDependencyNamespaceViolations(options),
3030
() => $"which depend only on {options.Describe()} ")));
31+
32+
/// <summary>
33+
/// Filter for types which depend on (reference in their signature) only types in the filtered collections
34+
/// of types <paramref name="target" /> or <paramref name="additional" />, their own namespace or framework
35+
/// assemblies.
36+
/// </summary>
37+
/// <remarks>
38+
/// The target collections are resolved once per filter; a dependency is allowed when it is a member of the
39+
/// union of the resolved collections (by <see cref="Type" /> identity; a generic type definition in a
40+
/// collection matches any construction of it). A type's own namespace is always allowed, including its
41+
/// sub-namespaces unless
42+
/// <see cref="Filtered.Types.TypeSetDependencyOnlyOnFilterResult.ExcludingOwnSubNamespaces" /> is used.
43+
/// <para />
44+
/// Dependencies on types whose assembly name matches one of the
45+
/// <see cref="AwexpectCustomization.ReflectionCustomizationValue.ExcludedAssemblyPrefixes" /> at a
46+
/// name-segment boundary (<c>System</c> covers <c>System.Text.Json</c>, but not
47+
/// <c>SystemsBiology.Core</c>) are ignored, so
48+
/// that framework types do not have to be included explicitly. The default prefixes include
49+
/// <c>Microsoft</c>, so e.g. <c>Microsoft.EntityFrameworkCore</c> is also ignored; forbid such a dependency
50+
/// explicitly via <c>WhichDoNotDependOn</c> or customize the prefixes.
51+
/// </remarks>
52+
public static Filtered.Types.TypeSetDependencyOnlyOnFilterResult WhichDependOnlyOn(
53+
this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional)
54+
=> new(new TypeSetDependencyOptions(target, additional),
55+
options => @this.Which(Filter.Suffix<Type>(
56+
async type =>
57+
{
58+
ResolvedTypeSet allowed = await options.Resolve();
59+
return !type.HasDependencyTypeSetViolations(allowed);
60+
},
61+
// The parentheses delimit the target description (which ends in the target's source scope,
62+
// e.g. "in all loaded assemblies") from the subject collection's own source suffix.
63+
() => $"which depend only on ({options.Describe()}) ")));
3164
}

Source/aweXpect.Reflection/Helpers/TypeHelpers.cs

Lines changed: 110 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -773,25 +773,34 @@ internal static bool MatchesType(Type dependency, Type target)
773773
{
774774
target = StripElementTypes(target);
775775

776-
if (dependency == target)
777-
{
778-
return true;
779-
}
780-
781-
return target.IsGenericTypeDefinition &&
782-
dependency.IsGenericType &&
783-
dependency.GetGenericTypeDefinition() == target;
776+
return dependency == target ||
777+
dependency.GetGenericTypeDefinitionOfConstruction() == target;
784778
}
785779

780+
/// <summary>
781+
/// Returns the generic type definition (e.g. <c>List&lt;&gt;</c>) when the <paramref name="dependency" />
782+
/// is a constructed generic type (e.g. <c>List&lt;Foo&gt;</c>), otherwise <see langword="null" />.
783+
/// </summary>
784+
/// <remarks>
785+
/// The single encoding of the rule that a dependency on any construction also matches its generic type
786+
/// definition; shared between <see cref="MatchesType" /> and <see cref="ResolvedTypeSet.Matches" />, so
787+
/// that the specific-type and the type-set matchers cannot drift apart.
788+
/// </remarks>
789+
internal static Type? GetGenericTypeDefinitionOfConstruction(this Type dependency)
790+
=> dependency is { IsGenericType: true, IsGenericTypeDefinition: false, }
791+
? dependency.GetGenericTypeDefinition()
792+
: null;
793+
786794
/// <summary>
787795
/// Strips array/by-ref/pointer wrappers from the <paramref name="type" />, returning the innermost
788796
/// element type.
789797
/// </summary>
790798
/// <remarks>
791-
/// Shared between dependency unwrapping (<see cref="Unwrap" />) and target matching
792-
/// (<see cref="MatchesType" />), so that the two sides of the documented symmetry cannot drift apart.
799+
/// Shared between dependency unwrapping (<see cref="Unwrap" />), target matching
800+
/// (<see cref="MatchesType" />) and target-set resolution (<see cref="TypeSetDependencyOptions.Resolve" />),
801+
/// so that the sides of the documented symmetry cannot drift apart.
793802
/// </remarks>
794-
private static Type StripElementTypes(Type type)
803+
internal static Type StripElementTypes(Type type)
795804
{
796805
while (type.HasElementType && type.GetElementType() is { } elementType)
797806
{
@@ -877,17 +886,12 @@ private static bool IsFrameworkDependency(this Type type, string[] excludedPrefi
877886
internal static IReadOnlyList<string> GetDependencyNamespaceViolations(
878887
this Type type, NamespaceDependencyOptions allowed)
879888
{
880-
string? ownNamespace = type.Namespace;
881-
string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get();
882889
List<string> violations = [];
883890
HashSet<string> seen = new(StringComparer.Ordinal);
884-
foreach (Type dependency in type.ResolveDependencies())
891+
foreach (Type dependency in type.GetDependencyViolations(
892+
(dependency, ownNamespace, excludedPrefixes)
893+
=> IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes)))
885894
{
886-
if (!IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes))
887-
{
888-
continue;
889-
}
890-
891895
string display = dependency.Namespace ?? GlobalNamespaceDisplay;
892896
if (seen.Add(display))
893897
{
@@ -908,30 +912,105 @@ internal static IReadOnlyList<string> GetDependencyNamespaceViolations(
908912
/// a verdict and not the violation list.
909913
/// </remarks>
910914
internal static bool HasDependencyNamespaceViolations(this Type type, NamespaceDependencyOptions allowed)
915+
=> type.GetDependencyViolations(
916+
(dependency, ownNamespace, excludedPrefixes)
917+
=> IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes)).Any();
918+
919+
private static bool IsDependencyViolation(
920+
Type dependency, string? ownNamespace, NamespaceDependencyOptions allowed, string[] excludedPrefixes)
921+
=> !IsExemptDependency(dependency, ownNamespace, allowed.IncludeOwnSubNamespaces, excludedPrefixes) &&
922+
!allowed.Matches(dependency.Namespace);
923+
924+
/// <summary>
925+
/// Checks the exemptions shared by all only-on rules: framework dependencies and dependencies in the
926+
/// type's own namespace are always allowed.
927+
/// </summary>
928+
/// <remarks>
929+
/// Shared between the namespace-based and type-set-based violation predicates, so that a future rule
930+
/// change cannot be applied to one family and missed in the other.
931+
/// </remarks>
932+
private static bool IsExemptDependency(
933+
Type dependency, string? ownNamespace, bool includeOwnSubNamespaces, string[] excludedPrefixes)
934+
=> dependency.IsFrameworkDependency(excludedPrefixes) ||
935+
IsOwnNamespace(dependency.Namespace, ownNamespace, includeOwnSubNamespaces);
936+
937+
/// <summary>
938+
/// Enumerates the <paramref name="type" />'s dependencies that the <paramref name="isViolation" />
939+
/// predicate flags as violations, supplying the type's own namespace and the configured excluded
940+
/// assembly prefixes.
941+
/// </summary>
942+
/// <remarks>
943+
/// Shared core of the namespace-based and type-set-based violation helpers, so that the two families
944+
/// cannot drift apart in how the dependencies are walked. Lazy, so verdict-only callers stop at the
945+
/// first violation.
946+
/// </remarks>
947+
private static IEnumerable<Type> GetDependencyViolations(
948+
this Type type, Func<Type, string?, string[], bool> isViolation)
911949
{
912950
string? ownNamespace = type.Namespace;
913951
string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get();
914952
return type.ResolveDependencies()
915-
.Any(dependency => IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes));
953+
.Where(dependency => isViolation(dependency, ownNamespace, excludedPrefixes));
916954
}
917955

918-
private static bool IsDependencyViolation(
919-
Type dependency, string? ownNamespace, NamespaceDependencyOptions allowed, string[] excludedPrefixes)
956+
private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNamespace, bool includeSubNamespaces)
957+
=> ownNamespace is null
958+
? dependencyNamespace is null
959+
: NamespaceMatches(dependencyNamespace, ownNamespace, includeSubNamespaces);
960+
961+
/// <summary>
962+
/// Collects the <paramref name="type" />'s dependencies that are not allowed by the resolved target set in
963+
/// <paramref name="allowed" />, the type's own namespace, or the framework rule.
964+
/// </summary>
965+
/// <remarks>
966+
/// Same framework and own-namespace rules as <see cref="GetDependencyNamespaceViolations" />, but the allowed
967+
/// set is a concrete set of types, so the violations are reported as formatted type names instead of
968+
/// namespaces. Distinct violators sharing a formatted name (the same simple name in different namespaces)
969+
/// are qualified by their namespace, so they do not collapse into one indistinguishable entry.
970+
/// </remarks>
971+
internal static IReadOnlyList<string> GetDependencyTypeSetViolations(
972+
this Type type, ResolvedTypeSet allowed)
920973
{
921-
if (dependency.IsFrameworkDependency(excludedPrefixes))
974+
List<string> violations = [];
975+
foreach (IGrouping<string, Type> sameName in type.GetDependencyViolations(
976+
(dependency, ownNamespace, excludedPrefixes)
977+
=> IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes))
978+
.GroupBy(dependency => Formatter.Format(dependency), StringComparer.Ordinal))
922979
{
923-
return false;
980+
Type[] violators = sameName.ToArray();
981+
if (violators.Length == 1)
982+
{
983+
violations.Add(sameName.Key);
984+
}
985+
else
986+
{
987+
violations.AddRange(violators.Select(violator => violator.Namespace is null
988+
? sameName.Key
989+
: $"{violator.Namespace}.{sameName.Key}"));
990+
}
924991
}
925992

926-
string? dependencyNamespace = dependency.Namespace;
927-
return !IsOwnNamespace(dependencyNamespace, ownNamespace, allowed.IncludeOwnSubNamespaces) &&
928-
!allowed.Matches(dependencyNamespace);
993+
violations.Sort(StringComparer.Ordinal);
994+
return violations;
929995
}
930996

931-
private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNamespace, bool includeSubNamespaces)
932-
=> ownNamespace is null
933-
? dependencyNamespace is null
934-
: NamespaceMatches(dependencyNamespace, ownNamespace, includeSubNamespaces);
997+
/// <summary>
998+
/// Checks whether the <paramref name="type" /> has at least one dependency outside the resolved target set,
999+
/// stopping at the first one.
1000+
/// </summary>
1001+
/// <remarks>
1002+
/// Same rules as <see cref="GetDependencyTypeSetViolations" />, for callers (like filters) that only need
1003+
/// a verdict and not the violation list.
1004+
/// </remarks>
1005+
internal static bool HasDependencyTypeSetViolations(this Type type, ResolvedTypeSet allowed)
1006+
=> type.GetDependencyViolations(
1007+
(dependency, ownNamespace, excludedPrefixes)
1008+
=> IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes)).Any();
1009+
1010+
private static bool IsDependencyTypeSetViolation(
1011+
Type dependency, string? ownNamespace, ResolvedTypeSet allowed, string[] excludedPrefixes)
1012+
=> !IsExemptDependency(dependency, ownNamespace, allowed.IncludeOwnSubNamespaces, excludedPrefixes) &&
1013+
!allowed.Matches(dependency);
9351014

9361015
/// <summary>
9371016
/// Resolves the dependencies of the <paramref name="type" /> through which all assertions and filters go,

0 commit comments

Comments
 (0)