diff --git a/.config/architecture-policy.json b/.config/architecture-policy.json index 04e9b10..719fce3 100644 --- a/.config/architecture-policy.json +++ b/.config/architecture-policy.json @@ -6,7 +6,7 @@ "qualifiedNameSeparator": ".", "dependencyArrowMeaning": "A ProjectReference: the referencing project compiles against the referenced one." }, - "sourceRoots": ["src"], + "sourceRoots": ["src/src"], "responsibilities": [ { "name": "ArchitectureTesting", diff --git a/.config/purview-build.json b/.config/purview-build.json index 04a0e8f..231c7fc 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -1,7 +1,7 @@ { "Build": { "Solution": "src/ArchitectureTesting.slnx", - "TestRoot": "src/tests", + "TestRoot": "src", "TestPatterns": "*Tests.csproj", "TestFilter": "/*/*/*/*[Category=Unit]/" }, @@ -13,8 +13,8 @@ "RequiredContent": { "purview.architecture.testing": [ "README.md", - "lib/net10.0/Purview.Architecture.Testing.dll", - "lib/net10.0/Purview.Architecture.Testing.xml", + "lib/$(TFM)/Purview.Architecture.Testing.dll", + "lib/$(TFM)/Purview.Architecture.Testing.xml", "content/architecture-policy.schema.json", "purview-logo-light.png" ] @@ -23,4 +23,4 @@ "Release": { "Mode": "None" } -} +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 6ebf8ce..cac191f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,11 @@ derived test class. The repository also applies that package to itself. | `src/src/ArchitectureTesting` | The package: `ArchitecturePolicy`, `ProjectGraph`, `AssemblyGraph`, `ArchitecturePolicyEvaluator`, `PurviewArchitectureTests` | | `src/tests/ArchitectureTesting.UnitTests` | Rule tests against in-memory and fixture graphs, and the reader tests | | `src/tests/Repository.UnitTests` | Repository-level assertions: the skill self-tests and the package applied to this repository | +| `src/samples/ArchitectureSample` | A small, fully working repository that adopts the package | +| `docs/` | The documentation site (MkDocs): getting started, policy reference, type-level rules, extending | | `architecture-policy.schema.json` | The published policy schema, embedded into the package as a resource | | `.config/architecture-policy.json` | This repository's own policy, exercised by the dogfood test | -| `skills/purview-architests` | The agent skill this repository publishes | +| `skills/` | The agent skills this repository publishes | | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package, analyzer, and build behavior | | `global.json` | Required .NET SDK and Microsoft.Testing.Platform selection | @@ -128,17 +130,25 @@ delivers skills through the skills CLI, which clones the *git* repository and on `.agents/skills/` is the CLI's install location for skills from **other** repositories, and is gitignored. This repository's own skills are never installed into it. -Install this repository's skill with: +Install this repository's skills with: ```bash npx skills add purview-dev/architests ``` -The single authored skill is `skills/purview-architests/`, which covers adopting, debugging and -extending `Purview.Architecture.Testing`. `src/tests/Repository.UnitTests/SkillTests.cs` validates it -on every test run through `Purview.Skills.Testing`'s rule set. The `[InheritsTests]` attribute on -that class is load-bearing — TUnit does not walk base types during discovery, so without it the run -is green and nothing is checked. +| Skill | Answers | +| -- | -- | +| `skills/purview-architests/` | adopting, configuring and debugging the package: the policy, the rules, waivers, the type-level rule, reading a failure | +| `skills/purview-architests-extending/` | adding a bespoke rule, the `ArchitectureContext`, and the package's internals | + +Both carry the `purview-architests` prefix, because skills install into one flat directory per agent +shared with every skill a developer has installed from anywhere. `src/tests/Repository.UnitTests/SkillTests.cs` +validates them on every test run through `Purview.Skills.Testing`'s rule set, and asserts the prefix. +The `[InheritsTests]` attribute on that class is load-bearing — TUnit does not walk base types during +discovery, so without it the run is green and nothing is checked. + +Keep the docs in step too: `README.md`, the package README, `docs/`, the schema and the skills all +describe the same behaviour. ## Completion checklist diff --git a/Directory.Packages.props b/Directory.Packages.props index 7aeeb1e..17e428e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ true true 1.75.0 - 9.4.0 + 9.4.1 @@ -21,6 +21,6 @@ - + diff --git a/README.md b/README.md index ef987b2..3653854 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,82 @@ [![NuGet](https://img.shields.io/nuget/v/Purview.Architecture.Testing.svg)](https://www.nuget.org/packages/Purview.Architecture.Testing) [![Release](https://github.com/purview-dev/architests/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/architests/actions/workflows/release.yml) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.md) -Enforces a repository's project and assembly dependency policy as TUnit tests, so the rules are -defined once rather than copied into every repository's test project. +Enforce a repository's architecture policy as TUnit tests, so the rules are defined once rather than +copied into every repository's test project. -Add a policy at `.config/architecture-policy.json`, add one derived test class, and the rules run: -dependency direction at both project and type level, sibling independence, composition boundaries, -forbidden packages and framework references, cycles, naming and test-reference checks. +- **Project-level dependency direction** — a declared `ProjectReference` the matrix forbids, or + does not permit. +- **Type-level dependency direction** — a type in one responsibility using a type in another, + including a dependency that only arrives transitively or through a package. Read from the compiled + assemblies by metadata only: nothing is loaded or executed. +- **Composition boundary** — only the composition root's category is exempt from being referenced + back to. +- **Sibling independence** — qualified implementations of a responsibility must not reference one + another. +- **Forbidden packages and framework references** — e.g. `Microsoft.AspNetCore.App` in a portable + layer. +- **Cycles, naming and test references** — the rest of the standard set, reported readably. +- **Waivers** — reviewed, per-project departures that keep every other rule in force and are + themselves checked. +- **An extension point** — add a bespoke rule to the same reporting, `SkipRule` and waiver model, + without a second architecture framework. + +Adoption is two things: a policy file and one derived class. + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": [], + "mustNotDependOn": ["Application", "Persistence"], + "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"] + } + ] +} +``` ```csharp [InheritsTests] public sealed class ArchitectureTests : PurviewArchitectureTests; ``` +> **`[InheritsTests]` is required.** TUnit does not walk base types during discovery, so without it +> the class compiles, the run is green and nothing is enforced. TUnit's analyzer reports `TUnit0030` +> — treat it as an error. + ## Install ```text dotnet add package Purview.Architecture.Testing ``` -See the [package README](src/src/ArchitectureTesting/README.md) for the full policy reference and -[`architecture-policy.schema.json`](architecture-policy.schema.json) for the schema. +## Documentation + +- [Getting started](docs/Getting-Started.md) — adopt the package in a repository, step by step. +- [Policy reference](docs/Policy-Reference.md) — every field, every rule id, every default. +- [Type-level rules](docs/Type-Level-Rules.md) — how the assembly check works and how an assembly + is attributed to a project. +- [Extending](docs/Extending.md) — add your own rule to the standard set. +- [The policy schema](architecture-policy.schema.json) — the published JSON Schema. + +## Sample + +[`src/samples/ArchitectureSample`](src/samples/ArchitectureSample/README.md) is a small, fully +working repository that adopts the package: four projects, a policy, and a test project whose whole +suite is one derived class. + +```bash +dotnet test src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj +``` ## Repository @@ -30,8 +85,9 @@ See the [package README](src/src/ArchitectureTesting/README.md) for the full pol evaluator, and the TUnit base class. - `src/tests/ArchitectureTesting.UnitTests` — rule tests against in-memory and fixture graphs. - `src/tests/Repository.UnitTests` — the skill self-tests and the package applied to this repository. +- `src/samples/ArchitectureSample` — the adoption sample. - `architecture-policy.schema.json` — the published policy schema, embedded into the package. -- `skills/purview-architests` — the agent skill this repository publishes. +- `skills/` — the agent skills this repository publishes. ## Agent skills @@ -43,7 +99,12 @@ npx skills add purview-dev/architests ```text dotnet tool restore +just restore just build just test +just lint-check just pack +just pipeline-pr ``` + +Part of [Purview.BuildSdk](https://github.com/purview-dev/build-sdk) · [purview.dev](https://purview.dev/projects/architests/) diff --git a/docs/Extending.md b/docs/Extending.md new file mode 100644 index 0000000..ec41e3c --- /dev/null +++ b/docs/Extending.md @@ -0,0 +1,66 @@ +# Extending + +A repository can add a bespoke check without a second architecture framework. Implement +`IArchitectureRule` and return the same `ArchitectureViolation` type as the built-in rules: + +```csharp +using Purview.Architecture.Testing; + +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests +{ + protected override IReadOnlyList AdditionalRules => [new NoConsoleLoggingRule()]; + + sealed class NoConsoleLoggingRule : IArchitectureRule + { + public string Rule => "noConsoleLogging"; + + public IReadOnlyList Evaluate(ArchitectureContext context) => + context.Projects.Values + .Where(project => !project.IsTestProject) + // ...inspect the project or its assembly... + .Select(project => new ArchitectureViolation(Rule, project.Name, "Console logging is banned here.")) + .ToArray(); + } +} +``` + +## What the context gives you + +`ArchitectureContext` carries the three inputs a rule can need: + +| Member | What it is | +| -- | -- | +| `Policy` | The parsed `ArchitecturePolicy`, so a rule reads the same definition the built-in rules do. | +| `Projects` | The project graph, keyed by project name, with declared references, packages, framework references and analyzers. | +| `Assemblies` | The compiled assemblies, keyed by project name, each with the external types it references. Empty when `assemblyRules.enabled` is false. | + +## What you get for free + +An additional rule participates in everything the built-in rules do: + +- **Its own `[Test]`.** Each rule id becomes a case of + `AdditionalRule_GivenTheRepository_RespectsThePolicy`, so a failure names the rule. +- **`SkipRule`.** Override `SkipRule` and return `true` for the rule's `Rule` id. +- **Waivers.** A waiver whose `rules` names the rule's id is honoured automatically, so a reviewed, + per-project departure needs no code. +- **The same reporting.** `ArchitecturePolicyEvaluator.Report` renders the violations, and the + failure's `.Because(...)` lists every one. + +## Keep the rule in a fixture + +The evaluator takes an in-memory graph, so a rule is testable as a small fixture rather than a +repository on disk: + +```csharp +var evaluator = new ArchitecturePolicyEvaluator( + policy, + projects, + assemblies, + [new NoConsoleLoggingRule()] +); + +await Assert.That(evaluator.EvaluateAdditionalRule("noConsoleLogging")).IsEmpty(); +``` + +That is exactly how the package's own rules are tested. diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md new file mode 100644 index 0000000..aa537e3 --- /dev/null +++ b/docs/Getting-Started.md @@ -0,0 +1,122 @@ +# Getting started + +## 1. Add the package + +```text +dotnet add package Purview.Architecture.Testing +``` + +Add it to a test project that already references the projects you want to govern, or create a +dedicated `Architecture.UnitTests` project. The test project must reference the production projects, +directly or transitively, because the type-level rule reads their compiled assemblies from the test +output. + +## 2. Write the policy + +Create `.config/architecture-policy.json` at the repository root. The smallest useful policy names +each responsibility and what it may depend on: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Domain", + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": [], + "mustNotDependOn": ["Application", "Persistence", "Composition"] + }, + { + "name": "Application", + "category": "application", + "purpose": "Coordinates the use cases and workflows.", + "mayDependOn": ["Domain"], + "mustNotDependOn": ["Persistence", "Composition"] + }, + { + "name": "Persistence", + "category": "infrastructure", + "purpose": "Durable storage and persistence-specific configuration.", + "mayDependOn": ["Domain", "Application"], + "mustNotDependOn": ["Composition"] + }, + { + "name": "Composition", + "category": "composition", + "purpose": "The composition root that selects and wires the implementations.", + "mayDependOn": ["Domain", "Application", "Persistence"], + "mustNotDependOn": [] + } + ] +} +``` + +A project name is `[.]`, and only the **first segment** is +classified: `Persistence.SqlServer` and `Persistence` are both `Persistence`. Qualification is a +naming choice, never a different set of rules. + +`sourceRoots` defaults to `["src"]`. Getting it wrong finds no projects, which the `graphDiscovery` +rule reports rather than letting every other rule pass vacuously. + +## 3. Add the test class + +```csharp +using Purview.Architecture.Testing; + +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +That is the whole suite. Each rule is a separate `[Test]`, so a failure names the rule rather than +collapsing the set into one assertion. + +> **`[InheritsTests]` is required.** TUnit does not walk base types during discovery, so without the +> attribute the class compiles, the run is green, and nothing is enforced — the worst failure this +> package can have. TUnit's analyzer reports `TUnit0030`; treat it as an error. + +## 4. Run it + +```bash +dotnet test +``` + +If `graphDiscovery` fails, `sourceRoots` is wrong. If `naming` fails, a project name does not map to +a responsibility — add an alias or a waiver. If `dependencyMatrix` or `assemblyDependencyMatrix` +fails, the message names the offending reference, the direction the dependency should run, and the +likely fix. + +## 5. Turn on the type-level rule + +The project-level matrix only sees declared `ProjectReference`s. To also catch a type that arrives +transitively or through a package, enable the assembly check: + +```json +"assemblyRules": { + "enabled": true +} +``` + +An empty `assemblyRoots` scans the test output directory, which already holds every project the test +project references. See [Type-level rules](Type-Level-Rules.md) for how an assembly is attributed to +a project. + +## Overrides + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests +{ + protected override string? RepositoryRoot => "/path/to/repo"; +} +``` + +`RepositoryRoot` defaults to probing upward from the test binary for the nearest policy file. The +search **stops at the repository boundary** (`.git`) rather than continuing into parent folders — +walking past it would happily find a sibling checkout's policy and validate this repository against +someone else's rules. + +`SkipRule` opts a whole repository out of a rule. Prefer a [waiver](Policy-Reference.md#waivers): it +is per project, carries a reason and a review date, and is itself checked. diff --git a/docs/Policy-Reference.md b/docs/Policy-Reference.md new file mode 100644 index 0000000..49e5129 --- /dev/null +++ b/docs/Policy-Reference.md @@ -0,0 +1,101 @@ +# Policy reference + +The policy is `.config/architecture-policy.json`. It is validated against the schema embedded in the +package before any rule runs, so a field the reader understands but the schema rejects is a bug. + +The authoritative definition is +[`architecture-policy.schema.json`](https://github.com/purview-dev/architests/blob/main/architecture-policy.schema.json). + +## Top-level fields + +| Field | Type | Default | Meaning | +| -- | -- | -- | -- | +| `$schema` | string | — | Pointer to the published schema, so an editor validates as you type. | +| `version` | integer ≥ 1 | — | The document version. The current version is **2**. | +| `description` | string | — | Free text. | +| `conventions` | object | — | `qualifiedNameSeparator` and `dependencyArrowMeaning`, for humans and editors. | +| `sourceRoots` | string[] | `["src"]` | Repo-relative folders holding the projects. An empty array scans the repository root. | +| `responsibilities` | object[] | — | **Required**, at least one. The responsibility catalogue. | +| `discouragedProjectNames` | object | — | Names that describe a category, with a `reason`. | +| `assemblyRules` | object | — | The type-level check. Off unless `enabled` is true. | +| `waivers` | object[] | `[]` | Deliberate, reviewed departures. | + +### `version` 2 + +Version 2 removed the `rules` object (`noCycles`, `noProductionToTestReferences`, +`implementationsMustNotReferenceComposition`, `siblingImplementationsIndependent`), whose flags were +deserialised but never consulted. Use `SkipRule` or a waiver to opt out of a rule. A version-1 policy +that set `rules` must delete the block; a version-1 policy that did not still validates. + +## Responsibilities + +| Field | Type | Required | Meaning | +| -- | -- | -- | -- | +| `name` | string | ✅ | A single PascalCase segment, with no dots. | +| `aliases` | string[] | | Recognised alternative names; an established project using one is not renamed. | +| `category` | enum | ✅ | `domain`, `application`, `infrastructure`, `composition`, `host` or `shared`. | +| `purpose` | string ≥ 20 | ✅ | Why the responsibility exists; quoted in failure messages. | +| `mayDependOn` | string[] | ✅ | Responsibilities this one may reference. An empty array means it depends on nothing. | +| `mustNotDependOn` | string[] | ✅ | Responsibilities this one must never reference. | +| `forbiddenPackagePrefixes` | string[] | | NuGet identifier prefixes that must not appear, e.g. `Microsoft.EntityFrameworkCore`. | +| `forbiddenFrameworkReferences` | string[] | | Framework reference prefixes that must not appear, e.g. `Microsoft.AspNetCore.App`. | +| `siblingIndependence` | boolean | | When true, qualified implementations of this responsibility must not reference one another. | +| `separateProjectWhen` | string ≥ 10 | | Documentation only. | +| `mayRemainCombinedWhen` | string ≥ 10 | | Documentation only. | + +## Assembly rules + +| Field | Type | Default | Meaning | +| -- | -- | -- | -- | +| `enabled` | boolean | `false` | Whether the type-level check runs. It reads compiled assemblies, so it needs a build to have produced them. | +| `assemblyRoots` | string[] | `[]` | Repo-relative folders to scan. An empty array scans the test output directory. | +| `assemblyGlobs` | string[] | `["*.dll"]` | File globs matched under each root. | + +## Waivers + +| Field | Type | Required | Meaning | +| -- | -- | -- | -- | +| `project` | string | ✅ | The project the waiver applies to, exactly as it appears on disk. | +| `mapsTo` | string | | The responsibility the project actually has. Keeps every dependency rule in force while excusing the name. | +| `rules` | string[] | ✅ | The rule ids this waiver excuses. At least one. | +| `reason` | string ≥ 40 | ✅ | Why the departure is acceptable. | +| `reviewedOn` | date | | When the departure was last reviewed. | + +Any rule id may be waived, including `siblingIndependence` and `assemblyDependencyMatrix`. An unknown +id is a `waiverIntegrity` failure, so a typo cannot silently excuse nothing. + +## Rules + +| Rule id | Catches | +| -- | -- | +| `graphDiscovery` | No production projects were discovered, so every other rule would pass vacuously. Not waivable. | +| `naming` | A production project whose name maps to no responsibility or alias. | +| `discouragedName` | A project named after a category, so nothing can be kept out of it. | +| `dependencyMatrix` | A declared `ProjectReference` the matrix forbids or does not permit. | +| `assemblyDependencyMatrix` | A type reference the matrix forbids or does not permit, including one that arrives transitively or through a package. | +| `siblingIndependence` | A qualified implementation referencing a qualified sibling. | +| `compositionBoundary` | A non-composition, non-host project referencing a project whose category is `composition`. | +| `forbiddenPackage` | A package matching a forbidden prefix for the responsibility. | +| `forbiddenFramework` | A `` matching a forbidden prefix for the responsibility. | +| `cycle` | A cycle in the project dependency graph, reported readably. | +| `testReference` | A production project referencing a test project. | +| `waiverIntegrity` | A waiver that is stale, unjustified, malformed, or excuses an unknown rule. Not waivable. | + +### Dependency matrix semantics + +Both `dependencyMatrix` and `assemblyDependencyMatrix` apply the same three-way rule: + +1. **Same responsibility is always allowed.** A qualified implementation referencing its unqualified + sibling is the same box on the diagram. +2. **`mustNotDependOn` is a violation.** +3. **`mayDependOn` is permitted.** + +A reference the matrix neither permits nor forbids is a **violation**: silence on a dependency is not +permission, it is a gap in the policy. Add the responsibility to `mayDependOn` if the dependency is +intended. + +### Test and production projects + +A project is a **test project** when its name ends with `Tests`, **or** its path contains a `tests` +directory. Test projects are excluded from every rule — a test references everything by design. +Everything else is a **production project**. diff --git a/docs/Type-Level-Rules.md b/docs/Type-Level-Rules.md new file mode 100644 index 0000000..6fe5456 --- /dev/null +++ b/docs/Type-Level-Rules.md @@ -0,0 +1,58 @@ +# Type-level rules + +A project file only shows a declared `ProjectReference`. It cannot show a type that arrives +transitively through another project, or a type pulled in by a package. The `assemblyDependencyMatrix` +rule closes that gap: it reads the compiled assemblies and applies the **same** +`mayDependOn` / `mustNotDependOn` matrix to the types each one actually references. + +It is off by default, because it needs a build to have produced the assemblies: + +```json +"assemblyRules": { + "enabled": true, + "assemblyRoots": [], + "assemblyGlobs": ["*.dll"] +} +``` + +## How it reads + +- **Metadata only.** The reader opens each assembly with `System.Reflection.Metadata` and walks the + `TypeReference` table. Nothing is loaded, executed, or resolved through a probing path, so a type + initialiser never runs and a missing dependency never throws. +- **The `TypeReference` table is the dependency set.** The compiler emits a row for every external + type a member signature, base type, interface, generic argument, attribute or method body touches — + including a type used only inside a method body. +- **References resolve against the scanned set.** A reference is attributed to a project only if the + defining assembly is itself one of the scanned project assemblies. A package is never mistaken for + a project: `Microsoft.Extensions.Logging.Abstractions` is not `Abstractions`. + +## How an assembly is attributed to a project + +1. **The SDK marker.** `Purview.BuildSdk` writes a global `public static partial class + Assembly` into every production project, so an assembly whose name is unrelated to + its project — an explicit `AssemblyName` — still maps exactly. This is the same marker the + generated assembly identifier exists for. +2. **Exact name.** A repository that opts the marker out, or a project the SDK did not build, maps + when its assembly name is its project name. + +An assembly that maps to no project is a package and is ignored. + +## Where the assemblies come from + +An empty `assemblyRoots` scans the test output directory (`AppContext.BaseDirectory`), which already +holds every project the test project references, directly or transitively. Set `assemblyRoots` to +repo-relative folders only if the assemblies live somewhere else; `obj` output is always skipped so a +reference assembly never stands in for the real one. + +## When it finds nothing + +Enabled with no production assemblies found is a **failure**, not a silent pass — the same guard +`graphDiscovery` provides for projects. Build the repository before running the tests, or point +`assemblyRoots` at the right folder. + +## Qualified implementations + +At the type level, a qualified implementation collapses into its responsibility, exactly as it does +at the project level: a type in `Persistence.SqlServer` referencing a type in `Persistence` is a +same-responsibility reference and is allowed. diff --git a/docs/index.md b/docs/index.md index 89a40fd..0a17a15 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,32 @@ # Purview Architecture Testing -Enforces a repository's project and assembly dependency policy as TUnit tests, so the rules are -defined once rather than copied into every repository's test project. +Enforce a repository's architecture policy as TUnit tests, so the rules are defined once rather than +copied into every repository's test project. -[Adopt the package](https://github.com/purview-dev/architests#adopt) · [purview.dev](https://purview.dev/projects/architests/) +Adoption is a policy file at `.config/architecture-policy.json` and one derived test class: + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +[Get started](Getting-Started.md){ .md-button .md-button--primary } ## Guides -- [Getting started](https://github.com/purview-dev/architests/blob/main/src/src/ArchitectureTesting/README.md) -- [The policy schema](https://github.com/purview-dev/architests/blob/main/architecture-policy.schema.json) +- [Getting started](Getting-Started.md) — adopt the package in a repository, step by step. +- [Policy reference](Policy-Reference.md) — every field, every rule id, every default. +- [Type-level rules](Type-Level-Rules.md) — how the assembly check works and how an assembly is + attributed to a project. +- [Extending](Extending.md) — add your own rule to the standard set. + +## Reference -## Skills +- [The policy schema](https://github.com/purview-dev/architests/blob/main/architecture-policy.schema.json) +- [The sample](https://github.com/purview-dev/architests/tree/main/src/samples/ArchitectureSample) +- [The package README](https://github.com/purview-dev/architests/blob/main/src/src/ArchitectureTesting/README.md) -This repository publishes the `purview-architests` agent skill: +## Agent skills ```bash npx skills add purview-dev/architests diff --git a/global.json b/global.json index e92348e..2851e76 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.6" + "Purview.BuildSdk": "1.0.7" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/mkdocs.yml b/mkdocs.yml index 5627a35..ef2ba1b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,13 @@ repo_url: https://github.com/purview-dev/architests edit_uri: edit/main/docs/ docs_dir: docs +nav: + - Home: index.md + - Getting started: Getting-Started.md + - Policy reference: Policy-Reference.md + - Type-level rules: Type-Level-Rules.md + - Extending: Extending.md + theme: name: material palette: diff --git a/package.json b/package.json index ca0af73..961a2d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "architests", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "author": { "name": "Kieron Lanning", @@ -14,4 +14,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/architests.git" } -} +} \ No newline at end of file diff --git a/skills/purview-architests-extending/SKILL.md b/skills/purview-architests-extending/SKILL.md new file mode 100644 index 0000000..96267b2 --- /dev/null +++ b/skills/purview-architests-extending/SKILL.md @@ -0,0 +1,123 @@ +--- +name: purview-architests-extending +description: Use when adding a bespoke rule to Purview.Architecture.Testing, implementing IArchitectureRule, or working on the package's internals - the three-layer design, the project graph, the metadata-only assembly reader, or the evaluator. Covers the ArchitectureContext, what an additional rule gets for free, and testing a rule against an in-memory fixture. +license: MIT +metadata: + product: Purview.Architecture.Testing + repository: purview-dev/architests +--- + +# Extending Purview.Architecture.Testing + +A repository can add a bespoke check without a second architecture framework. This skill covers the +extension point and the internals a rule is built on. To *consume* the package — adopting it, +writing a policy, reading a failure — use the `purview-architests` skill instead. + +## Add a rule + +Implement `IArchitectureRule` and list it in `AdditionalRules`: + +```csharp +using Purview.Architecture.Testing; + +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests +{ + protected override IReadOnlyList AdditionalRules => [new NoConsoleLoggingRule()]; + + sealed class NoConsoleLoggingRule : IArchitectureRule + { + public string Rule => "noConsoleLogging"; + + public IReadOnlyList Evaluate(ArchitectureContext context) => + context.Projects.Values + .Where(project => !project.IsTestProject) + // ...inspect the project or its assembly... + .Select(project => new ArchitectureViolation(Rule, project.Name, "Console logging is banned here.")) + .ToArray(); + } +} +``` + +The `Rule` id is the key everywhere: it names the generated test, it is what `SkipRule` receives, and +it is what a waiver's `rules` array matches. + +## What the context gives you + +`ArchitectureContext` carries the three inputs a rule can need: + +| Member | What it is | +| -- | -- | +| `Policy` | The parsed `ArchitecturePolicy`, so a rule reads the same definition the built-in rules do. | +| `Projects` | The project graph, keyed by project name, with declared references, packages, framework references and analyzers. | +| `Assemblies` | The compiled assemblies, keyed by project name, each with the external types it references. Empty when `assemblyRules.enabled` is false. | + +`ArchitecturePolicy.Classify(projectName)` is the classification every built-in rule uses — the first +segment of the project name, honouring aliases and a waiver's `mapsTo`. Use it so a custom rule +agrees with the standard set. + +## What an additional rule gets for free + +- **Its own `[Test]`.** Each rule id becomes a case of + `AdditionalRule_GivenTheRepository_RespectsThePolicy`, so a failure names the rule rather than + collapsing into one assertion. +- **`SkipRule`.** Override `SkipRule` and return `true` for the rule id. +- **Waivers.** A waiver whose `rules` names the rule id is honoured automatically, so a reviewed, + per-project departure needs no code. +- **Reporting.** `ArchitecturePolicyEvaluator.Report` renders the violations, and the failure's + `.Because(...)` lists every one. + +A violation's `Project` must be the project name (or the offender a waiver can name), and its `Rule` +must be the rule id, or the waiver filter cannot match it. + +## Test the rule against a fixture + +The evaluator takes an in-memory graph, so a rule is tested as a small fixture rather than a +repository on disk — exactly how the package's own rules are tested: + +```csharp +var evaluator = new ArchitecturePolicyEvaluator( + policy, + projects, + assemblies, + [new NoConsoleLoggingRule()] +); + +await Assert.That(evaluator.EvaluateAdditionalRule("noConsoleLogging")).IsEmpty(); +``` + +`ArchitecturePolicyEvaluator` also has a two-argument constructor for project-only rules, and a +four-argument one when the rule needs the assembly graph. + +## The three layers (non-negotiable) + +Keep the separation, because it is what makes a rule testable: + +- **`ArchitecturePolicy` / `ProjectGraph` / `AssemblyGraph`** — read the policy, the `.csproj` graph + and the compiled assembly metadata. **No MSBuild and no test-framework types.** +- **`ArchitecturePolicyEvaluator`** — the rules, returning typed `ArchitectureViolation` lists. It + takes an **in-memory graph**, so a rule is testable as a small fixture. +- **`PurviewArchitectureTests`** — the TUnit wrapper, one `[Test]` per rule. + +Every new rule must be evaluable from an in-memory fixture and asserted by a small +`ArchitectureTesting.UnitTests` test. + +## Reading assemblies + +- Read compiled assemblies by **metadata only** (`System.Reflection.Metadata`). Never `Assembly.Load` + or any API that executes code or needs a probing path. +- The `TypeReference` table is the dependency set: base types, interfaces, generic arguments, + signatures, attributes and method-body uses all appear there. +- Attribute an assembly to a project by the SDK's generated `Assembly` marker, falling + back to an exact project-name match. An assembly that maps to no project is a package and is + ignored — a package is never a project. +- Resolve references against the set of assemblies actually scanned, never by name guessing. + +## Repository conventions + +- TUnit on Microsoft.Testing.Platform. No xUnit, NUnit, MSTest or FluentAssertions. +- Tests use AAA and `Subject_Scenario_Expectation` naming, and must pass on Windows, Linux and macOS. +- `architecture-policy.schema.json` at the repository root is both the published schema and the + resource embedded in the package. Never keep a second copy. +- The repository applies its own package to itself (`src/tests/Repository.UnitTests`), with the + sample at `src/samples/ArchitectureSample` showing consumer adoption. diff --git a/skills/purview-architests/SKILL.md b/skills/purview-architests/SKILL.md index a248ac4..754f567 100644 --- a/skills/purview-architests/SKILL.md +++ b/skills/purview-architests/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-architests -description: Adopt or debug Purview.Architecture.Testing, the package that enforces a repository's project and assembly dependency policy as TUnit tests. Use when adding architecture tests to a repository, writing or changing .config/architecture-policy.json, recording a waiver, interpreting a dependency-matrix, type-level, cycle, sibling-independence, framework-reference or naming failure, adding a bespoke rule, or when the architecture tests pass but enforce nothing. +description: Adopt or debug Purview.Architecture.Testing, the package that enforces a repository's project and type-level dependency policy as TUnit tests. Use when adding architecture tests to a repository, writing or changing .config/architecture-policy.json, enabling the assembly rule, recording a waiver, interpreting a dependency-matrix, type-level, cycle, sibling-independence, composition-boundary, forbidden-package, framework-reference or naming failure, or when the architecture tests pass but enforce nothing. license: MIT metadata: product: Purview.Architecture.Testing @@ -165,6 +165,8 @@ Each additional rule becomes its own `[Test]` (`AdditionalRule_GivenTheRepositor so a failure names the rule. It participates in the same failure reporting, `SkipRule` (by its `Rule` id) and waiver model — a waiver whose `rules` names it is honoured automatically. +The `purview-architests-extending` skill covers the full extension point and the package's internals. + ## Reading a failure Each rule is its own test, so a failure names the rule rather than collapsing the set into one diff --git a/src/ArchitectureTesting.slnx b/src/ArchitectureTesting.slnx index 290aa2d..f88514a 100644 --- a/src/ArchitectureTesting.slnx +++ b/src/ArchitectureTesting.slnx @@ -12,6 +12,13 @@ + + + + + + + diff --git a/src/samples/ArchitectureSample/.config/architecture-policy.json b/src/samples/ArchitectureSample/.config/architecture-policy.json new file mode 100644 index 0000000..ebeb01f --- /dev/null +++ b/src/samples/ArchitectureSample/.config/architecture-policy.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, + "description": "The sample repository's architecture policy: a small layered application.", + "conventions": { + "qualifiedNameSeparator": ".", + "dependencyArrowMeaning": "A ProjectReference: the referencing project compiles against the referenced one." + }, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Domain", + "category": "domain", + "purpose": "The sample's domain model and its invariants.", + "mayDependOn": [], + "mustNotDependOn": ["Application", "Persistence", "Composition"], + "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"] + }, + { + "name": "Application", + "category": "application", + "purpose": "Coordinates the sample's use cases and workflows.", + "mayDependOn": ["Domain"], + "mustNotDependOn": ["Persistence", "Composition"], + "forbiddenFrameworkReferences": ["Microsoft.AspNetCore.App"] + }, + { + "name": "Persistence", + "category": "infrastructure", + "purpose": "Durable storage for the sample's orders.", + "mayDependOn": ["Domain"], + "mustNotDependOn": ["Application", "Composition"] + }, + { + "name": "Composition", + "category": "composition", + "purpose": "The sample's composition root, which selects and wires the implementations.", + "mayDependOn": ["Domain", "Application", "Persistence"], + "mustNotDependOn": [] + } + ], + "discouragedProjectNames": { + "names": ["Infrastructure", "Services", "Helpers", "Common", "Utilities", "Managers"], + "reason": "These name a category rather than a responsibility, so nothing can be kept out of them." + }, + "assemblyRules": { + "enabled": true + } +} diff --git a/src/samples/ArchitectureSample/README.md b/src/samples/ArchitectureSample/README.md new file mode 100644 index 0000000..fc5755f --- /dev/null +++ b/src/samples/ArchitectureSample/README.md @@ -0,0 +1,46 @@ +# Architecture sample + +A small, fully working repository that adopts `Purview.Architecture.Testing`. It is a repository +inside the repository: it has its own `.config/architecture-policy.json`, its own source layout and +its own test project, so it demonstrates exactly what a consumer does. + +``` +ArchitectureSample/ +├── .config/architecture-policy.json the policy — the single definition of the rules +├── src/ +│ ├── Domain/ the domain model, depends on nothing +│ ├── Application/ use cases, depends on Domain +│ ├── Persistence/ storage, depends on Domain +│ └── Composition/ the composition root, depends on all three +└── tests/Sample.ArchitectureTests/ one derived class; the whole suite +``` + +The entire suite is one line: + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +`[InheritsTests]` is required: TUnit does not walk base types during discovery, so without it the +class compiles, the run is green, and nothing is enforced. + +## What the policy enforces here + +| Rule | What it checks in this sample | +| -- | -- | +| `dependencyMatrix` | `Domain` depends on nothing; `Application` and `Persistence` may depend on `Domain`; `Composition` may depend on all three | +| `assemblyDependencyMatrix` | the same direction at the **type** level, read from the compiled assemblies | +| `compositionBoundary` | only `Composition` (category `composition`) may be referenced by no one — nothing points back at it | +| `forbiddenFramework` | `Domain` and `Application` must not reference `Microsoft.AspNetCore.App` | +| `cycle`, `naming`, `discouragedName`, `testReference`, `waiverIntegrity` | the rest of the standard set | + +## Run it + +```bash +dotnet test src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj +``` + +To see a rule fire, add a `ProjectReference` from `Domain` to `Persistence` in +`src/Domain/Domain.csproj` and run it again: `dependencyMatrix` fails and the message names the +direction and the fix. Remove it to go green. diff --git a/src/samples/ArchitectureSample/src/Application/Application.csproj b/src/samples/ArchitectureSample/src/Application/Application.csproj new file mode 100644 index 0000000..eeba661 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Application/Application.csproj @@ -0,0 +1,9 @@ + + + false + + + + + + diff --git a/src/samples/ArchitectureSample/src/Application/PlaceOrderHandler.cs b/src/samples/ArchitectureSample/src/Application/PlaceOrderHandler.cs new file mode 100644 index 0000000..1651396 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Application/PlaceOrderHandler.cs @@ -0,0 +1,12 @@ +using Purview.Architecture.Domain; + +namespace Purview.Architecture.Application; + +/// Coordinates placing an order. Application may depend on the domain. +public sealed class PlaceOrderHandler +{ + /// Places an order for the given total. + /// The order total. + /// The placed order. + public Order Place(decimal total) => new(Guid.NewGuid(), total); +} diff --git a/src/samples/ArchitectureSample/src/Composition/Composition.csproj b/src/samples/ArchitectureSample/src/Composition/Composition.csproj new file mode 100644 index 0000000..b1d3f53 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Composition/Composition.csproj @@ -0,0 +1,11 @@ + + + false + + + + + + + + diff --git a/src/samples/ArchitectureSample/src/Composition/CompositionRoot.cs b/src/samples/ArchitectureSample/src/Composition/CompositionRoot.cs new file mode 100644 index 0000000..a6a3199 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Composition/CompositionRoot.cs @@ -0,0 +1,27 @@ +using Purview.Architecture.Application; +using Purview.Architecture.Persistence; + +namespace Purview.Architecture.Composition; + +/// +/// The composition root: the one place that selects and wires the concrete implementations. +/// +public sealed class CompositionRoot +{ + /// The wired handler. + public PlaceOrderHandler Handler { get; } = new(); + + /// The wired repository. + public OrderRepository Repository { get; } = new(); + + /// Places an order and saves it. + /// The order total. + /// The placed order. + public Domain.Order PlaceAndSave(decimal total) + { + var order = Handler.Place(total); + Repository.Save(order); + + return order; + } +} diff --git a/src/samples/ArchitectureSample/src/Domain/Domain.csproj b/src/samples/ArchitectureSample/src/Domain/Domain.csproj new file mode 100644 index 0000000..9a01564 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Domain/Domain.csproj @@ -0,0 +1,6 @@ + + + + false + + diff --git a/src/samples/ArchitectureSample/src/Domain/Order.cs b/src/samples/ArchitectureSample/src/Domain/Order.cs new file mode 100644 index 0000000..8a0851b --- /dev/null +++ b/src/samples/ArchitectureSample/src/Domain/Order.cs @@ -0,0 +1,6 @@ +namespace Purview.Architecture.Domain; + +/// An order placed in the sample. +/// The order identifier. +/// The order total. +public sealed record Order(Guid Id, decimal Total); diff --git a/src/samples/ArchitectureSample/src/Persistence/OrderRepository.cs b/src/samples/ArchitectureSample/src/Persistence/OrderRepository.cs new file mode 100644 index 0000000..628a6bd --- /dev/null +++ b/src/samples/ArchitectureSample/src/Persistence/OrderRepository.cs @@ -0,0 +1,17 @@ +using Purview.Architecture.Domain; + +namespace Purview.Architecture.Persistence; + +/// Stores orders. Persistence may depend on the domain but not on the application. +public sealed class OrderRepository +{ + readonly List _orders = []; + + /// Saves an order. + /// The order to save. + public void Save(Order order) => _orders.Add(order); + + /// Returns every saved order. + /// The saved orders. + public IReadOnlyList All() => _orders; +} diff --git a/src/samples/ArchitectureSample/src/Persistence/Persistence.csproj b/src/samples/ArchitectureSample/src/Persistence/Persistence.csproj new file mode 100644 index 0000000..eeba661 --- /dev/null +++ b/src/samples/ArchitectureSample/src/Persistence/Persistence.csproj @@ -0,0 +1,9 @@ + + + false + + + + + + diff --git a/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/ArchitectureTests.cs b/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/ArchitectureTests.cs new file mode 100644 index 0000000..9644b43 --- /dev/null +++ b/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/ArchitectureTests.cs @@ -0,0 +1,20 @@ +using Purview.Architecture.Testing; + +namespace Purview.Architecture.Sample; + +/// +/// The sample's entire architecture suite: one derived class. +/// +/// +/// +/// [InheritsTests] is load-bearing. TUnit does not walk base types when it discovers +/// tests, so without it this class compiles, the run is green, and nothing is enforced. +/// +/// +/// There is no RepositoryRoot override: the default probe walks up from this test binary and +/// finds the sample's own .config/architecture-policy.json before the repository root's, so +/// the sample is a self-contained repository inside the repository. +/// +/// +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; diff --git a/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj b/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj new file mode 100644 index 0000000..28e7c90 --- /dev/null +++ b/src/samples/ArchitectureSample/tests/Sample.ArchitectureTests/Sample.ArchitectureTests.csproj @@ -0,0 +1,25 @@ + + + false + + + + + + + + + + + + + + + diff --git a/src/src/ArchitectureTesting/ArchitecturePolicy.cs b/src/src/ArchitectureTesting/ArchitecturePolicy.cs index 0cde790..29f6456 100644 --- a/src/src/ArchitectureTesting/ArchitecturePolicy.cs +++ b/src/src/ArchitectureTesting/ArchitecturePolicy.cs @@ -74,6 +74,7 @@ public static ArchitecturePolicy Load(string? repositoryRoot = null) ); } + // The policy file is the single definition of each rule, so deserialising to null is a fatal error. return JsonSerializer.Deserialize(File.ReadAllText(path), SerializerOptions) ?? throw new InvalidOperationException($"The architecture policy at '{path}' deserialised to null."); } diff --git a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs index 7fb4e28..0aa3831 100644 --- a/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs +++ b/src/src/ArchitectureTesting/ArchitecturePolicyEvaluator.cs @@ -10,9 +10,6 @@ namespace Purview.Architecture.Testing; /// public sealed class ArchitecturePolicyEvaluator { - readonly ArchitecturePolicy _policy; - readonly IReadOnlyDictionary _projects; - readonly IReadOnlyDictionary _assemblies; readonly IReadOnlyList _additionalRules; /// Creates an evaluator over an explicit policy and project graph. @@ -37,9 +34,9 @@ public ArchitecturePolicyEvaluator( ArgumentNullException.ThrowIfNull(projects); ArgumentNullException.ThrowIfNull(assemblies); - _policy = policy; - _projects = projects; - _assemblies = assemblies; + Policy = policy; + Projects = projects; + Assemblies = assemblies; _additionalRules = additionalRules ?? []; } @@ -66,16 +63,16 @@ public static ArchitecturePolicyEvaluator ForRepository( } /// The policy being applied. - public ArchitecturePolicy Policy => _policy; + public ArchitecturePolicy Policy { get; } /// The project graph. - public IReadOnlyDictionary Projects => _projects; + public IReadOnlyDictionary Projects { get; } /// The compiled assemblies, keyed by project name. - public IReadOnlyDictionary Assemblies => _assemblies; + public IReadOnlyDictionary Assemblies { get; } /// The production projects, excluding test projects. - public IEnumerable ProductionProjects => _projects.Values.Where(project => !project.IsTestProject); + public IEnumerable ProductionProjects => Projects.Values.Where(project => !project.IsTestProject); /// Runs every rule. /// Every violation found, in rule order. @@ -113,7 +110,7 @@ public IReadOnlyList GraphDiscovery() => ArchitectureRules.GraphDiscovery, "(repository)", "No production projects were discovered, so every other rule would pass without checking anything. " - + $"Looked under: {(_policy.SourceRoots.Count == 0 ? "(repository root)" : string.Join(", ", _policy.SourceRoots))}. " + + $"Looked under: {(Policy.SourceRoots.Count == 0 ? "(repository root)" : string.Join(", ", Policy.SourceRoots))}. " + "Set 'sourceRoots' in the policy to the folders holding the projects; an empty array scans the repository root." ), ]; @@ -123,13 +120,13 @@ public IReadOnlyList GraphDiscovery() => public IReadOnlyList Naming() => Excuse( ProductionProjects - .Where(project => _policy.Classify(project.Name) is null) + .Where(project => Policy.Classify(project.Name) is null) .Select(project => new ArchitectureViolation( ArchitectureRules.Naming, project.Name, $"'{project.Name}' does not map to a known responsibility or alias. " + "Rename it to '[.]', or record a waiver in the policy. " - + $"Known responsibilities: {string.Join(", ", _policy.Responsibilities.Select(responsibility => responsibility.Name))}." + + $"Known responsibilities: {string.Join(", ", Policy.Responsibilities.Select(responsibility => responsibility.Name))}." )) ); @@ -139,7 +136,7 @@ public IReadOnlyList DiscouragedNames() => Excuse( ProductionProjects .Where(project => - _policy.DiscouragedProjectNames.Names.Contains( + Policy.DiscouragedProjectNames.Names.Contains( project.Name.Split('.')[0], StringComparer.OrdinalIgnoreCase ) @@ -147,7 +144,7 @@ public IReadOnlyList DiscouragedNames() => .Select(project => new ArchitectureViolation( ArchitectureRules.DiscouragedName, project.Name, - $"'{project.Name}': {_policy.DiscouragedProjectNames.Reason}" + $"'{project.Name}': {Policy.DiscouragedProjectNames.Reason}" )) ); @@ -159,13 +156,13 @@ public IReadOnlyList DependencyMatrix() foreach (var project in ProductionProjects) { - var responsibility = _policy.Classify(project.Name); + var responsibility = Policy.Classify(project.Name); if (responsibility is null) continue; foreach (var reference in project.ProjectReferences) { - var referenced = _policy.Classify(reference); + var referenced = Policy.Classify(reference); if (referenced is null) continue; @@ -216,12 +213,12 @@ public IReadOnlyList DependencyMatrix() /// public IReadOnlyList AssemblyDependencyMatrix() { - if (!_policy.AssemblyRules.Enabled) + if (!Policy.AssemblyRules.Enabled) return []; - var sources = _assemblies + var sources = Assemblies .Values.Where(assembly => - _projects.TryGetValue(assembly.ProjectName, out var project) && !project.IsTestProject + Projects.TryGetValue(assembly.ProjectName, out var project) && !project.IsTestProject ) .ToArray(); @@ -243,7 +240,7 @@ public IReadOnlyList AssemblyDependencyMatrix() foreach (var assembly in sources) { - var responsibility = _policy.Classify(assembly.ProjectName); + var responsibility = Policy.Classify(assembly.ProjectName); if (responsibility is null) continue; @@ -253,7 +250,7 @@ public IReadOnlyList AssemblyDependencyMatrix() foreach (var target in targets) { - var referenced = _policy.Classify(target.Key); + var referenced = Policy.Classify(target.Key); if (referenced is null) continue; @@ -304,7 +301,7 @@ public IReadOnlyList SiblingIndependence() foreach (var project in ProductionProjects) { - var responsibility = _policy.Classify(project.Name); + var responsibility = Policy.Classify(project.Name); if (responsibility?.SiblingIndependence != true || !project.Name.Contains('.', StringComparison.Ordinal)) continue; @@ -315,7 +312,7 @@ public IReadOnlyList SiblingIndependence() reference.Contains('.', StringComparison.Ordinal) && !string.Equals(reference, project.Name, StringComparison.OrdinalIgnoreCase) && string.Equals( - _policy.Classify(reference)?.Name, + Policy.Classify(reference)?.Name, responsibility.Name, StringComparison.OrdinalIgnoreCase ); @@ -368,7 +365,7 @@ public IReadOnlyList ForbiddenPackages() foreach (var project in ProductionProjects) { - var responsibility = _policy.Classify(project.Name); + var responsibility = Policy.Classify(project.Name); if (responsibility is null) continue; @@ -404,7 +401,7 @@ public IReadOnlyList ForbiddenFrameworkReferences() foreach (var project in ProductionProjects) { - var responsibility = _policy.Classify(project.Name); + var responsibility = Policy.Classify(project.Name); if (responsibility is null) continue; @@ -437,7 +434,7 @@ public IReadOnlyList ForbiddenFrameworkReferences() public IReadOnlyList Cycles() => Excuse( ProjectGraph - .FindCycles(_projects) + .FindCycles(Projects) .Select(cycle => new ArchitectureViolation( ArchitectureRules.Cycle, cycle.Split(" -> ")[0], @@ -452,7 +449,7 @@ public IReadOnlyList TestReferences() => ProductionProjects.SelectMany(project => project .ProjectReferences.Where(reference => - _projects.TryGetValue(reference, out var target) && target.IsTestProject + Projects.TryGetValue(reference, out var target) && target.IsTestProject ) .Select(reference => new ArchitectureViolation( ArchitectureRules.TestReference, @@ -477,9 +474,9 @@ public IReadOnlyList WaiverIntegrity() .All.Concat(_additionalRules.Select(rule => rule.Rule)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var waiver in _policy.Waivers) + foreach (var waiver in Policy.Waivers) { - if (!_projects.ContainsKey(waiver.Project)) + if (!Projects.ContainsKey(waiver.Project)) { violations.Add( new ArchitectureViolation( @@ -513,7 +510,7 @@ public IReadOnlyList WaiverIntegrity() ); } - if (waiver.MapsTo.Length > 0 && _policy.Classify(waiver.Project) is null) + if (waiver.MapsTo.Length > 0 && Policy.Classify(waiver.Project) is null) { violations.Add( new ArchitectureViolation( @@ -533,7 +530,7 @@ public IReadOnlyList WaiverIntegrity() public IReadOnlyList EvaluateAdditionalRules() => [ .. _additionalRules.SelectMany(rule => - Excuse(rule.Evaluate(new ArchitectureContext(_policy, _projects, _assemblies))) + Excuse(rule.Evaluate(new ArchitectureContext(Policy, Projects, Assemblies))) ), ]; @@ -545,7 +542,7 @@ public IReadOnlyList EvaluateAdditionalRule(string rule) .. _additionalRules .Where(candidate => string.Equals(candidate.Rule, rule, StringComparison.OrdinalIgnoreCase)) .SelectMany(candidate => - Excuse(candidate.Evaluate(new ArchitectureContext(_policy, _projects, _assemblies))) + Excuse(candidate.Evaluate(new ArchitectureContext(Policy, Projects, Assemblies))) ), ]; @@ -569,12 +566,12 @@ public static string Report(IReadOnlyList violations) /// The violations to filter. /// The violations that are not excused. IReadOnlyList Excuse(IEnumerable violations) => - [.. violations.Where(violation => _policy.WaiverFor(violation.Project)?.Excuses(violation.Rule) != true)]; + [.. violations.Where(violation => Policy.WaiverFor(violation.Project)?.Excuses(violation.Rule) != true)]; /// Reports whether a project belongs to a category. /// The project name. /// The category to test. /// when the project's responsibility has that category. bool IsCategory(string projectName, string category) => - string.Equals(_policy.Classify(projectName)?.Category, category, StringComparison.OrdinalIgnoreCase); + string.Equals(Policy.Classify(projectName)?.Category, category, StringComparison.OrdinalIgnoreCase); } diff --git a/src/src/ArchitectureTesting/ArchitectureTesting.csproj b/src/src/ArchitectureTesting/ArchitectureTesting.csproj index 4323ec7..d6d1fd0 100644 --- a/src/src/ArchitectureTesting/ArchitectureTesting.csproj +++ b/src/src/ArchitectureTesting/ArchitectureTesting.csproj @@ -2,8 +2,8 @@ Purview.Architecture.Testing Purview Architecture Testing - Evaluates a repository's project and assembly graph against a declared architecture policy: dependency direction at both project and type level, sibling independence, composition boundaries, forbidden packages and framework references, cycles and project naming. Ships the policy JSON Schema and a TUnit base class, so a consuming repository adds a policy file and one derived test class. - architecture;architecture-tests;dependency-rules;tunit;purview;dotnet + Enforces a repository's architecture policy as TUnit tests: project and type-level dependency direction, sibling independence, composition boundaries, forbidden packages and framework references, cycles and naming. Ships the policy JSON Schema and a TUnit base class, so a consuming repository adds a policy file and one derived test class. + architecture;architecture-tests;dependency-rules;dependency-matrix;type-level-dependencies;tunit;policy;purview;dotnet;testing true