npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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/.

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-gates

Requires 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 gates

Exit 0 = all pass, 1 = a gate failed, 2 = usage/config error.

Onboard a new repo

cmp-arch-gates init --detect

Scaffolds 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/object matching; nested classes and pkg.* 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 no commonMain composables, modules with zero composables). Everything else must wire stabilityConfigurationFile.

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 just api()), on a module listed in transportModules (the same list the implementation-only-modules gate uses). This is stricter than that gate: presentation must not depend on transport at all.
  • Source — no .kt under a presentation module's src/every source set, commonTest / androidUnitTest / iosMain included (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, so com.streakbank.core.network. (or without the trailing dot) matches import …network.Foo and import …network.*, but never a same-prefixed sibling package like import …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:feature module, 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, default Screen.kt) hold the entry + dispatch tier only: at most maxScreenComposables (default 4) @Composable fun declarations AND at most maxScreenLines (default 400) lines.
  • Every other scanned .kt declaring ≥ 1 composable (section/views files) is at most maxSectionLines (default 400) lines. Composable-free files (models, mappers) are ignored, as are exemptFileSuffixes (default ["ViewModel.kt"]).
  • Scanning covers sourceSets (default ["commonMain"]) under every presentationModules match (default *:feature; add app-shell modules like ":app:compose" explicitly when they carry screens).
  • allowlist entries ({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 .kt under the module's src/<sourceSet>/… whose source-set segment contains Test (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 (so createdAtStart is caught but myCreatedAtStartFlag is 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/*.md that 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 .md filename, such as a design-token accessor Spacing.md mentioned 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 from allowPatterns, 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) by prefix, suffix, or regex; 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-direction fails 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 (default RemoteDataSource/LocalDataSource).
  • dataApiDir / dataImplDir — the data-layer directory segments.
  • conventionPluginDirs — included-build directories module-direction scans 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> check

Develop

npm test        # node --test test/*.test.mjs — fixture-driven, zero deps

Each 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.