Skip to content

Commit 33f04e9

Browse files
committed
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<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 33f04e9

25 files changed

Lines changed: 1619 additions & 8 deletions

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,9 @@ await Expect.That(Types.InNamespace("MyApp.Domain"))
492492
await Expect.That(typeof(MyDomainType)).DoesNotDependOn<DbContext>().OrOn<SqlConnection>();
493493
```
494494

495+
All three dependency families additionally accept a reusable `Filtered.Types` selection as target — see
496+
[Architecture rules](#architecture-rules).
497+
495498
> **Framework dependencies are ignored unless you name one explicitly.** `DependOnlyOn` ignores dependencies
496499
> whose assembly name matches one of the
497500
> [`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
554557
[custom dependency resolver](#dependency-resolver) (e.g. an IL-level one) also sharpens cycle
555558
detection: body-level references it surfaces can complete a cycle that the signature-level default cannot see.
556559

560+
### Architecture rules
561+
562+
There is no separate rule engine: a "layer" is just a reusable `Filtered.Types` selection (with the full
563+
filter vocabulary at your disposal), and an architecture rule is just an expectation on it.
564+
565+
```csharp
566+
Filtered.Types domain = Types.InNamespace("MyApp.Domain");
567+
Filtered.Types infrastructure = Types.InNamespace("MyApp.Infrastructure");
568+
Filtered.Types repositories = Types.InNamespace("MyApp.Data").WithName("Repository").AsSuffix();
569+
```
570+
571+
The dependency assertions and filters accept such a selection as a **target**, alongside the namespace and
572+
specific-type forms: `DependsOn` / `DoesNotDependOn` / `DependsOnlyOn` (and the plural `DependOn` /
573+
`DoNotDependOn` / `DependOnlyOn` and the `WhichDependOn` / `WhichDoNotDependOn` / `WhichDependOnlyOn`
574+
filters) take one or more `Filtered.Types` arguments. Each target selection is resolved once per assertion;
575+
a dependency matches when it is a member of the union of the resolved selections — by type identity, where a
576+
generic type definition in the selection (e.g. a scanned `Repository<>`) matches any of its constructions.
577+
Multiple targets and `.OrOn(…)` mean *any of*; for the *only-on* family the union is the allowed set, while
578+
the own-namespace and framework rules apply unchanged (an empty selection thus allows only the own namespace
579+
and framework dependencies). A selection is an explicit target, so framework types contained in it are
580+
matched normally by `DependsOn` / `DoesNotDependOn`.
581+
582+
```csharp
583+
// Outgoing rule with a selection as target:
584+
await Expect.That(domain).DoNotDependOn(infrastructure);
585+
586+
// Incoming rules are written explicitly from the other side:
587+
await Expect.That(infrastructure).DoNotDependOn(domain);
588+
589+
// Allowed set as union of selections (own namespace + framework stay allowed):
590+
await Expect.That(domain).DependOnlyOn(repositories).OrOn(infrastructure);
591+
```
592+
593+
Combine several rules into a single verification with aweXpect's `Expect.ThatAll(…)` (see
594+
[multiple expectations](https://docs.testably.org/aweXpect/advanced/multiple-expectations)) — every rule is
595+
evaluated and all failures are reported together. Any assertion works on a selection, not just the
596+
dependency ones, so naming conventions or sealing rules live in the same check:
597+
598+
```csharp
599+
await Expect.ThatAll(
600+
Expect.That(domain).DoNotDependOn(infrastructure),
601+
Expect.That(domain).DependOnlyOn(repositories).OrOn(infrastructure),
602+
Expect.That(domain).AreSealed());
603+
```
604+
605+
A failing rule reports all violations, numbered per expectation:
606+
607+
```
608+
Expected all of the following to succeed:
609+
[01] Expected that domain all do not depend on types within namespace "MyApp.Infrastructure" in all loaded assemblies
610+
[02] Expected that domain are all sealed
611+
but
612+
[01] it contained types with the dependency [
613+
OrderService
614+
]
615+
[02] it contained non-sealed types [
616+
Order,
617+
Invoice
618+
]
619+
```
620+
621+
Exemptions to a rule use the [`Except` filter](#filters-and-the-matching-assertions) on the subject
622+
selection:
623+
624+
```csharp
625+
await Expect.That(domain.Except<LegacyService>()).DoNotDependOn(infrastructure);
626+
await Expect.That(domain.Except(type => type.Name.StartsWith("Generated"))).AreSealed();
627+
```
628+
629+
A layer spanning several namespaces is built by widening a dependency *target* with additional selections
630+
(or `.OrOn(…)`); for a *subject* spanning several namespaces, assert each namespace selection as its own
631+
rule inside the same `Expect.ThatAll(…)`.
632+
557633
### Methods
558634

559635
In addition to [access modifiers](#access-modifiers),

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,5 +543,39 @@ 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+
}
546580
}
547581
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,44 @@ public static Filtered.Types.NamespaceDependencyFilterResult WhichDoNotDependOn(
2828
options => @this.Which(Filter.Suffix<Type>(
2929
type => !options.IsMatchedBy(type),
3030
() => $"which do not depend on {options.Describe()} ")));
31+
32+
/// <summary>
33+
/// Filter for types which depend on (reference in their signature) at least one type in the filtered
34+
/// collections of types <paramref name="target" /> or <paramref name="additional" />.
35+
/// </summary>
36+
/// <remarks>
37+
/// The target collections are resolved once per filter; a dependency matches when it is a member of the
38+
/// union of the resolved collections (by <see cref="Type" /> identity; a generic type definition in a
39+
/// collection matches any construction of it).
40+
/// </remarks>
41+
public static Filtered.Types.TypeSetDependencyFilterResult WhichDependOn(
42+
this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional)
43+
=> new(new TypeSetDependencyOptions(target, additional),
44+
options => @this.Which(Filter.Suffix<Type>(
45+
async type =>
46+
{
47+
await options.Resolve();
48+
return options.IsMatchedBy(type);
49+
},
50+
() => $"which depend on {options.Describe()} ")));
51+
52+
/// <summary>
53+
/// Filter for types which do not depend on (do not reference in their signature) any type in the filtered
54+
/// collections of types <paramref name="target" /> or <paramref name="additional" />.
55+
/// </summary>
56+
/// <remarks>
57+
/// The target collections are resolved once per filter; a dependency matches when it is a member of the
58+
/// union of the resolved collections (by <see cref="Type" /> identity; a generic type definition in a
59+
/// collection matches any construction of it).
60+
/// </remarks>
61+
public static Filtered.Types.TypeSetDependencyFilterResult WhichDoNotDependOn(
62+
this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional)
63+
=> new(new TypeSetDependencyOptions(target, additional),
64+
options => @this.Which(Filter.Suffix<Type>(
65+
async type =>
66+
{
67+
await options.Resolve();
68+
return !options.IsMatchedBy(type);
69+
},
70+
() => $"which do not depend on {options.Describe()} ")));
3171
}

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,33 @@ 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).
41+
/// <para />
42+
/// Dependencies on types whose assembly name matches one of the
43+
/// <see cref="AwexpectCustomization.ReflectionCustomizationValue.ExcludedAssemblyPrefixes" /> at a
44+
/// name-segment boundary (<c>System</c> covers <c>System.Text.Json</c>, but not
45+
/// <c>SystemsBiology.Core</c>) are ignored, so
46+
/// that framework types do not have to be included explicitly. The default prefixes include
47+
/// <c>Microsoft</c>, so e.g. <c>Microsoft.EntityFrameworkCore</c> is also ignored; forbid such a dependency
48+
/// explicitly via <c>WhichDoNotDependOn</c> or customize the prefixes.
49+
/// </remarks>
50+
public static Filtered.Types.TypeSetDependencyFilterResult WhichDependOnlyOn(
51+
this Filtered.Types @this, Filtered.Types target, params Filtered.Types[] additional)
52+
=> new(new TypeSetDependencyOptions(target, additional),
53+
options => @this.Which(Filter.Suffix<Type>(
54+
async type =>
55+
{
56+
await options.Resolve();
57+
return !type.HasDependencyTypeSetViolations(options);
58+
},
59+
() => $"which depend only on {options.Describe()} ")));
3160
}

