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.
@opensip-cli/datastoreis the persistence kernel — theDataStoreinterface, the SQLite + Drizzle implementation, the in-memory backend for tests, the workspace migration store undermigrations/. Paradigm-agnostic infrastructure: tools and session-store own their domain schemas (sessions in session-store; baseline/catalog in graph; baseline in fitness) and register them with the datastore at open time. Depends oncoreonly.@opensip-cli/contractsis the shared contract layer between Tools and the runner: theSignalEnvelopeshape every tool returns (with itsverdict/units[]/signals[]), theCommandOutcomewrapper the host stamps on every machine output, theCommandResultdiscriminated union the renderer dispatches on, the exit-code constants, the cross-toolStoredSessiontype, and theGraphCatalogtype surface that the graph tool produces and the dashboard consumes. It is a contract facade, not a host runtime package: it may re-export small tool-facing helpers such asdefineCommand, but theSessionReporuntime and sessions schema live insession-store, not here. Importscoreonly. Does not import any tool.@opensip-cli/tree-sitter(ADR-0010) is the grammar-agnosticweb-tree-sittersubstrate: the WASM parser lifecycle and grammar-neutral node accessors (createParser,walkNodes,findEnclosing, …). It importscoreonly (plusweb-tree-sitter) and is consumed from above — by the fitnesslang-adapters and the four tree-sittergraph-adapters (throughgraph-adapter-common) — so the WASM lifecycle lives in exactly one place. A dedicated dependency-cruiser rule (tree-sitter-imports-core-only) holds it at this substrate position.@opensip-cli/cli-uiis the Ink/React presentational primitives kit (Banner,Spinner,RunHeader,theme) — extracted fromcli/so tools that ship a live view depend on the UI kit without pulling in the dispatcher.
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.
@opensip-cli/session-storeowns session persistence: theSessionReporuntime, thesessions/session_tool_payloadschema, and thegenerateSessionId/sanitizeForFilenamehelpers. Depends oncore,datastore, andcontracts(for theStoredSessionshape it round-trips).@opensip-cli/output(renamed from@opensip-cli/reporting, ADR-0011) owns all machine output: pure(envelope) => stringformatters underformat/(json, sarif, table) and effectfulsink/delivery (cloud egress, entitlement). The CLI composition root composes a formatter with a sink per the run's flags; tool engines no longer import it. Depends oncoreandcontractsonly.@opensip-cli/configis the capability-configuration substrate (ADR-0023): thecomposeConfigSchemacomposer that folds each tool's namespaced Zod schema into one strict whole-document schema, the resolver, and theToolConfigDeclarationdeclaration type. The dependency-cruiser rule here is directional:configmust not import a tool. Tools, by contrast, do import@opensip-cli/config— for theToolConfigDeclarationtype they use to declare their config namespace. So the edge runs tool → config, never config → tool. Depends oncore.@opensip-cli/targetingis the host file-targeting runtime substrate (ADR-0037): theTargetRegistry, the uniform glob expansion (resolveTargets, always applying per-targetexcludeandglobalExcludes), andapplyGlobalExcludes. The CLI bootstrap builds it once per run from the validated config document and exposes it asscope.targets; any tool resolves named file sets without importing fitness. Depends onconfig(targeting types) andcore(the genericRegistry<T>base) — never a tool engine. The check-domain half (checkOverrides, scope matching, the contentfileCache) stays infitnessas a thin consumer.@opensip-cli/dashboardis the self-contained HTML report renderer; consumed by the CLI-ownedreportcommand and each tool's auto-open hook. It does not implement theToolcontract; it is a library the composition root consumes.- Language adapters —
lang-typescript,lang-rust,lang-python,lang-java,lang-go,lang-cppimplement theLanguageAdaptercontract used by fitness checks. (The graph engine has its ownGraphLanguageAdaptercontract, implemented by the publishablegraph-*adapter packs at Layer 5.) See50-extend/05-language-adapters.mdfor the distinction.
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:
lang-typescript→fitness(thefilterContentback-edge):filterContent/clearFilterCache/FilteredContentnow live in@opensip-cli/lang-typescriptitself, so no lang pack reaches up into a tool. Thelang-no-fitness-except-typescriptrule is gone.graph→fitness(SARIF reuse): SARIF is now the single sharedformatSignalSarifformatter in@opensip-cli/output, applied at the composition root (ADR-0011) —graphreturns aSignalEnvelopeand imports neither fitness nor@opensip-cli/output. Thegraph-may-import-fitness-sarifinfo-exception is gone.
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:
- More
package.jsonfiles to maintain. Version bumps span 37 publishable packages (plus the private workspace-rootpackage.jsonfor tooling versions and private@opensip-cli/test-support). We usepnpmworkspace protocol (workspace:*) so internal deps are auto-linked, and the release scripts verify the package set in lockstep. - More
tsconfig.jsonfiles. Each package has its own. Project references handle the build graph. The cost is configuration footprint, not build speed. - A discovery cost when reading the codebase. "Where does
Signallive?" is one search now:packages/core/src/types/signal.ts. But "where doesdefineChecklive?" requires knowing the layer (fitness) and the framework subdir (fitness/engine/src/framework/). The package catalog (70-reference/02-package-catalog.md) is the antidote.
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
04-contract-surfaces.md— the public edges this layer cake exposes. The Tool contract sits at the top of Layer 3; the JSON output sits across Layer 2.../70-reference/02-package-catalog.md— every package, by layer, with one-line role and key exports. Use this when you're hunting for a symbol.../80-implementation/05-layer-policy.md— the dep-cruiser config, rule by rule, with rationale.