Layer policy

The six-layer package graph (core → substrates → shared libraries/adapters → tools → check/adapter packs → cli) is enforced by dependency-cruiser. Build fails on any forbidden edge. This doc walks every rule and the reasoning.

For the conceptual layer narrative, see ../10-concepts/03-modular-monolith.md.

The literal rules are at .config/dependency-cruiser.cjs.


Generic hygiene rules

Three rules unrelated to the layer architecture but enforced workspace-wide.

no-circular

{ name: 'no-circular', from: {}, to: { circular: true } }

No circular dependencies between modules within a package. The main pass ignores type-only edges (tsPreCompilationDeps: false) so it flags only runtime cycles; the type-aware pass (.config/dependency-cruiser.types.cjs) re-runs no-circular over the type-inclusive graph, so type-only cycles are caught too. A circular dep — runtime or type-only — is a structural smell, usually meaning a type or constant belongs in a third file.

no-deprecated-core

{
  from: {},
  to: {
    dependencyTypes: ['core'],
    path: ['^(punycode|domain|constants|sys|_linklist|_stream_wrap)
  
],
  },
}

No imports from deprecated/removed Node core modules. Catches accidental usage of legacy Node APIs.

not-to-spec and dev-dependency hygiene

Production code can't import test files, and source code can't import undeclared runtime dependencies. These guards protect the runtime surface — a check pack accidentally importing vitest would crash any consumer who installed the pack as a regular dep.

{ name: 'not-to-spec',
  from: { pathNot: ['/__tests__/', '\\.test\\.(ts|tsx)
  
] },
  to:   { path: ['/__tests__/', '\\.test\\.(ts|tsx)
  
] },
}

not-to-spec lives in dependency-cruiser because it is a workspace file edge. Dev-dependency hygiene lives in ESLint (import-x/no-extraneous-dependencies, .config/eslint.config.mjs) because dependency-cruiser intentionally drops almost all node_modules edges under this config; a to: { dependencyTypes: ['npm-dev'] } cruiser rule would be structurally inert here.


Layer enforcement rules

The rules that pin the cross-package layer cake. The set below covers the load-bearing ones (core, datastore, contracts, config, fitness/simulation/graph, language/check/adapter-pack isolation). Several runtime packages carry their own narrow allowlist rules in the same shape: session-store-imports-core-datastore-contracts-only, output-imports-core-contracts-only, config-imports-core-only, targeting-imports-config-core-only (ADR-0037), dashboard-imports-only-core-contracts, cli-live-imports-core-cli-ui-only (ADR-0058 — shared live-run state machine; tools must not import ink directly), and cli-ui-no-workspace-deps / cli-ui-no-tools for the leaf UI kit. They read exactly like the ones below — a from package, a forbidden to path-list.

core-imports-nothing-workspace

{
  from: { path: '^packages/core/src/' },
  to: {
    path: '^packages/',
    pathNot: '^packages/core/',
  },
}

The kernel imports nothing from the workspace. Period.

This is the load-bearing rule. The kernel is what every Tool depends on; if the kernel reached up to a Tool, every Tool would transitively import every other Tool. The Tool plugin model breaks; the layer cake collapses.

The kernel is also where genuinely shared substrate lives so it doesn't get duplicated across peer tools. Besides Registry<T> and RunScope, core now hosts the generic recipe substrate (packages/core/src/recipes/RecipeRegistry<T>, selector resolution, per-unit config override), hoisted out of fitness so fitness, simulation, and graph share one selection + config-override mechanism (ADR-0005). Execution strategy stays tool-owned; only the generic selection machinery is shared. Because it lives in core, it's available to every layer above without inverting the dependency arrow.

The rule is future-proof by shape: any target under packages/ is forbidden unless it is still inside packages/core/. Adding a new package cannot accidentally create an unguarded core back-edge.

datastore-imports-core-only

