Layered package graph

Thirty-eight workspace packages. Six layers. One enforced rule: dependencies flow up only.

This document is the conceptual map. For the lookup-shaped catalog of every package's role and exports, jump to 70-reference/02-package-catalog.md. For the literal dep-cruiser rules, see 80-implementation/05-layer-policy.md.

What you'll understand after this:

- Why opensip-cli ships as 38 workspace packages instead of one.

- The six layers, in order, and what each one is for.

- How the layer rule is enforced (and what happens if you break it).

- How type-only edges are caught by a second cruiser pass, and the two cross-layer exceptions that were paid down.

- Trade-offs: what this shape buys you, what it costs.


The six layers

The layer model the dependency-cruiser config enforces (.config/dependency-cruiser.cjs):

┌────────────────────────────────────────────────────────────────────┐
│  Layer 6  ┌──────────────────────────────────────────────────┐    │
│           │                opensip-cli                       │    │
│           └──────────────────────────────────────────────────┘    │
│                                  ▲                                 │
│  Layer 5  ┌──────────────────────┴───────────────────────────┐    │
│           │  checks-cpp  checks-go  checks-java  checks-python │   │
│           │  checks-rust  checks-typescript  checks-universal  │   │
│           │  graph-{typescript,python,rust,go,java}            │   │
│           └──────────────────────────────────────────────────┘    │
│                                  ▲                                 │
│  Layer 4  ┌──────────────────────┴───────────────────────────┐    │
│           │       fitness     simulation     graph            │   │
│           └──────────────────────────────────────────────────┘    │
│                                  ▲                                 │
│  Layer 3  ┌──────────┬───────────┴───────────┬───────────────┐    │
│           │  session-store  output  config  targeting          │   │
│           │  dashboard  lang-{ts,rust,py,java,go,cpp}          │   │
│           └──────────────────────────────────────────────────┘    │
│                                  ▲                                 │
│  Layer 2  ┌──────────────────────┴───────────────────────────┐    │
│           │   datastore   contracts   tree-sitter   cli-ui     │   │
│           └──────────────────────────────────────────────────┘    │
│                                  ▲                                 │
│  Layer 1  ┌──────────────────────┴───────────────────────────┐    │
│           │              @opensip-cli/core                   │   │
│           └──────────────────────────────────────────────────┘    │
│                                                                    │
│            (arrows mean "depends on" — strictly upward)            │
└────────────────────────────────────────────────────────────────────┘

Layer 1 — @opensip-cli/core. The kernel. Ships types, errors, IDs, the logger, the path resolver, the language-adapter contract, the plugin discovery mechanics (including the generic marker-discovery walker), and the Tool registry. No knowledge of fitness, simulation, or any other tool. No dependency on Commander, Ink, or any UI library.

Layer 2 — @opensip-cli/datastore, @opensip-cli/contracts, @opensip-cli/tree-sitter, and @opensip-cli/cli-ui. Four substrate packages above the kernel, each depending only on core — never on a tool.

Layer 3 — persistence/output/config libraries and language adapters. Packages above the substrate, depending on core/contracts/datastore (and lower siblings within this layer), never on a tool.

Layer 4 — Tools. @opensip-cli/fitness, @opensip-cli/simulation, @opensip-cli/graph, @opensip-cli/yagni. Each implements the Tool contract, declares its config namespace (importing @opensip-cli/config for the declaration type), and contributes its own CLI command surface via declarative commandSpecs. Peers; none imports another. None imports cli (that would create a cycle, enforced by the *-no-cli rules).

Layer 5 — @opensip-cli/checks- and @opensip-cli/graph-. Seven fitness check packs (checks-universal, checks-typescript, checks-python, checks-go, checks-java, checks-cpp, checks-rust), each depending on fitness (for defineCheck) and core; plus five graph adapter packs (graph-typescript, graph-python, graph-rust, graph-go, graph-java, the latter four sharing graph-adapter-common), each depending on graph. These are the marketplace shapes — installable from npm without dragging the CLI in. Check packs do not depend on cli or contracts.

Layer 6 — opensip-cli. The composition root. Discovers every first-party tool and language adapter, registers them, builds the Commander tree, runs the dispatcher. The only package that knows everything below it.

That's it. Six layers, thirty-eight workspace packages. Thirty-seven are publishable; the workspace-private @opensip-cli/test-support package carries cross-package test scaffolding (ADR-0040). It is never published and production source may not import it, so it sits deliberately outside the runtime layer diagram.


How the layer rule is enforced

The layer rule — "dependencies flow up only" — is enforced by dependency-cruiser at lint time. The relevant rules:

// core imports nothing else from the workspace.
{ name: 'core-imports-nothing-workspace',
  from: { path: '^packages/core/src/' },
  to:   { path: '^packages/', pathNot: '^packages/core/' },
}

