Skip to content

Upgrading

Read this before you bump the version, and read it top to bottom.

Why the order matters

The per-release notes in CHANGELOG.md each say what to do about that release. Followed in release order they produce the wrong outcome, because the actions interact:

  • 0.19.0 says regenerate the baseline.

  • 0.23.0 says regenerate the baseline.

  • 0.24.0 says regenerate when convenient.

  • 0.28.0 says regenerate before upgrading, and record your finding count.

  • 0.31.0 says regenerate after upgrading, and only if you use the metric rules.

  • 0.29.0 says regenerate again — and if you are still on 0.27.x, upgrade straight to 0.29.0 and regenerate once, because going by way of 0.28.0 costs you two refreshes for one outcome.

    0.27.0 is the release that makes that last instruction checkable: baseline now prints the delta it applied, and --changed says how much it hid.

Someone on 0.18.1 who reads those in order regenerates last — after every widening has landed — and silently accepts every finding the newer releases added. The baseline records them as already-accepted, nothing reports them again, and the run is green.

So the rule for every multi-release jump is one line:

Refresh the baseline on the version you are leaving, commit it, then upgrade.

ts-archunit baseline now prints the delta it applied (41 → 78 entries (+37, −0)), so you can see what a refresh accepted instead of inferring it.

The recipe for any enforcement-changing upgrade

This applies to every jump listed as changing enforcement in the table below, not just to old versions. It used to live under a "Coming from 0.22.x or earlier" heading, which meant anyone on a recent version skipped it by its title.

In this order. Steps 1–3 happen on your current version — except where the table says the preview ships with the change, which is the case for 0.59.0: its zero-subjects diagnosis does not exist on 0.58.x, so running doctor there reports nothing and tells you nothing. For that row, install the new version first and run doctor before letting CI run check.

bash
# 1. See what is already broken. These findings are true today; 0.23.0 only stops
#    them passing in silence. Nothing here is a false positive.
npx ts-archunit doctor 'rules/**/*.rules.ts'      # needs 0.20.0+; skip if older

# 2. Fix every rule doctor names. A rule that asserts nothing has been counted as
#    coverage for as long as it has existed — that is the bug, not the report.

# 3. Refresh the baseline on the OLD version and commit it separately, so the
#    diff of what you accepted is reviewable on its own.
npx ts-archunit baseline --output arch-baseline.json
git commit -am 'chore: refresh arch baseline before upgrade'

# 4. Record the finding COUNT before you upgrade. The baseline delta covers the
#    additive direction only; nothing reports findings that DISAPPEAR.
npx ts-archunit check 'rules/**/*.rules.ts' --format json | tee before-upgrade.json

# 5. Now upgrade, and run normally.
npm install -D @nielspeter/ts-archunit@latest
npx ts-archunit check 'rules/**/*.rules.ts'

If your finding count DROPS, the release is the reason, not a fix. Step 4 exists because the subtractive direction has no instrument: a rule that starts passing, a condition that loses its duplicate findings, or an orphan that stops being reported all look identical to progress — fewer findings, no output, green run. baseline's delta line only tells you what a refresh accepted.

If step 4 produces more than you can triage in one sitting, do not regenerate the baseline again — that accepts the new findings permanently and invisibly. Downgrade their severity instead, which keeps them printing on every run:

ts
export default [...strictBoundaries(p, { boundaries }).map((b) => b.asSeverity('warn'))]

Then ratchet: fix a few, and drop the .asSeverity('warn') when the list is empty. A warn prints on every run and cannot be forgotten; a baselined finding is invisible forever.

.asSeverity('warn', { accepted }) makes this accountable rather than merely visible (plan 0090): name the exact findings you are deferring, and anything new — a violation this list did not accept, arriving while you work through the rest — fails instead of joining the pile silently. Plain .asSeverity('warn') still works exactly as shown above; accepted is additive. See violation-reporting for the full contract.

Do not reach for these, in either case:

ShortcutWhat it actually does
.excluding('index.ts')Silences every barrel in the project at once, including their legitimate imports — element is matched as a basename, not a path.
--changedHides findings in unchanged files. It now says how many it hid, but a green run under --changed is not a green run.
Regenerating againAccepts the new findings with no record of which ones. This is the failure mode the whole page exists to prevent.

Per-release table

Changes enforcement means the release can report findings on code you did not touch, or stop reporting findings it used to. Action required means doing nothing leaves you with either new red or silently reduced coverage.

