Migration Baselines¶
ArchLinterNet supports a frozen-debt workflow for repositories that already have architecture violations and want to enforce boundaries going forward without fixing everything at once.
Strict vs Audit¶
- Strict contracts block the build on violation. Use for boundaries you want enforced immediately.
- Audit contracts report violations without blocking. Use for visibility during a migration.
Both contract types go in the same YAML file under separate sections:
contracts:
strict: # blocks on violation
- name: app-must-not-depend-on-infrastructure
source: app
forbidden: [infrastructure]
reason: This boundary is enforced now.
audit: # reports only, doesn't block
- name: domain-must-not-depend-on-infrastructure
source: domain
forbidden: [infrastructure]
reason: Tracking for migration — will become strict in Q2.
Ignored violations (frozen debt)¶
The ignored_violations section allows you to acknowledge existing violations
so they don't cause failures, while still preventing new ones:
contracts:
strict:
- name: app-boundaries
source: app
forbidden: [infrastructure]
ignored_violations:
- source_type: MyApp.App.Legacy.LegacyService
forbidden_reference: MyApp.Infrastructure.LegacyDb
reason: "Known debt — tracked in #1234"
When a violation matches an ignored entry, it is suppressed. Any violation that does not match an ignored entry will still fail the build.
This approach enables:
- Freezing existing violations without fixing them immediately
- Tracking debt with issue references
- Gradually removing ignored entries as violations are resolved
- Preventing regression (new violations are still caught)
Automated Baseline Generation¶
ArchLinterNet can automatically generate a baseline file from the current state of violations. This is useful when adopting architecture rules for the first time on an existing codebase.
Generate a baseline¶
arch-linter-net baseline generate \
--config architecture/dependencies.arch.yml \
--output baseline.yml \
--reason "Initial baseline — migration tracked in #123"
The generated file captures every current violation as an ignored_violations
entry grouped by contract ID. When no selected contract uses a baseline-relative
metric budget, newly generated baselines use format version 2, which carries
a versioned, structured identity per entry instead of relying on the
source_type/forbidden_reference display text alone. When one or more selected
relative metric budgets need a reviewed value, generation uses version 3:
it retains the finding-level entries and adds a separate top-level
metric_baselines collection. Example version-2 finding output:
version: 2
baseline:
strict:
- id: app-boundaries
ignored_violations:
- source_type: MyApp.App.Legacy.LegacyService
forbidden_reference: MyApp.Infrastructure.LegacyDb
reason: "Initial baseline — migration tracked in #123"
identity_version: 2
contract_family: strict
kind: dependency
source_assembly: MyApp.App
target_assembly: MyApp.Infrastructure
target_member: MyApp.Infrastructure.LegacyDb
occurrence: 0
- source_type: MyApp.App.Old.OldController
forbidden_reference: MyApp.Infrastructure.SqlRepo
reason: "Initial baseline — migration tracked in #123"
identity_version: 2
contract_family: strict
kind: dependency
source_assembly: MyApp.App
target_assembly: MyApp.Infrastructure
target_member: MyApp.Infrastructure.SqlRepo
occurrence: 0
source_type/forbidden_reference remain present as human-readable display
fields, but for version 2 entries they are not the identity — the
structured fields are. This is what makes two same-named types in different
assemblies distinguishable (source_assembly/target_assembly), and what
lets multiple distinct forbidden-call occurrences inside one source type each
get their own entry instead of collapsing into one (occurrence). Fields that
a particular contract family doesn't yet resolve (e.g. source_member for
most families) are simply omitted.
Baseline files written before this change use format version 1 (the plain
(source_type, forbidden_reference) pair as identity). They continue to load
and match exactly as before — nothing about their behavior changes until you
explicitly run baseline migrate (below).
Metric baseline capture¶
Baseline-relative budgets are documented in
Architecture metrics.
They use the same strict_metric_budgets and audit_metric_budgets collections
as absolute budgets, with either baseline_mode: no_worse_than_baseline or
baseline_mode: max_delta. The former allows only delta 0; the latter
requires a non-negative max_delta. An optional maximum remains an absolute
safety cap, so the effective threshold is the lower of baseline plus allowed
delta and that cap. minimum is not valid in relative mode.
Run baseline generate deliberately to capture the current value of each
unique, complete metric selected by a relative budget:
arch-linter-net baseline generate \
--config architecture/dependencies.arch.yml \
--output architecture/baseline.arch.yml \
--reason "Reviewed metric starting points"
The generated file is version 3 when selected relative budgets are present. It
keeps ordinary finding debt under baseline and stores scalar metric values
separately under metric_baselines. Each scalar entry uses the canonical
identity fields metric_identity_version, metric_id, metric_kind,
native_subject, effective_scope, and value; include unit when the metric
definition has one:
version: 3
baseline:
strict_metric_budgets: []
metric_baselines:
- metric_identity_version: 1
metric_id: application-outgoing
metric_kind: outgoing_component_count
native_subject: application
effective_scope: application
value: 3
- metric_identity_version: 1
metric_id: application-footprint
metric_kind: component_footprint_count
native_subject: application
unit: project
effective_scope: application
value: 2
Do not use a budget contract ID, contributor label, display text, or finding
identity as the scalar key. metric_baselines entries are not
ignored_violations: they do not suppress findings and do not participate in
the #121 finding-level debt baseline. The existing finding-level baseline
collection keeps its version-1/version-2 matching behavior independently.
Relative validation fails closed when the selected version-3 baseline has no
matching entry or when its metric_identity_version, metric kind, native
subject, unit, or effective scope no longer matches the current metric
definition. Such evidence is stale or unassessable; it is not a zero, a pass,
or a finding-level baseline match. Ordinary validation does not refresh it.
Only an explicit, reviewed baseline generate captures a new scalar value, and
an incomplete measurement is never captured. baseline update and
baseline prune operate on ordinary finding debt and preserve existing metric
scalar values unchanged. They do not automatically update or prune those
values; generate again, or make a reviewed manual edit, when the starting point
should change.
Baseline lifecycle¶
- Generate — create the baseline from current violations (
baseline generate) - Merge — run
arch-linter-net --policy ... --baseline baseline.yml --mode strictto enforce boundaries going forward - Update — run
baseline updateto add newly-introduced debt while preserving thereasontext on entries that are still valid, without hand-editing YAML - Prune — run
baseline pruneto remove entries whose violation has been fixed or whose contract ID no longer exists, and see exactly what was removed - Diff — run
baseline diffat any time to seenew,matched,resolved,stale,ambiguous, andconfiguration-errorentries without changing the file - Verify — run
baseline verifyin CI to fail the build if the baseline has drifted out of sync (stale, ambiguous, or unknown-contract entries), keeping the baseline honest over time - Migrate — run
baseline migrateonce, on demand, to deterministically upgrade an existing version 1 baseline to version 2's structured identity
generate/update/prune/diff/verify share --config/--policy, --mode
(strict/audit/all), --condition-set, and --contract (repeatable, restricts
to specific contract IDs), consistent with validate. migrate shares
--config/--policy and --condition-set but deliberately has no --mode/--contract
— see below.
Which command to reach for:
| Command | Writes a file | Adds entries | Removes entries | Use it for |
|---|---|---|---|---|
generate |
yes | yes (all current violations) | n/a (fresh file) | first adoption, or re-baselining from scratch |
migrate |
yes (new path) | no | drops entries with no current match | one-time version 1 → version 2 identity upgrade |
update |
yes | yes (newly introduced debt) | never | recording new debt without hand-editing YAML |
prune |
yes | never | yes (resolved debt, unknown contract ids) | cleaning up after violations are fixed |
verify |
no | no | no | CI gate: fail when the baseline has drifted |
diff |
no | no | no | reviewing drift without gating |
Reviewing a change before it happens¶
Every writing command previews:
- omit
--outputand the proposed document goes to stdout; nothing on disk is touched; - pass
--dry-runwith--outputand the command reports the classification plus the proposed content, and writes nothing; - add
--jsonto get the same report as one machine-readable document, with the proposal inproposedContent.
generate and migrate refuse to replace an existing --output file: pass --force once you have
reviewed the proposal. update and prune writing back to the same path they read (--output equal
to --baseline) is the normal in-place step and needs no flag — naming the same file twice is the
statement of intent. Writing over some other existing file requires --force. That comparison is
case-sensitive: on a case-sensitive filesystem baseline.yml and BASELINE.yml are different files,
so a case-variant spelling asks for --force rather than being assumed to be the same destination.
Writes are atomic (temp file, then rename), so a failed write leaves the original baseline
byte-for-byte intact. A prune with nothing to remove goes further and hands back its input
document verbatim, so a no-op prune cannot reflow quoting, line endings, or blank lines.
Entry lifecycle¶
Every baseline subcommand classifies entries with one shared vocabulary — the same one the
adoption-stabilization-compatibility capability fixes for the whole tool — so a single entry can be
followed across commands, and across output formats:
| Status | Meaning |
|---|---|
new |
a current finding has no exact baseline entry |
matched |
an entry and a current finding have equal canonical identity |
resolved |
a valid, evaluable entry has no current finding — the debt was fixed |
stale |
the entry references a contract, family, source, schema, or identity form that is no longer valid or evaluable |
changed |
a predecessor/successor relationship is derivable but canonical identity differs, so the entry does not suppress until reviewed |
ambiguous |
more than one candidate could correspond to the entry, and the tool refuses to guess |
configuration-error |
malformed, unsupported, or inconsistent input prevents safe classification |
Only matched suppresses a finding. changed, stale, ambiguous, and configuration-error
never silently suppress one — each entry's JSON carries suppresses so this is readable without
inferring it from the status.
Reviewing a requalified identity¶
When a release adds a required semantic identity dimension, an older structured entry is never
silently widened to cover it. Review baseline diff (or let baseline verify fail in CI), then
use baseline update or a reviewed recapture to add the exact current entries. Run baseline prune
only after those entries have been accepted. changed means a single successor is provable but
still needs review; stale means the entry cannot be evaluated; ambiguous means several
successors are possible and the tool refuses to guess. Reasons and issue metadata are carried
only when that one-to-one relationship is deterministic.
What a command did with an entry is a separate axis, reported as its disposition: reported
(read-only), added, retained, or removed. This is how update and prune act differently on
the same classification without either renaming it — a fixed violation is resolved in both, and only
the disposition differs (retained vs removed).
--json output carries a counts object keyed by the seven status names (values the command cannot
produce are reported as 0), a flat entries list in the shared vocabulary, and the full canonical
structured identity of each entry.
SARIF and Testing API comparison results¶
baseline diff, baseline verify, and baseline migrate also accept
--format sarif. Each result carries baseline_status plus the canonical identity
fields in SARIF properties; integrations do not need to parse its message.
NUnit tests can use ArchitectureAssertions.FromPolicy(path).WithBaseline(path)
and then call DiffBaseline(), VerifyBaseline(), or MigrateBaseline(). These
return the typed Core outcomes, including comparison entries, identities, statuses,
and the verification gate result.
Display text is not identity: when an entry's canonical identity still matches but the live finding
renders forbidden_reference differently, the entry stays matched and its display text is
refreshed. changed is reserved for a genuine identity difference, which is why it does not suppress.
Ambiguous is the one worth understanding. Under version 1, identity is the
(source_type, forbidden_reference) pair, so a single entry can correspond to several distinct
violations — two same-named types in different assemblies, or two forbidden calls in one type. Such
an entry suppresses more than it was reviewed for. verify fails on it, update and prune carry it
through untouched rather than guessing which identity to keep, and migrate is the command that
resolves it.
Stale vs resolved are easy to conflate: resolved means the entry is fine and the debt is gone;
stale means the debt may well remain but the entry can no longer be evaluated — most often because
it names a contract id the policy no longer has.
Comments and issue metadata¶
A reviewed baseline usually carries context. Two kinds survive an update/prune:
# Baseline owned by the platform team.
# Reviewed 2026-07; next review with the Q4 boundary work.
version: 2
baseline:
strict:
- id: app-boundaries
ignored_violations:
- source_type: MyApp.App.Legacy.LegacyService
forbidden_reference: MyApp.Infrastructure.LegacyDb
reason: "Known debt — scheduled for extraction"
issue: "PROJ-1234"
identity_version: 2
contract_family: strict
kind: dependency
occurrence: 0
- the leading comment header (the run of comment lines before the first content line) is re-emitted verbatim above the regenerated document;
- the optional per-entry
issuefield is carried through verbatim on every entry a command retains, exactly likereason. It never participates in identity, matching, or deduplication.
A comment sitting next to an entry cannot be preserved: the document is rebuilt from the model, and
there is no stable position for that comment once entries are added, removed, or reordered — guessing
would move your note onto the wrong entry. So update/prune refuse to write such a file, report the
exact line numbers, and point at --dry-run, which still prints the proposed document so you can
merge the comments in by hand. Moving those notes into the header block (or into each entry's reason
/issue) makes the file updatable in place from then on.
This covers a comment trailing a value, not just one on its own line — reason: legacy debt # reviewed by Alice is reviewed content the serializer would drop, so it blocks the rewrite too. A #
inside a quoted scalar is not a comment and does not block anything.
Per-contract and per-family reasons¶
generate and update accept repeatable reason mappings alongside --reason:
arch-linter-net baseline update \
--config architecture/dependencies.arch.yml \
--baseline baseline.yml \
--output baseline.yml \
--reason-for-family package_dependency="Package debt — tracked in #501" \
--reason-for-family composition="Composition debt — tracked in #502" \
--reason-for-contract app-boundaries="Extraction in progress — #503" \
--reason "Accepted during the Q3 adoption pass"
A newly added entry resolves its reason as: --reason-for-contract for its contract id, then
--reason-for-family for its contract family (the group name without its strict_/audit_ prefix —
package_dependency, composition, method_body, …), then --reason, then the built-in default.
Entries carried over from the existing baseline keep their recorded reason regardless of any mapping.
A mapping without =, with an empty key, with empty text, or repeating a key is rejected up front
rather than silently ignored.
Update¶
arch-linter-net baseline update \
--config architecture/dependencies.arch.yml \
--baseline baseline.yml \
--output baseline.yml \
--reason "Newly accepted debt — tracked in #456"
Entries whose identity still matches a current violation are kept unchanged,
including their original reason and issue. New violations are appended using
the resolved reason (see per-contract and per-family reasons above). Entries that
no longer match any violation are reported as resolved and left in place —
update never removes entries; that is prune's job. Ambiguous entries are
carried through untouched rather than rewritten into one guessed identity.
Prune¶
arch-linter-net baseline prune \
--config architecture/dependencies.arch.yml \
--baseline baseline.yml \
--output baseline.yml
Removes baseline entries that no longer match any current violation (resolved)
or that reference a contract ID that no longer exists in the policy
(stale), and reports exactly what was removed and why. Add --json to
get the removed-entry list and lifecycle report as structured data, or
--dry-run to see the removals before committing to them.
Prune removes only entries whose exact identity matched nothing. An entry that
matches more than one current violation is reported as ambiguous and
retained — deleting it would drop accepted debt that is still real.
Diff¶
arch-linter-net baseline diff \
--config architecture/dependencies.arch.yml \
--baseline baseline.yml
Read-only comparison of the baseline against current violations, reporting every
entry with its lifecycle value: new, matched, resolved, stale,
ambiguous, and configuration-error. Never writes a file, and always exits 0 — it is a report,
not a gate. --json adds lifecycle counts and each entry's canonical structured
identity.
Verify¶
arch-linter-net baseline verify \
--config architecture/dependencies.arch.yml \
--baseline baseline.yml
Runs the same comparison as diff but exits non-zero if any resolved, stale,
or ambiguous entries are found — intended as a CI gate
that keeps a baseline from silently accumulating stale debt or broadening what it
suppresses. It does not fail on new, unbaselined violations (that's validate's
job). This is the only baseline command that belongs in CI; see
CI integration.
Migrate¶
arch-linter-net baseline migrate \
--config architecture/dependencies.arch.yml \
--baseline baseline.yml \
--output baseline.v2.yml \
--dry-run
Deterministically upgrades a legacy version 1 baseline to version 2's
structured identity. Every legacy entry, from every contract group in the
file, is correlated against freshly collected current-codebase violations by
its exact legacy (source_type, forbidden_reference) pair (matched only
against candidates from the same contract ID):
- Exactly one match — the entry is rewritten using that violation's full
structured identity; its
reasonis preserved verbatim. - Zero matches — the entry no longer corresponds to any current
violation. It is reported as
staleand dropped from the migrated output (the underlying debt is gone; there's nothing to migrate). - More than one match — the legacy pair is ambiguous: it could refer to
more than one distinct violation now that identity is structured. Migration
refuses to guess. The entry is reported as
ambiguousand, outside of--dry-run, the whole run fails closed — no file is written until you resolve the ambiguity (typically by baselining the specific occurrences you intend to keep with a freshbaseline generate --contract <id>pass, or by accepting the new, disambiguated entries as new debt).
Run --dry-run/--check first to see the classification report and the proposed
migrated document without writing anything — useful as its own CI gate (exit
code 1 if any entries are ambiguous, 0 otherwise). Once the report is clean,
drop --dry-run and provide --output to write the migrated file; add
--force if that file already exists. baseline migrate never writes to the
same path as --baseline — pick a distinct --output, review it, then swap it
in for the original file yourself. Like the other writing subcommands, it
replaces the destination atomically.
Unlike every other baseline subcommand, migrate does not accept
--mode/--contract — it always classifies every entry in the file. A
version-2 document cannot preserve version-1 matching semantics for only
part of a file: an entry left unexamined could be exactly the kind of
ambiguous legacy pair this command exists to catch, discoverable only by
actually correlating it against current violations. So there is no way to
migrate "just the strict entries" — the whole file is always classified
and, if it's clean, upgraded together.
Migration is opt-in and on-demand: nothing about validate, generate,
update, prune, diff, or verify changes for a baseline you haven't
migrated. Version 1 files keep working with their existing behavior
indefinitely.
baseline migrate only upgrades version 1 files — it refuses to run
against a file that already declares version: 2. This matters when a
contract family gains new structured-identity fields it didn't previously
populate (for example, strict_composition/audit_composition gained
source_assembly qualification): a version: 2 baseline generated before
that change carries entries with those fields left null, and those entries
will no longer structurally match the richer identity the same violations now
produce. There is no dedicated "re-qualify a version-2 file" command, because
migrate's job is specifically the version-1-to-2 identity upgrade. Instead:
- Run
baseline update --baseline old.yml --output new.yml— every currently-passing violation gets a fresh, fully-qualified entry added; the stale, unqualified entries are preserved untouched (updatenever removes anything). - Run
baseline prune --baseline new.yml --output new.yml— the stale unqualified entries no longer match any current violation (their identity is missing fields the live violation now has), sopruneremoves them and reports them asresolved.
The result is a version: 2 file where every entry is qualified with the
current identity shape. This two-step update + prune sequence is the
general answer for "a family's identity got more precise since I last
generated," not just for composition.
Merge semantics¶
When --baseline <path> is provided, the baseline entries are merged into the
policy's ignored_violations lists before validation. The merge:
- Appends new entries to each contract's existing ignores
- Reports an error if a baseline entry references a contract ID that doesn't exist in the policy (exit code 2)
- Deduplicates and matches using the same identity notion baseline
comparison uses — version 1 entries by the exact
(source_type, forbidden_reference)pair, version 2 entries by the full structuredArchitectureViolationIdentity(contract family, kind, source/target assembly, source/target type and member, and an occurrence discriminator). A version-2 entry's assembly/member/occurrence fields are exactly whatvalidate --baselineuses to decide whether a given violation is suppressed — this is what makesvalidate,diff,verify, andmigrateagree on identity, not just the read-only comparison commands.
One baseline entry always suppresses exactly one identity — two same-named
types in different assemblies, or two distinct forbidden calls in the same
source type, are never treated as the same entry under version 2, in
validate just as much as in diff/verify.
Stale baseline entries¶
Baseline entries that no longer match any current violation are detected by the
runner's unmatched ignored violation tracking (same as manual ignores). When
analysis.unmatched_ignored_violations is set to error (default), stale
baseline entries produce a blocking failure, encouraging proactive cleanup.
Coverage baselines¶
strict_coverage and audit_coverage contracts (see
architecture coverage) support the same
ignored_violations and baseline mechanism as ordinary dependency contracts.
This lets teams adopt coverage gates incrementally on a repository that
already has uncovered namespaces or stale rule-input references, rather than
having to resolve every coverage gap before turning the gate on.
arch-linter-net baseline generate \
--config architecture/dependencies.arch.yml \
--output baseline.yml \
--reason "Coverage baseline — tracked in #103"
For a namespace-scoped coverage contract, each currently uncovered namespace
is captured as source_type: <namespace> /
forbidden_reference: "uncovered namespace":
version: 2
baseline:
strict_coverage:
- id: feature-namespace-coverage
ignored_violations:
- source_type: MyApp.Features.Legacy
forbidden_reference: "uncovered namespace"
reason: "Coverage baseline — tracked in #103"
identity_version: 2
contract_family: coverage
kind: coverage
target_member: "uncovered namespace"
occurrence: 0
For a rule_input-scoped coverage contract, each unresolved or empty-input
rule reference is captured as source_type: <referenced-contract-id> /
forbidden_reference: <layer-name>:
version: 2
baseline:
strict_coverage:
- id: rule-input-coverage
ignored_violations:
- source_type: video-to-ghost-rule
forbidden_reference: ghost
reason: "Coverage baseline — tracked in #103"
identity_version: 2
contract_family: coverage
kind: coverage
target_member: ghost
occurrence: 0
Coverage identity does not yet carry assembly/member qualification (it's
categorical by nature — a namespace or a rule reference, not a symbol) — the
occurrence discriminator alone is enough to keep distinct coverage findings
from colliding.
validate --baseline suppresses these baselined coverage findings while still
reporting newly uncovered areas, exactly like ordinary dependency violations.
Coverage baseline entries only affect coverage contract findings — they never
suppress or otherwise interact with strict/audit dependency violations.
A coverage baseline entry whose underlying gap has since been resolved (the
namespace became covered, or the rule reference became resolved again) is
reported as a stale baseline entry through the same
unmatched_ignored_violations mechanism described above.
Gate new debt without rewriting the baseline¶
After a baseline is reviewed, CI can compare complete current architecture results through the read-only gate:
arch-linter-net gate \
--policy architecture/dependencies.arch.yml \
--baseline architecture/baseline.arch.yml \
--mode all \
--format json
Matched entries are reported as existing reviewed debt. New entries, a second
canonical occurrence, an identically named type from another assembly, resolved
entries, stale/configuration entries, and ambiguous entries remain distinct and
fail the gate rather than being silently accepted. gate does not modify the
file; use the explicit baseline lifecycle commands only after review.
To include policy-change protection, provide the optional base/current
effective-policy context artifacts. Those findings are a separate guardrail:
an error can fail the combined gate while no persistent finding is new, while
a warn or impact_not_proven result remains review evidence and is never
stored in baseline.yml merely to make CI green.