// contracts imports only core.
{ name: 'contracts-imports-core-only', /* ... */ }

// fitness / simulation / graph cannot import cli (would create a cycle).
{ name: 'fitness-no-cli',     from: { path: '^packages/fitness/' },    to: { path: '^opensip-cli($|/)' } }
{ name: 'simulation-no-cli',  from: { path: '^packages/simulation/' }, to: { path: '^opensip-cli($|/)' } }
{ name: 'graph-no-cli',       from: { path: '^packages/graph/' },      to: { path: '^opensip-cli($|/)' } }

// checks-* cannot reach into cli or contracts.
{ name: 'check-pack-no-cli', /* ... */ }

// lang-* cannot reach into cli, contracts, or checks-*.
{ name: 'lang-no-cli-or-shared', /* ... */ }

The build runs pnpm depcruise as part of the standard pnpm lint flow. A forbidden import is a build failure with a precise message: which file, which import, which rule. Refactor the offending edge or move the symbol to a layer where it belongs.


Two cruiser passes — no standing layer exception

Real codebases have edge cases. Two earlier cross-layer exceptions once lived in .config/dependency-cruiser.cjs; both have since been paid down and deleted:

What remains is not an exception but a second lens. The layer ruleset runs twice, and both passes gate pnpm lint.

Type-only edges are caught by the type-aware pass

The runtime pass (.config/dependency-cruiser.cjs) sets tsPreCompilationDeps: false, so type-only imports (import type { ... }) don't count as edges. It models what actually runs: two files that only import type from each other form no runtime cycle, and TypeScript erases those imports, so flagging them would be a false positive.

That leaves a blind spot — a type-only layer inversion or cycle would be invisible to the runtime pass. The type-aware pass (.config/dependency-cruiser.types.cjs) closes it: it flips tsPreCompilationDeps: true and re-runs the same forbidden ruleset over the type-inclusive graph. Every directional layer rule — and no-circular — therefore also fires on type-only edges.

The upshot: there is no standing "you may import type upward" allowance. A type-only import from a lower layer into a higher one trips the type-aware pass exactly as a runtime import trips the runtime pass. (The historical type-only cycles that predated this pass were paid down before it was promoted from visibility-only to gating.)


Why 38 workspace packages and not 1

A single mega-package was considered. It would compile faster, ship faster, and have a simpler package.json. We chose against it for three load-bearing reasons:

1. The marketplace shape

A check pack like @opensip-cli/checks-python has to be installable on its own. A user who only writes Python should be able to:

opensip fit plugin add @opensip-cli/checks-python

…and not pull in the JavaScript universe. With a single mega-package, every install pulls every check. With 38 workspace packages, an install pulls only what's needed. (Today the bundled distribution still installs everything; tomorrow's tree-shaken or selectively-installed distribution doesn't have to.)

2. The Tool contract's promise

The Tool contract says "any npm package can be a Tool." That promise only holds if a Tool can depend on @opensip-cli/core without depending on opensip-cli. With a mega-package, importing core would import the entire CLI, including Commander and Ink. A third-party Tool that runs in a non-CLI context (a CI plugin, a server-side runner, a future GUI) couldn't shed those deps.

3. The layer rule needs to be visible

A flat package can have any internal structure. With 38 workspace packages, the layer is the directory structure: looking at packages/ tells you the architecture in five seconds. If a contributor accidentally adds an upward edge, the build fails before the PR is even reviewed. The layer rule isn't aspiration — it's a wall.


What this shape costs

Trade-offs are real. The 38-package layout is more expensive in three places:

We've been comfortable with these costs. They're the price of the marketplace shape and the Tool-contract promise.


A worked example

Tracing the dependency arrows for the no-console-log check we followed in 01-fitness-loop.md:

opensip-cli           ─── imports ───►  @opensip-cli/fitness
                                                       │
                                                       │ imports
                                                       ▼
                                              @opensip-cli/core
                                                       ▲
                                                       │ imports
                                                       │
@opensip-cli/checks-universal ─── imports ──────────┘
       │
       │ exports `noConsoleLog`
       ▼
   the CLI's loaded check registry, populated at startup

The cli imports the bundled language adapters to register them (Layer 5 → Layer 3). First-party tools are not statically imported by runtime symbol: the CLI lists their package names, resolves their manifests on disk, admits them, and dynamic-imports the same tool export shape that installed tool plugins use. It does not import checks-universal directly — instead, the plugin loader walks node_modules at runtime and discovers any package declaring the fit-pack marker plus target-domain epoch (or listed exactly in plugins.checkPackages). The check pack imports fitness (for defineCheck) and core (for Signal), both lower layers. Every arrow points up.


What's next