VersionChanges enforcement?Action required
0.61.0Yes, for a narrow population — agentGuardrails/dataLayerIsolation called with none of their optional flags set now reports a preset/agent/constructs-nothing or preset/data/constructs-nothing config-finding instead of passing silently (plan 0100) — previously such a call constructed zero rules and enforced nothing, silently. Stays silent if a rule was attempted and every one was explicitly overridden 'off' (already a legitimate declaration). strictBoundaries/layeredArchitecture/recommended are unaffected — each constructs at least one rule unconditionally. Separately, .asSeverity('warn', { accepted }) (plan 0090) is purely additive — existing .asSeverity('warn') calls behave exactly as before; accepted is an opt-in list naming which findings you're deferring, and anything not on it escalates to error.Preset users: if you call agentGuardrails or dataLayerIsolation passing only the required field(s) and no other flags, you will see a new finding after upgrading. Set at least one flag to enforce something, or pass overrides: { '<id>': 'off' } for every rule if the emptiness is intentional. Everyone else: no action — .asSeverity('warn', { accepted }) is opt-in.
0.60.0Yes — every beFreeOfCycles() finding's element/identity changes, and smells.duplicateBodies() can report fewer findings than before. Cycles: one violation per internal cycle edge, not one per whole tangled component (plan 0104) — a waiver naming the old '[a, b, c, d]' shape matches nothing after upgrading; it does not silently keep working, it goes unused (Unused exclusion... it may be stale after a rename; misleading wording for this specific cause, but the finding itself is correct). No pre-upgrade preview is possible for .excluding() string patterns (unlike every other row in this table) — the old version can only ever show you one representative edge per tangle, never the rest. Baseline users: regenerate on the new version — every cycle baseline entry is orphaned by the HASH_VERSION bump (4 → 5), not just the changed ones, so there is nothing to compare against on the old version first. .excluding() users: upgrade, run check(), and replace each old whole-component pattern with one .excluding() call naming every printed edge string (.excluding('a -> b', 'b -> c', ...) — one call, multiple patterns, not one call per edge). Do not reach for a regex over the old membership textexecute-rule.ts now warns (writeStderr) when one exclusion pattern matches more than one distinct cycle edge, specifically to catch this. Rollback: pin to 0.59.x while migrating. Preset users are affected too: layeredArchitecture() and strictBoundaries() construct beFreeOfCycles(), so a row scoped by the condition name alone reads as "not me" to someone who never types it. Duplicate bodies: smells.duplicateBodies() gains a minDistinctVocabulary(n) floor (default 8) that can silently REDUCE a rule's finding count — a pair below the floor is never compared, regardless of similarity (plan 0103). If you baseline duplicateBodies findings, describe()'s new minDistinctVocabulary >= 8 clause moves every entry's identity (HASH_VERSION unchanged — the hash formula didn't move, one of its inputs did, same as any other rule-description-text change always has) — regenerate. Fingerprint.distinctVocabulary is a new REQUIRED field; if you hand-construct a Fingerprint literal instead of calling buildFingerprint(), this is a compile-time break. Smell detectors (plan 0102): smells.inconsistentSiblings() gains .inertAdvice() and diagnose()/doctor gain the 'inert' finding kind, previewing a rule that examines a real corpus but cannot ever fail. check() does not fail on this yet, but diagnose()/doctor report it starting on this version, unconditionally — doctor can go non-zero on this upgrade alone, before the separate check()-failing flip (plan 0105) ships. If you run doctor in CI, or assert expect(diagnose(rules)).toEqual([]) in a test, run it on 0.60.0 — the diagnosis does not exist on 0.59.x — before relying on either going green. Self-check today, on your current version, without waiting to upgrade: if your forPattern matches in under 60% of a scoped folder's files, this will report on that rule the moment you're on 0.60.0.Cycles: run check() after upgrading and replace any whole-component .excluding() pattern with one call naming every printed edge; regenerate any cycle baseline. Duplicate bodies: regenerate any duplicateBodies baseline; check whether the new floor excluded a pair you were relying on (unlikely — it only excludes low-vocabulary pairs). Smell detectors: run ts-archunit doctor (or your diagnose() assertion) right after upgrading — it can report new findings even though check() behaviour is unchanged.
0.59.1No action.requireGraphQL() reported "not installed" for every graphql load failure, not only a missing package. An installed-but-broken graphql (corrupt install, version mismatch, a throw during its own module init) now gets a message naming the real cause instead of an install instruction that cannot fix it.
0.59.0Yes — a rule that examines zero units now fails check() where it passed. Seven families gain the gate: both smell detectors, correspondence, crossLayer, both GraphQL builders and tsconfig. The RuleBuilder entry points — classes(), functions(), modules(), types(), calls(), jsxElements() — are not affected — their empty-selection gate has failed since 0.34.0.Do this on 0.59.0, not before. Unlike every other row here, the preview ships with the change: zero-subjects does not exist in 0.58.x, so doctor there is silent about exactly the rules that will flip. Install 0.59.0, run ts-archunit doctor (or expect(diagnose(rules)).toEqual([]) in a test), and fix or declare what it names before letting CI run check. There is no flag. --baseline, --changed, .warn(), .asSeverity('warn'), .excluding(), an inline // ts-archunit-exclude comment and overrides: { id: 'warn' } all leave the finding; --changed keeps it unconditionally, so a PR touching no source goes red. overrides: { id: 'off' } removes it by deleting the rule — permanent, and never revisited. Through a preset you are affected only if you passed noCopyPaste: true (preset/agent/no-copy-paste, preset/boundaries/no-duplicate-bodies); the larger population is direct smells.duplicateBodies(p) / smells.inconsistentSiblings(p) callers, who need no opt-in. The remedy is per cause and the finding names it — an empty project points at the tsconfig and never offers a declaration; a dead glob asks you to fix the glob; a rule whose own narrowing left nothing names the narrowing, including a default you did not write (minLines is 5 unless set). Declaring is a peer of widening, not a lesser option: for a layer that is not built yet widening is impossible, and expectEmpty states a fact that fails the day it stops being true. Preset users get the reachable spelling, expectEmpty: ['<rule id>']. ⚠️ Extension surface (ADR-010 rule 1): collectViolations() returns CollectResult and examined is behaviour-defining — a dialect satisfying the type with a constant 0 was unobserved before and now hard-fails every rule; it must be a real count.
0.58.0Yes, if you ratchet metrics through functions() — identities move for object-literal functions and for class methods, because functions() collects methods by default. classes() metrics keep byte-identical identities but their element changes, which affects .excluding() and not the baseline.Two actions, in this order. (1) On 0.57.0, run the preview below — it names every entry that will move. (2) Upgrade, then regenerate the baseline for your functions() metric rules and check the finding count from step 4 of the recipe is unchanged. Do not regenerate on the old version: the identity moves regardless, so a refresh there buys nothing. What changed: a metric finding's identity re-derived the subject's name from the AST, and for an object-literal function that resolved up to the enclosing function — so errorResponseBuilder's finding was identified as makeAlpha, byte-identical to makeAlpha's own. Since measured is a per-identity ceiling, those ceilings were keyed to a slot rather than to a function, and deleting one member could hand a survivor a ceiling never granted to it. Identities are now scope-qualified (makeAlpha.errorResponseBuilder) — qualified rather than merely renamed, because an object literal returned from a factory has no owning binding, so two factories returning { build } both call it build; scope is what separates them. Display names, messages and haveNameMatching are unchanged — only the opaque key moved. Not covered, and not covered before either: a literal passed as a call argument (register({ handler: … })) has no enclosing declaration, so two in one file sharing a key name still share an identity — bug 0070. A resolver map held in a const, returned from a factory, or written as method shorthand is covered. element changed for every metric finding, to the qualified name: it is what the terminal prints, what JSON reports, and one of three fields string-form .excluding() matches by exact membership. So .excluding('UserRepo.save') on a class metric starts working where it silently matched nothing before, and .excluding('save') stops matching — you get an "Unused exclusion" warning naming the pattern. One case gives no warning and is worth checking by hand: an exclusion naming an enclosing function (.excluding('makeAlpha')) used to silence every arrow nested inside it, and now silences only makeAlpha — the pattern still matches that one finding, so nothing reports it as stale, and the inner findings simply appear. That exclusion was over-broad; the new findings are pre-existing true violations. Preview, on 0.57.0 — every entry that will move, named: npx ts-archunit check <rules> --format json | jq -r '.violations[] | select(.measured != null) | select((.message | split(" has ")[0]) != .element) | "\(.file):\(.line) element=\(.element) → becomes \(.message|split(" has ")[0])"' — run it without --baseline, or accepted findings are filtered out and you see nothing. The empty-selection message also changed wording (it no longer names .expectNonEmpty(), which callers may never have called); that finding bypasses baselines, so nothing to regenerate, but an .excluding() matching its exact message text needs updating and will warn as unused rather than fail open.
0.57.0Yes — findings that were hidden inside another finding's baseline entry are now reported. noDeadModules(), beImported(), onlyBeImportedVia(), notImportFrom(), onlyImportFrom() and duplicate-body smells are all affected, including the agentGuardrails and strictBoundaries presets.Two actions, in this order. (1) On 0.56.0, regenerate the baseline for onlyBeImportedVia() rules and for any rule whose findings carry a multi-name import — those entries move. (2) Upgrade, then triage the new findings; nothing else needs regenerating. Two distinct violations could share one baseline entry, so accepting one silently accepted the other — including a sibling added months later. Measured: 138 findings across 123 entries on this project's own source (15 absorbed), and 710 across 526 on a 2,173-file monorepo (184 absorbed, 26%). Every finding of a rule now gets a distinct identity, by suffixing only the ones that collide, from the second occurrence onward. The first of every colliding group keeps its identity byte-for-byte, so the entry you already accepted still matches and only the hidden sibling reports as new — the finding you never got to see. This retracts the carve-out in the 0.56.0 row below: that row said two spellings resolving to one file (a paths alias, or a workspace package name, beside a relative path) still shared an entry and that "this release does not change your exposure". They no longer share one, so if that is your layout, 0.57.0 does change your exposure. Why two entries move: onlyBeImportedVia()'s message now names the importer by path (imported by src/a/index.ts) instead of by basename, because two colliding findings otherwise printed byte-identically — same element, same file, same line — and GitHub collapsed the two annotations into one, so the newly-surfaced finding was invisible in a PR; and the sort of a multi-name import's names no longer reads the host locale, which on a da-DK machine ordered them differently from CI. An .excluding() matching element or file is unchanged; one matching the exact message text needs updating, and will warn as unused rather than fail open. To see what this affects before upgrading, on 0.56.0: jq -r '(.violations|length) as $n | ([.violations[].hash]|unique|length) as $d | "\($n) entries, \($d) distinct — \($n - $d) finding(s) hidden inside another entry"' arch-baseline.json — duplicate hash values are the collisions. Refresh the baseline first, or the command only sees collisions that existed when the file was written. The new findings are pre-existing true violations, not new code, so triage them or hold the rule at .asSeverity('warn') and ratchet — and do not regenerate afterwards to absorb them. One residual: within a colliding group entries are matched by position, so the count of new findings is always right but the file named may be a sibling you did not touch. Steps 3 and 4 of the recipe above are optional here. Preset users are affected too: layeredArchitecture() construct notImportFrom(), onlyImportFrom(), so a row scoped by the condition name alone reads as "not me" to someone who never types it.
0.56.0Yes — new findings from notDependOn()/respectLayerOrder(), and from notImportFrom()/onlyImportFrom()/dependOn() if one file reaches one module twice the same way.One action: triage the new findings. There is nothing to regenerate — no existing baseline entry moves. notDependOn() and respectLayerOrder() now count dynamic import() and type-expression import('…').Y edges, which they previously ignored, so expect new violations on code you did not change. A second source of new red, and this one reaches notImportFrom()/onlyImportFrom()/dependOn() as well: a second edge of one kind reaching one module by the same specifier from one file now gets its own identity. Two import('./x.js') calls in one file used to share a single baseline entry, so accepting one silently pre-accepted the other — the hidden one is now reported. Same specifier, in this release only: two spellings that resolve to the same file (a paths alias beside a relative path, say) still share one entry here — that is bug 0064, pre-existing and not fixed in 0.56.0. It is fixed in 0.57.0 — see the row above, where that layout does change your exposure. Both sources are pre-existing true violations, not new code, so triage them, or .asSeverity('warn') the rule and ratchet — and do not regenerate the baseline afterwards, which would accept them with no record of which ones. The first edge of every group keeps a byte-identical identity, so every entry you already accepted still matches: nine forms (seven import/reexport spellings plus dynamic and type-expression) were replayed against 0.55.3 and diffed with zero difference, enumerated in tests/conditions/identity-does-not-move.test.ts. Steps 3 and 4 of the recipe above are optional here — this release only adds findings, it removes none. beFreeOfCycles() is unchanged: a dynamic-only cycle is still not a cycle, deliberately. Preset users are affected too: layeredArchitecture() and strictBoundaries() construct beFreeOfCycles(), notImportFrom(), onlyImportFrom(), respectLayerOrder(), so a row scoped by the condition name alone reads as "not me" to someone who never types it.
0.55.3No action.One internal derivation, no shipped behaviour change: the population of enforceable primitives is now computed from src/index.ts by a committed script rather than quoted from memory, and a self-application figure we had published in our own roadmap is withdrawn because its method was wrong. Nothing an adopter runs is affected.
0.55.2No action.Two internal guards, no shipped behaviour change: ADR-008 rule 6 (every plan declares its blast radius) and plan 0083 Phase 1 (every gated rule has been planted against) are enforced by tests rather than by convention. Both are green on arrival; their job is to stop the next omission being silent.
0.55.1No action — no behaviour change and no baseline movement.An internal helper could build a pattern that did not match its own input when a character's case mapping is multi-character (ßSS). Today's stub phrases are ASCII, so STUB_PATTERNS is byte-identical to 0.55.0 and no identity moves. Recorded because the first attempt at the fix would have moved every stub baseline for no behavioural gain, and the guard added in 0.54.0 is what caught that.
0.55.0Yes, if you baseline noStubComments findings — regenerate, and expect new findings.// NOT IMPLEMENTED, // COMING SOON and any other casing of those phrases match again; between 0.47.0 and now only // Not implemented-style casings did, because the pattern alternated just the first letter of each word. So you may see new findings — markers that were being missed — and the pattern's text is part of a finding's identity, so existing noStubComments baseline entries move. Markers themselves are still case-sensitive (// TODO matches, // todo: does not); that is deliberate and is what keeps the rule off prose that merely mentions a marker.
0.54.1No action.Two problems in 0.54.0's own code, found by reviewing it: the new baseline diagnostic was one 688-character paragraph (now line-broken), and the guard against a silent default-pattern change checked a hand-written list against itself — so a second default pattern would have been invisible to it. It now derives the set from source.
0.54.0No action — but the 0.47.0 row above changed, and it may apply to you retroactively.If you upgraded through 0.47.0 and use noStubComments, your baseline stopped matching then and this release is what finally says so; regenerate. Nothing in this release changes behaviour: a baseline diagnostic stops asserting that an unmatched baseline means a wrong root — it now lists the candidates, upgrading first — and a new guard makes a future default-pattern change impossible to ship without a migration note.
0.53.0Yes, if you baseline notImportFrom, onlyImportFrom, dependOn or notHaveAliasedImports findings — regenerate.Those four identified a finding in a way that could not tell two files apart when they shared a basename — two sibling folders each with an index.ts, say. Two distinct violations became one baseline entry, so accepting one silently accepted the other. Fixed by identifying the file by its full path (and by adding an identity to the two conditions that had none). Their hashes move once. This is the same defect the 0.52.1 note describes for slices() rules: the slice conditions copied this scheme, and fixing it there is what exposed this.
0.52.1Yes, if you regenerated a slice baseline for 0.52.0 — regenerate once more.0.52.0's slice identity was built from the file's basename, so two files sharing one (two sibling folders each with an index.ts, say) collided into a single baseline entry and accepting either accepted both. It now uses the full path, which moves slice identities a second time. Apologies for the double migration — it is the cost of the first one being wrong, and it is better than leaving a silent collision in place.
0.52.0Yes, if you baseline any slices() finding — regenerate. One migration covering four fixes.Slice findings now set their own identity, so every slice finding's hash moves, once. Four fixes were batched deliberately so you pay that once rather than three times. What you gain: a barrel's dependency sites are now distinct findings (previously all shared one hash, so one baseline entry accepted all of them — the same hazard the 0.28.0 row warns about for dependOn); cycle messages no longer print arrows that do not exist (Cycle detected between: a, b, c (e.g. a imports b at …) replaces a reversed pseudo-path); a cycle finding is located on a real edge instead of unknown:0; and a cycle's element is sorted, so reordering imports no longer reds CI or staleness-warns your .excluding() patterns. If you waive a cycle by .excluding('[a, c, b]'), that pattern must become '[a, b, c]' — sorted. Still open, and worth knowing: a new cycle between slices already inside a waived component is still absorbed by that waiver.
0.51.0No action.Two new guards and no behaviour change. Worth knowing what they now prevent, since both were unguarded until now: every exports subpath is verified to resolve by package name and to ship in the tarball (twelve subpaths had existed for six releases with nothing ever resolving one), and every shipped preset's rule array is verified to report identically when evaluated more than once in a process.
0.50.0Only if you use workspace() with packages whose tsconfigs disagree on verbatimModuleSyntax, or you pass an options object to beFreeOfCycles()workspace(): every package was previously judged by whichever tsconfig sorted alphabetically first, so beFreeOfCycles() was wrong in both directions — a real cycle could vanish, and a package could get a phantom cycle whose remedy cannot be applied. Now resolved per package. Expect findings to change in a mixed monorepo, in both directions, and regenerate the baseline; project() is unaffected. beFreeOfCycles({}): an options object that did not restate ignoreTypeImports silently gave the pre-0.47 graph. If you passed {} or a config-derived object, you were getting type-only cycles you had not asked for and will now stop — a reduction, so check the count before and after per this page's step 4. Preset users are affected too: layeredArchitecture() and strictBoundaries() construct beFreeOfCycles(), so a row scoped by the condition name alone reads as "not me" to someone who never types it.
0.49.2No action.Tests, guards and one ADR corollary. No shipped behaviour changed. Recorded here only because the release exists: six guards that could not fail now can, the largest being that respectLayerOrder had none of the three guards its sibling notDependOn received.
0.49.1No action — but re-read the 0.47.0 and 0.48.0 rows above if you upgraded through themNo behaviour changed. Fourteen shipped statements did, because they described behaviour v0.47.0–v0.49.0 replaced. Two matter to you. First: { ignoreTypeImports: false } was described here, and in four other places, as a way back to "the old graph". It is not — it counts type-only edges, and re-exports are counted regardless from 0.48.0, so it is a wider graph than 0.46 ever had. If you reached for it to buy migration time you got more findings, not fewer; use .asSeverity('warn') or a baseline instead. Second: if you use layeredArchitecture or strictBoundaries, the Why: line printed under a cycle finding told you barrel cycles were not detected. They are, since 0.48.0 — the finding was right and its own explanation was wrong. Also corrected: four statements scoping noStubComments() to function bodies (0.47.0 widened it to a function's leading docstring), and a docs/slices.md example of per-edge cycle output that has never existed.
0.49.0Only if your tsconfig sets verbatimModuleSyntax: true — then expect new cycle findingsUnder that flag TypeScript keeps the module request even when every specifier is erased: import { type X } from './b.js' emits import {} from './b.js', and export { type X } from './b.js' emits export {} from './b.js'. So those two forms cause the target module to be evaluated and can close a real cycle; beFreeOfCycles() treated them as erased and reported nothing. It now reads the flag from your tsconfig. The fix for a new finding is also its diagnosis: move the modifier to the declaration — import type { X } — and the module request goes away with the cycle. Exactly two spellings changed; import type { X }, export type { X } from, mixed specifier lists and export * from behave as before. Without the flag, nothing changes — an absent option counts as off. notDependOn() and respectLayerOrder() are deliberately unaffected: the bindings are type-level either way, and coupling is what they measure. Preset users are affected too: layeredArchitecture() and strictBoundaries() construct beFreeOfCycles(), respectLayerOrder(), so a row scoped by the condition name alone reads as "not me" to someone who never types it.
0.48.0Only if you use slices() rules — this release can turn a green build red, and each new finding is a real dependencybeFreeOfCycles(), notDependOn() and respectLayerOrder() now count re-export edges: export { x } from './b.js' and export * from './b.js' emit an import of the module, so they were always runtime dependencies and were simply invisible. The likeliest new finding is a barrel cyclea → barrel → a, the commonest cycle shape, previously undetectable. Baselines: a cycle's identity is its member list, so a cycle that got wider is a new violation rather than a moved one; regenerate. Dynamic import() and require() are still not counted (lazy, and CJS respectively). Also new: notDependOn() and respectLayerOrder() accept ImportOptions, and count type-only edges by default — unlike beFreeOfCycles(), because a cycle is about runtime initialization order while isolation is about coupling. Pass { ignoreTypeImports: true } to opt out. Preset users are affected too: layeredArchitecture() and strictBoundaries() construct beFreeOfCycles(), respectLayerOrder(), so a row scoped by the condition name alone reads as "not me" to someone who never types it.
0.47.0Only if you baseline beFreeOfCycles() findings, or relied on type-only imports counting as cycle edgesbeFreeOfCycles() now ignores type-only imports by default, because an import type is erased at compile time and cannot create a runtime dependency cycle — so cycles that only ever existed on paper stop being reported. Pass beFreeOfCycles({ ignoreTypeImports: false }) to count type-only edges again — but not as a way back to the 0.46 graph if you are landing on 0.48.0 or later, because re-exports are counted from 0.48.0 and type edges plus re-export edges is a wider graph than 0.46 ever had. To hold still while migrating, use .asSeverity('warn') or a baseline. Baselines: a cycle's identity is its member list, so a cycle that got narrower — a slice joined to it only by type edges is no longer a member — changes identity and its entry stops matching rather than moving; the narrower cycle is then reported as new. Regenerate those. This default deliberately differs from notDependOn()/dependOn(), which still count type edges: cycles are about runtime initialization order, layering is about coupling. Also: noStubComments() now sees a function's leading docstring, so rules using it may start reporting markers they missed — and it no longer fires on prose mentioning TODO, which requires markers to begin a comment line and to be uppercase. A lowercase // todo: is no longer matched. Baselines: regenerate any that contain noStubComments findings. The pattern's text is part of a finding's identity, so rebuilding the pattern moved every one of them — measured, 0 of 4 entries matched after upgrading. This row omitted that until v0.54.0, and the tool could not tell you either: both the rule description and the finding's subject move together, so it fell through to guessing at your root. Preset users are affected too: layeredArchitecture() and strictBoundaries() construct beFreeOfCycles(), so a row scoped by the condition name alone reads as "not me" to someone who never types it.
0.46.1Only if you relied on a named function expression keeping its own name{ handler: function legacyName(req) {} } reports handler, not legacyName — the second arm of 0.46.0's naming change, undeclared at the time and now pinned in both directions. Otherwise internal: the exemption that lets notExist() pass on an empty selection was readable off a public export and copyable onto any condition, switching off the empty-selection gate in one line. It is now unforgeable. If you were (knowingly or not) relying on that, those rules will start reporting.
0.46.0Only if you baseline violations about callbacks written as object propertiesThose callbacks now carry a name, so element changes from <anonymous> to e.g. handler (nested: hooks.onRequest). Whether that moves the baseline hash depends on the rule: a producer that sets its own identity — every body-analysis condition, notContain(call('x')) and siblings — keeps its hash, because it identifies a violation by the call site rather than the enclosing function's name. Structural conditions have no identity and do move. Regenerate those. (An earlier version of this row said the hash was "over rule + element + message" and told you to regenerate everything; that was wrong on both counts.) Positional callbacks are unchanged and still anonymous. In exchange, within(calls(...)).functions().that().haveNameMatching(/^handler$/) now selects what it says instead of nothing. The other change is internal: a cross-layer condition declares for itself whether it diagnoses an empty layer, so an untagged one is covered by the dead-glob gate rather than silently exempt.
0.45.6NoInternal only, plus two small robustness improvements: --version returns 0.0.0-unknown rather than throwing if package.json is unreadable, and an unrecognised --format falls back to auto instead of being asserted into the union. The substance is that this project enforces noTypeAssertions() against its own source with a rule that had been selecting classes in a codebase written in functions — so it never fired on any of the 22 casts we shipped. 18 removed, 4 waived in place at genuine JS-interop boundaries. No API change.
0.45.5NoTest-quality only, no source or behaviour change. Follow-ups from a review of v0.45.4: the guard that tracks count-only assertions now tracks which files contain them rather than how many exist, and three assertions that could have passed over an empty result were tightened.
0.45.4NoTest-quality only, no source or behaviour change. 45 assertions that checked how many elements a rule produced now check which — the ADR-008 corollary that had no guard. A seeded sample of 30 put 27% in the class where losing one element and gaining another still passes. Nothing about the library changes; two library observations came out of it and are recorded in plan 0079 and plan 0082.
0.45.3Only if you subclass TerminalBuilder externallyThe break was in 0.44.0 and went unmentioned: protected deadSelectorFindings() returns { selector, discovery } instead of ArchViolation[]. A TypeScript override stops compiling and a JavaScript one throws, so nothing fails quietly — but it was not written down, and it is now in the API reference along with ownsDiscoveryDiagnosis(). Override that one if your builder already reports a dead discovery glob itself, or the gate preempts you with a generic message. Everything else here is internal guard work plus one fix reachable only by calling a cross-layer condition’s evaluate() directly: fewer than two layers used to return nothing silently and now produces a configuration finding.
0.45.2NoInternal guards only. The v0.45.0 census checked "does this finding print the rule author's remedy?" by matching a hand-written list of spellings, so a destructured, aliased or helper-supplied read escaped it; it now resolves symbols through ts-morph. Two producers in one function also used to collapse into a single census entry. Four code comments that stated stale populations were corrected — one of them named as examples the exact two builders whose fix it was describing. No API, behaviour, message or exit-code change.
0.45.1Only if a cross-layer rule's last layer matches nothingIt used to report every file in the preceding layer as having "no matching counterpart" — advice that cannot work, since the target layer's glob is wrong. It now reports the mis-globbed layer, like the other two cross-layer conditions already did. If you were seeing counterpart violations you could not explain, this is why.
0.45.0NoInternal only: a derived census over the 15 configuration-finding producers, asserting each carries its own remedy rather than the rule author's. No API or behaviour change.
0.44.0Yes — a rule whose discovery glob matches nothing now failssmells.*.inFolder(), resolvers(p, glob) and crossLayer().layer() globs that match no files used to pass in silence — measured, two of three cross-layer conditions went from 4 violations to 0 on a dead layer. They now produce an unsuppressable configuration finding, because such a rule was checking nothing. doctor has reported these for several releases, so run it first to see the list before upgrading. Rules whose globs match are unaffected, and slice rules are unaffected — including one empty entry among populated ones, which stays legitimate.
0.43.3NoInternal only: a JSDoc reattached, and the orphan check's file reject deduplicated and guarded by counting. No behaviour change.
0.43.2Only if doctor is in CI and you pass one rule file at a timev0.43.0's orphan-exclusion check reported working comments as orphans when doctor was given a subset of your rule files, and advised deleting them — which would un-waive a real violation. It now says what it checked. Pass every rule file for a clean report. Also: the check no longer loses findings on in-memory projects or unreadable files; it reports one aggregate finding when no rule declares an id (previously silent, though every exclusion was inert); the terminal output now prints the source file and line; and the JSON finding carries sourceFile instead of overloading ruleFile.
0.43.1NoFixes a vacuous pass introduced in v0.42.0, reachable only by calling a pair condition's evaluate() directly: a context carrying one layer beat a usable two-layer argument, and the condition then returned nothing. The threshold is now two or more. Builder paths are unaffected — .mapping() already guarantees at least two layers.
0.43.0Only if doctor is in CIdoctor gained a finding kind: an inline // ts-archunit-exclude comment naming a rule id no rule declares. It suppresses nothing, so the rule it was meant to waive is being enforced — and nothing reported that before, because a comment is only read in a file that already produced a finding for that rule. If you have renamed a rule and left the comments behind, doctor will now exit non-zero and name them. Fix by correcting the id or deleting the comment. check is unaffected: no new findings, no exit-code change. New export orphanExclusions(rules) — pass all your rules, since the declared-id set is the union across rule files.
0.42.1NoDiagnostic quality only. If your tsconfig loads no source files, rules used to report every selector glob as dead and tell you to correct it — the glob was fine, the tsconfig was empty. doctor already said so; the check now says the same thing, once per project. Message text changes on that path; nothing changes about which findings fire, the exit code, or baselines.
0.42.0No — additivehaveMatchingCounterpart() now takes no argument: the builder passes its own resolved layers, which it previously could not, since layers is private and resolveLayer unexported. Existing haveMatchingCounterpart(layers) calls still compile and the argument is ignored when the builder supplies them — so drop it whenever convenient. One silent change if you hand-built that array deliberately narrower than your .layer() declarations: the builder's layers now win. That is the fix (the condition was judging a copy the library never resolved), but check such call sites. PairCondition.evaluate now receives a PairConditionContext; a condition declaring ConditionContext still satisfies the interface, so custom pair conditions compile unchanged.
0.41.0Yes, if any preset override key is misspelledA typo'd override key used to do nothing, silently — measured, '…/no-silent-cach': 'error' left the rule at warn and the build passed. Keys are now typed as a union of each preset's rule ids, so a literal typo is a compile error; anything a type cannot reach (a JS consumer, a runtime-built object, config from disk) becomes a configuration finding that fails the build. A build carrying such a typo goes from green to red, which is the fix — the override was never applied. Correct the key, or delete it if you did not mean it; the finding lists the valid ids. agentGuardrails' no-inline-logic/${api} keys stay open at the type level by construction, and are covered at run time. validateOverrides is unchanged.
0.40.0Only if an exclusion comment was not really a commentA // ts-archunit-exclude directive now counts only where it genuinely is one. Previously the parser matched the characters on any line, so the text inside a string, template literal, regex or JSX element created a real exclusion that silenced a real finding — silently. It also now has to begin its comment, so prose mentioning the syntax (including a JSDoc block explaining it) no longer declares an exemption, and /* … */ blocks are excluded since the grammar was always //-only. The documented trailing form still works. If a finding reappears after upgrading, the comment suppressing it was never a valid directive; write it as its own line comment if the exemption was intended. Most affected: projects whose source discusses this library.
0.39.1NoDiagnostic quality only. A symlinked node_modules — what pnpm and git worktree add produce — was recorded as a file rather than pruned, so doctor and diagnose() described a glob beneath it as absent ("no such path") when the truth was not-determined ("this walk cannot tell"). If you use pnpm and have ever been told a node_modules glob matches nothing that exists, that message was wrong and is now correct. Advice text on existing findings changes; nothing changes about which findings fire, the exit code, or baselines.
0.39.0Only if you parse check --format jsonFindings with no source location — those reporting that a rule enforces nothing — now emit "file": null, "line": null instead of "" and a meaningless number, and every violation carries a new "kind" of "violation" or "configuration". The loud break (v.file.endsWith(...) throwing) is the easy one; check first for the quiet coercions — `${v.file}:${v.line}` now yields the literal "null:null", and byFile[v.file] buckets under "null". Branch on v.kind === 'configuration' before reading the location. Nothing else changes: no enforcement difference, no exit-code difference, and a 0.38.0 baseline still matches (measured). The document is now typed — import ArchJsonReport and let the compiler find these for you. Note kind is the field to test: the CLI attributes most configuration findings to the rule file, so a non-null file does not mean it is a finding about your code.
0.38.0Yes — an exclusion comment without a reason now fails// ts-archunit-exclude <rule-id> with no : <why> becomes an unsuppressable configuration finding. The exemption still applies — what fails is the missing justification — so adding a reason clears the finding and changes nothing else. Find them with grep -rn "ts-archunit-exclude" src/. This raises the cost of a suppression; it does not prevent one, and the finding says so. Also: nested -start blocks now nest properly. The old parser refused a nested start and let the inner -end close the outer block, so a region you believed was exempt may not have been — those findings will now appear.
0.37.0Yes — findings may disappear, and a red build may go greenInline // ts-archunit-exclude comments did nothing unless the producing condition stamped ruleId itself: they worked for classes() and were silently inert for the dependency, exports, slice, reverse-dependency and module-body families. They now work everywhere. Exemptions you wrote and believed had failed are now honoured, so a report can shrink and an exit code can drop. A comment matches by rule id, file, and the line immediately below it and nothing else, so one left in place after the code beneath it moved will silence whatever violation of that rule now lands there. Note that the Undocumented exclusion warning is unchanged in wording but has inverted in meaning — it used to accompany a red build, because the exclusion did not apply; it now accompanies a green one. Audit with grep -rn "ts-archunit-exclude" src/ before upgrading, and read check --format jsoncommentSuppressed after, which lists every suppression by rule and file. Baselines are unaffected — measured, a 0.36.3 baseline matches on 0.37.0.
0.36.3Only if you used a relative glob at crossLayer().layer(), smells.*.inFolder(), importFrom or onlyBeImportedViaThose four surfaces matched a relative glob against the absolute path, so they selected nothing — and "nothing" is not always quiet: onlyBeImportedVia('src/api/index.ts') reported 5 violations on correct code because its allowlist resolved to nothing, and importFrom('src/domain/**') selected 0 subjects where the anchored spelling selected 5. They now resolve against the project root, like every other path glob. If a rule of yours was red for this reason it goes green; if it was green because it selected nothing, it starts enforcing what you wrote — which is the point, but budget for the findings. Anchored '**/' globs are unchanged and still mean "anywhere".
0.36.2Only if you use workspace() or a relative glob in an import rule — both report differently nowTwo fixes, both in the direction of matching more. In a workspace(), a project-relative glob used to resolve against the alphabetically-first package only; it now resolves per package, so a rule scoped that way may report findings in packages it silently skipped before. And an import glob (onlyImportFrom, notImportFrom, dependOn) now accepts the relative spelling, which fixes a false red: layeredArchitecture({ shared: ['src/shared/**'], strict: true }) reported a violation on a correct architecture. Baselines are unaffected — the primary candidate that identities hash is unchanged. Bare package specifiers are unaffected. If you anchored globs with '**/' to work around either, they still work and still mean "anywhere".
0.36.1No — additiveNone. slices().assignedFrom() and the layer options that discover through it now accept a project-relative glob ('src/api/**') and resolve it against the project root, closing the last surface that required '**/src/api/**'. Anchored globs are unchanged. The only way this alters a result is if you had a relative glob there that was matching nothing and you wanted it to keep matching nothing — in which case the rule was enforcing nothing, and 0.34.0's discovery guard was already failing it.
0.36.0Yes — more comment findings, on code you did not touchYour baseline still works — do not refresh it. Body-analysis identities carry no line number, so every entry you already accepted keeps matching: measured, a baseline generated on 0.35.0 matched 5 of 5 after upgrading, and the 4 additional findings are the ones that were being missed. What changes is that comment()-based rules — noStubComments(), anything built on comment() — now find every matching comment instead of one per nesting level. Measured on a 9-comment corpus: 5 findings become 9. The delta scales with nesting, so your longest functions move most; a function with one stub comment is unaffected. These are ordinary violations, so .excluding(), the baseline and --changed all apply — triage them, or .asSeverity('warn') the rule while you do, rather than regenerating the baseline, which would accept them invisibly. Two smaller notes: findings now name the comment's own line rather than the line below it, so tooling that greps check --format json messages needs re-checking; and if you hoist a comment() rule and evaluate it twice in one process — the shape running-in-tests.md recommends — your second evaluation used to return nothing and now returns the truth.
0.35.0Yes, in the permissive direction — globs that failed now matchUsually nothing. resideInFolder('src/domain/**') and its two siblings now resolve against the project root instead of matching nothing, so a rule that 0.34.0 failed on may simply start working. Two things to check. First, if you anchored a glob to '**/src/domain/**' to satisfy 0.34.0, it still works and still means "anywhere" — revert it to the relative form only if you meant the root one. Second, and the reason this is not purely additive: a relative glob you intended as "anywhere" now silently selects only the root copy rather than failing loudly, so if you have a nested src/ you want covered, keep the **/. Unaffected: slices().assignedFrom() and the layer options, which still need an anchored glob (bug 0033).
0.34.0Yes — the big one. Rules that silently enforced nothing now failRun ts-archunit doctor on 0.33.x first, and fix what it reports before you upgrade — it names the dead-glob half without a red build. Three new failures, all of which were already enforcing nothing: an unsatisfiable selector glob; a selection that is empty at runtime; and preset fan-out, which now reports once per option instead of once per generated rule (a fix, not a new failure). None can be baselined or warned away — that is deliberate, because accepting a rule that cannot fail makes the gap permanent. The baseline does not help you here and refreshing it will not silence these. For a selection that is legitimately empty, declare .expectEmpty(); for a pre-emptive ban, .should().notExist() is exempt. .expectNonEmpty() still works and is now redundant. Unaffected: condition and exclusion globs, and every ordinary violation.
0.33.0No — diagnostic messages onlyNone for check: the same violations are reported. If you consume doctor --format json or the exported DiagnosticFinding type, kind gains a fourth value 'project-empty'source-breaking for an exhaustive switch. Two messages changed text: a project that loaded no source files is now reported once naming the tsconfig rather than once per glob, and a glob whose path was not found on the searched tree gets its own advice instead of a generic cause list. If your tsconfig is solution-style ("files": [] with "references") you will see one new finding saying so — it was always true; it was previously reported as several misleading ones.
0.32.0No — a command's visibility and one flag's validationNone. ts-archunit doctor is listed in --help and documented as supported rather than experimental; its behaviour and findings are unchanged. If you scripted doctor --format <something> with a value it never supported, it silently ran as terminal before and now exits 1 — the valid values are terminal and json. It is still not a build gate: check is.
0.31.1No — internal performance onlyNone. Function bodies are now walked once per kind and once for the kind-independent case, shared across matchers instead of repeated per matcher. Measured: eight agentGuardrails banned APIs 88 → 16 ms; the marginal broad expression() / comment() rule ~57 → ~17 ms. No API change, no baseline impact, and findings are identical.
0.31.0Yes — metric findings only. They ratchet now, and their identity changedRefresh the baseline after upgrading, not before — the reverse of this page's usual rule, because the new identity is what you want recorded. Only entries for the size and complexity metrics (maxMethods, maxClassLines, maxMethodLines, maxParameters, maxCyclomaticComplexity, the two function variants, havePropertyNamed's max and haveMaxExports) change hash; every other entry keeps matching, so a mostly-non-metric baseline will look fine and quietly re-report its metric entries as new. If you use those rules, regenerate. The behaviour change is in your favour: improving a metric no longer counts as a new finding. Note the accepted value only tightens when you regenerate, and that the baseline-growth gate cannot see a metric regression — check catches it.
0.30.0No — additive and opt-inNone. definePredicate / defineCondition gain an optional third argument for declaring the path globs they match against. Existing two-argument calls are unaffected and keep declaring nothing. Declaring a glob can only add doctor findings, and only for globs you chose to declare — so the one way this changes a result is if you declare a kind that does not match what your predicate really tests: a bare specifier declared file-path is reported dead when it is fine. The kinds are tabulated in Custom rules.
0.29.0Yes — baseline identity only. Dependency findings gain a distinct identity, so two findings in one file that differ by imported name or line no longer collideRefresh the baseline. HASH_VERSION moves 2 → 3 and every dependency baseline entry changes hash, including the ones that never collided — no printed text moves, which makes this an invisible invalidation, so it is stated here rather than left to be noticed. A 0.28.x baseline matches nothing and the run says so: a non-empty baseline that matches nothing at all now emits a diagnostic. Coming from 0.27.x, refresh once on 0.27.x and upgrade straight here. No enforcement change otherwise — the same findings are reported, with identities that tell them apart.
0.28.0Yes — the widening. Dependency conditions see export … from, import() and type X = import(…), not just static importsRefresh the baseline on 0.27.x and record your finding count first (the recipe above). Expect new findings on barrels — this repo's src/index.ts went 0 → 114. Do not baseline a barrel: 46.5% of its findings share an identity with a sibling, so accepting one accepts the rest. dependOn reverses: a runtime re-export or dynamic import now satisfies it. noDeadModules() reports fewer orphans. Predicate-position notImportFrom selects fewer subjects, so some rules check less.
0.27.0No — instruments only, for the 0.28.0 upgradeNone. Two output changes: a --changed run that hides findings prints one extra stderr line and sets summary.reason in --format json (previously always null), and ts-archunit baseline's first line is now the delta rather than Baseline generated: N violations recorded.
0.26.0No — output routing onlyNone. .warn() and every other library message now reach stderr from inside a test runner, so a passing test prints where it did not before. .warn() output loses vitest's per-test attribution.
0.25.0Yes — a dead strictBoundaries({ shared }) glob is now a configuration findingFix or delete shared globs that match no file. Baselines are unaffected: no-cross-boundary's text changed but its identity did not.
0.24.0Yes — a rule file that cannot be evaluated is a finding instead of a crashFix any rule file that fails to load; it no longer takes the rest of the run down. Violation output is one line longer per finding. runCheck/runBaseline resolve with a non-zero code instead of rejecting.
0.23.0Yes — all seven assertion-less rule shapes fail on every terminal, and cannot be suppressedFix every rule that asserts nothing (doctor lists them). Refresh the baseline: conditions now accumulate, so entries for rules whose description changed stop matching.
0.22.0No — adds the instrument for 0.23.0None, but run ts-archunit doctor now: it reports exactly what 0.23.0 will fail on, before it fails.
0.21.0Yes — a held builder is immutable, so narrowing one no longer mutates itRe-read any rule built by narrowing a shared builder: it may now select different subjects than it did.
0.20.0Yes, two ways — .warn() can throw for a configuration finding, and import globs match bare package namesFix vacuous rules (.warn() no longer hides them). notImportFrom('fastify') now matches an installed fastify, which is the fix for bug 0014 and may be new red. Preset users are affected too: layeredArchitecture() and strictBoundaries() construct notImportFrom(), so a row scoped by the condition name alone reads as "not me" to someone who never types it.
0.19.0Yes — violation identity became portable, every preset rule gained a remedy, and the smell detectors see handler mapsRefresh the baseline. A baseline written on ≤0.18.1 encoded absolute paths in its hashes, so it matches almost nothing after this.
0.18.1Yes, both directionsslices().matching() globs that resolve for the first time now produce real slices (red → green). An .excluding() that happened to match a discovery finding's text can no longer silence it (green → red).
0.18.0Yes (breaking) — empty discovery fails instead of passingFix slices().matching() / .assignedFrom() globs that resolve to no slices, and crossLayer rules whose left layer matches zero files. Both used to pass vacuously.
0.17.0NoNone. init gains --preset.
0.16.0Yes (breaking, action required) — shape presets return rules instead of throwingWrap every preset call: checkAll(layeredArchitecture(p, opts)). A bare call now builds rules and asserts nothing.
0.15.0NoNone. Adds tsconfig(p).
0.14.0NoNone. Adds ts-archunit init.
0.13.0NoNone. check --format json becomes one document for the whole run; update anything parsing per-rule documents.
0.12.0NoNone. Adds jsxText().
0.11.0NoNone. Adds calls().identifiedByArg().
0.10.0Yes (breaking) — the TypeScript rules widened to constructors, getters and setters, and their message text changedRefresh the baseline — message text is part of a finding's identity. Expect new findings from the widened scope.
0.9.0NoNone. Adds jsxElements(p).
0.8.0YesbeImported() and noDeadModules() resolve dynamic importsExpect fewer dead-module findings. A baseline entry that stops matching here means the finding is gone, not that it moved.
0.7.2Yes — element names now include constructors, getters, setters and property initializersRefresh the baseline — the element name is part of identity. .excluding() also starts working with satisfy() conditions, so an exclusion that never applied may now apply.
0.7.1No — internal refactorNone.
0.7.0NoNone. Adds body analysis, export and reverse-dependency conditions.
0.6.0Yesexpression() no longer reports every ancestor nodeRefresh the baseline. Counts drop sharply (189 → 13 in the case that prompted it) for any rule using expression().
0.5.0NoNone. Adds property() and argument matchers.
0.4.0No, but source-breakingReplace the removed notType export with not(), which now handles both predicates and type matchers.
0.3.0NoNone. Adds member, parameter, visibility and return-type rules.
0.2.0NoNone. notImportFrom() / importFrom() become variadic. Preset users are affected too: layeredArchitecture() and strictBoundaries() construct notImportFrom(), so a row scoped by the condition name alone reads as "not me" to someone who never types it.
0.1.0n/a — nothing earlier to changeNone. This is the earliest release the table covers; treat adopting it as a fresh start.

What a version number does not tell you

A separately-installed older CLI does not reproduce an older version's behaviour. ts-archunit loads your rule file from your project, and that file's import … from '@nielspeter/ts-archunit' resolves against your node_modules — so a pinned ts-archunit@0.27.0 binary prints 0.27.0 and reports the new version's findings.

If you are bisecting "which version started reporting this?", change the dependency, not the binary.

Released under the MIT License.