ADR 004: Decentralized CLI Package¶
Status: Active Decider: Architecture Lead Date: 2026-04-15 (D062-B / D063 / D064)
Context¶
Zenzic's original CLI lived in a single file: src/zenzic/cli.py. Over the
course of the current series 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 tempted
to import cli.py utilities directly from core, inverting the dependency
direction.
- UI state scattering. The Rich
consoleobject was instantiated multiple
times 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 release:
- D062-B:
src/zenzic/ui.py→src/zenzic/core/ui.py. UI primitives are
consumed by both CLI and Core; placing them in core/ ensures Core can use
them without importing from cli/, which would violate the Layer Law.
- D063:
src/zenzic/lab.py→src/zenzic/cli/_lab.py. The lab showcase is
pure 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.pyinto
core/rules.py. The public zenzic.rules module became a 6-line re-export
façade — backwards compatible for any third-party code that imported it
directly, while ensuring the implementation lives in core/.
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/ 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.
4. Unified Console State (Visual State Manager)¶
Instantiating multiple Console() objects across different command modules breaks the command-line argument overrides. When --no-color or --force-color is passed, configure_console() overrides the singletons in _shared.py. Any locally-instantiated Console would bypass this configuration, leading to mixed-mode coloring or ignored user preferences.
Invariants (Non-Negotiable)¶
src/zenzic/core/never imports fromsrc/zenzic/cli/— any PR that introduces
such an import is an automatic revert candidate.
_shared.pyis the only place incli/where the Richconsoleobject is
instantiated. 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 in
core/rules.py.
Consequences¶
- New CLI commands are added to the appropriate
cli/_*.pymodule, not to a
catch-all monolith.
- The
run_rule()function is importable as bothzenzic.rules.run_rule(public
façade) and zenzic.core.rules.run_rule (direct). Both paths are stable.
- The lab showcase (
cli/_lab.py) can be extended with new acts without
affecting the analysis pipeline's test surface.
Companion Decision D082 — CLI Decomposition¶
Status: Accepted — v0.8.0
Context: _check.py had grown to 1641 lines, accumulating four categories of
helper that properly belong elsewhere: governance filters, target resolution, command
setup boilerplate, and the governance reporting already in _governance.py.
Decision: Extract helpers into dedicated modules with backward-compatible re-exports.
| New module | Extracted from | Functions |
|---|---|---|
_governance.py |
_check.py |
_apply_per_file_ignores, _apply_directory_policies |
_target_resolver.py |
_check.py |
_resolve_target, _apply_target |
_command_setup.py |
_check.py |
setup_command() factory |
Zero-Regression Contract: 1550 tests pass unchanged. All moved symbols remain
importable from _check.py via re-export stubs, so any downstream code that imports
directly from _check continues to work without modification.
Outcome: _check.py reduced from 1641 → 1478 lines. Each new module has a single,
clearly-named responsibility consistent with this ADR's decentralised-ownership model.