{
  from: { path: '^packages/datastore/src/' },
  to: {
    path: [
      '^@opensip-cli/contracts',
      '^opensip-cli($|/)',
      '^@opensip-cli/fitness',
      '^@opensip-cli/simulation',
      '^@opensip-cli/lang-',
      '^@opensip-cli/checks-',
      '^@opensip-cli/graph',
    ],
  },
}

@opensip-cli/datastore is paradigm-agnostic infrastructure (SQLite + Drizzle wrapper, factory, migration runner). It depends on core only — not on any tool, lang pack, check pack, or contracts package.

The reasoning mirrors contracts: schemas live with their owning packages (sessions in contracts; baseline/catalog in graph; baseline in fitness). Datastore knows nothing about domain shapes — bundling them would invert the ownership and force schema changes to ripple through datastore.

For the deeper rationale (why a separate package, why not core, why not contracts), see ../80-implementation/03-session-and-persistence.md and the persistence-migration decisions log.

contracts-imports-core-only

{
  from: { path: '^packages/contracts/src/' },
  to: {
    path: [
      '^opensip-cli($|/)',
      '^@opensip-cli/fitness',
      '^@opensip-cli/simulation',
      '^@opensip-cli/lang-',
      '^@opensip-cli/checks-',
    ],
  },
}

contracts depends only on core. It can't reach up to a tool, the CLI, or check packs.

The reasoning: contracts exists to define the contract facade (SignalEnvelope, CommandResult, EXIT_CODES, StoredSession, and small tool-facing helpers such as defineCommand) that every Tool consumes. If it took a dep on one Tool, it'd be coupled to that Tool's lifecycle. Runtime services such as persistence, rendering, config loading, and tool execution stay outside contracts.

fitness-no-cli and simulation-no-cli

{ name: 'fitness-no-cli',     from: { path: '^packages/fitness/' },    to: { path: '^opensip-cli($|/)' } }
{ name: 'simulation-no-cli',  from: { path: '^packages/simulation/' }, to: { path: '^opensip-cli($|/)' } }

Tools cannot import the CLI. This would create a cycle (cli depends on every tool). Tools call back into shared CLI infrastructure via ToolCliContext (the inversion-of-control seam from the Tool contract).

check-pack-no-cli

{
  from: { path: '^packages/fitness/checks-' },
  to: {
    path: ['^opensip-cli($|/)', '^@opensip-cli/contracts'],
  },
}

Check packs are self-contained units of fitness-domain logic. They depend on fitness (for defineCheck) and core (for Signal, errors). They don't depend on the CLI or contracts — they're the marketplace shape, designed to be installable from npm without dragging the CLI in.

A consumer using @opensip-cli/checks-typescript from inside their own custom Tool gets the checks without the CLI's transitive deps.

lang-no-cli-or-contracts

{
  from: { path: '^packages/languages/lang-' },
  to: {
    path: ['^opensip-cli($|/)', '^@opensip-cli/contracts', '^@opensip-cli/checks-'],
  },
}

Language adapter packages depend only on core (for the LanguageAdapter contract). They don't reach into the CLI, contracts, or check packs.

