Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,501 changes: 1,501 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="NetArchTest.Rules" Version="1.3.2" />
<PackageVersion Include="TngTech.ArchUnitNET" Version="0.13.3" />
<PackageVersion Include="TngTech.ArchUnitNET.xUnit" Version="0.13.3" />
<!-- <PackageVersion Include="TeamCity.VSTest.TestAdapter" Version="1.0.41" />-->
<PackageVersion Include="Verify.Xunit" Version="31.7.1" />
<PackageVersion Include="Verify.DiffPlex" Version="3.1.2" />
Expand Down
73 changes: 73 additions & 0 deletions docs/architecture-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Architecture-fitness tests

`tests/Fallout.Architecture.Specs` is a solution-wide [ArchUnitNET](https://github.com/TNG/ArchUnitNET) suite
that asserts Fallout's intended shape so it can't drift as new code lands. It's an ordinary xUnit project — the
rules run inside `[Fact]`/`[Theory]` and fail the build like any other test (no separate tool to run).

It tracks issue [#95](https://github.com/ChrisonSimtian/Fallout/issues/95). The earlier `Fallout.Core` purity
guard from #88 was migrated here off the (unmaintained) `NetArchTest.Rules`.

## Why scoping is by *assembly*, not namespace

Layering in this repo is a project/assembly concern. The legacy NUKE namespaces deliberately span several
assemblies — `Fallout.Common.*` types live in `Fallout.Build`, `Fallout.Build.Shared` and `Fallout.Common`;
the `Fallout.Utilities.Net` assembly still emits `Fallout.Common.Utilities.Net.*` — so a namespace-based layering
rule would be meaningless. Every rule scopes its subject and target with `ResideInAssembly(...)`.

The shared helper `FalloutArchitecture.TypesIn(...)` also constrains every subject/target to first-party
(`Fallout.*` / `Nuke.*`) namespaces. That strips the no-namespace, tooling-generated types injected into every
assembly (`ThisAssembly` from Nerdbank.GitVersioning, `RefSafetyRulesAttribute`, coverlet instrumentation), which
would otherwise read as spurious cross-assembly dependencies.

## What's governed

The reference set in `Fallout.Architecture.Specs.csproj` **is the contract** — an assembly is scanned only if it's
referenced (so it lands in the test output and gets loaded). Adding a production assembly without adding it there
silently drops it from the gate. Deliberately out of scope: the Roslyn build-time tooling
(`Fallout.SourceGenerators`, `Fallout.Tooling.Generator`, `Fallout.Migrate[.Analyzers]`, `Fallout.MSBuildTasks`)
and the vendored `Fallout.Persistence.Solution` parser.

Current rules:

| File | Rule(s) |
|---|---|
| `LayeringSpecs` | `Core` / `Utilities` are foundations (no in-repo deps); utility satellites depend only on `Utilities`; `Tooling` / `ProjectModel` / `Build.Shared` don't reach upward; `Solution` is a thin facade; nothing depends on the `Cli` composition root; `Fallout.*` never depends on the `Nuke.*` shims. |
| `PuritySpecs` | `Fallout.Core` takes no dependency on `System.IO`, `System.Diagnostics.Process`, `System.Console`, or Serilog (issue #88). |
| `NamingSpecs` | A type's namespace should be rooted at its assembly name. The main debt-bearing rule. |

## The ratchet

The architecture is known to be partially broken, so rules aren't all strict. Each rule is enforced by
`Ratchet.Enforce(rule, because, baseline)` against a baseline of known violations in `KnownViolations`:

- a violation **not** in the baseline fails the test — a new regression;
- a baseline entry that **no longer** violates also fails the test — the architecture improved, so the stale
entry must be deleted to lock the gain in.

The baseline can therefore only shrink. Invariants that already hold pass `KnownViolations.None` (zero tolerance).
The naming rule ships with a ~220-entry baseline (`NamespaceAssemblyDrift`) capturing today's `Fallout.Common.*`
sprawl; it shrinks as the onion-architecture refactor (ADR-0006, on `refactor/architecture`) renames things.

## Working with it

**A rule went red on your change.** Don't reflexively add the offender to `KnownViolations`. First decide:
is the new dependency *wrong* (fix the code — the rule just did its job) or *intended* (add it to the relevant
list with a one-line justification)? Baseline keys are type full names, exactly as ArchUnitNET reports them — including nested types
(`Fallout.Common.CI.Partition+TypeConverter`) and generic arity (`RequiresAttribute` + backtick + `1`).

**You fixed some debt.** The test will now fail telling you which baseline entries are stale — delete them.

**Regenerating the drift baseline wholesale** (e.g. after a large rename):

```powershell
dotnet test tests/Fallout.Architecture.Specs/Fallout.Architecture.Specs.csproj
```

The failure message for `Types_reside_in_a_namespace_rooted_at_their_assembly_name` lists every current offender
(the `Offenders:` block). Replace `KnownViolations.NamespaceAssemblyDrift` with that sorted, de-duplicated set.

## Adding a rule

Write the ArchUnitNET rule with `FalloutArchitecture.TypesIn(...)` for the subject/target and pass it through
`Ratchet.Enforce` with a `because` string and a baseline (`KnownViolations.None` if it should be strict). Use the
assembly-name constants on `FalloutArchitecture` rather than string literals.
2 changes: 1 addition & 1 deletion docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Still on Matt's personal NuGet account; supply-chain SPOF, high-priority to repl
| `coverlet.msbuild` | Code coverage |
| `GitHubActionsTestLogger` | Format test output for GitHub Actions annotations |
| `Basic.Reference.Assemblies.NetStandard20` | Reference assemblies for source-generator compile tests |
| `NetArchTest.Rules` | Architecture-fitness tests (e.g. `Fallout.Core` purity); broader suite tracked in #95 |
| `TngTech.ArchUnitNET` (+ `.xUnit`) | Architecture-fitness suite (`tests/Fallout.Architecture.Specs`, #95) — assembly layering, `Fallout.Core` purity, shim direction, and namespace/assembly alignment, enforced as a ratcheting baseline. Replaced the unmaintained `NetArchTest.Rules`. |

### FluentAssertions licensing

Expand Down
2 changes: 1 addition & 1 deletion fallout.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
<Project Path="build\_build.csproj">
<Configuration Solution="Debug|Any CPU" Project="Debug|Any CPU|NoBuild" />
<Configuration Solution="Release|Any CPU" Project="Debug|Any CPU|NoBuild" />
<Build Project="false" />
</Project>
<Project Path="src\Fallout.Build.Shared\Fallout.Build.Shared.csproj" />
<Project Path="src\Fallout.Build\Fallout.Build.csproj" />
Expand All @@ -35,6 +34,7 @@
<Project Path="src\Fallout.Utilities.Text.Json\Fallout.Utilities.Text.Json.csproj" />
<Project Path="src\Fallout.Utilities.Text.Yaml\Fallout.Utilities.Text.Yaml.csproj" />
<Project Path="src\Fallout.Utilities\Fallout.Utilities.csproj" />
<Project Path="tests\Fallout.Architecture.Specs\Fallout.Architecture.Specs.csproj" />
<Project Path="tests\Fallout.Build.Specs\Fallout.Build.Specs.csproj" />
<Project Path="tests\Fallout.Common.Specs\Fallout.Common.Specs.csproj" />
<Project Path="tests\Fallout.Components.Specs\Fallout.Components.Specs.csproj" />
Expand Down
11 changes: 0 additions & 11 deletions src/Fallout.Build/Attributes/DisableDefaultOutputAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,3 @@ public virtual bool IsApplicable(IFalloutBuild build)
return true;
}
}

public enum DefaultOutput
{
Logo,
TargetHeader,
TargetCollapse,
ErrorsAndWarnings,
TargetOutcome,
BuildOutcome,
Timestamps
}
16 changes: 0 additions & 16 deletions src/Fallout.Build/ITargetDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,19 +239,3 @@ ITargetDefinition DependsOnContext<T>()
/// </summary>
ITargetDefinition Partition(int size);
}

/// <summary>
/// The behavior of dependent targets if the target is skipped.
/// </summary>
public enum DependencyBehavior
{
/// <summary>
/// Skip all dependencies which are not required by another target.
/// </summary>
Skip,

/// <summary>
/// Execute all dependencies.
/// </summary>
Execute
}
12 changes: 12 additions & 0 deletions src/Fallout.Core/DefaultOutput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Fallout.Common;

public enum DefaultOutput
{
Logo,
TargetHeader,
TargetCollapse,
ErrorsAndWarnings,
TargetOutcome,
BuildOutcome,
Timestamps
}
17 changes: 17 additions & 0 deletions src/Fallout.Core/DependencyBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Fallout.Common;

/// <summary>
/// The behavior of dependent targets if the target is skipped.
/// </summary>
public enum DependencyBehavior
{
/// <summary>
/// Skip all dependencies which are not required by another target.
/// </summary>
Skip,

/// <summary>
/// Execute all dependencies.
/// </summary>
Execute
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
using System;
using System.Linq;

namespace Fallout.Common;
namespace Fallout.Common;

public enum Verbosity
{
Expand Down
65 changes: 65 additions & 0 deletions tests/Fallout.Architecture.Specs/Fallout.Architecture.Specs.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="TngTech.ArchUnitNET" />
<PackageReference Include="TngTech.ArchUnitNET.xUnit" />
</ItemGroup>

<!--
The reference set IS the contract. The fitness rules govern only the assemblies that are
loaded at runtime, and an assembly is loaded only if it lands in this project's output — i.e.
only if it is referenced here. Adding a production assembly to the repo without adding it here
silently drops it from the architecture gate, so this list must track src/ for the runtime
libraries below.

Deliberately OUT of scope (not referenced): the Roslyn build-time tooling
(Fallout.SourceGenerators, Fallout.Tooling.Generator, Fallout.Migrate[.Analyzers],
Fallout.MSBuildTasks) and the vendored Fallout.Persistence.Solution parser. They are
composition-root / build-time / vendored code outside the runtime layering, with rules of their
own (or none). Fallout.Persistence.Solution still arrives transitively via Fallout.Solution and
can appear as a forbidden dependency *target*, but is never a governed *subject*.
-->

<!-- Foundation: depend on nothing else in the repo. -->
<ItemGroup>
<ProjectReference Include="..\..\src\Fallout.Core\Fallout.Core.csproj" />
<ProjectReference Include="..\..\src\Fallout.Utilities\Fallout.Utilities.csproj" />
</ItemGroup>

<!-- Utility satellites: may depend only on Fallout.Utilities. -->
<ItemGroup>
<ProjectReference Include="..\..\src\Fallout.Utilities.IO.Compression\Fallout.Utilities.IO.Compression.csproj" />
<ProjectReference Include="..\..\src\Fallout.Utilities.IO.Globbing\Fallout.Utilities.IO.Globbing.csproj" />
<ProjectReference Include="..\..\src\Fallout.Utilities.Net\Fallout.Utilities.Net.csproj" />
<ProjectReference Include="..\..\src\Fallout.Utilities.Text.Json\Fallout.Utilities.Text.Json.csproj" />
<ProjectReference Include="..\..\src\Fallout.Utilities.Text.Yaml\Fallout.Utilities.Text.Yaml.csproj" />
</ItemGroup>

<!-- Mid layers. -->
<ItemGroup>
<ProjectReference Include="..\..\src\Persistence\Fallout.Solution\Fallout.Solution.csproj" />
<ProjectReference Include="..\..\src\Fallout.Tooling\Fallout.Tooling.csproj" />
<ProjectReference Include="..\..\src\Fallout.ProjectModel\Fallout.ProjectModel.csproj" />
<ProjectReference Include="..\..\src\Fallout.Build.Shared\Fallout.Build.Shared.csproj" />
</ItemGroup>

<!-- Upper layers + composition root. -->
<ItemGroup>
<ProjectReference Include="..\..\src\Fallout.Build\Fallout.Build.csproj" />
<ProjectReference Include="..\..\src\Fallout.Common\Fallout.Common.csproj" />
<ProjectReference Include="..\..\src\Fallout.Components\Fallout.Components.csproj" />
<ProjectReference Include="..\..\src\Fallout.Cli\Fallout.Cli.csproj" />
</ItemGroup>

<!-- Transition shims: must point inward (Nuke.* -> Fallout.*), never the reverse. -->
<ItemGroup>
<ProjectReference Include="..\..\src\Shims\Nuke.Common\Nuke.Common.csproj" />
<ProjectReference Include="..\..\src\Shims\Nuke.Build\Nuke.Build.csproj" />
<ProjectReference Include="..\..\src\Shims\Nuke.Components\Nuke.Components.csproj" />
</ItemGroup>

</Project>
132 changes: 132 additions & 0 deletions tests/Fallout.Architecture.Specs/FalloutArchitecture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using ArchUnitNET.Loader;
using ArchUnitNET.Fluent.Syntax.Elements.Types;
using static ArchUnitNET.Fluent.ArchRuleDefinition;

namespace Fallout.Architecture.Specs;

/// <summary>
/// The loaded picture of the repo's runtime assemblies, plus the small vocabulary the fitness rules are
/// written against. Layering in this codebase is an <b>assembly</b> concern, not a namespace one — the legacy
/// NUKE namespaces (<c>Fallout.Common.*</c>, …) deliberately span several assemblies — so every rule scopes its
/// subject and target by assembly, never by namespace.
/// </summary>
internal static class FalloutArchitecture
{
// Governed runtime libraries (assembly simple names). Mirrors the csproj reference set.
public const string Core = "Fallout.Core";
public const string Utilities = "Fallout.Utilities";
public const string UtilitiesIoCompression = "Fallout.Utilities.IO.Compression";
public const string UtilitiesIoGlobbing = "Fallout.Utilities.IO.Globbing";
public const string UtilitiesNet = "Fallout.Utilities.Net";
public const string UtilitiesTextJson = "Fallout.Utilities.Text.Json";
public const string UtilitiesTextYaml = "Fallout.Utilities.Text.Yaml";
public const string Solution = "Fallout.Solution";
public const string Tooling = "Fallout.Tooling";
public const string ProjectModel = "Fallout.ProjectModel";
public const string BuildShared = "Fallout.Build.Shared";
public const string Build = "Fallout.Build";
public const string Common = "Fallout.Common";
public const string Components = "Fallout.Components";
public const string Cli = "Fallout.Cli";

// Transition shims.
public const string NukeCommon = "Nuke.Common";
public const string NukeBuild = "Nuke.Build";
public const string NukeComponents = "Nuke.Components";

/// <summary>The utility satellites — each may depend only on <see cref="Utilities"/>.</summary>
public static readonly string[] UtilitySatellites =
[
UtilitiesIoCompression, UtilitiesIoGlobbing, UtilitiesNet, UtilitiesTextJson, UtilitiesTextYaml,
];

/// <summary>
/// Assemblies whose own namespaces should be rooted at the assembly name (the naming-alignment subjects).
/// Excludes the vendored <c>Fallout.Persistence.Solution</c> parser, which is not ours to rename.
/// </summary>
public static readonly string[] RuntimeLibraries =
[
Core, Utilities, UtilitiesIoCompression, UtilitiesIoGlobbing, UtilitiesNet, UtilitiesTextJson,
UtilitiesTextYaml, Solution, Tooling, ProjectModel, BuildShared, Build, Common, Components, Cli,
NukeCommon, NukeBuild, NukeComponents,
];

private static readonly IReadOnlyDictionary<string, System.Reflection.Assembly> Loaded = LoadProductionAssemblies();

/// <summary>The architecture under test — built once from every loaded production assembly.</summary>
public static readonly ArchUnitNET.Domain.Architecture Architecture =
new ArchLoader().LoadAssemblies(Loaded.Values.ToArray()).Build();

private static IReadOnlyDictionary<string, System.Reflection.Assembly> LoadProductionAssemblies()
{
var directory = AppContext.BaseDirectory;

// Out of governance scope: this test assembly, the Roslyn build-time tooling, and the Migrate tooling.
// (They aren't referenced by the csproj, so normally they aren't even here — belt and braces.)
var excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Fallout.Architecture.Specs.dll",
"Fallout.SourceGenerators.dll",
"Fallout.Tooling.Generator.dll",
"Fallout.Migrate.dll",
"Fallout.Migrate.Analyzers.dll",
};

var files = Directory.EnumerateFiles(directory, "Fallout.*.dll")
.Concat(Directory.EnumerateFiles(directory, "Nuke.*.dll"))
.Where(file => !excluded.Contains(Path.GetFileName(file)));

var map = new Dictionary<string, System.Reflection.Assembly>(StringComparer.Ordinal);
foreach (var file in files)
{
var assembly = System.Reflection.Assembly.LoadFrom(file);
map[assembly.GetName().Name!] = assembly;
}

return map;
}

/// <summary>The loaded reflection assembly for <paramref name="name"/>, or a helpful failure if it is missing.</summary>
public static System.Reflection.Assembly Asm(string name) =>
Loaded.TryGetValue(name, out var assembly)
? assembly
: throw new InvalidOperationException(
$"Assembly '{name}' was not loaded. Add a ProjectReference for it in " +
$"Fallout.Architecture.Specs.csproj. Loaded: {string.Join(", ", Loaded.Keys.OrderBy(k => k))}.");

/// <summary>Every loaded in-repo assembly except the named ones.</summary>
public static System.Reflection.Assembly[] AllAssembliesExcept(params string[] names)
{
var excluded = new HashSet<string>(names, StringComparer.Ordinal);
return Loaded.Values.Where(a => !excluded.Contains(a.GetName().Name!)).ToArray();
}

/// <summary>Every loaded <c>Fallout.*</c> assembly (i.e. excluding the <c>Nuke.*</c> shims).</summary>
public static System.Reflection.Assembly[] FalloutAssemblies() =>
Loaded.Values.Where(a => a.GetName().Name!.StartsWith("Fallout.", StringComparison.Ordinal)).ToArray();

// Our own code lives under Fallout.* or Nuke.*. Constraining every subject/target to these namespaces
// strips the no-namespace, compiler/tooling-generated types that get injected into every assembly —
// `ThisAssembly` (Nerdbank.GitVersioning), `RefSafetyRulesAttribute`, coverlet instrumentation — which
// would otherwise read as spurious cross-assembly dependencies and naming violations. (The original #88
// NetArchTest guard scoped itself the same way, via ResideInNamespaceStartingWith("Fallout").)
public const string OwnNamespacePattern = @"^(?:Fallout|Nuke)\.";

/// <summary>
/// "Every <i>first-party</i> type residing in any of these assemblies" — usable both as a rule subject (it
/// has <c>.Should()</c>) and as a dependency target (it is an <c>IObjectProvider&lt;IType&gt;</c>). Handles
/// the <c>ResideInAssembly(first, rest)</c> spread and the first-party namespace filter in one place.
/// </summary>
public static GivenTypesConjunction TypesIn(params System.Reflection.Assembly[] assemblies) =>
Types().That().ResideInAssembly(assemblies[0], assemblies.Skip(1).ToArray())
.And().ResideInNamespaceMatching(OwnNamespacePattern);

/// <summary>"Every type residing in any of these named assemblies".</summary>
public static GivenTypesConjunction TypesIn(params string[] assemblyNames) =>
TypesIn(assemblyNames.Select(Asm).ToArray());
}
Loading
Loading