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 @@
[](https://www.nuget.org/packages/Purview.Architecture.Testing)
[](https://github.com/purview-dev/architests/actions/workflows/release.yml)
+[](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