Files
atomic-design-poc/.dependency-cruiser.base.js
T
ehoandClaude Sonnet 5 b6bd7eea5a refactor: enforce the atomic ladder in dependency-cruiser (RD-29)
Add three dependency-cruiser rules that forbid upward edges inside
libs/shared/src/ui: atoms-compose-nothing-above, molecules-below-organisms,
and design-system-not-layout. RD-27 made the atomic ladder expressible by
moving components into atoms/molecules/organisms folders; these rules make
it enforced, so an upward import now fails dep:check instead of shipping.

No pathNot exemption for specs and stories: measured, zero upward edges
exist anywhere in libs/shared/src/ui today, in production code, specs, and
stories alike, so the exemption PLAN proposed has nothing to protect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:32:52 +02:00

170 lines
6.9 KiB
JavaScript

// Dependency-cruiser (WP-38, generalized for the WP-67 monorepo split): the single
// declarative source for the bounded-context + atomic-layer boundaries. Each app
// (apps/ssp, apps/behandelportal) is cruised SEPARATELY against its own tsconfig.json
// (see .dependency-cruiser.<app>.js) — a single merged tsconfig can't resolve both
// apps' `@auth/*` alias at once, since each points at a different physical directory.
// This file is the shared rule *factory*; it never runs standalone.
//
// libs/shared and libs/beheer are cross-app libraries, not per-app feature contexts:
// any app context may import either; neither may import an app's feature context;
// libs/shared may not import libs/beheer (shared stays the base, beheer a peer leaf).
/**
* @param {Record<string, string[] | null>} contextAllowed context name -> the OTHER
* app-local contexts it may additionally import (besides itself + libs/shared|beheer).
* `null` = unrestricted (showcase, the sanctioned teaching page).
* @param {string} appName the apps/<appName> directory this config cruises.
* @param {string} tsConfigFileName this app's own tsconfig.json (resolves its aliases).
*/
module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) {
const FEATURES = Object.keys(contextAllowed).join('|');
const appRoot = `apps/${appName}/src/app`;
const contextRule = (from) => {
const allowed = contextAllowed[from];
if (allowed === null) return null;
const forbidden = Object.keys(contextAllowed)
.filter((name) => name !== from && !allowed.includes(name))
.join('|');
return {
name: `${appName}-${from}-scope`,
comment: `${from} may depend only on its allowed contexts (+ libs/shared|beheer). See CLAUDE.md §1.`,
severity: 'error',
from: { path: `^${appRoot}/${from}/` },
to: { path: `^${appRoot}/(${forbidden})/` },
};
};
// Atomic-layer rules apply uniformly across this app's tree AND both libraries.
const anyRoot = `(${appRoot}|libs/shared/src|libs/beheer/src)`;
return {
forbidden: [
// --- Bounded-context direction (the "dependencies point inward" spine) ---
{
name: 'shared-no-features',
comment: 'libs/shared is the base — it must not import any app feature context.',
severity: 'error',
from: { path: '^libs/shared/src/' },
to: { path: `^${appRoot}/(${FEATURES})/` },
},
{
name: 'beheer-no-features',
comment: 'libs/beheer is a cross-app library — it must not import any app feature context.',
severity: 'error',
from: { path: '^libs/beheer/src/' },
to: { path: `^${appRoot}/(${FEATURES})/` },
},
{
name: 'shared-no-beheer',
comment: 'libs/shared stays the base — it must not depend on the beheer library.',
severity: 'error',
from: { path: '^libs/shared/src/' },
to: { path: '^libs/beheer/src/' },
},
{
name: `${appName}-no-other-app`,
comment: "An app may not import another app's source directly.",
severity: 'error',
from: { path: `^${appRoot}/` },
to: { path: '^apps/(?!' + appName + '/)' },
},
...Object.keys(contextAllowed).map(contextRule).filter(Boolean),
// --- Atomic-layer rules (dependencies point inward: ui → application → domain) ---
{
name: 'domain-is-pure',
comment: 'domain/ is framework-free business logic — no Angular.',
severity: 'error',
from: { path: `^${anyRoot}/.*/domain/` },
to: { path: 'node_modules/@angular/' },
},
{
name: 'contracts-import-nothing',
comment: 'contracts/ are pure wire DTO shapes — they import nothing (ADR-0001).',
severity: 'error',
from: { path: `^${anyRoot}/.*/contracts/` },
to: { pathNot: '/contracts/', path: `^(${anyRoot}/|node_modules/@angular/)` },
},
{
name: 'ui-not-infrastructure',
comment:
'ui/ + layout/ reach data through an application store/command, never infrastructure directly (type-only DTO imports allowed).',
severity: 'error',
from: {
path: `^${anyRoot}/.*(/ui/|/layout/)`,
pathNot: '\\.stories\\.ts$|\\.spec\\.ts$',
},
to: { path: '/infrastructure/', dependencyTypesNot: ['type-only'] },
},
{
name: 'apiclient-infrastructure-only',
comment:
'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
severity: 'error',
from: { pathNot: '/infrastructure/' },
to: {
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
dependencyTypesNot: ['type-only'],
},
},
// --- Atomic ladder within libs/shared/src/ui (folder = layer, CLAUDE.md decision 2) ---
{
name: 'atoms-compose-nothing-above',
comment: 'An atom composes nothing above it — no molecule or organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/atoms/' },
to: { path: '^libs/shared/src/ui/(molecules|organisms)/' },
},
{
name: 'molecules-below-organisms',
comment: 'A molecule composes nothing above it — no organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/molecules/' },
to: { path: '^libs/shared/src/ui/organisms/' },
},
{
name: 'design-system-not-layout',
comment: 'The design system (ui/) does not depend on layout/ templates. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/' },
to: { path: '^libs/shared/src/layout/' },
},
{
name: 'no-testing-in-production',
comment:
'Test-only fixture helpers (libs/shared/src/testing/** and any *.testing.ts) are reached from specs/stories only — production code gets its data through the real domain/application doors (ADR-0006), never the test escape hatch.',
severity: 'error',
from: {
pathNot: '\\.(spec|stories)\\.ts$|\\.testing\\.ts$|^libs/shared/src/testing/',
},
to: { path: '^libs/shared/src/testing/|\\.testing\\.ts$' },
},
// --- Hygiene (cheap wins a graph makes obvious) ---
{
name: 'no-circular',
comment: 'No cyclic dependencies.',
severity: 'error',
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: 'node_modules' },
tsConfig: { fileName: tsConfigFileName },
tsPreCompilationDeps: true, // needed so `type-only` imports are distinguished
enhancedResolveOptions: {
exportsFields: ['exports'],
conditionNames: ['import', 'require', 'node', 'default'],
},
reporterOptions: {
archi: { collapsePattern: '^(apps/[^/]+/src/app|libs/[^/]+/src)/[^/]+' },
},
},
};
};