diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b3b89ba..fbad6ba0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,8 +84,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- **Architecture-fitness suite — `tests/Fallout.Architecture.Tests`** (#95). A solution-wide [ArchUnitNET](https://github.com/TNG/ArchUnitNET) suite that locks in the runtime layering so future work can't silently invert a dependency edge that still compiles. Rules are scoped by **assembly** (the legacy NUKE namespaces span assemblies, so namespace-based layering would be meaningless): foundation purity (`Fallout.Core` / `Fallout.Utilities` depend on nothing else in-repo), utility-satellite confinement, downward-only layering for `Tooling`/`ProjectModel`/`Build.Shared`/`Solution`, "nothing depends on the `Fallout.Cli` composition root", and "`Fallout.*` never depends on the `Nuke.*` shims". Known-broken areas are governed by a **one-way ratchet**: each rule is allowed exactly its documented baseline of pre-existing violations (`KnownViolations`), and the test fails on any *new* violation or any baseline entry that has been fixed — so the debt can only shrink. The headline debt rule (a type's namespace should be rooted at its assembly name) ships with a ~220-entry baseline capturing today's `Fallout.Common.*` namespace sprawl, which the onion-architecture refactor will whittle down. Migrated the issue-#88 `Fallout.Core` purity guard off the unmaintained `NetArchTest.Rules` (last release 2023) onto ArchUnitNET and retired that package. See [docs/architecture-tests.md](docs/architecture-tests.md). - **Reintroduced `.editorconfig` and `fallout.slnx.DotSettings`** to restore shared IDE configuration for Rider and Visual Studio. Corresponding Rule 6 in `AGENTS.md` removed. -- **Extracted `Fallout.Core` — the pure domain + graph foundation** (#88, v11 plugin-architecture foundation). New bottom-layer project (`netstandard2.1;net10.0`) holding the side-effect-free types of the build execution pipeline: `ITargetModel` (read-only target projection — the seed for the v12 plugin SDK), `ExecutionStatus`, and `TopoSort` / `PlanResult` (pure topological sort + strongly-connected-component cycle detection). `Fallout.Core` references nothing else in the repo and is held to a strict purity bar — no `System.IO`, `Process`, `Console`, or Serilog — enforced by a `NetArchTest` architecture-fitness test (broader suite tracked in #95). +- **Extracted `Fallout.Core` — the pure domain + graph foundation** (#88, v11 plugin-architecture foundation). New bottom-layer project (`netstandard2.1;net10.0`) holding the side-effect-free types of the build execution pipeline: `ITargetModel` (read-only target projection — the seed for the v12 plugin SDK), `ExecutionStatus`, and `TopoSort` / `PlanResult` (pure topological sort + strongly-connected-component cycle detection). `Fallout.Core` references nothing else in the repo and is held to a strict purity bar — no `System.IO`, `Process`, `Console`, or Serilog — enforced by an `ArchUnitNET` architecture-fitness test (the broader suite, #95, now lives in `tests/Fallout.Architecture.Tests`). - **Fully backward-compatible.** `ExecutionStatus` moved assemblies (`Fallout.Build` → `Fallout.Core`) but is `[TypeForwardedTo]`, and namespaces are unchanged, so existing consumer `using Fallout.Common.Execution;` code compiles untouched. The live `ExecutableTarget` stays in `Fallout.Build` and now implements `ITargetModel`; `ExecutionPlanner` is reduced to a thin orchestration wrapper over `Fallout.Core`'s pure `TopoSort` (it still owns reading the `strict` parameter, failing the build on a cycle, and mutating target state). - The graph primitives (`Vertex` / `StronglyConnectedComponent*`) moved from `Fallout.Utilities` into `Fallout.Core` as an internal implementation detail of `TopoSort`; they had no external consumers. diff --git a/Directory.Packages.props b/Directory.Packages.props index 644d92315..0087797d7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,7 +37,8 @@ - + + diff --git a/docs/architecture-tests.md b/docs/architecture-tests.md new file mode 100644 index 000000000..1b8d18433 --- /dev/null +++ b/docs/architecture-tests.md @@ -0,0 +1,73 @@ +# Architecture-fitness tests + +`tests/Fallout.Architecture.Tests` 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.Tests.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) | +|---|---| +| `LayeringTests` | `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. | +| `PurityTests` | `Fallout.Core` takes no dependency on `System.IO`, `System.Diagnostics.Process`, `System.Console`, or Serilog (issue #88). | +| `NamingTests` | 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.Tests/Fallout.Architecture.Tests.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. diff --git a/docs/dependencies.md b/docs/dependencies.md index 6e8cbcbba..db3690a19 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -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.Tests`, #95) — assembly layering, `Fallout.Core` purity, shim direction, and namespace/assembly alignment, enforced as a ratcheting baseline. Replaced the unmaintained `NetArchTest.Rules`. | ## Build-time CLI tools (`PackageDownload`) diff --git a/fallout.slnx b/fallout.slnx index 0f6ae9699..921932ed1 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -34,6 +34,7 @@ + diff --git a/tests/Fallout.Architecture.Tests/Fallout.Architecture.Tests.csproj b/tests/Fallout.Architecture.Tests/Fallout.Architecture.Tests.csproj new file mode 100644 index 000000000..64047c195 --- /dev/null +++ b/tests/Fallout.Architecture.Tests/Fallout.Architecture.Tests.csproj @@ -0,0 +1,65 @@ + + + + net10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Fallout.Architecture.Tests/FalloutArchitecture.cs b/tests/Fallout.Architecture.Tests/FalloutArchitecture.cs new file mode 100644 index 000000000..6d2def4d2 --- /dev/null +++ b/tests/Fallout.Architecture.Tests/FalloutArchitecture.cs @@ -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.Tests; + +/// +/// 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 assembly concern, not a namespace one — the legacy +/// NUKE namespaces (Fallout.Common.*, …) deliberately span several assemblies — so every rule scopes its +/// subject and target by assembly, never by namespace. +/// +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"; + + /// The utility satellites — each may depend only on . + public static readonly string[] UtilitySatellites = + [ + UtilitiesIoCompression, UtilitiesIoGlobbing, UtilitiesNet, UtilitiesTextJson, UtilitiesTextYaml, + ]; + + /// + /// Assemblies whose own namespaces should be rooted at the assembly name (the naming-alignment subjects). + /// Excludes the vendored Fallout.Persistence.Solution parser, which is not ours to rename. + /// + 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 Loaded = LoadProductionAssemblies(); + + /// The architecture under test — built once from every loaded production assembly. + public static readonly ArchUnitNET.Domain.Architecture Architecture = + new ArchLoader().LoadAssemblies(Loaded.Values.ToArray()).Build(); + + private static IReadOnlyDictionary 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(StringComparer.OrdinalIgnoreCase) + { + "Fallout.Architecture.Tests.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(StringComparer.Ordinal); + foreach (var file in files) + { + var assembly = System.Reflection.Assembly.LoadFrom(file); + map[assembly.GetName().Name!] = assembly; + } + + return map; + } + + /// The loaded reflection assembly for , or a helpful failure if it is missing. + 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.Tests.csproj. Loaded: {string.Join(", ", Loaded.Keys.OrderBy(k => k))}."); + + /// Every loaded in-repo assembly except the named ones. + public static System.Reflection.Assembly[] AllAssembliesExcept(params string[] names) + { + var excluded = new HashSet(names, StringComparer.Ordinal); + return Loaded.Values.Where(a => !excluded.Contains(a.GetName().Name!)).ToArray(); + } + + /// Every loaded Fallout.* assembly (i.e. excluding the Nuke.* shims). + 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)\."; + + /// + /// "Every first-party type residing in any of these assemblies" — usable both as a rule subject (it + /// has .Should()) and as a dependency target (it is an IObjectProvider<IType>). Handles + /// the ResideInAssembly(first, rest) spread and the first-party namespace filter in one place. + /// + public static GivenTypesConjunction TypesIn(params System.Reflection.Assembly[] assemblies) => + Types().That().ResideInAssembly(assemblies[0], assemblies.Skip(1).ToArray()) + .And().ResideInNamespaceMatching(OwnNamespacePattern); + + /// "Every type residing in any of these named assemblies". + public static GivenTypesConjunction TypesIn(params string[] assemblyNames) => + TypesIn(assemblyNames.Select(Asm).ToArray()); +} diff --git a/tests/Fallout.Architecture.Tests/KnownViolations.cs b/tests/Fallout.Architecture.Tests/KnownViolations.cs new file mode 100644 index 000000000..1a21fe120 --- /dev/null +++ b/tests/Fallout.Architecture.Tests/KnownViolations.cs @@ -0,0 +1,253 @@ +using System; + +namespace Fallout.Architecture.Tests; + +/// +/// The debt register. Each array is the set of known, pre-existing violations a single fitness rule is +/// allowed to have today — keyed by offending type full name. fails the test if a rule +/// gains a violation not listed here (a regression) or if a listed entry stops violating (improvement to lock +/// in). The lists may only shrink over time; an empty list means a strict, zero-tolerance invariant. +/// +/// When a rule legitimately reports a new violation, do not reflexively add it here — first decide whether the +/// dependency is wrong (fix the code) or intended (add it with a one-line justification). This file is the +/// honest record of where Fallout's architecture does not yet match its intended shape. +/// +/// To regenerate after the architecture changes, see docs/architecture-tests.md. +/// +internal static class KnownViolations +{ + /// For invariants that already hold — zero tolerance. + public static readonly string[] None = Array.Empty(); + + /// + /// Namespace ↔ assembly drift: types whose namespace is not rooted at their assembly name. This is the + /// legacy NUKE namespace sprawl — chiefly the Fallout.Common.* namespace spanning the Build / Common / + /// Tooling assemblies, plus the Fallout.Utilities.* satellites still emitting Fallout.Common.* + /// types and the Fallout.Solutions.* facade types. It shrinks as the onion-architecture refactor + /// (ADR-0006, on refactor/architecture) renames things; each removed entry is a permanent gain the ratchet + /// locks in. Generated, off-by-default at time. + /// + public static readonly string[] NamespaceAssemblyDrift = + [ + "Fallout.Common.ArgumentParser", + "Fallout.Common.Assert", + "Fallout.Common.AsyncHelper", + "Fallout.Common.CI.BuildServerConfigurationGeneration", + "Fallout.Common.CI.BuildServerConfigurationGenerationAttributeBase", + "Fallout.Common.CI.CIAttribute", + "Fallout.Common.CI.ChainedConfigurationAttributeBase", + "Fallout.Common.CI.ConfigurationAttributeBase", + "Fallout.Common.CI.ConfigurationEntity", + "Fallout.Common.CI.GenerateBuildServerConfigurationsAttribute", + "Fallout.Common.CI.IBuildServer", + "Fallout.Common.CI.IConfigurationGenerator", + "Fallout.Common.CI.InvokeBuildServerConfigurationGenerationAttribute", + "Fallout.Common.CI.NoConvertAttribute", + "Fallout.Common.CI.Partition", + "Fallout.Common.CI.Partition+TypeConverter", + "Fallout.Common.CI.PartitionAttribute", + "Fallout.Common.CI.SerializeBuildServerStateAttribute", + "Fallout.Common.CI.ShutdownDotNetAfterServerBuildAttribute", + "Fallout.Common.Cleanup", + "Fallout.Common.Constants", + "Fallout.Common.ControlFlow", + "Fallout.Common.DefaultOutput", + "Fallout.Common.DependencyBehavior", + "Fallout.Common.DisableDefaultOutputAttribute", + "Fallout.Common.EnvironmentInfo", + "Fallout.Common.ExecutableTargetExtensions", + "Fallout.Common.Execution.ArgumentsFromGitCommitMessageAttribute", + "Fallout.Common.Execution.ArgumentsFromParametersFileAttribute", + "Fallout.Common.Execution.BuildExecutor", + "Fallout.Common.Execution.BuildExtensionAttributeBase", + "Fallout.Common.Execution.BuildManager", + "Fallout.Common.Execution.DelegateRequirementService", + "Fallout.Common.Execution.EventInvoker", + "Fallout.Common.Execution.ExecutableTarget", + "Fallout.Common.Execution.ExecutableTargetFactory", + "Fallout.Common.Execution.ExecutionPlanner", + "Fallout.Common.Execution.ExecutionStatus", + "Fallout.Common.Execution.HandleHelpRequestsAttribute", + "Fallout.Common.Execution.HandleReSharperSurrogateArgumentsAttribute", + "Fallout.Common.Execution.HandleShellCompletionAttribute", + "Fallout.Common.Execution.HandleVisualStudioDebuggingAttribute", + "Fallout.Common.Execution.IBuildExtension", + "Fallout.Common.Execution.IOnBuildCreated", + "Fallout.Common.Execution.IOnBuildFinished", + "Fallout.Common.Execution.IOnBuildInitialized", + "Fallout.Common.Execution.IOnTargetFailed", + "Fallout.Common.Execution.IOnTargetRunning", + "Fallout.Common.Execution.IOnTargetSkipped", + "Fallout.Common.Execution.IOnTargetSucceeded", + "Fallout.Common.Execution.IOnTargetSummaryUpdated", + "Fallout.Common.Execution.ITargetModel", + "Fallout.Common.Execution.Logging", + "Fallout.Common.Execution.Logging+ExecutingTargetLogEventEnricher", + "Fallout.Common.Execution.Logging+InMemorySink", + "Fallout.Common.Execution.Rider", + "Fallout.Common.Execution.SchemaUtility", + "Fallout.Common.Execution.SchemaUtility+SchemaContext", + "Fallout.Common.Execution.TargetDefinition", + "Fallout.Common.Execution.TargetExecutionException", + "Fallout.Common.Execution.Telemetry", + "Fallout.Common.Execution.TelemetryAttribute", + "Fallout.Common.Execution.Terminal", + "Fallout.Common.Execution.Theming.AnsiConsoleHostTheme", + "Fallout.Common.Execution.Theming.IHostTheme", + "Fallout.Common.Execution.Theming.SystemConsoleHostTheme", + "Fallout.Common.Execution.ToolRequirementService", + "Fallout.Common.Execution.UnsetVisualStudioEnvironmentVariablesAttribute", + "Fallout.Common.Execution.UpdateNotificationAttribute", + "Fallout.Common.Execution.VSCode", + "Fallout.Common.Execution.VisualStudio", + "Fallout.Common.FalloutBuild", + "Fallout.Common.Git.GitProtocol", + "Fallout.Common.Git.GitRepository", + "Fallout.Common.Git.GitRepositoryExtensions", + "Fallout.Common.Host", + "Fallout.Common.Host+LogEventSink", + "Fallout.Common.Host+TypeConverter", + "Fallout.Common.IFalloutBuild", + "Fallout.Common.IO.AbsolutePath", + "Fallout.Common.IO.AbsolutePath+TypeConverter", + "Fallout.Common.IO.AbsolutePathExtensions", + "Fallout.Common.IO.CompressionExtensions", + "Fallout.Common.IO.ExistsPolicy", + "Fallout.Common.IO.Globbing", + "Fallout.Common.IO.GlobbingCaseSensitivity", + "Fallout.Common.IO.IAbsolutePathHolder", + "Fallout.Common.IO.PathConstruction", + "Fallout.Common.IO.RelativePath", + "Fallout.Common.IO.UnixRelativePath", + "Fallout.Common.IO.WinRelativePath", + "Fallout.Common.ITargetDefinition", + "Fallout.Common.LegacyEnvironment", + "Fallout.Common.LogLevel", + "Fallout.Common.OnDemandAttribute", + "Fallout.Common.OnDemandValueInjectionAttribute", + "Fallout.Common.OptionalAttribute", + "Fallout.Common.ParameterAttribute", + "Fallout.Common.ParameterPrefixAttribute", + "Fallout.Common.ParameterService", + "Fallout.Common.PlatformFamily", + "Fallout.Common.RequiredAttribute", + "Fallout.Common.RequiresAttribute", + "Fallout.Common.RequiresAttribute`1", + "Fallout.Common.SecretAttribute", + "Fallout.Common.Setup", + "Fallout.Common.SpecialFolders", + "Fallout.Common.Target", + "Fallout.Common.Tooling.AptGetPackageRequirement", + "Fallout.Common.Tooling.AptGetToolAttribute", + "Fallout.Common.Tooling.ArgumentAttribute", + "Fallout.Common.Tooling.ArgumentEscapeAttribute", + "Fallout.Common.Tooling.ArgumentStringHandler", + "Fallout.Common.Tooling.BuilderAttribute", + "Fallout.Common.Tooling.CombinatorialConfigure`1", + "Fallout.Common.Tooling.CommandAttribute", + "Fallout.Common.Tooling.ConfigureExtensions", + "Fallout.Common.Tooling.Configure`1", + "Fallout.Common.Tooling.Configure`2", + "Fallout.Common.Tooling.DefaultLogLevel", + "Fallout.Common.Tooling.DelegateHelper", + "Fallout.Common.Tooling.EnumValueAttribute", + "Fallout.Common.Tooling.Enumeration", + "Fallout.Common.Tooling.Enumeration+TypeConverter`1", + "Fallout.Common.Tooling.EnumerationExtensions", + "Fallout.Common.Tooling.EnumerationJsonConverterFactory", + "Fallout.Common.Tooling.EnumerationJsonConverterFactory+TypedConverter`1", + "Fallout.Common.Tooling.IOptions", + "Fallout.Common.Tooling.IProcess", + "Fallout.Common.Tooling.IRequireAptGetPackage", + "Fallout.Common.Tooling.IRequireNpmPackage", + "Fallout.Common.Tooling.IRequireNuGetPackage", + "Fallout.Common.Tooling.IRequirePathTool", + "Fallout.Common.Tooling.IRequireTool", + "Fallout.Common.Tooling.IRequireToolWithVersion", + "Fallout.Common.Tooling.IToolOptionsWithFramework", + "Fallout.Common.Tooling.LogErrorAsStandard", + "Fallout.Common.Tooling.LogLevelPattern", + "Fallout.Common.Tooling.NpmPackageRequirement", + "Fallout.Common.Tooling.NpmToolAttribute", + "Fallout.Common.Tooling.NpmToolPathResolver", + "Fallout.Common.Tooling.NpmVersionResolver", + "Fallout.Common.Tooling.NuGetPackageRequirement", + "Fallout.Common.Tooling.NuGetPackageResolver", + "Fallout.Common.Tooling.NuGetPackageResolver+InstalledPackage", + "Fallout.Common.Tooling.NuGetPackageResolver+InstalledPackage+Comparer", + "Fallout.Common.Tooling.NuGetToolAttribute", + "Fallout.Common.Tooling.NuGetToolPathResolver", + "Fallout.Common.Tooling.NuGetVersionResolver", + "Fallout.Common.Tooling.ObjectFromFieldConverter", + "Fallout.Common.Tooling.ObjectFromFieldConverter+TypedConverter`1", + "Fallout.Common.Tooling.Options", + "Fallout.Common.Tooling.Options+TypeConverter", + "Fallout.Common.Tooling.Options+TypeConverter+InnerConverter`1", + "Fallout.Common.Tooling.OptionsExtensions", + "Fallout.Common.Tooling.Output", + "Fallout.Common.Tooling.OutputType", + "Fallout.Common.Tooling.PaketPackageResolver", + "Fallout.Common.Tooling.PathToolAttribute", + "Fallout.Common.Tooling.PathToolRequirement", + "Fallout.Common.Tooling.Process2", + "Fallout.Common.Tooling.ProcessException", + "Fallout.Common.Tooling.ProcessExtensions", + "Fallout.Common.Tooling.ProcessTasks", + "Fallout.Common.Tooling.Tool", + "Fallout.Common.Tooling.ToolAttribute", + "Fallout.Common.Tooling.ToolExecutor", + "Fallout.Common.Tooling.ToolInjectionAttributeBase", + "Fallout.Common.Tooling.ToolOptions", + "Fallout.Common.Tooling.ToolOptionsExtensions", + "Fallout.Common.Tooling.ToolOptionsWithFrameworkExtensions", + "Fallout.Common.Tooling.ToolPathResolver", + "Fallout.Common.Tooling.ToolRequirement", + "Fallout.Common.Tooling.ToolResolver", + "Fallout.Common.Tooling.ToolTasks", + "Fallout.Common.Tooling.ToolingExtensions", + "Fallout.Common.Tooling.VerbosityMapping", + "Fallout.Common.Tooling.VerbosityMappingAttribute", + "Fallout.Common.Utilities.AssemblyExtensions", + "Fallout.Common.Utilities.Collections.ArrayExtensions", + "Fallout.Common.Utilities.Collections.DictionaryExtensions", + "Fallout.Common.Utilities.Collections.EnumerableExtensions", + "Fallout.Common.Utilities.Collections.EnumerableExtensions+DelegateEqualityComparer`2", + "Fallout.Common.Utilities.Collections.LookupExtensions", + "Fallout.Common.Utilities.Collections.LookupTable`2", + "Fallout.Common.Utilities.CompletionUtility", + "Fallout.Common.Utilities.ConsoleUtility", + "Fallout.Common.Utilities.CredentialStore", + "Fallout.Common.Utilities.CustomFileWriter", + "Fallout.Common.Utilities.DelegateDisposable", + "Fallout.Common.Utilities.DisposableExtensions", + "Fallout.Common.Utilities.EncryptionUtility", + "Fallout.Common.Utilities.ExceptionExtensions", + "Fallout.Common.Utilities.JsonExtensions", + "Fallout.Common.Utilities.JsonNodeExtensions", + "Fallout.Common.Utilities.Lazy", + "Fallout.Common.Utilities.Net.HttpClientExtensions", + "Fallout.Common.Utilities.Net.HttpRequestBuilder", + "Fallout.Common.Utilities.Net.HttpRequestExtensions", + "Fallout.Common.Utilities.Net.HttpResponseException", + "Fallout.Common.Utilities.Net.HttpResponseExtensions", + "Fallout.Common.Utilities.Net.HttpResponseInspector", + "Fallout.Common.Utilities.ObjectExtensions", + "Fallout.Common.Utilities.ReflectionUtility", + "Fallout.Common.Utilities.ResourceUtility", + "Fallout.Common.Utilities.StringExtensions", + "Fallout.Common.Utilities.TaskExtensions", + "Fallout.Common.Utilities.UrlExtensions", + "Fallout.Common.Utilities.XElementExtensions", + "Fallout.Common.Utilities.XNodeExtensions", + "Fallout.Common.Utilities.XmlExtensions", + "Fallout.Common.ValueInjection.InjectNonParameterValuesAttribute", + "Fallout.Common.ValueInjection.InjectParameterValuesAttribute", + "Fallout.Common.ValueInjection.ValueInjectionAttributeBase", + "Fallout.Common.ValueInjection.ValueInjectionUtility", + "Fallout.Common.Verbosity", + "Fallout.Migrate.Shims.ShimAllPublicTypesUnderAttribute", + "Fallout.Solutions.ProjectExtensions", + "Fallout.Solutions.ProjectModelTasks", + "Fallout.Solutions.SolutionAttribute", + ]; +} diff --git a/tests/Fallout.Architecture.Tests/LayeringTests.cs b/tests/Fallout.Architecture.Tests/LayeringTests.cs new file mode 100644 index 000000000..0690471e8 --- /dev/null +++ b/tests/Fallout.Architecture.Tests/LayeringTests.cs @@ -0,0 +1,92 @@ +using Xunit; +using Arch = Fallout.Architecture.Tests.FalloutArchitecture; + +namespace Fallout.Architecture.Tests; + +/// +/// Dependency-direction rules. These lock in the layering the ProjectReference graph already enforces at +/// build time, so that future work (new types, the onion refactor, AI-assisted changes) cannot quietly invert an +/// edge that still compiles. Most carry an empty baseline — they are strict invariants today. +/// +/// Subjects and targets are scoped by assembly, never namespace: the legacy NUKE namespaces +/// (Fallout.Common.*, …) span several assemblies, so namespace-based layering would be meaningless here. +/// +public class LayeringTests +{ + [Fact] + public void Core_is_a_foundation_depending_on_nothing_else_in_the_repo() => + Ratchet.Enforce( + Arch.TypesIn(Arch.Core) + .Should().NotDependOnAny(Arch.TypesIn(Arch.AllAssembliesExcept(Arch.Core))), + "Fallout.Core is the pure reactor core and must reference no other Fallout/Nuke assembly", + KnownViolations.None); + + [Fact] + public void Utilities_is_a_foundation_depending_on_nothing_else_in_the_repo() => + Ratchet.Enforce( + Arch.TypesIn(Arch.Utilities) + .Should().NotDependOnAny(Arch.TypesIn(Arch.AllAssembliesExcept(Arch.Utilities))), + "Fallout.Utilities is a foundation library and must not depend on any other Fallout/Nuke assembly", + KnownViolations.None); + + [Theory] + [InlineData(Arch.UtilitiesIoCompression)] + [InlineData(Arch.UtilitiesIoGlobbing)] + [InlineData(Arch.UtilitiesNet)] + [InlineData(Arch.UtilitiesTextJson)] + [InlineData(Arch.UtilitiesTextYaml)] + public void Utility_satellite_depends_only_on_Fallout_Utilities(string satellite) => + Ratchet.Enforce( + Arch.TypesIn(satellite) + .Should().NotDependOnAny(Arch.TypesIn(Arch.AllAssembliesExcept(satellite, Arch.Utilities))), + $"{satellite} is a utility satellite and may depend only on Fallout.Utilities", + KnownViolations.None); + + [Fact] + public void Tooling_does_not_depend_on_upper_layers() => + Ratchet.Enforce( + Arch.TypesIn(Arch.Tooling) + .Should().NotDependOnAny(Arch.TypesIn(Arch.Build, Arch.Common, Arch.Cli, Arch.Components, Arch.ProjectModel, Arch.BuildShared)), + "Fallout.Tooling sits below the engine and must not depend on Build/Common/Cli/Components/ProjectModel/Build.Shared", + KnownViolations.None); + + [Fact] + public void ProjectModel_does_not_depend_on_upper_layers() => + Ratchet.Enforce( + Arch.TypesIn(Arch.ProjectModel) + .Should().NotDependOnAny(Arch.TypesIn(Arch.Build, Arch.Common, Arch.Cli, Arch.Components, Arch.BuildShared)), + "Fallout.ProjectModel must not depend on the Build/Common/Cli/Components/Build.Shared layers above it", + KnownViolations.None); + + [Fact] + public void Solution_facade_depends_only_on_Utilities_and_the_vendored_parser() => + Ratchet.Enforce( + Arch.TypesIn(Arch.Solution) + .Should().NotDependOnAny(Arch.TypesIn(Arch.AllAssembliesExcept(Arch.Solution, Arch.Utilities, "Fallout.Persistence.Solution"))), + "Fallout.Solution is a thin facade over the vendored parser and may depend only on Fallout.Utilities (+ the parser)", + KnownViolations.None); + + [Fact] + public void BuildShared_depends_only_on_Utilities() => + Ratchet.Enforce( + Arch.TypesIn(Arch.BuildShared) + .Should().NotDependOnAny(Arch.TypesIn(Arch.Build, Arch.Common, Arch.Cli, Arch.Components, Arch.ProjectModel, Arch.Tooling, Arch.Solution)), + "Fallout.Build.Shared is a low-level helper and must not depend on the layers above Fallout.Utilities", + KnownViolations.None); + + [Fact] + public void Nothing_depends_on_the_Cli_composition_root() => + Ratchet.Enforce( + Arch.TypesIn(Arch.AllAssembliesExcept(Arch.Cli)) + .Should().NotDependOnAny(Arch.TypesIn(Arch.Cli)), + "Fallout.Cli is the composition root (the dotnet tool) — nothing may depend back on it", + KnownViolations.None); + + [Fact] + public void Fallout_never_depends_on_the_Nuke_transition_shims() => + Ratchet.Enforce( + Arch.TypesIn(Arch.FalloutAssemblies()) + .Should().NotDependOnAny(Arch.TypesIn(Arch.NukeCommon, Arch.NukeBuild, Arch.NukeComponents)), + "The Nuke.* shims wrap Fallout.* for NUKE-era consumers; dependencies point inward (Nuke.* -> Fallout.*), never the reverse", + KnownViolations.None); +} diff --git a/tests/Fallout.Architecture.Tests/NamingTests.cs b/tests/Fallout.Architecture.Tests/NamingTests.cs new file mode 100644 index 000000000..21bbb6d65 --- /dev/null +++ b/tests/Fallout.Architecture.Tests/NamingTests.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; +using ArchUnitNET.Fluent; +using Xunit; +using Arch = Fallout.Architecture.Tests.FalloutArchitecture; + +namespace Fallout.Architecture.Tests; + +/// +/// Naming invariants. The headline one — a type's namespace should be rooted at its assembly name — is the main +/// debt-bearing rule in the suite: today's legacy NUKE namespaces span assemblies, so it starts with a sizeable +/// baseline () that the onion refactor will whittle down. +/// +public class NamingTests +{ + [Fact] + public void Types_reside_in_a_namespace_rooted_at_their_assembly_name() => + Ratchet.Enforce( + BuildNamespaceAlignmentRule(), + "A type's namespace should be rooted at its assembly name; mismatches are the legacy NUKE namespace sprawl", + KnownViolations.NamespaceAssemblyDrift); + + // One ArchUnitNET rule per assembly (the expected namespace differs per assembly), AND-combined into a single + // rule so the ratchet sees one flat set of offending type names across the whole repo. + private static IArchRule BuildNamespaceAlignmentRule() + { + IArchRule? combined = null; + foreach (var assembly in Arch.RuntimeLibraries) + { + var expectedRoot = "^" + Regex.Escape(assembly); + var rule = Arch.TypesIn(assembly) + .Should().ResideInNamespaceMatching(expectedRoot); + combined = combined is null ? rule : combined.And(rule); + } + + return combined!; + } +} diff --git a/tests/Fallout.Architecture.Tests/PurityTests.cs b/tests/Fallout.Architecture.Tests/PurityTests.cs new file mode 100644 index 000000000..27c700fea --- /dev/null +++ b/tests/Fallout.Architecture.Tests/PurityTests.cs @@ -0,0 +1,23 @@ +using Xunit; +using Arch = Fallout.Architecture.Tests.FalloutArchitecture; + +namespace Fallout.Architecture.Tests; + +/// +/// Purity invariants — what an assembly is allowed to reach for in the BCL / third-party world. +/// +public class PurityTests +{ + // Anything under System.IO, the System.Diagnostics.Process API, the console, or Serilog. Matched against the + // dependency type's full name (anchored alternation). Mirrors the original NetArchTest guard from issue #88. + private const string ImpureSurface = + @"^(?:System\.IO\.|System\.Diagnostics\.Process|System\.Console|Serilog(?:\.|$))"; + + [Fact] + public void Core_stays_pure_no_io_process_console_or_logging() => + Ratchet.Enforce( + Arch.TypesIn(Arch.Core) + .Should().NotDependOnAnyTypesThat().HaveFullNameMatching(ImpureSurface), + "Fallout.Core is held to a strict purity bar — no System.IO, Process, Console, or Serilog (issue #88)", + KnownViolations.None); +} diff --git a/tests/Fallout.Architecture.Tests/Ratchet.cs b/tests/Fallout.Architecture.Tests/Ratchet.cs new file mode 100644 index 000000000..4d0f3b00b --- /dev/null +++ b/tests/Fallout.Architecture.Tests/Ratchet.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ArchUnitNET.Domain; +using ArchUnitNET.Fluent; +using FluentAssertions; +using FluentAssertions.Execution; + +namespace Fallout.Architecture.Tests; + +/// +/// Enforces an architecture rule as a one-way ratchet against a documented baseline of known, +/// pre-existing violations (see ). The architecture here is known to be +/// partially broken; rather than fail the build on day-one debt, each rule is allowed exactly its baseline +/// violations and no more: +/// +/// a violation that is not in the baseline fails the test — a new regression sneaking in; +/// 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 only ever shrink. +/// +/// Pass for invariants that already hold (most of them) — those are then +/// strict, with zero tolerance. +/// +internal static class Ratchet +{ + public static void Enforce(IArchRule rule, string because, IReadOnlyCollection baseline) + { + var violations = rule.Evaluate(FalloutArchitecture.Architecture) + .Where(result => !result.Passed) + .Select(Identify) + .ToHashSet(StringComparer.Ordinal); + + var baselineSet = baseline.ToHashSet(StringComparer.Ordinal); + + var regressions = violations.Except(baselineSet).OrderBy(x => x, StringComparer.Ordinal).ToList(); + var resolved = baselineSet.Except(violations).OrderBy(x => x, StringComparer.Ordinal).ToList(); + + using var scope = new AssertionScope(); + + regressions.Should().BeEmpty( + $"{because}.\nNew architecture violation(s) were introduced that are not in the baseline. " + + "Fix them, or — if the dependency is genuinely intended — add them to the relevant list in " + + "KnownViolations with a justifying comment.\nOffenders:\n " + string.Join("\n ", regressions)); + + resolved.Should().BeEmpty( + $"{because}.\nThese baseline entries no longer violate, so the architecture improved here. " + + "Delete them from KnownViolations to lock the gain in — the ratchet only tightens.\nResolved:\n " + + string.Join("\n ", resolved)); + } + + /// + /// A stable identifier for a failing evaluation result. Type rules — all of ours — carry the offending + /// , whose full name is the baseline key; anything else falls back to ArchUnitNET's own + /// identifier. + /// + private static string Identify(EvaluationResult result) => + result.EvaluatedObject is IType type ? type.FullName : result.EvaluatedObjectIdentifier.ToString(); +} diff --git a/tests/Fallout.Core.Tests/ArchitectureFitnessTests.cs b/tests/Fallout.Core.Tests/ArchitectureFitnessTests.cs deleted file mode 100644 index d4aed0960..000000000 --- a/tests/Fallout.Core.Tests/ArchitectureFitnessTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Linq; -using System.Reflection; -using FluentAssertions; -using Fallout.Core.Planning; -using NetArchTest.Rules; -using Xunit; - -namespace Fallout.Core.Tests; - -/// -/// The acceptance criterion for issue #88: Fallout.Core is the pure reactor core. It depends on -/// nothing in the repo and never touches I/O, processes, the console, or logging. The broader -/// architecture-fitness suite lands in #95; these two tests guard the Core invariant specifically. -/// -public class ArchitectureFitnessTests -{ - private static readonly Assembly CoreAssembly = typeof(TopoSort).Assembly; - - [Fact] - public void Core_has_no_io_process_console_or_logging_dependency() - { - // Scope to our own Fallout.* types only. This excludes build-tool noise injected into the - // assembly that we don't author and can't keep pure: the generated `ThisAssembly` - // (Nerdbank.GitVersioning, no namespace) and `Coverlet.Core.Instrumentation.Tracker.*` - // (coverage instrumentation under `./build.ps1 Test`, which legitimately touches System.IO). - // Precise tokens (e.g. "System.Diagnostics.Process") rather than the broad "System.Diagnostics" - // namespace also avoid NetArchTest false-positives on generic types. - var result = Types.InAssembly(CoreAssembly) - .That().ResideInNamespaceStartingWith("Fallout") - .Should() - .NotHaveDependencyOnAny( - "System.IO", - "System.Diagnostics.Process", - "System.Console", - "Serilog") - .GetResult(); - - result.IsSuccessful.Should().BeTrue( - because: "Fallout.Core must stay pure; offending types: " + FailingTypes(result)); - } - - [Fact] - public void Core_does_not_depend_on_higher_fallout_layers() - { - var result = Types.InAssembly(CoreAssembly) - .That().ResideInNamespaceStartingWith("Fallout") - .Should() - .NotHaveDependencyOnAny( - "Fallout.Build", - "Fallout.Common.Tooling", - "Fallout.Common.Utilities", - "Fallout.ProjectModel", - "Fallout.Tooling", - "Fallout.Utilities") - .GetResult(); - - result.IsSuccessful.Should().BeTrue( - because: "Fallout.Core sits at the bottom and must reference no other Fallout project; " + - "offending types: " + FailingTypes(result)); - } - - private static string FailingTypes(TestResult result) => - result.FailingTypeNames is null ? "(none reported)" : string.Join(", ", result.FailingTypeNames); -} diff --git a/tests/Fallout.Core.Tests/Fallout.Core.Tests.csproj b/tests/Fallout.Core.Tests/Fallout.Core.Tests.csproj index fdb7ca21c..62647e18b 100644 --- a/tests/Fallout.Core.Tests/Fallout.Core.Tests.csproj +++ b/tests/Fallout.Core.Tests/Fallout.Core.Tests.csproj @@ -8,8 +8,4 @@ - - - -