cmp-arch-gates
v0.5.0
Published
Deterministic architecture gates for KMP / Compose Multiplatform codebases: a layered-module-DAG + boundary/visibility linter, run in CI or locally. Project-agnostic CORE; per-project CONFIG in the consuming repo's .arch-gates/.
Maintainers
Readme
cmp-arch-gates
Deterministic architecture gates for KMP / Compose Multiplatform codebases — a
layered-module-DAG + boundary/visibility linter you run in CI or locally. It fails the
build when the module graph or layer boundaries drift, so an accumulated-drift class of
regression (a core → feature inversion, a transport type leaking into a data:api
contract, a data-source going public) can't silently return.
Project-agnostic core, per-project config. The CLI hardcodes no module names, no
allowlists, no doc paths. Every project fact lives in the consuming repo's
.arch-gates/config.json. Point the CLI at any KMP repo that follows the
:<module>:feature / :<module>:data:api / :<module>:data:impl / :core:*
convention and it works.
Install
npx cmp-arch-gates@<version> check # no install; pin the version in CI
# or
npm i -D cmp-arch-gatesRequires Node ≥ 18. Zero runtime dependencies.
Use
cmp-arch-gates check # scan cwd, config from ./.arch-gates/config.json
cmp-arch-gates check --root path/to/repo # scan a specific repo root
cmp-arch-gates check --only module-direction
cmp-arch-gates list # print the gatesExit 0 = all pass, 1 = a gate failed, 2 = usage/config error.
Onboard a new repo
cmp-arch-gates init --detectScaffolds a starter .arch-gates/config.json and scans the repo for candidate
facts — :core:* modules that depend on Ktor (transport candidates), existing
cross-owner data:api api() edges (allowlist-with-a-reason or remove), and
whether the config-independent gates already pass. It prints them for you to
confirm; it never silently writes a module name or auto-allowlists an existing
edge (that would make the gate green by hiding drift). Fill in the 1–2 confirmed
facts, add the CI line, done. init refuses to overwrite an existing config
without --force.
The gates
| Gate | Fails when | Config |
|------|-----------|--------|
| module-direction | core → feature/data/domain, data:api → data:impl, feature → feature, or domain → data:impl/feature edge exists — whether declared literally OR injected by a convention plugin | conventionPluginDirs (opt.) |
| implementation-only-modules | a configured module is api()-exported instead of implementation() | transportModules / implementationOnlyModules |
| data-api-purity | a */data/api source file imports a banned transport/serialization prefix | dataApiBannedImports |
| datasource-visibility | a *(Remote\|Local)DataSource type in */data/impl isn't internal/private | — (naming convention) |
| cross-owner-dataapi | a */data/api api()-re-exports another owner's data:api and it isn't allowlisted | crossOwnerDataApiAllowlist |
| transport-free-presentation | a presentation module depends on a transportModules entry (any configuration), or a presentation-module source file imports a banned transport package | transportFreePresentation (skipped if absent) |
| stability-config | a listed Compose-stability FQN resolves to no declaration, or a module applies the Compose compiler plugin directly without wiring the stability config | stabilityConfig (skipped if absent) |
| presentation-file-shape | a *Screen.kt exceeds the composable/line ceilings (entry+dispatch tier only), any other composable-bearing presentation file exceeds the section-file line ceiling, or an allowlist entry has gone stale | presentationFileShape (skipped if absent) |
| test-presence | a module matching a configured pattern ships fewer than minFiles test source files, or an allowlist entry has gone stale | testPresence (skipped if absent) |
| no-created-at-start | a createdAtStart (config-overridable token) usage appears in Kotlin source outside the allowlist, or an allowlist entry has gone stale | noCreatedAtStart (skipped if absent) |
| stale-doc-refs | a comment names a *.md doc that exists nowhere in the repo (and matches no allowPattern), or an allowlist entry has gone stale | staleDocRefs (skipped if absent) |
Layer is inferred from the Gradle-notation suffix; build/, build-logic/, VCS/IDE
caches, and test-fixtures are excluded; datasource-visibility also skips every
*Test* source set.
module-direction reads edges from two places: a module's own build.gradle.kts
and the precompiled convention plugins it applies (build-logic / buildSrc) —
a convention plugin's project(…) deps are attributed (transitively) to every
module applying its id, so a layering violation injected by a convention plugin is
caught even though it never appears in the applying module's build file.
Configure
.arch-gates/config.json in the consuming repo:
{
"$schema": "https://raw.githubusercontent.com/StreakBank/cmp-arch-gates/main/schema/config.schema.json",
"transportModules": [":core:network"],
"dataApiBannedImports": ["io.ktor", "kotlinx.serialization"],
"crossOwnerDataApiAllowlist": [
{ "owner": "catalog/data/api", "dep": ":pricing:data:api", "reason": "documented carve-out" }
],
"stabilityConfig": {
"file": "app/compose/stability-config.txt",
"composeCompilerMarkers": ["alias(libs.plugins.composeCompiler)"],
"allowlist": [
{ "module": ":app:android", "reason": "app shell — no commonMain composables of its own" }
]
},
"transportFreePresentation": {
"bannedImportPrefixes": ["com.streakbank.core.network."]
},
"docsRef": "docs/ARCHITECTURE.md"
}All keys are optional. Omitted: transportModules / implementationOnlyModules →
the implementation-only-modules gate is a no-op (the two keys are aliases —
transportModules is the historical spelling, implementationOnlyModules the generic
one; both are accepted and unioned);
dataApiBannedImports → the io.ktor / kotlinx.serialization default;
crossOwnerDataApiAllowlist → zero tolerance (any cross-owner re-export fails);
stabilityConfig → the stability-config gate is skipped (reports a warn, not a
silent ok); transportFreePresentation → the transport-free-presentation gate is
skipped (same warn); presentationFileShape / testPresence / noCreatedAtStart /
staleDocRefs → their gates are skipped (same warn); docsRef → no pointer in
failure output.
stabilityConfig — the Compose-stability gate
A Compose stability configuration file lists fully-qualified types the compiler should treat as stable, so strong-skipping applies to composables that take them. Two ways it silently lies — each disabling stability coverage with no build error: a listed FQN no longer resolves (a class was renamed / moved package), or a module applies the Compose compiler plugin directly but never wires the config. The gate makes both loud:
file(required) — path (relative to repo root) of the stability-config file. Every non-comment line must resolve to a real Kotlin declaration under the source roots (package-line +class/interface/objectmatching; nested classes andpkg.*wildcards supported).composeCompilerMarkers(optional) — substrings identifying direct application of the Compose compiler plugin in a module build file. Default:["org.jetbrains.kotlin.plugin.compose", "kotlin(\"plugin.compose\")"]. Repos that apply it via a version-catalog alias set their own, e.g.["alias(libs.plugins.composeCompiler)"]. Modules that inherit the plugin through a convention plugin are not direct appliers and aren't checked.allowlist(optional) —{ module, reason }entries for direct appliers that intentionally don't wire the config (app shells with nocommonMaincomposables, modules with zero composables). Everything else must wirestabilityConfigurationFile.
transportFreePresentation — the presentation-transport-isolation gate
A presentation module (feature UI + its ViewModel) should consume errors — and any
wire concern — as a domain type, never a transport type. Transport→domain
translation belongs once at the data boundary (data:impl), behind the repository
contract; a presentation module that reaches a transport module or imports a
transport package re-introduces the very leak the boundary exists to prevent. The
gate enforces two layers:
- Gradle — no presentation module declares a
project(…)dependency, in any configuration (not justapi()), on a module listed intransportModules(the same list theimplementation-only-modulesgate uses). This is stricter than that gate: presentation must not depend on transport at all. - Source — no
.ktunder a presentation module'ssrc/— every source set,commonTest/androidUnitTest/iosMainincluded (tests must build domain errors too) — imports a banned transport package.
Config:
bannedImportPrefixes(required within the block) — import path prefixes banned in presentation-module source, e.g.["com.streakbank.core.network."]. Matched on a dotted-segment boundary, socom.streakbank.core.network.(or without the trailing dot) matchesimport …network.Fooandimport …network.*, but never a same-prefixed sibling package likeimport …networkutils.Retry. Required because a package prefix can't be derived from a module path.presentationModules(optional) — Gradle module notation patterns treated as presentation, where*matches any run of characters (anchored), e.g.["*:feature", ":core:feature"]. Defaults to every module whose notation ends in:feature(which already covers a:core:featuremodule, since its notation is:core:feature).
Absent the whole block, the gate is skipped (reports a warn, not a silent ok).
presentationFileShape — the *Screen file contract gate
In real codebases, extracting section composables out of a screen file is event-driven (it happens inside feature commits) and never size-driven — so screen files and extracted "views" files accrete without bound (observed: a 1,055-line Screen file with 16 composables; a 3,775-line one before anyone split it; an extracted views file that grew 600 → 932 lines). This gate makes the ceiling mechanical:
- Screen files (basename ends in
screenFileSuffix, defaultScreen.kt) hold the entry + dispatch tier only: at mostmaxScreenComposables(default 4)@Composablefun declarations AND at mostmaxScreenLines(default 400) lines. - Every other scanned
.ktdeclaring ≥ 1 composable (section/views files) is at mostmaxSectionLines(default 400) lines. Composable-free files (models, mappers) are ignored, as areexemptFileSuffixes(default["ViewModel.kt"]). - Scanning covers
sourceSets(default["commonMain"]) under everypresentationModulesmatch (default*:feature; add app-shell modules like":app:compose"explicitly when they carry screens). allowlistentries ({file, reason}) are a burn-down ratchet: an entry whose file no longer violates (or no longer exists) FAILS as stale, so the list only ever shrinks.
"presentationFileShape": {
"presentationModules": ["*:feature", ":app:compose"],
"allowlist": [
{ "file": "home/feature/src/commonMain/kotlin/com/x/HomeScreen.kt", "reason": "pre-contract; decomposition scheduled" }
]
}Absent the whole block, the gate is skipped (reports a warn, not a silent ok).
testPresence — the test-presence gate
A module with zero tests is a silent coverage hole: nothing fails when its logic regresses, and the gap stays invisible until an incident. This gate requires every module in an enforced set to ship at least a minimum number of test source files. WHICH modules are enforced is a project fact; the rule is generic.
modulePatterns(required within the block) — Gradle module notation globs (*= any run of chars; anchored) whose modules must ship tests, e.g.[":core:*", "*:feature"]. Required — there is no safe default for "which modules must have tests."minFiles(optional, default 1) — the minimum count of test source files. A test source file is any.ktunder the module'ssrc/<sourceSet>/…whose source-set segment containsTest(commonTest,androidUnitTest,androidInstrumentedTest,iosTest,jvmTest, …).allowlist(optional) —{ module, reason }entries for modules temporarily exempt (a pure data holder, a generated module). Burn-down ratchet: an entry whose module now ships tests, no longer matches a pattern, or was removed FAILS as stale.
"testPresence": {
"modulePatterns": [":core:*"],
"minFiles": 1,
"allowlist": [
{ "module": ":core:model", "reason": "pure data holders; covered by consumers' tests" }
]
}Absent the whole block, the gate is skipped (reports a warn, not a silent ok).
noCreatedAtStart — the eager-singleton gate
A createdAtStart = true eager singleton (Koin) is created at container start with
nothing resolving it — so when the flag or the wiring that motivated it is later
dropped, the side effect it performed dies silently: no compile error, no
unresolved reference, just a behavior that quietly stops happening. The visible
alternative is an explicit initializer registration a caller resolves (an init list, an
onStart hook, an eagerly-observed producer) — a dropped registration then fails loudly
at its call site. This gate bans the token in Kotlin source.
tokens(optional, default["createdAtStart"]) — identifier tokens banned in source. Each is matched as a whole identifier (socreatedAtStartis caught butmyCreatedAtStartFlagis not), over comment-and-string-blanked text (a mention in a comment or string never counts).allowlist(optional) —{ file, reason }entries for files where the token is permitted for now. Burn-down ratchet: an entry whose file no longer uses the token (or no longer exists) FAILS as stale.
"noCreatedAtStart": {
"allowlist": [
{ "file": "app/src/androidMain/kotlin/com/x/AndroidBootstrap.kt", "reason": "platform eager init; migration scheduled" }
]
}Absent the whole block, the gate is skipped (reports a warn, not a silent ok).
staleDocRefs — the broken-doc-reference gate
Comments rot independently of code: a // see docs/FOO.md outlives the doc it points
at, and the next reader chases a dead reference. This gate scans comment context —
a line whose trimmed start is //, *, or /* (block/KDoc); inline trailing comments
are deliberately out of scope — for *.md tokens and fails any that resolves to no file
in the repo. Existence is checked against the on-disk source tree (build output and VCS
caches pruned) via a filesystem walk, not a shell-out to git ls-files, so the gate
stays zero-dependency and behaves identically against a fixture tree and a real checkout.
sourceExtensions(optional, default[".kt"]) — file extensions scanned for comment-context doc refs.allowPatterns(optional) — regexes; a ref matching any is permanently allowed even when it doesn't resolve locally. Use for cross-repo doc names (a.claude/rules/*.mdthat lives in a sibling workspace repo).excludePatterns(optional) — regexes; a token matching any is not treated as a doc ref at all. Use to suppress false positives — a token that merely looks like a.mdfilename, such as a design-token accessorSpacing.mdmentioned in KDoc.allowlist(optional) —{ ref, reason }entries for a specific known-broken ref tolerated pending cleanup. Burn-down ratchet: an entry whose ref now resolves, was removed, or now matches a pattern FAILS as stale. (This differs fromallowPatterns, which is a permanent cross-repo allowance; the allowlist is a temporary burn-down.)
A ref resolves when its exact repo-relative path exists, when some existing doc path ends
with /<ref>, or (fallback) when a file with the ref's basename exists anywhere —
lenient by design, since "the doc exists somewhere" is the property that matters;
excludePatterns handle the reverse.
"staleDocRefs": {
"allowPatterns": ["^\\.claude/rules/", "^docs/OPS/"],
"excludePatterns": ["^Spacing\\.md$", "^Type\\.md$"],
"allowlist": [
{ "ref": "docs/OLD_ARCH.md", "reason": "doc migration in flight; ref removed next sweep" }
]
}Absent the whole block, the gate is skipped (reports a warn, not a silent ok).
Adapting to a different module layout
The defaults assume the conventional suffix-style KMP layout (:<module>:feature,
:<module>:data:api, :<module>:data:impl), (Remote|Local)DataSource naming, and
data/api / data/impl directories. Repos that differ configure it — nothing is
hardcoded in the core:
{
"layerRules": [
{ "layer": "core", "prefix": ":core:" },
{ "layer": "feature", "prefix": ":feature:" },
{ "layer": "data-api", "suffix": ":data:api" },
{ "layer": "data-impl", "suffix": ":data:impl" }
],
"dataSourceTypeSuffixes": ["NetworkDataSource", "LocalDataSource"],
"dataApiDir": "data/api",
"dataImplDir": "data/impl"
}layerRules— ordered rules mapping a Gradle notation to a canonical layer (core/feature/data-api/data-impl/domain/fixtures) byprefix,suffix, orregex; first match wins. This is how a prefix-style repo (:feature:home, à la Now-in-Android) opts in. If the configured rules classify no feature/data module,module-directionfails loudly rather than greenwash — a mismatched convention is never mistaken for a clean graph.dataSourceTypeSuffixes— type-name suffixes the visibility gate treats as a data source (defaultRemoteDataSource/LocalDataSource).dataApiDir/dataImplDir— the data-layer directory segments.conventionPluginDirs— included-build directoriesmodule-directionscans for precompiled convention plugins (default["build-logic", "buildSrc"], the Gradle conventions). Override for a differently-named included build.
A config-independent gate that scans zero candidate files prints a warn (not a
silent ok), so "not applicable to this layout" is always visibly distinct from
"clean."
CI
Pin an exact version and call it from any CI system:
- run: npx cmp-arch-gates@<version> checkDevelop
npm test # node --test test/*.test.mjs — fixture-driven, zero depsEach gate is a pure run(root, config) → { ok, violations } module under lib/gates/.
Adding a gate: add the module, register it in lib/gates/registry.mjs, add a fixture
test. Keep every project fact in config, never in the gate.
