Skip to main content

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:

ResponsibilityExamples
Analysis commandscheck links, check orphans, check all
Engine inspectioninspect capabilities
Maintenance commandsclean
Lab showcasezenzic lab — 11 interactive acts
Standalone operationsdiff, score, init
Shared UI/output helpersbanner, console, exclusion manager builder

This monolith created compounding problems:

  1. Circular import risk. As core/ modules grew, contributors were tempted

    to import cli.py utilities directly from core, inverting the dependency direction.

  2. UI state scattering. The Rich console object was instantiated multiple

    times across different function scopes, causing inconsistent output formatting and race conditions in test environments.

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

  4. 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.pysrc/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.pysrc/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 from cli.py into

    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 from src/zenzic/cli/ or src/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 from src/zenzic/cli/ — any PR that introduces

    such an import is an automatic revert candidate.

  • _shared.py is the only place in cli/ where the Rich console object is

    instantiated. All other cli/ modules call _ui() from _shared.py.

  • src/zenzic/main.py contains no analysis logic — only Typer app wiring.

  • zenzic.rules remains a re-export façade. The implementation lives in

    core/rules.py.


Consequences

  • New CLI commands are added to the appropriate cli/_*.py module, not to a

    catch-all monolith.

  • The run_rule() function is importable as both zenzic.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.