Skip to content
367 changes: 226 additions & 141 deletions README.md

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions Source/aweXpect.Reflection/Collections/Filtered.Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -543,5 +543,90 @@ public Types InEntryAssembly()
public Types InExecutingAssembly()
=> In.ExecutingAssembly().Types().WithinNamespace(_namespace);
}

/// <summary>
/// A filtered collection of <see cref="System.Type" /> from a dependency filter whose targets are filtered
/// collections of types, allowing to widen the targeted/allowed collections.
/// </summary>
/// <remarks>
/// Like all filtered collections, this is an immutable value object: <see cref="OrOn" /> 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.
/// </remarks>
public sealed class TypeSetDependencyFilterResult : Types
{
private readonly Func<TypeSetDependencyOptions, Types> _build;
private readonly TypeSetDependencyOptions _options;

internal TypeSetDependencyFilterResult(
TypeSetDependencyOptions options,
Func<TypeSetDependencyOptions, Types> build)
: base(build(options))
{
_options = options;
_build = build;
}

/// <summary>
/// Widens the filter by the given <paramref name="targets" />.
/// </summary>
public TypeSetDependencyFilterResult OrOn(params Filtered.Types[] targets)
{
TypeSetDependencyOptions widened = _options.Copy();
widened.OrOn(targets);
return new TypeSetDependencyFilterResult(widened, _build);
}
}

/// <summary>
/// A filtered collection of <see cref="System.Type" /> 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.
/// </summary>
/// <remarks>
/// Like all filtered collections, this is an immutable value object: <see cref="OrOn" /> and
/// <see cref="ExcludingOwnSubNamespaces" /> 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.
/// </remarks>
public sealed class TypeSetDependencyOnlyOnFilterResult : Types
{
private readonly Func<TypeSetDependencyOptions, Types> _build;
private readonly TypeSetDependencyOptions _options;

internal TypeSetDependencyOnlyOnFilterResult(
TypeSetDependencyOptions options,
Func<TypeSetDependencyOptions, Types> build)
: base(build(options))
{
_options = options;
_build = build;
}

/// <summary>
/// Widens the filter by the given <paramref name="targets" />.
/// </summary>
public TypeSetDependencyOnlyOnFilterResult OrOn(params Filtered.Types[] targets)
{
TypeSetDependencyOptions widened = _options.Copy();
widened.OrOn(targets);
return new TypeSetDependencyOnlyOnFilterResult(widened, _build);
}

/// <summary>
/// Excludes sub-namespaces of the type's own namespace from being implicitly allowed (so a <c>Foo</c>
/// type referencing <c>Foo.Bar</c> is filtered out unless <c>Foo.Bar</c> types are part of an allowed
/// collection).
/// </summary>
/// <remarks>
/// The type's own namespace itself is always allowed.
/// </remarks>
public TypeSetDependencyOnlyOnFilterResult ExcludingOwnSubNamespaces()
{
TypeSetDependencyOptions refined = _options.Copy();
refined.ExcludingOwnSubNamespaces();
return new TypeSetDependencyOnlyOnFilterResult(refined, _build);
}
}
}
}
44 changes: 44 additions & 0 deletions Source/aweXpect.Reflection/Filters/TypeFilters.WhichDependOn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ public static Filtered.Types.NamespaceDependencyFilterResult WhichDependOn(
type => options.IsMatchedBy(type),
() => $"which depend on {options.Describe()} ")));

