Coverage Contracts¶
Coverage contracts detect parts of the policy that are not actually exercised against the analyzed codebase: namespaces with no representing layer (scope: namespace), rules whose source/target layer references are dangling or currently match no first-party code (scope: rule_input), projects or assemblies with no code mapped to any layer (scope: project/scope: assembly), or observed dependency edges between declared layers that no rule actually governs (scope: dependency_edge).
Groups:
strict_coverageaudit_coverage
Use coverage contracts when you want architectural namespace discovery, or rule-input health, to stay intentional as the codebase grows. They are especially useful for feature-folder architectures, modular monoliths, template-driven layer layouts, and policies with many hand-authored dependency/layer rules where a typo'd layer name or a rule that quietly stopped matching anything should not pass silently.
Namespace coverage example¶
analysis:
coverage: error
contracts:
strict_coverage:
- id: feature-namespace-coverage
name: feature-namespace-coverage
scope: namespace
roots:
- namespace: MyApp.Features
exclude:
- namespace: MyApp.Features.*
namespace_suffix: Generated
reason: Generated code is excluded from manual architecture coverage.
reason: Every feature namespace must be declared as a layer or explicitly excluded.
Rule-input coverage example¶
contracts:
strict:
- id: cli-must-not-depend-on-testing
name: cli-must-not-depend-on-testing
source: cli
forbidden: [testing]
reason: CLI must stay independent from test-only helpers.
strict_coverage:
- id: rule-input-coverage
name: rule-input-coverage
scope: rule_input
contract_ids: [cli-must-not-depend-on-testing]
exclude:
- contract_id: some-other-rule
reason: This rule intentionally targets a layer that has no code yet.
reason: Flag rules whose source/target layers stop matching any code.
A scope: rule_input contract resolves each entry in contract_ids to its referenced contract's layer-bearing fields (source, forbidden, allowed, layers, protected, allowed_importers, depending on the contract family) and reports:
unresolved— the referenced field names a layer that is not declared underlayersat all (a typo or a deleted layer);empty-input— the referenced field names a declared layer whose namespace pattern currently matches zero namespaces in the analyzed codebase, whether it never matched any code or stopped matching after a rename/deletion.
contract_ids may reference dependency, layer, allow_only, cycle, method_body, independence, protected, or external contracts — the families whose layer-bearing fields are plain layers keys. Referencing an unknown contract ID, an asmdef/acyclic_sibling/layer_template contract (whose fields are not plain layer-name references), or a coverage contract, is rejected at load time.
What counts as covered¶
A namespace under roots is considered covered when at least one of these is true:
- it matches a declared layer;
- it matches a declared namespace-glob layer such as
MyApp.Features.*; - it is introduced by an expanded layer template;
- it matches an explicit
excluderule.
Coverage is evaluated against discovered first-party namespaces that contain loadable types, not against arbitrary strings in the repository.
Severity behavior¶
analysis.coverage controls whether findings affect the run result:
| Value | Behavior |
|---|---|
error |
Coverage findings fail validation and produce exit code 1. |
warn |
Findings are reported but the run still passes. |
off |
Coverage findings are suppressed. |
In human output, findings appear in a separate Coverage findings: section. In JSON output, they appear in the top-level coverage_findings array alongside violations, cycles, unmatched_ignored_violations, and policy_consistency_findings.
Coverage summary¶
Alongside the raw findings, validate reports a deterministic coverage summary for every coverage contract that ran, independent of analysis.coverage severity (it is reported even when coverage: warn or coverage: off). The summary buckets each in-scope item into exactly one of five counts:
| Count | scope: namespace |
scope: project/scope: assembly |
scope: dependency_edge |
scope: rule_input |
|---|---|---|---|---|
covered |
namespace matched a declared layer, namespace-glob layer, or expanded layer template | project/assembly has at least one matching type | observed edge's pair is governed by an existing dependency, layer, independence, or expanded-template contract | referenced layer exists and currently matches code |
excluded |
namespace matched an exclude rule |
project/assembly matched an exclude rule |
observed edge's pair matched an exclude rule |
referenced contract ID matched an exclude rule |
uncovered |
namespace matched none of the above ("uncovered namespace" finding) |
no matching type found ("uncovered project"/"uncovered assembly" finding) |
pair is not governed and not excluded ("uncovered dependency edge" finding) |
always 0 — this scope reports stale/unknown instead |
stale |
always 0 — this scope reports uncovered instead |
always 0 |
always 0 |
referenced layer exists but currently matches zero namespaces ("empty-input" finding) |
unknown |
always 0 |
scope: project only — discovered project could not be resolved to a target assembly ("unresolved project" finding); always 0 for scope: assembly |
always 0 |
referenced field names a layer that isn't declared at all ("unresolved" finding) |
Each excluded item is reported with its reason text from the contract's exclude entry. Each covered/uncovered/stale/unknown item is reported with evidence — a representative type for namespace/project/assembly coverage, the source/target namespace pair plus a representative source type for dependency-edge coverage, or the dangling/empty layer name for rule-input coverage — and is kept in a bucket-specific list (covered_items for every scope; uncovered_items for namespace/project/assembly/dependency-edge coverage, unknown_items additionally for project coverage, stale_items/unknown_items for rule-input coverage) rather than a single combined list, since stale and unknown mean different things and must stay distinguishable by a reviewer or downstream tooling. covered_items is the only list naming units found covered by positive evidence — downstream tooling (such as a CI new-code coverage report) should never infer "covered" from a unit's mere absence from the other lists.
In human output, the summary appears in a Coverage summary: section, one line per contract, after Coverage findings:, with each evidence sub-line explicitly labeled uncovered:, stale:, or unknown::
Coverage summary:
- [validation-namespace-coverage] [validation-namespace-coverage] scope: namespace covered=0 excluded=0 uncovered=1 stale=0 unknown=0
uncovered: ArchLinterNet.Core.Validation (ArchLinterNet.Core.Validation.ArchitectureBaselineService)
- [rule-input-coverage] [rule-input-coverage] scope: rule_input covered=2 excluded=0 uncovered=0 stale=1 unknown=1
stale: ghost-rule (ghost)
unknown: typo-rule (does_not_exist_layer)
In JSON output, the summary appears as a top-level coverage_summary array, additive to (not nested inside) coverage_findings. Every entry always includes all four evidence arrays (covered_items, uncovered_items, stale_items, unknown_items); only the ones relevant to the contract's scope are ever non-empty (besides covered_items, which is populated whenever a unit is found covered, for every scope) — namespace, project, assembly, and dependency-edge coverage populate uncovered_items (project coverage additionally populates unknown_items for unresolved projects), rule-input coverage only populates stale_items/unknown_items:
{
"coverage_summary": [
{
"contract": "validation-namespace-coverage",
"contract_id": "validation-namespace-coverage",
"scope": "namespace",
"counts": { "covered": 0, "excluded": 0, "uncovered": 1, "stale": 0, "unknown": 0 },
"excluded_items": [],
"uncovered_items": [
{ "item": "ArchLinterNet.Core.Validation", "evidence": "ArchLinterNet.Core.Validation.ArchitectureBaselineService" }
],
"stale_items": [],
"unknown_items": [],
"covered_items": []
},
{
"contract": "rule-input-coverage",
"contract_id": "rule-input-coverage",
"scope": "rule_input",
"counts": { "covered": 2, "excluded": 0, "uncovered": 0, "stale": 1, "unknown": 1 },
"excluded_items": [],
"uncovered_items": [],
"stale_items": [{ "item": "ghost-rule", "evidence": "ghost" }],
"unknown_items": [{ "item": "typo-rule", "evidence": "does_not_exist_layer" }],
"covered_items": [
{ "item": "core-validation:core_validation", "evidence": "core_validation" },
{ "item": "core-validation:core_execution", "evidence": "core_execution" }
]
}
]
}
A coverage contract only appears in the summary when it is actually selected to run. If validate --contract <id> is used to run only specific contracts and a coverage contract's ID isn't among them, that coverage contract is omitted from coverage_summary entirely — it never appears as a zero-count row.
New-code coverage reporting and test projects¶
Test assemblies (typically named *.Tests) are usually never listed in analysis.target_assemblies, so their namespaces/projects/assemblies never enter the coverage inventory and can never appear in any contract's covered_items/uncovered_items/stale_items/unknown_items. Downstream tooling that maps a pull request's changed files onto coverage buckets to build a "new-code coverage" report should treat a file inside a *.Tests project as out of scope entirely, rather than reporting it as unknown/"requiring policy update" — there is no coverage evidence to check it against by construction, so flagging it would be tooling noise, not a real policy gap.
This repository's own CI report generator (tools/scripts/architecture_coverage_report.py) implements exactly this: any changed file whose enclosing .csproj stem ends in .Tests is skipped before classification, so new test files never surface as "requiring policy update" in the PR coverage comment, regardless of which contract family the surrounding feature belongs to.
Baselining coverage debt¶
Coverage contracts support ignored_violations, the same baseline mechanism described in
migration baselines. This lets you adopt a
coverage gate incrementally on a codebase that already has uncovered namespaces or stale
rule-input references, by accepting the current findings as baseline debt and failing only on new
ones:
contracts:
strict_coverage:
- id: feature-namespace-coverage
name: feature-namespace-coverage
scope: namespace
roots:
- namespace: MyApp.Features
ignored_violations:
- source_type: MyApp.Features.Legacy
forbidden_reference: "uncovered namespace"
reason: "Accepted — tracked in #103"
arch-linter-net baseline generate and validate --baseline work the same way for
strict_coverage/audit_coverage contracts as they do for ordinary dependency contracts.
Coverage baseline entries only suppress coverage findings — they never affect strict/audit
dependency violations. A baselined coverage finding that has since been resolved is reported as a
stale baseline entry via unmatched_ignored_violations.
Exclusion rules¶
Use exclude only for units you intentionally do not want to model yet. For scope: namespace, exclude by namespace:
exclude:
- namespace: MyApp.Features.Legacy
reason: Legacy feature area is being migrated incrementally.
- namespace: MyApp.Features.*
namespace_suffix: Generated
reason: Generated code is excluded from manual architecture coverage.
For scope: rule_input, exclude by the referenced contract's ID:
exclude:
- contract_id: some-other-rule
reason: This rule intentionally targets a layer that has no code yet.
Rules:
- every exclusion must include a non-empty
reason; - namespace coverage exclusions support the same
namespace/namespace_suffixmatching model as layers; - rule-input coverage exclusions must use
contract_idand suppress bothunresolvedandempty-inputfindings for that referenced contract; - exclusions are the right place for generated code, temporary migration debt, intentionally unused rules, or known framework-produced namespaces that should not become layers.
Project and assembly coverage¶
scope: project and scope: assembly detect first-party .csproj projects or resolved
assemblies that have no code matching any declared layer, namespace-glob layer, or expanded
layer-template layer — the same coverage-provider rule namespace coverage uses, just applied to a
whole project/assembly instead of a namespace. Use these scopes when you want to catch a project
or assembly that was added to the solution (or renamed) but never wired into any layer at all,
something namespace coverage cannot see if nobody rooted it.
Neither scope declares roots; they classify every unit discovered/resolved for the run.
analysis:
solution: MyApp.slnx
coverage: error
contracts:
strict_coverage:
- id: project-coverage
name: project-coverage
scope: project
exclude:
- project: samples/Demo/Demo.csproj
reason: Sample project is intentionally out of architecture scope.
reason: Every discovered project must be mapped to a layer or explicitly excluded.
- id: assembly-coverage
name: assembly-coverage
scope: assembly
exclude:
- assembly: MyApp.TestUtilities
reason: Test-only helper assembly.
reason: Every resolved assembly must be mapped to a layer or explicitly excluded.
scope: project requires analysis.solution or analysis.projects to be set, since discovered
projects (path, assembly name, target frameworks) are the units it classifies — see
analysis.solution/analysis.projects in the
YAML schema reference for how those are resolved. scope: assembly
has no such requirement: it classifies ArchitectureAnalysisContext's resolved first-party
assemblies, which exist whether they came from explicit analysis.target_assemblies or from
discovery.
A discovered project that cannot be resolved to one of the run's target assemblies (filtered out,
missing/stale build output, ambiguous multi-target selection) is classified unknown, not
uncovered — it produces an "unresolved project" finding naming the project path and the
assembly name discovery expected, rather than asserting the project's code is unmapped.
Exclusions use exclude[].project (matched against the discovered project's repository-relative
path or its file name, exact match) or exclude[].assembly (matched against the assembly's simple
name, ordinal), each with a required reason. Unlike namespace coverage, project/assembly
exclusions do not support glob matching.
.asmdef contracts (the Unity assembly-definition checks documented in
asmdef contracts) are a separate mechanism with their own identity model and are not
affected by, or folded into, scope: project/scope: assembly coverage.
Dependency-edge coverage¶
scope: dependency_edge answers a narrower question than namespace/project/assembly coverage:
for code that is inside a declared layer, is the specific dependency edge between two layers
actually governed by a dependency, layer, independence, allow-only, protected, or layer-template
contract — or does it bypass policy because no contract happens to mention that layer pair?
Each contract declares between as a list of declared-layer-name pairs. For every pair, the
system looks at every observed first-party namespace-to-namespace edge whose source namespace
resolves to the pair's first layer and target namespace resolves to the pair's second layer, and
classifies it:
analysis:
coverage: error
contracts:
strict:
- id: cli-must-not-depend-on-testing
name: cli-must-not-depend-on-testing
source: cli
forbidden: [testing]
reason: CLI must stay independent from test-only helpers.
strict_coverage:
- id: layer-edge-coverage
name: layer-edge-coverage
scope: dependency_edge
between:
- [cli, testing]
- [cli, core]
exclude:
- between: [cli, core]
reason: Already enforced structurally; CLI is allowed to depend on core.
reason: Every edge between cli and other layers must be governed by a declared rule.
A pair is covered when any of these already governs it:
- a dependency contract whose
sourceequals the pair's first layer and whoseforbiddenlist contains the second layer; - a layer contract (
scope: layer/strict_layers/audit_layers) whoselayerschain contains both layer names, in either order — the chain's ordering check governs every pair within it; - an independence contract whose
layerslist contains both layer names — independence is bidirectional by definition; - an allow-only contract whose
sourceequals the pair's first layer — an allow-only contract governs the entire outbound surface of its source layer, so the pair is covered even if the second layer is not itself in that contract'sallowedlist; - a protected contract whose
protectedlist contains the pair's second layer — a protected contract governs every reference into its protected layer, allowed and disallowed alike, so the pair is covered even if the first layer is not itself in that contract'sallowed_importerslist; - an expanded layer template whose container layers match both declared layers' namespace patterns.
A pair that matches none of the above, and matches no exclude entry, is uncovered: every
observed edge for that pair produces an "uncovered dependency edge" finding naming the source
namespace, target namespace, and a representative source type. Layer pairs that are not declared
in any between list are simply not evaluated — they are out of scope, not a fourth status.
Exclusions match by declared pair, not by individual namespace: exclude[].between names the same
[sourceLayer, targetLayer] pair as the contract's own between entry, plus a required reason.
Every observed edge for an excluded pair is suppressed.
Current limits¶
Coverage support is intentionally narrow in the current product surface:
scope: namespace,scope: rule_input,scope: project,scope: assembly, andscope: dependency_edgeare implemented;- namespace coverage roots must use
roots[].namespace; - discovery-style fields such as
includeandexcludeare not valid on namespace coverage roots; - rule-input coverage contracts must use
contract_idsand must not declarerootsorbetween; scope: project/scope: assemblycontracts must not declareroots,between, orcontract_ids;scope: dependency_edgecontracts must declarebetweenand must not declarerootsorcontract_ids; both layer names in everybetweenpair must be declared underlayers.
For the YAML contract shape, see YAML schema reference.