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>
170 lines
6.9 KiB
JavaScript
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)/[^/]+' },
|
|
},
|
|
},
|
|
};
|
|
};
|