ADR 004: Decentralized CLI Package
Status: Active Decider: Architecture Lead Date: 2026-04-15 (v0.7.0 sprint, D062-B / D063 / D064)
Context
Zenzic's original CLI lived in a single file: src/zenzic/cli.py. Over the
course of the v0.6.x release cycle, that file grew to exceed 2,000 lines,
containing six conceptually distinct responsibilities in a single namespace:
| Responsibility | Examples |
|---|---|
| Analysis commands | check links, check orphans, check all |
| Engine inspection | inspect capabilities |
| Maintenance commands | clean |
| Lab showcase | zenzic lab — 11 interactive acts |
| Standalone operations | diff, score, init |
| Shared UI/output helpers | banner, console, exclusion manager builder |
This monolith created compounding problems:
-
Circular import risk. As
core/modules grew, contributors were temptedto import
cli.pyutilities directly from core, inverting the dependency direction. -
UI state scattering. The Rich
consoleobject was instantiated multipletimes across different function scopes, causing inconsistent output formatting and race conditions in test environments.
-
Test isolation failure. Every test that touched any CLI command had to
import the entire
cli.py— including the lab showcase, the Rich live display, and all Typer sub-apps. This inflated test startup time and made mocking unreliable. -
Contributor friction. A new contributor adding a check command had no
clear "where does this go?" signal from the file structure alone.
Decision
src/zenzic/cli.py was dissolved into a package src/zenzic/cli/ with the
following module structure:
src/zenzic/cli/
__init__.py — public re-exports
_check.py — check sub-app: links, orphans, snippets, references, assets, all
_inspect.py — inspect sub-app: capabilities
_clean.py — clean sub-app
_lab.py — lab command: 11 Acts (0–10), interactive showcase
_standalone.py — standalone commands: diff, init, score
_shared.py — shared helpers: _build_exclusion_manager, _validate_docs_root,
_ui, console
src/zenzic/main.py became the Typer entry point — a thin orchestrator
that imports each sub-app and registers it on the root Typer application. It
contains no analysis logic.
Three companion decisions were applied in the same sprint:
-
D062-B:
src/zenzic/ui.py→src/zenzic/core/ui.py. UI primitives areconsumed by both CLI and Core; placing them in
core/ensures Core can use them without importing fromcli/, which would violate the Layer Law. -
D063:
src/zenzic/lab.py→src/zenzic/cli/_lab.py. The lab showcase ispure CLI orchestration — interactive Rich displays, act sequencing, user prompts. It belongs with the CLI layer, not adjacent to the core.
-
D064 (SDK Cleansing):
run_rule()was extracted fromcli.pyintocore/rules.py. The publiczenzic.rulesmodule became a 6-line re-export façade — backwards compatible for any third-party code that imported it directly, while ensuring the implementation lives incore/.
The Layer Law (Rule R05)
This ADR formalises the dependency direction invariant as a named rule:
R05 — Core never imports upward. Modules in
src/zenzic/core/must never import fromsrc/zenzic/cli/orsrc/zenzic/main.py.
The enforced direction is:
cli/ → core/ → models/
cli/ may import anything from core/. core/ may import from models/. The
reverse is permanently forbidden. This ensures that core/ can be used as a
standalone SDK without dragging in Typer, Rich live displays, or any interactive
I/O dependencies.
Rationale
1. Single Responsibility at the File Level
A 2,000-line file is not a file — it is an undeclared package. Formalising the
package structure makes the single-responsibility principle visible in the
filesystem: a contributor looking for orphan-detection logic opens _check.py,
not a monolith where they must search by function name.
2. Test Isolation
After the split, test_cli.py can import only the specific sub-app under test.
The lab showcase's Rich live displays are no longer loaded when testing check links. Startup time for individual test modules dropped measurably.
3. SDK Contract
The zenzic.rules façade preserves backwards compatibility for any project that
used from zenzic.rules import run_rule. No import path changes were required for
existing integrations, despite the internal reorganisation.
Invariants (Non-Negotiable)
-
src/zenzic/core/never imports fromsrc/zenzic/cli/— any PR that introducessuch an import is an automatic revert candidate.
-
_shared.pyis the only place incli/where the Richconsoleobject isinstantiated. All other
cli/modules call_ui()from_shared.py. -
src/zenzic/main.pycontains no analysis logic — only Typer app wiring. -
zenzic.rulesremains a re-export façade. The implementation lives incore/rules.py.
Consequences
-
New CLI commands are added to the appropriate
cli/_*.pymodule, not to acatch-all monolith.
-
The
run_rule()function is importable as bothzenzic.rules.run_rule(publicfaçade) and
zenzic.core.rules.run_rule(direct). Both paths are stable. -
The lab showcase (
cli/_lab.py) can be extended with new acts withoutaffecting the analysis pipeline's test surface.