A compile-safety architecture release: per-module validation is removed entirely in favor
of a single, authoritative full-graph check at each Koin entry point. This closes a real,
measured false-positive class, at the cost of leaf modules with no entry point of their own now
getting no compile-time safety diagnostics until something assembles a real graph around them.
Also ships incremental-compilation freshness hardening, allWarningsAsErrors compatibility, and a
collision-safe hint-file-naming scheme.
⚠️ Behavior change — per-module validation removed, full-graph validation only (#32, #51)
Why. A module validated in isolation cannot know how it will be wired into a larger app. This
stopped being theoretical: :core:notifications in a real playground app genuinely false-positived
on a dependency (PeerService) that a peer module provides — with no Gradle edge between the two,
the two are only unified downstream at the app's entry point. Per-module validation — checking a
module against its own definitions, its includes = [...], and its @Configuration siblings —
cannot see that far, and reported a hard KOIN-D001 for a dependency that resolves correctly once
the real app assembles both modules together. Rather than keep tuning the per-module oracle around
each new false-positive shape, per-module validation (and its "defer iff a provider exists
somewhere" oracle) is deleted outright. Full-graph validation — the check that runs at
startKoin/koinApplication/@KoinApplication — is now the sole compile-safety verifier.
What this means for you:
- Rooted compiles (an app module with a real
startKoin/koinApplication/@KoinApplication):
more accurate. Genuine cross-module false positives like the peer-provider case above disappear;
KOIN-D001now always shows the real, assembled graph. - Leaf/library modules with no Koin entry point in their own compilation:
KOIN-D001
(missing dependency),KOIN-D004(circular dependency),KOIN-D005/KOIN-D006(parametersOf
shape mismatches resolved via the graph), andKOIN-P001(missing@PropertyValue) are now
silent in that compilation — not because the module is safe, but because compile-time cannot
know how it will be assembled downstream. The graph is still checked, correctly, at the real
entry point once one exists in the compilation. This is disclosed via a default-visible
(INFO-severity) message rather than failing silently; seelogSeveritybelow to control its
visibility. KOIN-W002(the old "deferred, no provider hint found anywhere" warning) is deleted — there
is no more deferral machinery to warn about.- Circular-dependency detection (
KOIN-D004) going silent for a leaf module is intentional, not a
regression: detecting a cycle requires seeing the whole graph, and a same-module-only check was
never a complete cycle detector even under the old per-module validation (it only ever saw
local/sibling visibility).
Full account, including the design docs this reverses: docs/COMPILE_SAFETY_A3_PLAN.md
(superseded-banner) and docs/COMPILE_TIME_SAFETY.md.
🐛 Fixes
Orphaned @Module classes were silently treated as reachable (found during this release's own verification)
A plain @Module @ComponentScan(...) class with no @Configuration and not referenced by anyone's
includes = [...] was silently treated as part of the graph anyway, as long as the entry point used
a bare/default-labeled @KoinApplication/startKoin — the overwhelmingly common case. Its
@ComponentScan-discovered definitions (including cross-module ones) were folded into the resolved
graph and validated as satisfied, when the actual generated module tree never wired them in at all:
build green, runtime crash. Root cause: the entry-point module-discovery step accidentally called a
label-reader meant for the entry-point class's @KoinApplication(configurations=[...]) argument
against module classes, which never carry that annotation — so it always hit that reader's
"annotation absent" fallback (["default"]), making any @Module class match. This bug predates
1.1.0 (traced to 1.0.0-GA1) but was masked by the old per-module validation, which used to
validate each such module in isolation too; removing it made this bug load-bearing. Fixed, with a
regression test
(entry_orphan_module_not_reachable_d001) proving an orphaned module without @Configuration or an
includes edge is now correctly excluded from the graph.
KOIN-D001 now names the real culprit module and source location
Missing-dependency errors now carry file:line for the failing definition and the actual owning
module's name (previously degraded to a generic app/root label once every KOIN-D001 funnels
through the one remaining full-graph check). Also fixed: attribution for FunctionDef-shaped definitions
used a bare simple name, which could collide across same-named modules in different packages — now
uses the fully-qualified name.
KOIN-D001 deduplication across multiple entry points
A module reachable from more than one startKoin/koinApplication/@KoinApplication in the same
compilation (common in test-apps: ~9 entry points is typical) previously re-validated and re-emitted
the same missing-dependency error once per entry point. Now deduplicated by (definition, missing
requirement), so a shared module with one real problem reports it exactly once.
D005/D006 (parametersOf shape checks) no longer require a Koin entry point
The parametersOf(...) argument-count/presence check is graph-independent — the expected slots come
from the target's own constructor, not from an assembled graph — so it now runs unconditionally
instead of being skipped whenever no entry point is present in the compilation, matching its actual
data dependency. KOIN-D002 (call-site resolution) correctly keeps requiring an assembled graph
and stays silent without one — the two diagnostics no longer share a gate they don't share a
dependency on.
Cross-module qualifier and typed-scope resolution verified under the new sole-verifier design
New regression coverage confirms full-graph validation matches @Named qualifiers and typed
@Scope(X::class) keys correctly across Gradle module boundaries, not just "some provider of this
type exists somewhere" — this matters more now that there's no per-module fallback to catch a wrong
match.
Known pre-existing limitation, found while writing this coverage (not new, not fixed this
release): BindingRegistry.findProvider's scope-visibility check only matches a typed
@Scope(X::class); a named @Scope(name = "...") provider has no scopeClass and is treated
as visible everywhere regardless of name.
🔒 Incremental-compilation freshness
Removing per-module leaf-local checking made full-graph validation's own freshness across
incremental (IC) rebuilds load-bearing in a way it wasn't before — these changes close that gap:
strictSafetyis now mandatory once an aggregator is auto-detected, not opt-in. Previously,
an explicitstrictSafety = falsesilently won over the plugin's ownstartKoin/
koinApplication/@KoinApplicationdetection, letting an aggregator'scompileKotlinstay
cacheable/up-to-date even when the DI graph changed underneath it (lambda-body DSL edits and
new@ComponentScan-covered files don't register as ABI changes IC can see).strictSafety = true
still works everywhere; the new escape hatch for a genuine detector misfire (the marker appears
only in a comment/string, not a real entry point) isstrictSafetyForceOff = true— a separate,
explicit acknowledgement from a plainfalse.- Extended IC tracker linking:
KoinDSLTransformer's 5 DSL definition call sites now register
withExpectActualTracker(alongside the existingLookupTrackercalls), matching the pairing
KoinAnnotationProcessor/KoinStartTransformeralready had — closes another source of stale
incremental state around DSL hint files. - A theorized
@ComponentScannew-file freshness gap did not reproduce: adding a new
@Singleton/@Factoryclass to a scanned package is itself a source-set input change, which
Gradle already invalidates the owning module'scompileKotlintask for, independent of anything
Koin-specific — verified live on a real playground app. No plugin-side fix was needed here. - Known limitation, unchanged by this release: a module going completely empty (its last
includes()or its last local definition removed, with nothing replacing it) is not detected
incrementally without a full clean +--no-build-cache. This is a K2-internals residual (a
keep-alive hint's signature not being re-resolved within one IC session), not a missing source
edge — seeplayground-apps/README.md's "Known limitation" note.
🔇 allWarningsAsErrors / -Werror compatibility (#73)
Informational plugin output (userLogs/debugLogs messages, the @Monitor-tracing-enabled
summary) was emitted at WARNING severity unconditionally, which fails a build compiled with
allWarningsAsErrors even though none of it is a real diagnostic.
- New
logSeverityoption ("warning"default, or"info") covers all of the above. - New, separate
versionCheckSeverityoption covers only the Kotlin-version-compatibility
warning ("you're on an unverified Kotlin version") — kept independent because muting informational
noise shouldn't also silence a real compiler-compatibility risk; set it to"info"only after
assessing that risk yourself. - Real diagnostics (
KOIN-Dxxx/KOIN-Wxxx/etc.) are unaffected by either setting — they always
report at their own severity.
koinCompiler {
logSeverity = "info" // downgrade informational output, default "warning"
versionCheckSeverity = "info" // downgrade the version-compatibility check, default "warning"
}🧷 Hint-file collision safety (#75)
Five internal call sites that generate synthetic hint file names for cross-module discovery used
unbounded-length, collision-prone name sanitization (e.g. p.q_r.mod and p.q.r_mod both
flattening to p_q_r_mod). All five now go through one shared utility: a bounded, readable prefix
plus a 64-bit hash suffix computed over the untruncated input, so truncation itself can never cause
a collision — this is a plain naming fix, not a diagnostic; file names have no external
reconstructors, so this changes freely.
Separately, the frozen, cross-version-reconstructed function-name encoder
(flattenFqNameForHint) stays unchanged (other Kotlin modules built with an older plugin version
reconstruct it, so it can't just be swapped for a hash) — but two distinct DSL module ids can still
flatten to the same identifier through it. New diagnostic KOIN-D008 hard-errors on that
specific collision when it happens within one compilation (e.g. two zero-parameter keep-alive hints
sharing a signature, a real KLIB SignatureClashDetector failure mode) — there is no legitimate
scenario where two distinct modules should collide, so there's no opt-out. Detecting the same
collision across Gradle modules is a known, explicitly deferred gap (would need to run at the
entry-point aggregator over already-decoded ids) — documented, not silent.
✅ Compatibility
- Koin: 4.2.0+
- Kotlin: verified range 2.3.0–2.3.10 (see
CLAUDE.mdfor the version-gate policy)
📦 Install
plugins {
id("io.insert-koin.compiler.plugin") version "1.1.0"
}Full changelog: 1.0.2...1.1.0