feat(arch): WP-38 — dependency graph + declarative boundaries (dependency-cruiser)
CI / frontend (push) Successful in 2m11s
CI / storybook-a11y (push) Successful in 5m46s
CI / backend (push) Successful in 1m29s
CI / e2e (push) Successful in 2m55s
CI / semgrep (push) Successful in 1m1s
CI / api-client-drift (push) Successful in 2m5s

Adopt dependency-cruiser as the single declarative source for bounded-context +
atomic-layer boundaries, replacing the per-context no-restricted-imports blocks that
had to be hand-copied (and had left herregistratie uncovered). `.dependency-cruiser.js`
encodes context direction (everyone→shared, herregistratie→registratie, showcase→*),
domain-purity, contracts-import-nothing, ui↛infrastructure, ApiClient confinement, and
no-circular. `npm run dep:check` enforces (wired into ci-local.sh + the frontend CI job);
`npm run dep:graph` emits a committed mermaid context×layer graph. ESLint slimmed to
no-explicit-any + template a11y. Docs + new-context skill updated to the single source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 13:51:04 +02:00
co-authored by Claude Opus 4.8
parent fe9e3121c7
commit 7d2a36ff22
12 changed files with 641 additions and 248 deletions
+9 -225
View File
@@ -2,11 +2,15 @@ import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
/**
* Enforces the architecture's working agreements that were previously only
* documented (CLAUDE.md): no `any`, domain/ stays framework-free, and the
* dependency direction between contexts (herregistratie → registratie → shared,
* auth → shared; shared depends on nothing). Boundary rules use path patterns on
* the import aliases, so they read as the direction statement they enforce.
* ESLint now owns only the rules that are NOT dependency-graph shaped: no `any`, and
* template accessibility. The architecture's **boundary** rules — bounded-context
* direction (herregistratie → registratie → shared, auth/brief/beheer → shared; shared
* depends on nothing), `domain/` framework-freedom, `contracts/` purity, `ui ↛
* infrastructure`, and ApiClient confinement — moved to **dependency-cruiser** (WP-38,
* `.dependency-cruiser.js`): one declarative source that also emits the architecture graph
* (`npm run dep:graph`) and is enforced by `npm run dep:check` (in CI). That replaced the
* per-context `no-restricted-imports` blocks that had to be hand-copied (and had left
* `herregistratie` uncovered).
*/
export default [
{
@@ -46,224 +50,4 @@ export default [
files: ['src/**/*.spec.ts'],
rules: { '@typescript-eslint/no-explicit-any': 'off' },
},
// domain/ = pure business rules + types. No Angular, ever.
{
files: ['src/app/**/domain/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@angular/*', '@angular/**'],
message: 'domain/ must stay framework-free (pure TS) — no Angular imports.',
},
],
},
],
},
},
// shared/ is the base layer: it may not depend on any feature context.
// The dev-only debug panel is the sanctioned exception (it observes every store).
{
files: ['src/app/shared/**/*.ts'],
ignores: ['src/app/shared/ui/debug-state/**'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*', '@beheer/*'],
message: 'shared/ must not depend on a feature context.',
},
],
},
],
},
},
// auth/ may depend only on shared.
{
files: ['src/app/auth/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@registratie/*', '@herregistratie/*', '@brief/*', '@beheer/*'],
message: 'auth/ may depend only on shared.',
},
],
},
],
},
},
// registratie/ may depend on shared, not on herregistratie (direction points the other way).
{
files: ['src/app/registratie/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@herregistratie/*', '@brief/*', '@beheer/*'],
message: 'Dependencies point herregistratie → registratie → shared, never back.',
},
],
},
],
},
},
// brief/ (letter composition) is an independent leaf context: it may depend only on shared.
{
files: ['src/app/brief/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@beheer/*'],
message: 'brief/ may depend only on shared.',
},
],
},
],
},
},
// beheer/ (stamdata maintenance) is an independent leaf context: it may depend only on shared.
{
files: ['src/app/beheer/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*'],
message: 'beheer/ may depend only on shared.',
},
],
},
],
},
},
// contracts/ is the FE⇄BE wire seam: pure DTO shapes that must import NOTHING
// (CLAUDE.md §1, ADR-0001) — not Angular, not a context alias, not relative app
// code. Enums are inlined string-literal unions; the adapter's parse* maps them.
// (This comes after the per-context rules so it wins for contracts files.)
{
files: ['src/app/**/contracts/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'@angular/**',
'@shared/**',
'@auth/**',
'@registratie/**',
'@herregistratie/**',
'@brief/**',
'@beheer/**',
'./*',
'../*',
'./**',
'../**',
],
message:
'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.',
},
],
},
],
},
},
// BFF-lite anti-corruption boundary (ADR-0001): the ApiClient (the network
// client) may be imported as a VALUE only from infrastructure-role files.
// Type-only imports of generated wire DTOs are allowed anywhere — they grant no
// network access. UI/application reach the network through an adapter or command.
{
files: ['src/app/**/*.ts'],
plugins: { '@typescript-eslint': tseslint.plugin },
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@shared/infrastructure/api-client'],
allowTypeImports: true,
message:
'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)',
},
],
},
],
},
},
// …the infrastructure adapters ARE that boundary and own the client. shared/upload
// is a feature-scoped adapter that lives outside a /infrastructure/ folder.
{
files: ['src/app/**/infrastructure/**/*.ts', 'src/app/shared/upload/**/*.ts'],
plugins: { '@typescript-eslint': tseslint.plugin },
rules: { '@typescript-eslint/no-restricted-imports': 'off' },
},
// ui/ and layout/ are the presentation layer: dependencies point inward
// (ui → application → domain, CLAUDE.md §1), so they must NOT import
// infrastructure/ directly — they reach data through an application store or
// command. (Stories/specs are test scaffolding and may wire the real client.)
// Uses the @typescript-eslint variant so it composes with the base
// no-restricted-imports context-direction rules above (last-wins is per rule name).
{
files: ['src/app/**/ui/**/*.ts', 'src/app/**/layout/**/*.ts'],
// debug-state is the sanctioned devtool (same precedent as the cross-context
// exemption above): its WP-33 role/scenario switchers write the infrastructure
// dev-mechanism helpers directly. Never a product feature — isDevMode()-gated.
ignores: ['**/*.stories.ts', '**/*.spec.ts', 'src/app/shared/ui/debug-state/**'],
plugins: { '@typescript-eslint': tseslint.plugin },
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'@shared/infrastructure/*',
'@auth/infrastructure/*',
'@registratie/infrastructure/*',
'@herregistratie/infrastructure/*',
'@brief/infrastructure/*',
'@beheer/infrastructure/*',
],
allowTypeImports: true,
message:
'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)',
},
],
},
],
},
},
// Sanctioned exception: showcase/ is the teaching page whose whole point is showing
// multiple contexts side by side (ARCHITECTURE.md §6). It may read every context;
// nothing imports showcase. Same precedent as the shared/ui/debug-state exemption.
{
files: ['src/app/showcase/**/*.ts'],
rules: { 'no-restricted-imports': 'off' },
},
];