Layered package graph

Sixty-one workspace packages: 58 publishable and three private. Six runtime 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 58 publishable 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  yagni  mcp  tool-*    │   │
│           └──────────────────────────────────────────────────┘    │
│                                  ▲                                 │
│  Layer 3  ┌──────────┬───────────┴───────────┬───────────────┐    │
│           │  cli-live  session-store  output  config  targeting │  │
│           │  shared-analysis  codebase  dashboard              │   │
│           │  external-tool-adapter  lang-*                     │   │
│           └──────────────────────────────────────────────────┘    │
│                                  ▲                                 │
│  Layer 2  ┌──────────────────────┴───────────────────────────┐    │
│           │ datastore contracts tree-sitter clone-detection    │   │
│           │ format cli-ui tool-test-kit                         │   │
│           └──────────────────────────────────────────────────┘    │
│                                  ▲                                 │
│  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, @opensip-cli/clone-detection, @opensip-cli/format, @opensip-cli/cli-ui, and @opensip-cli/tool-test-kit. Substrate packages above the kernel. Most depend only on core; leaf pure packages (clone-detection, format) depend on nothing. Never on a tool.

Layer 3 — persistence/output/config/codebase 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, @opensip-cli/mcp, and the external scanner @opensip-cli/tool-* adapters. Each implements the Tool contract and contributes declarative commandSpecs. Peer imports are denied except the explicit MCP → graph public-surface relationship. YAGNI builds its own TypeScript inventory and has no graph dependency; MCP reads graph state through @opensip-cli/graph/read, never graph/internal.

Layer 5 — @opensip-cli/checks- and @opensip-cli/graph-. Seven publishable fitness check packs plus private repository-only checks-dogfood; and five graph adapter packs, with the tree-sitter adapters sharing graph-adapter-common. These are the marketplace shapes installable without the CLI. 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.

The generated architecture map is the inventory source of truth: 61 workspace

packages, 58 publishable, and three private. @opensip-cli/agent-eval is a

black-box agent-usability harness outside the runtime layers;

@opensip-cli/test-support carries cross-package test scaffolding; and

@opensip-cli/checks-dogfood carries this repository's architecture checks.

None is published. The eval harness has zero workspace source edges in either

direction, while the support packages are unavailable to production source.


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 58 publishable 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 58 publishable 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 61 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 60-package workspace 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        ─ dynamic-loads ─►  @opensip-cli/fitness
(resolves manifest,                              │
 dynamic-imports the                             │ imports
 `tool` export — no                              ▼
 static symbol import)                  @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.


Complete boundaries, derived and locked

The layer cake above is enforced by more than the dependency-cruiser layer rules.

Two decisions (ADR-0151

and ADR-0150)

make the boundaries derived and complete rather than hand-maintained. The

current package/tool inventory is authoritative in the generated

architecture-map.md and the release

governance projection, not in a frozen count here:

runtime (cli-no-static-tool-package-import, manifest-derived so it covers a

Tool with any package name); a bundled Tool and an installed Tool travel the

same dynamic plugin path, so install-source independence is structural.

opensipTools.kind; an unlisted fit pack throws at dependency-cruiser config

load rather than getting a permissive default.

completely (file and directory forms); MCP reads graph evidence only through

the public @opensip-cli/graph/read surface, with a single sanctioned

adapter-registrar root exception.

exact value and type namespace by a TypeScript-AST walker

(verify-core-exports), so a leaked type is caught even though a runtime

Object.keys check would miss it.

test/spec/fixture trees are excluded from both tsc output and the verified

pnpm pack packlist, while test sources stay semantically checked by a

per-package no-emit test project (ADR-0150).

Every one of these gates ships with a firing probe and a legal-edge control in

scripts/verify-gate-live.mjs.

What's next