Source/aweXpect.Reflection/Helpers/TypeHelpers.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,68 @@ private static bool IsOwnNamespace(string? dependencyNamespace, string? ownNames
933933
? dependencyNamespace is null
934934
: NamespaceMatches(dependencyNamespace, ownNamespace, includeSubNamespaces);
935935

936+
/// <summary>
937+
/// Collects the <paramref name="type" />'s dependencies that are not allowed by the resolved target set in
938+
/// <paramref name="allowed" />, the type's own namespace, or the framework rule.
939+
/// </summary>
940+
/// <remarks>
941+
/// Same framework and own-namespace rules as <see cref="GetDependencyNamespaceViolations" />, but the allowed
942+
/// set is a concrete set of types, so the violations are reported as formatted type names instead of
943+
/// namespaces. Requires <see cref="TypeSetDependencyOptions.Resolve" /> to have been awaited before.
944+
/// </remarks>
945+
internal static IReadOnlyList<string> GetDependencyTypeSetViolations(
946+
this Type type, TypeSetDependencyOptions allowed)
947+
{
948+
string? ownNamespace = type.Namespace;
949+
string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get();
950+
List<string> violations = [];
951+
HashSet<string> seen = new(StringComparer.Ordinal);
952+
foreach (Type dependency in type.ResolveDependencies())
953+
{
954+
if (!IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes))
955+
{
956+
continue;
957+
}
958+
959+
string display = Formatter.Format(dependency);
960+
if (seen.Add(display))
961+
{
962+
violations.Add(display);
963+
}
964+
}
965+
966+
violations.Sort(StringComparer.Ordinal);
967+
return violations;
968+
}
969+
970+
/// <summary>
971+
/// Checks whether the <paramref name="type" /> has at least one dependency outside the resolved target set,
972+
/// stopping at the first one.
973+
/// </summary>
974+
/// <remarks>
975+
/// Same rules as <see cref="GetDependencyTypeSetViolations" />, for callers (like filters) that only need
976+
/// a verdict and not the violation list.
977+
/// </remarks>
978+
internal static bool HasDependencyTypeSetViolations(this Type type, TypeSetDependencyOptions allowed)
979+
{
980+
string? ownNamespace = type.Namespace;
981+
string[] excludedPrefixes = Customize.aweXpect.Reflection().ExcludedAssemblyPrefixes.Get();
982+
return type.ResolveDependencies()
983+
.Any(dependency => IsDependencyTypeSetViolation(dependency, ownNamespace, allowed, excludedPrefixes));
984+
}
985+
986+
private static bool IsDependencyTypeSetViolation(
987+
Type dependency, string? ownNamespace, TypeSetDependencyOptions allowed, string[] excludedPrefixes)
988+
{
989+
if (dependency.IsFrameworkDependency(excludedPrefixes))
990+
{
991+
return false;
992+
}
993+
994+
return !IsOwnNamespace(dependency.Namespace, ownNamespace, true) &&
995+
!allowed.Matches(dependency);
996+
}
997+
936998
/// <summary>
937999
/// Resolves the dependencies of the <paramref name="type" /> through which all assertions and filters go,
9381000
/// using the resolver configured via the <c>DependencyResolver</c> customization

0 commit comments

Comments
 (0)