/// <summary>
/// Filter for types which depend on (reference in their signature) at least one type in the filtered
/// collections of types <paramref name="target" /> or <paramref name="additional" />.
/// </summary>
/// <remarks>
/// The target collections are resolved once per filter; a dependency matches when it is a member of the
/// union of the resolved collections (by <see cref="Type" /> identity; a generic type definition in a
/// collection matches any construction of it).
/// </remarks>
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<Type>(
async type =>
{
ResolvedTypeSet targetSet = await options.Resolve();
return targetSet.IsMatchedBy(type);
},
// 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()}) ")));

/// <summary>
/// Filter for types which do not depend on (do not reference in their signature) any type in one of the
/// <paramref name="namespaces" /> (including sub-namespaces).
Expand All @@ -28,4 +50,26 @@ public static Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(
options => @this.Which(Filter.Suffix<Type>(
type => !options.IsMatchedBy(type),
() => $"which do not depend on {options.Describe()} ")));

/// <summary>
/// Filter for types which do not depend on (do not reference in their signature) any type in the filtered
/// collections of types <paramref name="target" /> or <paramref name="additional" />.
/// </summary>
/// <remarks>
/// The target collections are resolved once per filter; a dependency matches when it is a member of the
/// union of the resolved collections (by <see cref="Type" /> identity; a generic type definition in a
/// collection matches any construction of it).
/// </remarks>
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<Type>(
async type =>
{
ResolvedTypeSet targetSet = await options.Resolve();
return !targetSet.IsMatchedBy(type);
},
// 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()}) ")));
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,37 @@ public static Filtered.Types.NamespaceDependencyOnlyOnFilterResult WhichDependOn
options => @this.Which(Filter.Suffix<Type>(
type => !type.HasDependencyNamespaceViolations(options),
() => $"which depend only on {options.Describe()} ")));

/// <summary>
/// Filter for types which depend on (reference in their signature) only types in the filtered collections
/// of types <paramref name="target" /> or <paramref name="additional" />, their own namespace or framework
/// assemblies.
/// </summary>
/// <remarks>
/// 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 <see cref="Type" /> identity; a generic type definition in a
/// collection matches any construction of it). A type's own namespace is always allowed, including its
/// sub-namespaces unless
/// <see cref="Filtered.Types.TypeSetDependencyOnlyOnFilterResult.ExcludingOwnSubNamespaces" /> is used.
/// <para />
/// Dependencies on types whose assembly name matches one of the
/// <see cref="AwexpectCustomization.ReflectionCustomizationValue.ExcludedAssemblyPrefixes" /> at a
/// name-segment boundary (<c>System</c> covers <c>System.Text.Json</c>, but not
/// <c>SystemsBiology.Core</c>) are ignored, so
/// that framework types do not have to be included explicitly. The default prefixes include
/// <c>Microsoft</c>, so e.g. <c>Microsoft.EntityFrameworkCore</c> is also ignored; forbid such a dependency
/// explicitly via <c>WhichDoNotDependOn</c> or customize the prefixes.
/// </remarks>
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<Type>(
async type =>
{
ResolvedTypeSet allowed = await options.Resolve();
return !type.HasDependencyTypeSetViolations(allowed);
},
// 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()}) ")));
}
141 changes: 110 additions & 31 deletions Source/aweXpect.Reflection/Helpers/TypeHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -773,25 +773,34 @@ 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;
}

/// <summary>
/// Returns the generic type definition (e.g. <c>List&lt;&gt;</c>) when the <paramref name="dependency" />
/// is a constructed generic type (e.g. <c>List&lt;Foo&gt;</c>), otherwise <see langword="null" />.
/// </summary>
/// <remarks>
/// The single encoding of the rule that a dependency on any construction also matches its generic type
/// definition; shared between <see cref="MatchesType" /> and <see cref="ResolvedTypeSet.Matches" />, so
/// that the specific-type and the type-set matchers cannot drift apart.
/// </remarks>
internal static Type? GetGenericTypeDefinitionOfConstruction(this Type dependency)
=> dependency is { IsGenericType: true, IsGenericTypeDefinition: false, }
? dependency.GetGenericTypeDefinition()
: null;