The lang layer is below check packs in the implicit ordering, even though both sit at "Layer 3" in the conceptual model — a check pack imports lang-typescript (transitively, through the framework's adapter dispatch), but a lang pack never imports a check pack.

lang-no-fitness

{
  name: 'lang-no-fitness',
  from: { path: '^packages/languages/lang-' },
  to: { path: '^packages/fitness/engine/' },
}

A flat rule: no lang pack reaches up into fitness. The historical lang-typescript → fitness exception (@opensip-cli/lang-typescript re-exporting filterContent, clearFilterCache, FilteredContent) was paid down by moving those symbols into the adapter package itself — they now live in packages/languages/lang-typescript/src/filter.ts alongside the rest of the TS-aware string/comment stripping. With that, the rule simplified from the named carve-out (lang-no-fitness-except-typescript) to the unconditional form above.

Output-boundary rules (ADR-0011)

ADR-0011 makes the SignalEnvelope the single output currency: a tool engine returns an envelope and never renders or delivers its own output. The CLI composition root maps flags → (formatter × sink). Four guards keep that honest — three dependency-cruiser rules plus one fitness check, because the contract has both an import shape and a call shape:

All three are production-source-only — test files are globally excluded, so graph's relocated golden SARIF test may import formatSignalSarif from the barrel.

The complementary positive contract is the CommandResult return type: a tool returns its envelope/result and routes output through the ToolCliContext seam (cli.render / cli.emitJson / cli.emitEnvelope / cli.emitError / cli.deliverSignals / cli.writeArtifact / cli.writeSarif).


Tool-internal partitioning rules

Beyond the cross-package layer cake, two tools define their own internal-shape rules. These don't enforce the package layering — they enforce per-tool stage discipline (graph) and dashboard-panel isolation.

Graph tool — the seven-stage pipeline and adapter-package isolation

A cluster of rules in .config/dependency-cruiser.cjs keep the graph tool's stages clean and the adapter packs isolated. Some pin the original cross-stage discipline (rules-no-parser, renderers-no-pipeline, visitors-resolvers-disjoint); the rest landed when the graph language adapters were extracted into their own publishable npm packages under packages/graph/graph-*/ (and again when the four tree-sitter adapters were consolidated onto a shared graph-adapter-common scaffolding package). The former info-severity SARIF allow-rule is gone — graph no longer imports fitness at all (see below).

Engine ↔ adapter-pack boundaries (the adapter-package split):

In-engine stage discipline (unchanged from the pre-split layout, just with no engine/src/lang-* subtrees to police):

Cross-tool decoupling (graph and fitness are now fully independent):

Superseded graph checks (recorded here so future contributors know which

package-edge rules took over):

These mirror the conceptual seven-stage pipeline (../40-graph/01-stages-and-catalog.md) and the language-pluggability layering (../40-graph/03-adding-a-language.md). Stages can't reach forward; visitors and resolvers share helpers, not each other; rules and renderers consume frozen data; language-specific code is quarantined to its own publishable adapter package.

Dashboard — panel isolation

Six rules guard the dashboard's HTML-generator package against the failure modes that broke earlier panel layouts:

These rules exist because the dashboard ships as a single self-contained index.html. Every layering violation here would either bloat the file, break the no-server promise, or reintroduce panel-cross-talk bugs.


What this enforces in practice

Concrete examples of edges that fail the build:

All of these surface during pnpm depcruise — and, re-run over the type-inclusive graph, during pnpm depcruise:types. Both run as part of pnpm lint. Each violation prints the offending file, the import line, and the rule name.


How to add a new exception

There are no standing layer exceptions. tsPreCompilationDeps: false on the main pass is not one: it only defers type-only edges to the type-aware pass (.config/dependency-cruiser.types.cjs, tsPreCompilationDeps: true), which re-runs the full ruleset over the type-inclusive graph — so a type-only layer inversion or cycle is rejected just like a runtime one. The two earlier cross-package exceptions were both paid down — lang-typescript → fitness (by moving filterContent into the adapter) and graph → fitness via render/sarif.ts (by moving SARIF formatting and cloud delivery into @opensip-cli/output and applying them at the CLI composition root). New exceptions are rare and require justification.

Process:

Exceptions are debt. Each one weakens the architectural promise. Add them when you must, and document them in plain English.


What this doesn't enforce

A few patterns dep-cruiser can't catch but the workspace still cares about:

These are review concerns. The layer rules pin the load-bearing constraint; the rest is the contributor's responsibility.

Tool failure reporting (ADR-0077)

Handler-time command failures split across three packages by design:

Core must not import contracts for the failure input type; the CLI composition root combines both.


What's next