/// <summary>
/// Strips array/by-ref/pointer wrappers from the <paramref name="type" />, returning the innermost
/// element type.
/// </summary>
/// <remarks>
/// Shared between dependency unwrapping (<see cref="Unwrap" />) and target matching
/// (<see cref="MatchesType" />), so that the two sides of the documented symmetry cannot drift apart.
/// Shared between dependency unwrapping (<see cref="Unwrap" />), target matching
/// (<see cref="MatchesType" />) and target-set resolution (<see cref="TypeSetDependencyOptions.Resolve" />),
/// so that the sides of the documented symmetry cannot drift apart.
/// </remarks>
private static Type StripElementTypes(Type type)
internal static Type StripElementTypes(Type type)
{
while (type.HasElementType && type.GetElementType() is { } elementType)
{
Expand Down Expand Up @@ -877,17 +886,12 @@ private static bool IsFrameworkDependency(this Type type, string[] excludedPrefi
internal static IReadOnlyList<string> GetDependencyNamespaceViolations(
this Type type, NamespaceDependencyOptions allowed)
{
string? ownNamespace = type.Namespace;
string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get();
List<string> violations = [];
HashSet<string> 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))
{
Expand All @@ -908,30 +912,105 @@ internal static IReadOnlyList<string> GetDependencyNamespaceViolations(
/// a verdict and not the violation list.
/// </remarks>
internal static bool HasDependencyNamespaceViolations(this Type type, NamespaceDependencyOptions allowed)
=> type.GetDependencyViolations(
(dependency, ownNamespace, excludedPrefixes)
=> IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes)).Any();

private static bool IsDependencyViolation(
Type dependency, string? ownNamespace, NamespaceDependencyOptions allowed, string[] excludedPrefixes)
=> !IsExemptDependency(dependency, ownNamespace, allowed.IncludeOwnSubNamespaces, excludedPrefixes) &&
!allowed.Matches(dependency.Namespace);

/// <summary>
/// Checks the exemptions shared by all only-on rules: framework dependencies and dependencies in the
/// type's own namespace are always allowed.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static bool IsExemptDependency(
Type dependency, string? ownNamespace, bool includeOwnSubNamespaces, string[] excludedPrefixes)
=> dependency.IsFrameworkDependency(excludedPrefixes) ||
IsOwnNamespace(dependency.Namespace, ownNamespace, includeOwnSubNamespaces);

/// <summary>
/// Enumerates the <paramref name="type" />'s dependencies that the <paramref name="isViolation" />
/// predicate flags as violations, supplying the type's own namespace and the configured excluded
/// assembly prefixes.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static IEnumerable<Type> GetDependencyViolations(
this Type type, Func<Type, string?, string[], bool> isViolation)
{
string? ownNamespace = type.Namespace;
string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get();
return type.ResolveDependencies()
.Any(dependency => IsDependencyViolation(dependency, ownNamespace, allowed, excludedPrefixes));
.Where(dependency => isViolation(dependency, ownNamespace, excludedPrefixes));
}

private static bool IsDependencyViolation(
Type dependency, string? ownNamespace, NamespaceDependencyOptions allowed, string[] excludedPrefixes)
private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNamespace, bool includeSubNamespaces)
=> ownNamespace is null
? dependencyNamespace is null
: NamespaceMatches(dependencyNamespace, ownNamespace, includeSubNamespaces);

/// <summary>
/// Collects the <paramref name="type" />'s dependencies that are not allowed by the resolved target set in
/// <paramref name="allowed" />, the type's own namespace, or the framework rule.
/// </summary>
/// <remarks>
/// Same framework and own-namespace rules as <see cref="GetDependencyNamespaceViolations" />, but the allowed
/// 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.
/// </remarks>
internal static IReadOnlyList<string> GetDependencyTypeSetViolations(
this Type type, ResolvedTypeSet allowed)
{
if (dependency.IsFrameworkDependency(excludedPrefixes))
List<string> violations = [];
foreach (IGrouping<string, Type> sameName in type.GetDependencyViolations(
(dependency, ownNamespace, excludedPrefixes)
=> IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes))
.GroupBy(dependency => Formatter.Format(dependency), StringComparer.Ordinal))
{
return false;
Type[] violators = sameName.ToArray();
if (violators.Length == 1)
{
violations.Add(sameName.Key);
}
else
{
violations.AddRange(violators.Select(violator => violator.Namespace is null
? sameName.Key
: $"{violator.Namespace}.{sameName.Key}"));
}
}

string? dependencyNamespace = dependency.Namespace;
return !IsOwnNamespace(dependencyNamespace, ownNamespace, allowed.IncludeOwnSubNamespaces) &&
!allowed.Matches(dependencyNamespace);
violations.Sort(StringComparer.Ordinal);
return violations;
}

private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNamespace, bool includeSubNamespaces)
=> ownNamespace is null
? dependencyNamespace is null
: NamespaceMatches(dependencyNamespace, ownNamespace, includeSubNamespaces);
/// <summary>
/// Checks whether the <paramref name="type" /> has at least one dependency outside the resolved target set,
/// stopping at the first one.
/// </summary>
/// <remarks>
/// Same rules as <see cref="GetDependencyTypeSetViolations" />, for callers (like filters) that only need
/// a verdict and not the violation list.
/// </remarks>
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, ResolvedTypeSet allowed, string[] excludedPrefixes)
=> !IsExemptDependency(dependency, ownNamespace, allowed.IncludeOwnSubNamespaces, excludedPrefixes) &&
!allowed.Matches(dependency);

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